SLOPSHOPPER

call-my-name

Calls you by name out loud when Claude needs you or finishes a task

newguardmodelprocess
v0.6.0no licenseupdated 2026-10-09juhaodong/call-my-name
A shopper browsing a rack in a slop shop
README

call-my-name

A Claude Code mod that calls you by name, out loud, when Claude needs you:

  • when it asks for permission to run a tool
  • when it asks you a question
  • when a task finishes (10s+ by default; set minSeconds to change it, 0 for every task), with a one-line spoken summary of what was done

It stays quiet while you're in a meeting, i.e. while any app is using a microphone or camera (set muteInMeetings to off to disable). The first check compiles a tiny Swift helper into ~/.cache/call-my-name/, so it needs the Xcode command line tools; without them the mod just keeps talking.

Available in English, 中文, Deutsch and 日本語, with a female, male or alternating voice. macOS only.

Install

From any shell (works for the desktop app too):

claude plugin marketplace add juhaodong/call-my-name
claude plugin install call-my-name@haodong-mods
uv tool install edge-tts   # optional, much nicer voices

Or, inside a Claude Code terminal session:

/plugin install call-my-name --marketplace juhaodong/call-my-name

Restart Claude Code afterwards.

With /plugin install, answer y to add the marketplace, pick the user scope, then set your options. With the shell install, set them in /config:

OptionValuesDefault
nicknameanything; empty skips the nameempty
languageen, zh, de, jaen
minSecondsseconds; 0 announces every finished task10
voicerandom, alternate, female, malerandom
muteInMeetingson, offon

Change them later in /config.

Better voices (recommended)

The mod uses edge-tts neural voices when available and falls back to macOS say otherwise:

uv tool install edge-tts

(or pipx install edge-tts). For the say fallback, a Premium/Enhanced system voice for your language sounds much better: System Settings → Accessibility → Spoken Content → System Voice → Manage Voices.

中文说明

安装(在终端里运行):

claude plugin marketplace add juhaodong/call-my-name
claude plugin install call-my-name@haodong-mods
uv tool install edge-tts   # 可选,声音更自然

装好后重启 Claude Code,在 /config 里设置 nickname(昵称)、language 选 zh、voice(女声 / 男声 / 轮流)。

Source 1 files
hooks/register.ts 269 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3type Language = 'zh' | 'en' | 'de' | 'ja'
4type VoiceChoice = 'random' | 'alternate' | 'female' | 'male'
5
6type Phrases = {
7  edgeVoices: { female: string; male: string }
8  macVoices: string[]
9  separator: string
10  tools: Record<string, string>
11  otherTool: (name: string) => string
12  permission: (label: string) => string
13  question: (text: string) => string
14  error: string
15  doneFallback: string
16  summarySystem: string
17  summaryPrompt: (prompt: string, answer: string) => string
18}
19
20const PHRASES: Record<Language, Phrases> = {
21  zh: {
22    edgeVoices: { female: 'zh-CN-XiaoxiaoNeural', male: 'zh-CN-YunxiNeural' },
23    macVoices: ['Lilian (Premium)', 'Lili (Premium)', 'Tingting (Premium)', 'Tingting (Enhanced)', 'Tingting'],
24    separator: ',',
25    tools: { Bash: '跑个命令', Edit: '改个文件', Write: '写个文件', WebFetch: '上网看个网页', WebSearch: '上网搜一下' },
26    otherTool: name => `用一下 ${name}`,
27    permission: label => `我想${label},你点个同意呗。`,
28    question: text => `问你个事儿:${text}`,
29    error: '哎,出了点岔子,你来瞅一眼吧。',
30    doneFallback: '活儿干完啦,你来看看吧。',
31    summarySystem:
32      '你是用户的搭档,刚帮他干完一件活儿。用一句很口语、很随意的中文告诉他干完了什么,不超过二十五个字,不要称呼他,不要引号。例如:登录页那个报错我搞定啦,你来瞅一眼吧。',
33    summaryPrompt: (prompt, answer) => `用户的请求:${prompt}\n\n助手的最终回复:${answer}`,
34  },
35  en: {
36    edgeVoices: { female: 'en-US-AvaNeural', male: 'en-US-AndrewNeural' },
37    macVoices: ['Ava (Premium)', 'Zoe (Premium)', 'Ava (Enhanced)', 'Samantha (Enhanced)', 'Samantha'],
38    separator: ', ',
39    tools: {
40      Bash: 'run a command',
41      Edit: 'edit a file',
42      Write: 'write a file',
43      WebFetch: 'open a web page',
44      WebSearch: 'search the web',
45    },
46    otherTool: name => `use ${name}`,
47    permission: label => `I want to ${label}, mind giving me the okay?`,
48    question: text => `quick question: ${text}`,
49    error: 'oops, something went wrong, come take a look.',
50    doneFallback: 'all done, come take a look.',
51    summarySystem:
52      "You are the user's teammate and just finished a task for them. In one short, casual spoken English sentence (max 15 words), tell them what you got done. Do not address them by name, no quotes. Example: Fixed that login page error, come check it out.",
53    summaryPrompt: (prompt, answer) => `User's request: ${prompt}\n\nAssistant's final reply: ${answer}`,
54  },
55  de: {
56    edgeVoices: { female: 'de-DE-KatjaNeural', male: 'de-DE-ConradNeural' },
57    macVoices: ['Anna (Premium)', 'Petra (Premium)', 'Anna (Enhanced)', 'Anna'],
58    separator: ', ',
59    tools: {
60      Bash: 'einen Befehl ausführen',
61      Edit: 'eine Datei ändern',
62      Write: 'eine Datei schreiben',
63      WebFetch: 'eine Webseite öffnen',
64      WebSearch: 'im Web suchen',
65    },
66    otherTool: name => `${name} benutzen`,
67    permission: label => `ich möchte ${label}, gibst du mir kurz das Okay?`,
68    question: text => `kurze Frage: ${text}`,
69    error: 'hoppla, da ist was schiefgelaufen, schau mal kurz rein.',
70    doneFallback: 'fertig, schau mal rein.',
71    summarySystem:
72      'Du bist der Teamkollege des Nutzers und hast gerade eine Aufgabe für ihn erledigt. Sag ihm in einem kurzen, lockeren gesprochenen deutschen Satz (höchstens 15 Wörter), was du erledigt hast. Keine Anrede mit Namen, keine Anführungszeichen. Beispiel: Den Fehler auf der Login-Seite hab ich behoben, schau mal rein.',
73    summaryPrompt: (prompt, answer) => `Anfrage des Nutzers: ${prompt}\n\nLetzte Antwort des Assistenten: ${answer}`,
74  },
75  ja: {
76    edgeVoices: { female: 'ja-JP-NanamiNeural', male: 'ja-JP-KeitaNeural' },
77    macVoices: ['Kyoko (Premium)', 'Kyoko (Enhanced)', 'Kyoko'],
78    separator: '、',
79    tools: {
80      Bash: 'コマンドを実行し',
81      Edit: 'ファイルを編集し',
82      Write: 'ファイルを作成し',
83      WebFetch: 'ウェブページを開き',
84      WebSearch: 'ウェブで検索し',
85    },
86    otherTool: name => `${name} を使い`,
87    permission: label => `${label}たいんだけど、許可してもらえる?`,
88    question: text => `ちょっと聞いていい?${text}`,
89    error: 'あれ、何か問題が起きたみたい。ちょっと見てくれる?',
90    doneFallback: '終わったよ、見てみて。',
91    summarySystem:
92      'あなたはユーザーの相棒で、たった今作業を一つ終えたところです。何を終えたかを、くだけた話し言葉の日本語一文(三十文字以内)で伝えてください。名前で呼びかけず、引用符も使わないこと。例:ログイン画面のエラー直しといたよ、見てみて。',
93    summaryPrompt: (prompt, answer) => `ユーザーの依頼:${prompt}\n\nアシスタントの最終回答:${answer}`,
94  },
95}
96
97type Settings = { voice: VoiceChoice; nickname: string; minTurnMs: number; muteInMeetings: boolean; phrases: Phrases }
98
99function readOptions(options: PluginOptions): Settings {
100  const language: Language = Object.hasOwn(PHRASES, String(options.language)) ? (options.language as Language) : 'en'
101  const voice: VoiceChoice = ['female', 'male', 'alternate'].includes(String(options.voice)) ? (options.voice as VoiceChoice) : 'random'
102  const nickname = typeof options.nickname === 'string' ? options.nickname.trim() : ''
103  const minSeconds = Number(options.minSeconds)
104  const minTurnMs = Number.isFinite(minSeconds) && minSeconds > 0 ? minSeconds * 1000 : 0
105  const muteInMeetings = options.muteInMeetings !== 'off'
106  return { voice, nickname, minTurnMs, muteInMeetings, phrases: PHRASES[language] }
107}
108
109function toolLabel({ phrases }: Settings, tool: string): string {
110  if (phrases.tools[tool]) return phrases.tools[tool]
111  const name = tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool).replaceAll('_', ' ') : tool
112  return phrases.otherTool(name)
113}
114
115function withName({ nickname, phrases }: Settings, text: string): string {
116  return nickname ? `${nickname}${phrases.separator}${text}` : text
117}
118
119// Prints `busy` while any app records from a microphone or a camera, `free` otherwise.
120const IN_USE_SWIFT = `import AVFoundation
121import CoreAudio
122
123func micInUse() -> Bool {
124  var addr = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
125  var size: UInt32 = 0
126  AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size)
127  var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
128  AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids)
129  return ids.contains { id in
130    var input = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyStreamConfiguration, mScope: kAudioObjectPropertyScopeInput, mElement: kAudioObjectPropertyElementMain)
131    var s: UInt32 = 0
132    guard AudioObjectGetPropertyDataSize(id, &input, 0, nil, &s) == noErr, s > 0 else { return false }
133    let buf = UnsafeMutableRawPointer.allocate(byteCount: Int(s), alignment: 16)
134    defer { buf.deallocate() }
135    guard AudioObjectGetPropertyData(id, &input, 0, nil, &s, buf) == noErr,
136          UnsafeMutableAudioBufferListPointer(buf.assumingMemoryBound(to: AudioBufferList.self)).count > 0 else { return false }
137    var running = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
138    var value: UInt32 = 0
139    var valueSize = UInt32(MemoryLayout<UInt32>.size)
140    return AudioObjectGetPropertyData(id, &running, 0, nil, &valueSize, &value) == noErr && value != 0
141  }
142}
143
144func cameraInUse() -> Bool {
145  AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], mediaType: .video, position: .unspecified)
146    .devices.contains { $0.isInUseByAnotherApplication }
147}
148
149print(micInUse() || cameraInUse() ? "busy" : "free")
150`
151
152const IN_USE_BINARY = 'in-use-v1'
153
154let inUseBinary: Promise<string | undefined> | undefined
155
156async function buildInUseBinary($: EngineInterface, home: string): Promise<string | undefined> {
157  const dir = `${home}/.cache/call-my-name`
158  const binary = `${dir}/${IN_USE_BINARY}`
159  if (await $.fs.exists(binary)) return binary
160  await $.fs.write(`${dir}/${IN_USE_BINARY}.swift`, IN_USE_SWIFT)
161  const built = await $.process.run(['swiftc', '-O', `${dir}/${IN_USE_BINARY}.swift`, '-o', binary], { timeoutMs: 180_000 })
162  return built.exitCode === 0 ? binary : undefined
163}
164
165async function isInMeeting($: EngineInterface, { muteInMeetings }: Settings): Promise<boolean> {
166  if (!muteInMeetings) return false
167  const home = await $.env.get('HOME')
168  if (!home) return false
169  inUseBinary ??= buildInUseBinary($, home).catch(() => undefined)
170  const binary = await inUseBinary
171  if (!binary) {
172    inUseBinary = undefined
173    return false
174  }
175  const { stdout } = await $.process.run([binary], { timeoutMs: 5_000 })
176  return stdout.trim() === 'busy'
177}
178
179let spokenCount = 0
180
181function nextEdgeVoice({ voice, phrases }: Settings): string {
182  if (voice === 'female' || voice === 'male') return phrases.edgeVoices[voice]
183  const useFemale = voice === 'alternate' ? spokenCount++ % 2 === 0 : Math.random() < 0.5
184  return useFemale ? phrases.edgeVoices.female : phrases.edgeVoices.male
185}
186
187async function sayWithEdge($: EngineInterface, settings: Settings, text: string): Promise<boolean> {
188  const edgeVoice = nextEdgeVoice(settings)
189  const file = `/tmp/call-my-name-${await $.clock.now()}.mp3`
190  const home = await $.env.get('HOME')
191  for (const bin of home ? ['edge-tts', `${home}/.local/bin/edge-tts`] : ['edge-tts']) {
192    const made = await $.process
193      .run([bin, '--voice', edgeVoice, '--text', text, '--write-media', file], { timeoutMs: 10_000 })
194      .catch(() => undefined)
195    if (made?.exitCode !== 0) continue
196    const played = await $.process.run(['afplay', file], { timeoutMs: 60_000 })
197    await $.process.run(['rm', '-f', file])
198    return played.exitCode === 0
199  }
200  return false
201}
202
203async function sayWithMacVoice($: EngineInterface, { phrases }: Settings, text: string): Promise<void> {
204  const { stdout } = await $.process.run(['say', '-v', '?'])
205  const installed = stdout.split('\n').map(line => line.split(/\s{2,}/)[0].trim())
206  const macVoice = phrases.macVoices.find(v => installed.includes(v))
207  await $.process.run(macVoice ? ['say', '-v', macVoice, text] : ['say', text], { timeoutMs: 60_000 })
208}
209
210async function say($: EngineInterface, settings: Settings, text: string): Promise<void> {
211  const spoken = await sayWithEdge($, settings, text).catch(() => false)
212  if (!spoken) await sayWithMacVoice($, settings, text)
213}
214
215async function sayUnlessInMeeting($: EngineInterface, settings: Settings, text: () => Promise<string>): Promise<void> {
216  if (await isInMeeting($, settings).catch(() => false)) return
217  await say($, settings, withName(settings, await text()))
218}
219
220function speak($: EngineInterface, settings: Settings, text: string): void {
221  void sayUnlessInMeeting($, settings, async () => text).catch(() => undefined)
222}
223
224async function describeDone($: EngineInterface, { phrases }: Settings, prompt: string, answer: string): Promise<string> {
225  const reply = await $.model.complete({
226    model: 'haiku',
227    maxTokens: 80,
228    system: phrases.summarySystem,
229    prompt: phrases.summaryPrompt(prompt.slice(0, 1500), answer.slice(0, 3000)),
230  })
231  const sentence = reply.isAnswered ? reply.text.trim().replace(/^["“”'「」]+|["“”'「」]+$/g, '') : ''
232  return sentence || phrases.doneFallback
233}
234
235async function announceDone($: EngineInterface, settings: Settings, prompt: string, answer: string): Promise<void> {
236  await sayUnlessInMeeting($, settings, () => describeDone($, settings, prompt, answer))
237}
238
239export const register: Register = (on, options) => {
240  const settings = readOptions(options)
241  const { phrases } = settings
242  const prompts = new Map<string, string>()
243
244  on('turn.start', ($, e, next) => {
245    prompts.set(e.turnId, e.text)
246    return next(e)
247  })
248
249  on('classic.PermissionRequest', ($, e, next) => {
250    speak($, settings, phrases.permission(toolLabel(settings, e.tool_name)))
251    return next(e)
252  }).catch(($, e, next) => next(e))
253
254  on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
255    speak($, settings, phrases.question(e.questions[0]?.question ?? ''))
256    return next(e)
257  }).catch(($, e, next) => next(e))
258
259  on('turn.complete', async ($, e, next) => {
260    const done = await next(e)
261    const prompt = prompts.get(e.turnId) ?? ''
262    prompts.delete(e.turnId)
263    if (e.agentId !== undefined || e.isAborted || e.durationMs < settings.minTurnMs) return done
264    if (e.reason === 'error') speak($, settings, phrases.error)
265    else void announceDone($, settings, prompt, e.answer).catch(() => speak($, settings, phrases.doneFallback))
266    return done
267  })
268}
269