SLOPSHOPPER

producer-tag

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

newaudio
v0.1.0NOASSERTIONupdated 2026-10-11thangdo19/claude-code-mods/producer-tag
A shopper browsing a rack in a slop shop
README

producer-tag

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.

When it plays

  • Claude finished its reply (the Stop hook).
  • Claude asks permission: to run a tool, or to go ahead with a plan (PermissionRequest).
  • Claude needs you for something else, like a question from an MCP server (Notification).

Install

claude plugin install producer-tag --marketplace thangdo19/claude-code-mods

More ways (inside Claude Code, older versions): see the main README.

Use your own sound

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.

  • Formats: .wav, .mp3, .m4a, .aiff, up to 4 MB.
  • Path: a full path (/Users/you/sounds/done.mp3) or one starting with ~/.
  • Back to the default: clear the option (leave it empty).
  • If your file is missing or won't play, the default tag plays instead, so you never miss an alert.

Change the gap between plays

COOLDOWN_MS in hooks/register.ts (4 seconds by default).

Limits

  • macOS only, because Claude Code plays sound with afplay. On Linux and Windows it stays silent.

Details

  • No double plays: one prompt can trigger two alerts at once, so the tag won't play again within 4 seconds.
  • Reminders: after 6 seconds at a permission dialog, Claude Code sends a reminder. For a tool or plan dialog the tag already played, so the reminder stays quiet. Any other dialog plays on its reminder.
  • Stays quiet when a helper agent (subagent) finishes, when a Stop hook sends Claude back to work, and for the 60-second "still idle" nudge.
  • Never in the way: the sound plays in the background. It never slows Claude down, and if it can't play, nothing breaks.

Credits

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.

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