SLOPSHOPPER

ambience

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…

newguardcommandstatustimeraudio
v0.1.1MITupdated 2026-10-26kadaliao/claude-mods/ambience
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ambience
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ambience ⎿ ambience: sounds on (volume normal), turn tally on, notification after 60s. Try /ambience demo. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ambience: 🏁 上轮 ✎ 3 文件 +0 −0 · ⚡ 4 命令 1✗ · 📖 2 · ⏱ 1s
README

ambience

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
SoundWhen
clickA command starts (Bash)
popEdit, Write or NotebookEdit changes a file
fanfareA test command passes (npm test, pytest, go test, cargo test, vitest, jest, ...)
sad tromboneA test command fails, an edit fails, another tool errors, or the turn ends in an error
two notes upgit commit succeeds
chimeThe 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.

Install

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.

The tally

PartWhat it counts
⚔ 本轮 / 🏁 上轮This turn, while it runs / the last one, once it ended
✎ 3 文件 +120 −30Files 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
📖 12Read, Grep and Glob calls
⏱ 1m05sHow 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).

Commands

CommandWhat it does
/ambienceShows the settings
/ambience off / onTurns the sounds off or on (mute / unmute work too)
/ambience report off / report onHides or shows the tally
/ambience demoPlays every sound once

Mute and the tally's switch are remembered across sessions.

Options

OptionValuesDefault
volumelow, normal, highnormal
notifyAfter30, 60, 120, 300 seconds, or off60

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.

Limits

  • Claude Code plays a mod's sounds with afplay, so they play on macOS only; the tally and the notification work everywhere.
  • Tool calls run in parallel can end close together; the same sound plays at most once in 120 ms (a click once in 300 ms), so a burst of edits pops once.
  • Mods (function hooks) are an early-access API of Claude Code and may change between releases. Built and tested on Claude Code 2.1.310.

Development

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

Source 2 files
hooks/register.tsx 267 lines
1import { 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}
267
types/index.d.ts 25 lines
1// 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