Two cuelume sounds for Claude Code: ready when a long turn ends, attention when a permission prompt waits.

Two Cuelume sounds for Claude Code, so you can look away while it works.
| When | Cue |
|---|---|
| A turn of ten seconds or more ends with an answer | ready |
| A permission prompt is waiting on you | attention |
Short replies, interrupted turns and subagent turns stay silent.
/plugin marketplace add danielwh2/cuelume
/plugin install cuelume@cuelume
It needs a Claude Code version with mods, and macOS: Claude Code plays plugin audio through afplay, so other platforms stay silent.
/cuelume show the current theme
/cuelume bubble switch to default, mech, bubble or press, and hear it
/cuelume off no sounds
Your choice is kept across sessions.
press is modelled on the trackpad and keycap sounds in DawoodUI's Artasaka preview.
It plays the WAV files bundled in this folder and keeps your theme choice in Claude Code's plugin store. It reads nothing else, runs no other program and sends nothing over the network.
The plugin registers four hooks. None of them makes a decision for you. The first three pass their event on unchanged and return what Claude Code gave back; the last answers the plugin's own command.
| Hook | When it runs | What it does | What it decides |
|---|---|---|---|
classic.PermissionRequest | A permission prompt is about to show | Plays attention | Nothing. It never allows, denies or answers the request; that decision stays with you |
turn.complete | A turn ends | Plays ready if the turn ran ten seconds or more | Nothing. The answer is returned as it was |
session.start | A session starts | Registers the /cuelume command | Nothing |
command.run | You run /cuelume | Saves the theme you chose and plays it | Nothing |
Cuelume synthesizes its cues live with Web Audio. A Claude Code mod can only play audio files, so each cue here is rendered once from Cuelume's own recipes to a 48 kHz WAV. The renderer skips Cuelume's output limiter, so a cue is close to what the library plays in a browser, not identical.
hooks/register.ts 78 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const THEMES = ['default', 'mech', 'bubble', 'press'] as const
4const OFF = 'off'
5const DEFAULT_THEME = 'default'
6const THEME_KEY = 'theme'
7// The cues are rendered near -21 dBFS, the library's own level.
8const GAIN = 2
9// A short reply is still on screen when it lands; only a turn worth walking away from rings.
10const MIN_TURN_MS = 10_000
11
12type Cue = 'ready' | 'attention'
13type Choice = (typeof THEMES)[number] | typeof OFF
14
15export const parseChoice = (text: string): Choice | undefined => {
16 const word = text.trim().toLowerCase()
17
18 return word === OFF || THEMES.some(theme => theme === word) ? (word as Choice) : undefined
19}
20
21async function chosen($: EngineInterface): Promise<Choice> {
22 const stored = await $.store.get(THEME_KEY)
23
24 return (typeof stored === 'string' && parseChoice(stored)) || DEFAULT_THEME
25}
26
27async function cue($: EngineInterface, name: Cue) {
28 const theme = await chosen($)
29 if (theme === OFF) return
30
31 await $.audio
32 .play({ asset: `sounds/${theme}/${name}.wav` }, { gain: GAIN })
33 .catch((error: unknown) => $.ui.toast(`${$.plugin.name}: ${name} did not play: ${String(error)}`))
34}
35
36export const register: Register = on => {
37 on('session.start', async ($, e, next) => {
38 await $.command.register({
39 name: 'cuelume',
40 description: 'Pick the sound theme for turn-end and permission cues, or turn them off',
41 argumentHint: [...THEMES, OFF].join(' | '),
42 })
43
44 return next(e)
45 })
46
47 on('command.run', { command: 'cuelume' }, async ($, e) => {
48 const choice = parseChoice(e.args)
49 if (choice === undefined) {
50 return { text: `cuelume: ${await chosen($)}. Options: ${[...THEMES, OFF].join(', ')}.` }
51 }
52
53 await $.store.set(THEME_KEY, choice)
54 await cue($, 'ready')
55
56 return { text: choice === OFF ? 'cuelume: off.' : `cuelume: ${choice}.` }
57 })
58
59 // The cue starts before next(e) and is awaited after it: a call left running once the hook returns never
60 // plays, and this way the answer and the dialog do not wait on the sound. Each hook returns what next gave back.
61 on('turn.complete', async ($, e, next) => {
62 const isWorthRinging = e.agentId === undefined && e.reason === 'answer' && e.durationMs >= MIN_TURN_MS
63 const playing = isWorthRinging ? cue($, 'ready') : undefined
64 const answer = await next(e)
65 await playing
66
67 return answer
68 })
69
70 on('classic.PermissionRequest', async ($, e, next) => {
71 const playing = cue($, 'attention')
72 const decision = await next(e)
73 await playing
74
75 return decision
76 })
77}
78