Plays an anime-voice producer tag (「マスター!確認してください」, 'Master! Please check this.') when Claude finishes or needs your approval.

When Claude finishes or needs you, an anime-style voice calls out:
「マスター! …確認してください」 ("Master! …Please check this.")
You can look away while Claude works. The voice tells you when it's your turn.
Stop hook).PermissionRequest).Notification).claude plugin install producer-tag --marketplace thangdo19/claude-code-mods
More ways (inside Claude Code, older versions): see the main README.
Set the sound option to your file. Pick one way:
# when installing
claude plugin install producer-tag --marketplace thangdo19/claude-code-mods --config 'sound=~/sounds/done.mp3'
# any time later (then restart Claude Code)
echo '{"sound": "~/sounds/done.mp3"}' | claude plugin configure producer-tag@claude-code-mods --values-stdin
Or inside Claude Code: /plugin configure producer-tag@claude-code-mods, or /config → Sound file.
.wav, .mp3, .m4a, .aiff, up to 4 MB./Users/you/sounds/done.mp3) or one starting with ~/.COOLDOWN_MS in hooks/register.ts (4 seconds by default).
afplay. On Linux and Windows it stays silent.Voice: Amitaro's Voice Material Studio (https://amitaro.net/) 音声素材:あみたろの声素材工房(https://amitaro.net/)
sounds/tag.wav joins two of Amitaro's free clips, 「マスター!」 (203_sentou/master_01.wav) and 「確認してください」 (205_system/kakuninshite_01.wav). Silence was trimmed, the volume evened out, and a short pause added between them. Under Amitaro's terms, the clips may ship inside a mod with this credit, but must not be shared as standalone sound files.
hooks/register.ts 121 lines1import type { AudioClip, EngineInterface, Register } from 'claude-code'
2
3export const TAG = 'sounds/tag.wav'
4// One prompt can raise both PermissionRequest and Notification; play it once.
5export const COOLDOWN_MS = 4000
6
7// The formats `afplay` plays, by extension, for the `sound` option's file.
8const MIME: Record<string, string> = {
9 wav: 'audio/wav',
10 mp3: 'audio/mpeg',
11 m4a: 'audio/mp4',
12 aiff: 'audio/aiff',
13 aif: 'audio/aiff',
14}
15
16export const mimeOf = (path: string): string | undefined =>
17 MIME[path.slice(path.lastIndexOf('.') + 1).toLowerCase()]
18
19// What a tool dialog's reminder says. Claude Code sends it once the person has
20// been idle 6s at the dialog, after PermissionRequest already played for it.
21const TOOL_DIALOG_REMINDERS = [
22 'Claude needs your permission',
23 'Claude Code needs your approval for the plan',
24 'Claude Code wants to enter plan mode',
25]
26const HEADLESS_REMINDER_PREFIX = 'Claude needs your permission to use '
27
28let lastPlayedAt = -Infinity
29// Tools whose dialog PermissionRequest announced, one entry per dialog, until
30// its reminder comes or the tool is done.
31const heralds: string[] = []
32// The `sound` option: the person's own file, '' for the bundled tag.
33let sound = ''
34
35// The person's file, read fresh each time so a replaced file plays at once.
36async function ownClip($: EngineInterface): Promise<AudioClip> {
37 const mime = mimeOf(sound)
38 if (!mime) throw new Error(`not a supported sound file: ${sound}`)
39 const path = sound.startsWith('~/') ? `${await $.env.get('HOME')}${sound.slice(1)}` : sound
40 const { base64 } = await $.fs.read(path, { as: 'bytes' })
41 return { base64, mime }
42}
43
44// The person's file when set; the bundled tag when it isn't, or it can't be read or played.
45async function playTag($: EngineInterface) {
46 if (sound) {
47 try {
48 return await $.audio.play(await ownClip($))
49 } catch {
50 // Falls through to the bundled tag.
51 }
52 }
53 await $.audio.play({ asset: TAG })
54}
55
56async function play($: EngineInterface) {
57 const now = await $.clock.now()
58 if (now - lastPlayedAt < COOLDOWN_MS) return
59 lastPlayedAt = now
60 // Not awaited: the clip never holds up Claude, and a failed play is only a missed sound.
61 playTag($).catch(() => {})
62}
63
64function forget(tool: string) {
65 const i = heralds.indexOf(tool)
66 if (i !== -1) heralds.splice(i, 1)
67}
68
69// A tool dialog's reminder while some dialog was announced: that dialog already played.
70// Any other dialog's reminder plays, even one opened inside an approved tool.
71function isAnnouncedReminder(message: string, type: string) {
72 if (type !== 'permission_prompt' || heralds.length === 0) return false
73 return TOOL_DIALOG_REMINDERS.includes(message) || message.startsWith(HEADLESS_REMINDER_PREFIX)
74}
75
76export const register: Register = (on, options) => {
77 sound = typeof options.sound === 'string' ? options.sound.trim() : ''
78
79 // Claude finished its reply. A Stop hook that sent Claude back to work isn't a finish.
80 // No dialog outlives the turn, so every herald goes.
81 on('classic.Stop', async ($, e, next) => {
82 heralds.length = 0
83 if (!e.stop_hook_active) await play($)
84 return next(e)
85 }).catch(($, e, next) => next(e))
86
87 // A permission dialog is up: a tool approval, or ExitPlanMode's plan approval.
88 on('classic.PermissionRequest', async ($, e, next) => {
89 heralds.push(e.tool_name)
90 await play($)
91 return next(e)
92 }).catch(($, e, next) => next(e))
93
94 // Other "needs you" alerts. The 60s idle nudge is left out: Stop already played.
95 on('classic.Notification', async ($, e, next) => {
96 if (isAnnouncedReminder(e.message, e.notification_type)) heralds.shift()
97 else if (e.notification_type !== 'idle_prompt') await play($)
98 return next(e)
99 }).catch(($, e, next) => next(e))
100
101 // The tool ran, failed or was denied: its dialog is gone, so a later reminder is another's.
102 on('classic.PostToolUse', async ($, e, next) => {
103 forget(e.tool_name)
104 return next(e)
105 }).catch(($, e, next) => next(e))
106 on('classic.PostToolUseFailure', async ($, e, next) => {
107 forget(e.tool_name)
108 return next(e)
109 }).catch(($, e, next) => next(e))
110 on('classic.PermissionDenied', async ($, e, next) => {
111 forget(e.tool_name)
112 return next(e)
113 }).catch(($, e, next) => next(e))
114
115 // A new prompt means no dialog is up.
116 on('classic.UserPromptSubmit', async ($, e, next) => {
117 heralds.length = 0
118 return next(e)
119 }).catch(($, e, next) => next(e))
120}
121