Звуки: ответ дописан, сессия ждёт ответа; тревогу предохранителя играет fuse

Плагин Claude Code: короткий звук, когда сессии нужен человек. Можно уйти в другое окно и вернуться на звук.
| Звук | Когда |
|---|---|
done.wav, звонкая капля | ход закончился ответом: ответ дописан |
ask.wav, «кря-кря» живой кряквы | модель задала вопрос (AskUserQuestion), принесла план на одобрение (ExitPlanMode) или открылся диалог разрешения |
alarm.wav в плагине fuse, короткая сирена | сработал предохранитель |
Молчит:
Звук играет движок через afplay, поэтому работает на macOS. Ход и диалог звука не ждут.
Каплю и сирену синтезирует скрипт:
cd ~/work/ai-settings/plugins
python3 sounds/scripts/make_sounds.py
Он пишет sounds/sounds/done.wav и fuse/sounds/alarm.wav. Чтобы поменять звук, правь рецепт в скрипте и запусти его заново.
ask.wav — запись кряквы: Jonathon Jongsma, xeno-canto XC62258, лицензия CC BY-SA 3.0, взята с Wikimedia Commons. Из записи вырезан отрезок 1,16–2,02 с, срезан гул ниже 150 Гц, края сглажены, громкость выровнена. Этот файл распространяется под той же лицензией CC BY-SA 3.0:
ffmpeg -i "Anas_platyrhynchos_-_Mallard_-_XC62258.ogg" -ac 1 -ar 44100 mallard.wav
ffmpeg -i mallard.wav -af "atrim=1.16:2.02,asetpts=PTS-STARTPTS,highpass=f=150,afade=t=in:d=0.01,afade=t=out:st=0.82:d=0.04,loudnorm=I=-16:TP=-2:LRA=7" -ar 44100 -ac 1 sounds/ask.wav
claude plugin marketplace update popovs-plugins
claude plugin install sounds@popovs-plugins
hooks/register.ts — хуки turn.complete, tool.call, classic.PermissionRequest, prompt.submit.claude plugin validate . и claude plugin test .hooks/register.ts 53 lines1import type { EngineInterface, Register } from 'claude-code'
2
3export const DONE = 'sounds/done.wav'
4export const ASK = 'sounds/ask.wav'
5
6// Tools whose whole point is a question to the person.
7const QUESTION_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
8// The fuse asks with this header and plays its own alarm; one sound is enough.
9const FUSE_HEADER = '⛔ Опасно'
10
11// Module state: a scheduled task runs with nobody to hear it.
12let isUnattended = false
13
14// Not awaited: the clip plays alongside, the turn and the dialog do not wait for it.
15function chime($: EngineInterface, asset: string): void {
16 if (isUnattended) return
17 $.audio.play({ asset }).catch(error => {
18 $.ui.log(`sounds: ${asset} did not play: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
19 })
20}
21
22function isFuseQuestion(e: unknown): boolean {
23 const questions = (e as { questions?: Array<{ header?: unknown }> }).questions ?? []
24 return questions.some(question => question.header === FUSE_HEADER)
25}
26
27export const register: Register = on => {
28 on('prompt.submit', async ($, e, next) => {
29 if (e.text.startsWith('<scheduled-task')) isUnattended = true
30 return next(e)
31 })
32
33 // The answer is written: the main loop's turn ended on its own, not by a stop
34 // the person pressed while watching. A subagent's turn is part of the work.
35 on('turn.complete', async ($, e, next) => {
36 const answer = await next(e)
37 if (e.agentId === undefined && e.reason !== 'aborted') chime($, DONE)
38 return answer
39 })
40
41 // The model asks a question or brings a plan to approve.
42 on('tool.call', async ($, e, next) => {
43 if (QUESTION_TOOLS.has(e.tool) && !isFuseQuestion(e)) chime($, ASK)
44 return next(e)
45 })
46
47 // A permission dialog. Questions and plans come through here too, and already rang above.
48 on('classic.PermissionRequest', async ($, e, next) => {
49 if (!QUESTION_TOOLS.has(e.tool_name)) chime($, ASK)
50 return next(e)
51 })
52}
53