SLOPSHOPPER

cuelume

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

newcommandtoastaudio
★ 1,947v0.1.2MITupdated 2026-10-05danielwh2/cuelume/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cuelume
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /cuelume ⎿ cuelume: cuelume: default. Options: default, mech, bubble, press, off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Cuelume for Claude Code

Two Cuelume sounds for Claude Code, so you can look away while it works.

WhenCue
A turn of ten seconds or more ends with an answerready
A permission prompt is waiting on youattention

Short replies, interrupted turns and subagent turns stay silent.

Install

/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.

Themes

/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.

What it does on your machine

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.

Hooks

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.

HookWhen it runsWhat it doesWhat it decides
classic.PermissionRequestA permission prompt is about to showPlays attentionNothing. It never allows, denies or answers the request; that decision stays with you
turn.completeA turn endsPlays ready if the turn ran ten seconds or moreNothing. The answer is returned as it was
session.startA session startsRegisters the /cuelume commandNothing
command.runYou run /cuelumeSaves the theme you chose and plays itNothing

How the sounds are made

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.

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