Sounds for what Claude does (commands, edits, tests, commits, a finished turn), a notification after a long turn, and a live tally of the turn's work under the…

English | 简体中文
Sounds for what Claude does, a notification when a long turn ends, and a live tally of the turn's work in the status line under the prompt:
⚔ 本轮 ✎ 3 文件 +120 −30 · ⚡ 8 命令 1✗ · 📖 12 · ⏱ 1m05s
| Sound | When |
|---|---|
| click | A command starts (Bash) |
| pop | Edit, Write or NotebookEdit changes a file |
| fanfare | A test command passes (npm test, pytest, go test, cargo test, vitest, jest, ...) |
| sad trombone | A test command fails, an edit fails, another tool errors, or the turn ends in an error |
| two notes up | git commit succeeds |
| chime | The turn ends with an answer |
The sounds are synthesized by scripts/make-sounds.py (Python standard library only) and ship in sounds/. Only the main conversation's calls make a sound; a subagent's are counted in the tally and kept quiet. A Bash command that exits non-zero (a grep with no match) is too common to sound.
At the prompt of a terminal session:
/plugin install ambience --marketplace kadaliao/claude-mods
Or from a shell:
claude plugin marketplace add kadaliao/claude-mods
claude plugin install ambience@kadaliao
Type /ambience demo to hear every sound once.
| Part | What it counts |
|---|---|
⚔ 本轮 / 🏁 上轮 | This turn, while it runs / the last one, once it ended |
✎ 3 文件 +120 −30 | Files changed by Edit, Write and NotebookEdit, and the lines their patches added and removed (a new file's lines count as added) |
⚡ 8 命令 1✗ | Bash commands run, and tool calls that ended in an error |
📖 12 | Read, Grep and Glob calls |
⏱ 1m05s | How long the turn has run, ticking every second |
A part with nothing to count is left out. It sits in the status line under the prompt, so it does not take the band above the prompt that statusline draws, and it leaves out what statusline already shows (tokens and cost).
| Command | What it does |
|---|---|
/ambience | Shows the settings |
/ambience off / on | Turns the sounds off or on (mute / unmute work too) |
/ambience report off / report on | Hides or shows the tally |
/ambience demo | Plays every sound once |
Mute and the tally's switch are remembered across sessions.
| Option | Values | Default |
|---|---|---|
volume | low, normal, high | normal |
notifyAfter | 30, 60, 120, 300 seconds, or off | 60 |
A turn that ran at least notifyAfter seconds sends a system notification when it ends with an answer: the tally and the answer's first 60 characters. It goes by your own notification channel (/config → notifications), so with notifications off nothing is sent. Set the options in /config, or in ~/.claude/settings.json under pluginConfigs.ambience.
afplay, so they play on macOS only; the tally and the notification work everywhere.python3 -I ~/workspace/claude-mods/ambience/scripts/make-sounds.py # rewrite sounds/
claude --plugin-dir ~/workspace/claude-mods/ambience
claude plugin validate ~/workspace/claude-mods/ambience
claude plugin test ~/workspace/claude-mods/ambience
License: MIT
hooks/register.tsx 267 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Prefs, Report } from '../types'
5
6// Sounds for what Claude does, a notification after a long turn, and a tally of
7// the turn's work in the status line under the prompt:
8//
9// ⚔ 本轮 ✎ 3 文件 +120 −30 · ⚡ 8 命令 1✗ · 📖 12 · ⏱ 1m05s
10// 🏁 上轮 ✎ 3 文件 +120 −30 · ⚡ 8 命令 1✗ · 📖 12 · ⏱ 2m13s
11//
12// Only the main loop's calls make a sound; a subagent's are counted and kept quiet.
13
14export type Sound = 'click' | 'pop' | 'chime' | 'fanfare' | 'fail' | 'commit'
15
16const GAIN: Record<string, number> = { low: 0.3, normal: 0.6, high: 1.0 }
17// The same sound plays at most once in this long (a click, once in a longer while), so
18// a burst of edits pops once; different sounds may play over each other.
19const MIN_GAP_MS = 120
20const MIN_CLICK_GAP_MS = 300
21const TICK_MS = 1000
22
23export const TEST_COMMAND =
24 /\b(pytest|jest|vitest|mocha|go test|cargo test|(npm|pnpm|yarn|bun) (run )?test|claude plugin test|make test)\b/
25export const COMMIT_COMMAND = /\bgit\b[^|;&]*\bcommit\b/
26const READ_TOOLS = new Set(['Read', 'Grep', 'Glob'])
27const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
28
29const report = atom({ plugin: 'ambience', key: 'report' } as const, null, { shape: 'report-1' })
30const prefs = atom({ plugin: 'ambience', key: 'prefs' } as const, { isMuted: false, isReportOn: true }, {
31 shape: 'prefs-1',
32})
33
34export const emptyReport = (startedAt: number): Report => ({
35 startedAt,
36 endedAt: null,
37 files: [],
38 added: 0,
39 removed: 0,
40 commands: 0,
41 reads: 0,
42 failed: 0,
43})
44
45// --- reading a tool's result ---------------------------------------------------
46
47// Lines added and removed by an Edit or Write, from the result's patch; a new file
48// written whole has no patch, so its lines count as added.
49export function countLines(result: unknown): { added: number; removed: number } {
50 const r = (result ?? {}) as {
51 structuredPatch?: Array<{ lines?: string[] }>
52 type?: string
53 content?: string
54 originalFile?: string | null
55 }
56 let added = 0
57 let removed = 0
58 for (const hunk of r.structuredPatch ?? []) {
59 for (const line of hunk.lines ?? []) {
60 if (line.startsWith('+')) added += 1
61 else if (line.startsWith('-')) removed += 1
62 }
63 }
64 if (added === 0 && removed === 0 && r.type === 'create' && typeof r.content === 'string') {
65 added = r.content === '' ? 0 : r.content.replace(/\n$/, '').split('\n').length
66 }
67 return { added, removed }
68}
69
70const filePathOf = (input: unknown): string | undefined => {
71 const i = input as { file_path?: unknown; notebook_path?: unknown }
72 const path = i.file_path ?? i.notebook_path
73 return typeof path === 'string' ? path : undefined
74}
75
76// --- the status line -----------------------------------------------------------
77
78export function fmtDuration(ms: number): string {
79 const s = Math.max(0, Math.round(ms / 1000))
80 if (s < 60) return `${s}s`
81 const m = Math.floor(s / 60)
82 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
83 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
84}
85
86// The tally without its lead: what the status line and the notification share.
87export function summarize(r: Report, now: number): string {
88 const parts: string[] = []
89 if (r.files.length > 0) parts.push(`✎ ${r.files.length} 文件 +${r.added} −${r.removed}`)
90 if (r.commands > 0) parts.push(`⚡ ${r.commands} 命令${r.failed > 0 ? ` ${r.failed}✗` : ''}`)
91 else if (r.failed > 0) parts.push(`${r.failed}✗`)
92 if (r.reads > 0) parts.push(`📖 ${r.reads}`)
93 parts.push(`⏱ ${fmtDuration((r.endedAt ?? now) - r.startedAt)}`)
94 return parts.join(' · ')
95}
96
97export function formatReport(r: Report, now: number): string {
98 return `${r.endedAt === null ? '⚔ 本轮' : '🏁 上轮'} ${summarize(r, now)}`
99}
100
101// --- sounds and the status line -------------------------------------------------
102
103// Set by `register` from the options; module variables, so a reload starts them over.
104let gain = GAIN.normal
105let notifyAfterMs = 60_000
106let ticker: Timer | undefined
107const lastPlayedAt = new Map<Sound, number>()
108
109async function play($: EngineInterface, sound: Sound): Promise<void> {
110 if ((await read($, prefs)).isMuted) return
111 const now = await $.clock.now()
112 const gap = sound === 'click' ? MIN_CLICK_GAP_MS : MIN_GAP_MS
113 if (now - (lastPlayedAt.get(sound) ?? -Infinity) < gap) return
114 lastPlayedAt.set(sound, now)
115 await $.audio.play({ asset: `sounds/${sound}.wav` }, { gain })
116}
117
118function playSoon($: EngineInterface, sound: Sound): void {
119 void play($, sound).catch(() => {})
120}
121
122async function showStatus($: EngineInterface): Promise<void> {
123 const [r, p] = await Promise.all([read($, report), read($, prefs)])
124 $.ui.status(p.isReportOn && r !== null ? formatReport(r, await $.clock.now()) : undefined)
125}
126
127function showSoon($: EngineInterface): void {
128 void showStatus($).catch(() => {})
129}
130
131async function tally($: EngineInterface, fn: (r: Report) => Report): Promise<void> {
132 const now = await $.clock.now()
133 await update($, report, r => fn(r ?? emptyReport(now)))
134 showSoon($)
135}
136
137function startTicker($: EngineInterface): void {
138 ticker?.cancel()
139 ticker = $.clock.every(TICK_MS, () => showSoon($))
140}
141
142function stopTicker(): void {
143 ticker?.cancel()
144 ticker = undefined
145}
146
147// --- hooks ----------------------------------------------------------------------
148
149export const register: Register = (on, options) => {
150 gain = GAIN[String(options.volume)] ?? GAIN.normal
151 notifyAfterMs = options.notifyAfter === 'off' ? Infinity : (Number(options.notifyAfter) || 60) * 1000
152
153 on('session.start', async ($, e, next) => {
154 const started = await next(e)
155 await $.command.register({
156 name: 'ambience',
157 description: 'Sounds and the turn tally: on, off, report on|off, demo',
158 argumentHint: '[on|off|report on|report off|demo]',
159 })
160 const saved = (await $.store.get('prefs')) as Partial<Prefs> | undefined
161 if (saved !== undefined) await update($, prefs, p => ({ ...p, ...saved }))
162 showSoon($)
163 return started
164 })
165
166 on('command.run', { command: 'ambience' }, async ($, e) => {
167 const arg = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
168 if (arg === 'demo') {
169 for (const sound of ['click', 'pop', 'commit', 'fanfare', 'fail', 'chime'] as const) {
170 await $.audio.play({ asset: `sounds/${sound}.wav` }, { gain }).catch(() => {})
171 await $.clock.sleep(250)
172 }
173 return { text: 'ambience: click · pop · commit · fanfare · fail · chime' }
174 }
175 const change: Partial<Prefs> =
176 arg === 'off' || arg === 'mute' || arg === 'sound off'
177 ? { isMuted: true }
178 : arg === 'on' || arg === 'unmute' || arg === 'sound on'
179 ? { isMuted: false }
180 : arg === 'report on'
181 ? { isReportOn: true }
182 : arg === 'report off'
183 ? { isReportOn: false }
184 : {}
185 const now = (await update($, prefs, p => ({ ...p, ...change }))) ?? (await read($, prefs))
186 await $.store.set('prefs', now)
187 showSoon($)
188 const isKnown = arg === '' || Object.keys(change).length > 0
189 return {
190 text:
191 `${isKnown ? '' : `ambience: unknown "${arg}". `}` +
192 `sounds ${now.isMuted ? 'muted' : 'on'} (volume ${String(options.volume ?? 'normal')}), ` +
193 `turn tally ${now.isReportOn ? 'on' : 'off'}, ` +
194 `notification ${Number.isFinite(notifyAfterMs) ? `after ${notifyAfterMs / 1000}s` : 'off'}. ` +
195 'Try /ambience demo.',
196 }
197 })
198
199 // Each turn of the main loop starts a new tally.
200 on('turn.start', async ($, e, next) => {
201 if (e.agentId !== undefined) return next(e)
202 const now = await $.clock.now()
203 await update($, report, () => emptyReport(now))
204 startTicker($)
205 showSoon($)
206 return next(e)
207 })
208
209 on('tool.call', async ($, e, next) => {
210 const isMain = e.agentId === undefined
211 const tool = String(e.tool)
212 const command = e.tool === 'Bash' ? e.command : ''
213 if (isMain && tool === 'Bash') playSoon($, 'click')
214
215 const ran = await next(e)
216 if (ran.deny !== undefined) return ran
217 const isError = ran.isError === true
218
219 void tally($, r => {
220 const t = { ...r }
221 if (isError) t.failed += 1
222 if (tool === 'Bash') t.commands += 1
223 else if (READ_TOOLS.has(tool)) t.reads += 1
224 else if (EDIT_TOOLS.has(tool) && !isError) {
225 const path = filePathOf(e)
226 if (path !== undefined && !t.files.includes(path)) t.files = [...t.files, path]
227 const { added, removed } = countLines(ran.result)
228 t.added += added
229 t.removed += removed
230 }
231 return t
232 }).catch(() => {})
233
234 if (isMain) {
235 if (tool === 'Bash' && TEST_COMMAND.test(command)) playSoon($, isError ? 'fail' : 'fanfare')
236 else if (tool === 'Bash' && COMMIT_COMMAND.test(command) && !isError) playSoon($, 'commit')
237 else if (EDIT_TOOLS.has(tool)) playSoon($, isError ? 'fail' : 'pop')
238 // A Bash command that exits non-zero (a grep with no match) is too common to sound.
239 else if (isError && tool !== 'Bash') playSoon($, 'fail')
240 }
241 return ran
242 }).catch(($, e, next) => next(e))
243
244 on('turn.complete', async ($, e, next) => {
245 const done = await next(e)
246 if (e.agentId !== undefined) return done
247 stopTicker()
248
249 const now = await $.clock.now()
250 const ended = await update($, report, r => ({ ...(r ?? emptyReport(now - e.durationMs)), endedAt: now }))
251 showSoon($)
252
253 if (e.reason === 'answer') {
254 playSoon($, 'chime')
255 if (e.durationMs >= notifyAfterMs && ended !== null) {
256 const gist = e.answer.replace(/\s+/g, ' ').trim().slice(0, 60)
257 void $.ui
258 .notify(`${summarize(ended, now)}${gist ? `\n${gist}` : ''}`, { title: 'Claude 干完了' })
259 .catch(() => {})
260 }
261 } else if (e.reason === 'error' || e.reason === 'refusal') {
262 playSoon($, 'fail')
263 }
264 return done
265 })
266}
267types/index.d.ts 25 lines1// What one turn of the main loop did, tallied as its tool calls end.
2export type Report = {
3 startedAt: number
4 // Set when the turn ended; null while it runs.
5 endedAt: number | null
6 // Files Edit, Write or NotebookEdit changed, each once.
7 files: string[]
8 added: number
9 removed: number
10 // Bash calls.
11 commands: number
12 // Read, Grep and Glob calls.
13 reads: number
14 // Tool calls that ended in an error.
15 failed: number
16}
17
18export type Prefs = { isMuted: boolean; isReportOn: boolean }
19
20declare module 'claude-code' {
21 interface PluginState {
22 ambience: { report: Shaped<Report | null>; prefs: Shaped<Prefs> }
23 }
24}
25