SLOPSHOPPER

jinglebox

Plays sounds from your Jinglebox workspace on Claude Code events: answer waiting, question waiting, long turn, tests passed or failed, merge request created

newguardcommandtoastaudio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jinglebox
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ jinglebox │ ⏺ Read(src/auth.ts) │ 🎵 Jinglebox: no sound (TypeError: │ ⎿ Read 6 lines │ undefined is not an object (evaluating │ ⏺ Update(src/auth.ts) │ 'blocks')) │ ⎿ 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 › /jinglebox ⎿ jinglebox: Jinglebox: TypeError: undefined is not an object (evaluating 'blocks') ⎿ jinglebox: ⎿ jinglebox: Usage: ⎿ jinglebox: /jinglebox — current sounds ⎿ jinglebox: /jinglebox workspace [slug] — list or pick the workspace ⎿ jinglebox: /jinglebox set <event> <329 | #329 | sound URL> — a fixed sound ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Jinglebox for Claude Code

A Claude Code plugin that plays sounds from your Jinglebox workspace when something happens in a session.

EventWhen it plays
turn-doneClaude finished and its answer waits for you
needs-answerClaude waits on you: a question, a plan to approve, a permission prompt, or an answer that ends on a question
long-turna turn longer than 60 s finished (instead of turn-done)
tests-passed / tests-faileda test command run by Claude passed or failed (artisan test, pest, phpunit, pytest, jest, vitest, go test, cargo test, npm test…)
pr-createdglab mr create or gh pr create succeeded

Out of the box every event plays a random short sound of your workspace; set your own with /jinglebox set.

The plugin ships no audio. Each machine downloads its sounds through its own Jinglebox MCP connection, with its own account, and caches them locally (40 sounds at most).

Install

  1. Connect the Jinglebox MCP server (once per machine): ``bash claude mcp add --transport http --scope user jinglebox https://jinglebox.app/mcp ` then run /mcp in an interactive claude terminal, pick jinglebox` and authenticate.
  1. Allow its read-only tools, so the plugin's calls are not refused in auto mode. In ~/.claude/settings.json: ``json "permissions": { "allow": [ "mcp__jinglebox__get-sound", "mcp__jinglebox__search-sounds", "mcp__jinglebox__get-workspace", "mcp__jinglebox__list-workspaces" ] } ``
  1. Install the plugin: ``bash claude plugin marketplace add lamberttraccard/jinglebox-marketplace claude plugin install jinglebox@jinglebox ` Or try it without installing: claude --plugin-dir ./plugins/jinglebox`.
  1. Start a new session. If your account has several workspaces, pick one with /jinglebox workspace <slug>.

Commands

/jinglebox                                       current sounds
/jinglebox workspace [slug]                      list or pick the workspace
/jinglebox set <event> 329 | #329 | <sound URL>  a fixed sound
/jinglebox set <event> 21,349,336                random among these
/jinglebox set <event> random [tag]              random short sound of the workspace, or of one tag
/jinglebox set <event> default                   back to the default (random from the workspace)
/jinglebox search <words>                        find sounds and their IDs
/jinglebox test <event>                          play an event's sound now
/jinglebox mute | unmute
/jinglebox export                                your setup as one /jinglebox import line
/jinglebox import <workspace> <event>=<choice>…  apply a shared setup
/jinglebox debug                                 check the MCP call, the playback and the cache

Choices are kept per machine and per workspace, across sessions. A random pick never plays the same sound twice in a row, and the next sound is downloaded ahead so it starts at once. The toast names each sound played, so a random one you like can be pinned with /jinglebox set.

Share a setup

/jinglebox export prints your setup as a single line, for instance:

/jinglebox import my-team turn-done=random:fun needs-answer=177 long-turn=339 tests-passed=325 tests-failed=329 pr-created=344

Paste it wherever your team talks; whoever runs it gets the same workspace and sounds, then can change any of them with /jinglebox set. A choice is a sound ID (177), a list to pick from (21,349,336), random, or random:<tag>. Nothing is hosted: the line is the setup.

Until a workspace is picked (or imported) on an account with several, no sound plays; the plugin says so in a toast, at most once every 10 minutes, like any other failure.

Settings

SettingDefaultMeaning
workspaceemptythe workspace slug; empty to pick it with /jinglebox workspace
mcp_serverjingleboxthe Jinglebox MCP server's name, as /mcp lists it
long_turn_seconds60a turn longer than this plays long-turn
random_max_seconds4a random pick only takes sounds this short

How it works

It is a plugin of Claude Code function hooks (plugins/jinglebox/hooks/register.ts), an early-access API: it needs a Claude Code build that supports it.

  • turn.complete, at the end of a main-loop turn: needs-answer when the answer's last paragraph asks a question, else long-turn or turn-done.
  • classic.PermissionRequest, and tool.call on AskUserQuestion and ExitPlanMode: needs-answer, at most once every 3 s.
  • tool.call on Bash: tests-passed, tests-failed, pr-created.
  • The plugin declares the Jinglebox MCP server in its manifest and connects it with $.mcp.connect before its first call, so it works even when the session has not loaded the server's tools yet; when the session already runs the same server (step 1), that connection is reused.
  • Sounds come from the MCP's get-sound, are cached in the plugin's store and played with $.audio.play. Random picks draw from search-sounds, refreshed once a day.

Troubleshooting

/jinglebox debug runs each step and says which one fails.

  • "MCP server is not connected": the plugin could not connect the Jinglebox MCP server (network, or the server timed out at startup). Run /mcp, reconnect jinglebox, and try again.
  • "call refused": the permissions of step 2 are missing.

License

MIT, see LICENSE.

Source 1 files
hooks/register.ts 609 lines
1import type { Engine, Register } from 'claude-code'
2
3type Jingle = 'turn-done' | 'needs-answer' | 'long-turn' | 'tests-passed' | 'tests-failed' | 'pr-created'
4
5type CachedAudio = { data: string; mime: string; title: string }
6
7// A fixed sound, a pick among chosen sounds, or a random sound of the workspace (or of one tag).
8type Choice = number | readonly number[] | { random: true; tag?: string }
9
10type PoolSound = { id: number; duration_ms: number }
11
12type Pool = { fetchedAt: number; ids: readonly number[] }
13
14type Workspace = { slug: string; name: string }
15
16type Settings = { workspace: string; server: string; randomMaxMs: number }
17
18type Blocks = ReadonlyArray<Record<string, unknown>>
19
20type SoundMetadata = { title?: string; play_url?: string }
21
22const DESCRIPTIONS: Record<Jingle, string> = {
23  'turn-done': 'Answer waiting',
24  'needs-answer': 'Question waiting',
25  'long-turn': 'Long turn done',
26  'tests-passed': 'Tests passed',
27  'tests-failed': 'Tests failed',
28  'pr-created': 'Merge request created',
29}
30
31const JINGLES = Object.keys(DESCRIPTIONS) as Jingle[]
32const DEFAULT_CHOICE: Choice = { random: true }
33
34const TEST_COMMAND = /\b(artisan\s+test|pest|phpunit|pytest|jest|vitest|go\s+test|cargo\s+test|(npm|pnpm|yarn|bun)\s+(run\s+)?test)\b/
35const PR_CREATE_COMMAND = /\b(glab\s+mr\s+create|gh\s+pr\s+create)\b/
36const QUESTION_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
37const NEEDS_ANSWER_DEBOUNCE_MS = 3000
38const QUESTION_TAIL_CHARS = 400
39const POOL_TTL_MS = 24 * 60 * 60 * 1000
40const CACHE_LIMIT = 40
41const SAVED_BLOB = /\(([^,()]+),[^)]*\) saved to (\S+?\.bin)/
42
43const READ_TOOLS = ['get-sound', 'search-sounds', 'get-workspace', 'list-workspaces']
44
45// The server this plugin's manifest declares; $.mcp.connect dials it, or answers the name the session already runs it under.
46const MANIFEST_SERVER = 'jinglebox'
47
48let settings: Settings = { workspace: '', server: 'jinglebox', randomMaxMs: 4000 }
49let connectedServer: string | undefined
50const ERROR_TOAST_EVERY_MS = 10 * 60 * 1000
51const toastedErrors: Record<string, number> = {}
52let lastNeedsAnswerAt = 0
53const lastPlayed: Partial<Record<Jingle, number>> = {}
54const upcoming: Partial<Record<Jingle, number>> = {}
55
56const permissionHint = (): string =>
57  `Add ${READ_TOOLS.map(tool => `"mcp__${settings.server}__${tool}"`).join(', ')} to permissions.allow in ~/.claude/settings.json`
58
59const explain = (error: unknown): string => {
60  const text = String(error)
61  if (/no connected MCP/i.test(text)) {
62    return `the "${settings.server}" MCP server is not connected: run /mcp, reconnect it, and try again`
63  }
64  if (/classifier|refused|denied/i.test(text)) {
65    return `call refused. ${permissionHint()}`
66  }
67
68  return text
69}
70
71// The last paragraph of an answer asking something of the person.
72const endsWithQuestion = (answer: string): boolean => {
73  const paragraphs = answer.trim().split(/\n\s*\n/)
74  const last = paragraphs[paragraphs.length - 1] ?? ''
75
76  return last.slice(-QUESTION_TAIL_CHARS).includes('?')
77}
78
79// 329, #329, or a Jinglebox URL ending in /sounds/329.
80const parseSoundId = (value: string): number | undefined => {
81  const match = /^#?(\d+)$/.exec(value) ?? /\/sounds\/(\d+)\/?(?:[?#].*)?$/.exec(value)
82
83  return match === null ? undefined : Number(match[1])
84}
85
86const isJingle = (value: string): value is Jingle => value in DESCRIPTIONS
87
88function jsonFrom(blocks: Blocks): Record<string, unknown> | undefined {
89  for (const block of blocks) {
90    if (block.type !== 'text') {
91      continue
92    }
93    const text = String(block.text)
94    try {
95      return JSON.parse(text.slice(text.indexOf('{'))) as Record<string, unknown>
96    } catch {
97      continue
98    }
99  }
100
101  return undefined
102}
103
104// The session's name for the Jinglebox server, connected on first use (tools may be deferred until then).
105async function serverName($: Engine): Promise<string> {
106  if (connectedServer !== undefined) {
107    return connectedServer
108  }
109  const connection = await $.mcp.connect(MANIFEST_SERVER).catch(() => undefined)
110  if (connection?.isConnected === true) {
111    connectedServer = connection.server
112
113    return connection.server
114  }
115
116  return settings.server
117}
118
119async function callJinglebox($: Engine, tool: string, args: Record<string, unknown>): Promise<Blocks> {
120  const server = await serverName($)
121  const result = await $.mcp.call(server, tool, args).catch(error => {
122    connectedServer = undefined
123    throw error
124  })
125  if (result.isError) {
126    throw new Error(`Jinglebox ${tool} failed`)
127  }
128
129  return result.content as Blocks
130}
131
132async function listWorkspaces($: Engine): Promise<readonly Workspace[]> {
133  const json = jsonFrom(await callJinglebox($, 'list-workspaces', {}))
134
135  return (json?.workspaces ?? []) as readonly Workspace[]
136}
137
138// The configured workspace, the one picked with /jinglebox workspace, or the only one there is.
139async function workspaceOf($: Engine): Promise<string> {
140  if (settings.workspace !== '') {
141    return settings.workspace
142  }
143  const picked = (await $.store.get('workspace')) as string | undefined
144  if (picked !== undefined) {
145    return picked
146  }
147  const workspaces = await listWorkspaces($)
148  const [only] = workspaces
149  if (workspaces.length !== 1 || only === undefined) {
150    throw new Error('pick a workspace with /jinglebox workspace <slug>, or paste a /jinglebox import line')
151  }
152  await $.store.set('workspace', only.slug)
153
154  return only.slug
155}
156
157const cacheKey = (workspace: string, soundId: number): string => `audio:${workspace}:${soundId}`
158
159async function choiceFor($: Engine, jingle: Jingle): Promise<Choice> {
160  const workspace = await workspaceOf($)
161  const choices = ((await $.store.get(`sounds:${workspace}`)) ?? {}) as Partial<Record<Jingle, Choice>>
162
163  return choices[jingle] ?? DEFAULT_CHOICE
164}
165
166async function searchSounds($: Engine, workspace: string, tags: readonly string[], query?: string): Promise<readonly (PoolSound & { title: string })[]> {
167  const json = jsonFrom(
168    await callJinglebox($, 'search-sounds', { workspace_slug: workspace, status: 'ready', tags, query: query ?? null, limit: 50 }),
169  )
170
171  return (json?.sounds ?? []) as readonly (PoolSound & { title: string })[]
172}
173
174async function workspaceTags($: Engine, workspace: string): Promise<readonly string[]> {
175  const json = jsonFrom(await callJinglebox($, 'get-workspace', { workspace_slug: workspace }))
176
177  return (json?.tags ?? []) as readonly string[]
178}
179
180// The short sounds of the workspace or of one tag, refreshed once a day.
181async function poolFor($: Engine, workspace: string, tag?: string): Promise<readonly number[]> {
182  const key = `pool:${workspace}:${tag ?? '*'}:${settings.randomMaxMs}`
183  const now = await $.clock.now()
184  const stored = (await $.store.get(key)) as Pool | undefined
185  if (stored !== undefined && now - stored.fetchedAt < POOL_TTL_MS && stored.ids.length > 0) {
186    return stored.ids
187  }
188  // A search answers 50 sounds at most: the union over every tag reaches the rest.
189  const groups = tag === undefined
190    ? [await searchSounds($, workspace, []), ...(await Promise.all((await workspaceTags($, workspace)).map(one => searchSounds($, workspace, [one]))))]
191    : [await searchSounds($, workspace, [tag])]
192  const ids = [...new Set(groups.flat().filter(sound => sound.duration_ms <= settings.randomMaxMs).map(sound => sound.id))]
193  await $.store.set(key, { fetchedAt: now, ids })
194
195  return ids
196}
197
198async function candidatesFor($: Engine, workspace: string, choice: Choice): Promise<readonly number[]> {
199  if (typeof choice === 'number') {
200    return [choice]
201  }
202  if (Array.isArray(choice)) {
203    return choice
204  }
205
206  return poolFor($, workspace, (choice as { tag?: string }).tag)
207}
208
209async function pickSound($: Engine, jingle: Jingle): Promise<number> {
210  const workspace = await workspaceOf($)
211  const candidates = await candidatesFor($, workspace, await choiceFor($, jingle))
212  const fresh = candidates.filter(id => id !== lastPlayed[jingle])
213  const among = fresh.length > 0 ? fresh : candidates
214  const picked = among[Math.floor(Math.random() * among.length)]
215  if (picked === undefined) {
216    throw new Error('no sound to pick from')
217  }
218
219  return picked
220}
221
222async function audioFrom($: Engine, blocks: Blocks, metadata: SoundMetadata): Promise<{ data: string; mime: string }> {
223  const inline = blocks.find(block => block.type === 'audio')
224  if (inline !== undefined) {
225    return { data: String(inline.data), mime: String(inline.mimeType ?? 'audio/mp4') }
226  }
227  // Claude Code saves a binary MCP block to a file and hands over its path.
228  const saved = blocks
229    .filter(block => block.type === 'text')
230    .map(block => SAVED_BLOB.exec(String(block.text)))
231    .find(match => match !== null)
232  if (saved !== undefined && saved !== null) {
233    const { base64 } = await $.fs.read(saved[2], { as: 'bytes' })
234
235    return { data: base64, mime: saved[1].trim() }
236  }
237  if (metadata.play_url !== undefined) {
238    return { data: '', mime: metadata.play_url }
239  }
240  throw new Error('no audio in the Jinglebox answer')
241}
242
243async function remember($: Engine, workspace: string, soundId: number): Promise<void> {
244  const order = ((await $.store.get('cache-order')) ?? []) as readonly string[]
245  const key = cacheKey(workspace, soundId)
246  const kept = [...order.filter(one => one !== key), key]
247  const evicted = kept.slice(0, Math.max(0, kept.length - CACHE_LIMIT))
248  await Promise.all(evicted.map(one => $.store.delete(one)))
249  await $.store.set('cache-order', kept.slice(evicted.length))
250}
251
252async function download($: Engine, soundId: number): Promise<CachedAudio> {
253  const workspace = await workspaceOf($)
254  const cached = (await $.store.get(cacheKey(workspace, soundId))) as CachedAudio | undefined
255  if (cached !== undefined) {
256    return cached
257  }
258  const blocks = await callJinglebox($, 'get-sound', { workspace_slug: workspace, sound_id: soundId, include_audio: true })
259  const metadata = (jsonFrom(blocks)?.sound ?? {}) as SoundMetadata
260  const audio = await audioFrom($, blocks, metadata)
261  const title = metadata.title ?? `#${soundId}`
262  if (audio.data === '') {
263    // Only a signed, expiring URL: play it without caching.
264    return { data: '', mime: audio.mime, title }
265  }
266  const fetched: CachedAudio = { data: audio.data, mime: audio.mime, title }
267  await $.store.set(cacheKey(workspace, soundId), fetched)
268  await remember($, workspace, soundId)
269
270  return fetched
271}
272
273async function playAudio($: Engine, audio: CachedAudio): Promise<void> {
274  if (audio.data === '') {
275    await $.audio.play({ url: audio.mime })
276
277    return
278  }
279  await $.audio.play({ base64: audio.data, mime: audio.mime })
280}
281
282// Picks and downloads the next sound ahead, so the next play starts at once.
283async function prepare($: Engine, jingle: Jingle): Promise<void> {
284  const soundId = await pickSound($, jingle)
285  await download($, soundId)
286  upcoming[jingle] = soundId
287}
288
289// The same failure toasts once every 10 minutes, not at every event.
290async function toastError($: Engine, message: string): Promise<void> {
291  const now = await $.clock.now()
292  const last = toastedErrors[message]
293  if (last !== undefined && now - last < ERROR_TOAST_EVERY_MS) {
294    return
295  }
296  toastedErrors[message] = now
297  $.ui.toast(`🎵 Jinglebox: no sound (${message})`)
298}
299
300// The inverse of parseChoice: 177, 21,349,336, random, random:tag.
301function formatChoice(choice: Choice): string {
302  if (typeof choice === 'number') {
303    return String(choice)
304  }
305  if (Array.isArray(choice)) {
306    return choice.join(',')
307  }
308  const { tag } = choice as { tag?: string }
309
310  return tag === undefined ? 'random' : `random:${tag}`
311}
312
313async function exportSetup($: Engine): Promise<string> {
314  const workspace = await workspaceOf($)
315  const pairs = await Promise.all(JINGLES.map(async jingle => `${jingle}=${formatChoice(await choiceFor($, jingle))}`))
316
317  return `/jinglebox import ${workspace} ${pairs.join(' ')}`
318}
319
320// /jinglebox import <workspace> <event>=<choice>...: a whole setup in one line.
321async function importSetup($: Engine, values: readonly string[]): Promise<string> {
322  const [workspace = '', ...pairs] = values
323  const workspaces = await listWorkspaces($)
324  if (!workspaces.some(one => one.slug === workspace)) {
325    return `You have no access to the workspace "${workspace}". Workspaces: ${workspaces.map(one => one.slug).join(', ')}`
326  }
327  const choices: Partial<Record<Jingle, Choice>> = {}
328  for (const pair of pairs) {
329    const [event = '', value = ''] = pair.split('=')
330    const choice = parseChoice(value.startsWith('random:') ? ['random', value.slice('random:'.length)] : [value])
331    if (!isJingle(event) || choice === undefined || choice === 'default') {
332      return `Cannot read "${pair}": expected <event>=<329 | 21,349,336 | random | random:tag>.`
333    }
334    choices[event] = choice
335  }
336  await $.store.set('workspace', workspace)
337  await $.store.set(`sounds:${workspace}`, choices)
338  for (const jingle of JINGLES) {
339    delete upcoming[jingle]
340  }
341  void prefetch($)
342
343  return `Imported: workspace ${workspace}.\n${await describeMapping($, workspace)}`
344}
345
346async function play($: Engine, jingle: Jingle): Promise<void> {
347  if ((await $.store.get('muted')) === true) {
348    return
349  }
350  let audio: CachedAudio
351  let soundId: number
352  try {
353    soundId = upcoming[jingle] ?? (await pickSound($, jingle))
354    delete upcoming[jingle]
355    lastPlayed[jingle] = soundId
356    audio = await download($, soundId)
357  } catch (error) {
358    await toastError($, explain(error))
359
360    return
361  }
362  $.ui.toast(`🎵 ${DESCRIPTIONS[jingle]}: « ${audio.title} » (#${soundId})`)
363  void prepare($, jingle).catch(() => undefined)
364  await playAudio($, audio).catch(error => {
365    $.ui.toast(`🎵 cannot play: ${String(error)}`)
366  })
367}
368
369async function needsAnswer($: Engine): Promise<void> {
370  const now = await $.clock.now()
371  if (now - lastNeedsAnswerAt < NEEDS_ANSWER_DEBOUNCE_MS) {
372    return
373  }
374  lastNeedsAnswerAt = now
375  await play($, 'needs-answer')
376}
377
378async function prefetch($: Engine): Promise<void> {
379  for (const jingle of JINGLES) {
380    await prepare($, jingle).catch(() => undefined)
381  }
382}
383
384async function diagnose($: Engine): Promise<string> {
385  const lines: string[] = [`server=${await serverName($)}`]
386  try {
387    const workspace = await workspaceOf($)
388    lines.push(`workspace=${workspace}`)
389    const soundId = await pickSound($, 'turn-done')
390    await $.store.delete(cacheKey(workspace, soundId))
391    const audio = await download($, soundId)
392    lines.push(`download ok: #${soundId} « ${audio.title} » ${audio.data === '' ? 'url only' : `${audio.mime}, ${audio.data.length} base64 chars`}`)
393    await playAudio($, audio)
394    lines.push('audio.play ok')
395  } catch (error) {
396    lines.push(`failed: ${explain(error)}`)
397  }
398  lines.push(`stored keys: ${(await $.store.keys()).length}`)
399
400  return lines.join('\n')
401}
402
403async function describeSound($: Engine, workspace: string, soundId: number): Promise<string> {
404  const cached = (await $.store.get(cacheKey(workspace, soundId))) as CachedAudio | undefined
405
406  return `#${soundId}${cached === undefined ? '' : ` « ${cached.title} »`}`
407}
408
409async function describeChoice($: Engine, workspace: string, choice: Choice): Promise<string> {
410  if (typeof choice === 'number') {
411    return describeSound($, workspace, choice)
412  }
413  if (Array.isArray(choice)) {
414    return `random among ${(await Promise.all(choice.map(id => describeSound($, workspace, id)))).join(', ')}`
415  }
416  const { tag } = choice as { tag?: string }
417
418  return `random from ${tag === undefined ? 'the whole workspace' : `tag ${tag}`} (sounds ≤ ${settings.randomMaxMs / 1000} s)`
419}
420
421// default | random [tag] | 329 | #329 | URL | 21,349,336 | 21 349 336
422function parseChoice(values: readonly string[]): Choice | 'default' | undefined {
423  const [first = ''] = values
424  if (first === 'default') {
425    return 'default'
426  }
427  if (first === 'random') {
428    return values[1] === undefined ? { random: true } : { random: true, tag: values[1] }
429  }
430  const ids = values.flatMap(value => value.split(',')).filter(token => token !== '').map(parseSoundId)
431  if (ids.length === 0 || ids.some(id => id === undefined)) {
432    return undefined
433  }
434  const unique = [...new Set(ids as number[])]
435
436  return unique.length === 1 ? (unique[0] as number) : unique
437}
438
439async function describeMapping($: Engine, workspace: string): Promise<string> {
440  const rows = await Promise.all(
441    JINGLES.map(async jingle => `- ${jingle} (${DESCRIPTIONS[jingle]}): ${await describeChoice($, workspace, await choiceFor($, jingle))}`),
442  )
443
444  return rows.join('\n')
445}
446
447async function search($: Engine, query: string): Promise<string> {
448  const sounds = await searchSounds($, await workspaceOf($), [], query)
449
450  return sounds.map(sound => `- #${sound.id} « ${sound.title} » (${(sound.duration_ms / 1000).toFixed(1)} s)`).join('\n') || 'No sound found.'
451}
452
453async function chooseWorkspace($: Engine, slug: string): Promise<string> {
454  const workspaces = await listWorkspaces($)
455  const listing = workspaces.map(workspace => `- ${workspace.slug} (${workspace.name})`).join('\n')
456  if (slug === '') {
457    return `Workspaces:\n${listing}\n\nPick one with /jinglebox workspace <slug>.`
458  }
459  if (!workspaces.some(workspace => workspace.slug === slug)) {
460    return `Unknown workspace "${slug}". Workspaces:\n${listing}`
461  }
462  await $.store.set('workspace', slug)
463  for (const jingle of JINGLES) {
464    delete upcoming[jingle]
465  }
466  void prefetch($)
467
468  return `Workspace set to ${slug}.`
469}
470
471const USAGE = [
472  'Usage:',
473  '/jinglebox — current sounds',
474  '/jinglebox workspace [slug] — list or pick the workspace',
475  '/jinglebox set <event> <329 | #329 | sound URL> — a fixed sound',
476  '/jinglebox set <event> 21,349,336 — random among these',
477  '/jinglebox set <event> random [tag] — random short sound of the workspace or of a tag',
478  '/jinglebox set <event> default — back to the default (random)',
479  '/jinglebox search <words>',
480  '/jinglebox test <event>',
481  '/jinglebox mute | unmute',
482  '/jinglebox export — your setup as one /jinglebox import line to share',
483  '/jinglebox import <workspace> <event>=<choice>... — apply a shared setup',
484  '/jinglebox debug',
485  `Events: ${JINGLES.join(', ')}`,
486].join('\n')
487
488export const register: Register = (on, options) => {
489  const longTurnMs = Number(options.long_turn_seconds ?? 60) * 1000
490  settings = {
491    workspace: String(options.workspace ?? ''),
492    server: String(options.mcp_server ?? 'jinglebox'),
493    randomMaxMs: Number(options.random_max_seconds ?? 4) * 1000,
494  }
495
496  on('session.start', async ($, e, next) => {
497    await $.command.register({
498      name: 'jinglebox',
499      description: 'Jinglebox sounds: workspace, set, search, test, mute, unmute, debug',
500    })
501    void prefetch($)
502
503    return next(e)
504  })
505
506  on('command.run', { command: 'jinglebox' }, async ($, e) => {
507    const [action = '', ...rest] = e.args.trim().split(/\s+/)
508    const [target = ''] = rest
509
510    try {
511      if (action === 'mute' || action === 'unmute') {
512        await $.store.set('muted', action === 'mute')
513
514        return { text: action === 'mute' ? 'Jinglebox muted.' : 'Jinglebox unmuted.' }
515      }
516      if (action === 'debug') {
517        return { text: await diagnose($) }
518      }
519      if (action === 'export') {
520        return { text: `Share this line; pasting it applies your setup:\n\n${await exportSetup($)}` }
521      }
522      if (action === 'import') {
523        return { text: await importSetup($, rest) }
524      }
525      if (action === 'workspace') {
526        return { text: await chooseWorkspace($, target) }
527      }
528      if (action === 'test' && isJingle(target)) {
529        void play($, target)
530
531        return { text: `Playing ${target}.` }
532      }
533      if (action === 'search' && rest.length > 0) {
534        return { text: await search($, rest.join(' ')) }
535      }
536      if (action === 'set') {
537        if (!isJingle(target)) {
538          return { text: `Unknown event "${target}". Events: ${JINGLES.join(', ')}` }
539        }
540        const choice = parseChoice(rest.slice(1))
541        if (choice === undefined) {
542          return { text: `Cannot read "${rest.slice(1).join(' ')}": give 329, #329, a sound URL, 21,349,336, random [tag], or default.` }
543        }
544        const workspace = await workspaceOf($)
545        const choices = ((await $.store.get(`sounds:${workspace}`)) ?? {}) as Partial<Record<Jingle, Choice>>
546        const { [target]: _previous, ...others } = choices
547        const updated = choice === 'default' ? others : { ...others, [target]: choice }
548        await $.store.set(`sounds:${workspace}`, updated)
549        delete upcoming[target]
550        void play($, target)
551
552        return { text: `${target} → ${await describeChoice($, workspace, updated[target] ?? DEFAULT_CHOICE)}` }
553      }
554      const workspace = await workspaceOf($)
555      const muted = (await $.store.get('muted')) === true
556
557      return { text: `Jinglebox is ${muted ? 'muted' : 'on'} (workspace ${workspace}).\n${await describeMapping($, workspace)}\n\n${USAGE}` }
558    } catch (error) {
559      return { text: `Jinglebox: ${explain(error)}\n\n${USAGE}` }
560    }
561  })
562
563  // A permission prompt waits for the person.
564  on('classic.PermissionRequest', ($, e, next) => {
565    void needsAnswer($)
566
567    return next(e)
568  })
569
570  // A question or a plan waits for the person.
571  on('tool.call', ($, e, next) => {
572    if (QUESTION_TOOLS.includes(e.tool)) {
573      void needsAnswer($)
574    }
575
576    return next(e)
577  })
578
579  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
580    const ran = await next(e)
581    if (ran.deny !== undefined || e.agentId !== undefined) {
582      return ran
583    }
584    if (TEST_COMMAND.test(e.command)) {
585      void play($, ran.isError === true ? 'tests-failed' : 'tests-passed')
586    }
587    if (PR_CREATE_COMMAND.test(e.command) && ran.isError !== true) {
588      void play($, 'pr-created')
589    }
590
591    return ran
592  })
593
594  // The end of a main-loop turn: a question when the answer ends on one, else done.
595  on('turn.complete', async ($, e, next) => {
596    if (e.agentId !== undefined || e.isAborted) {
597      return next(e)
598    }
599    if (endsWithQuestion(e.answer)) {
600      void needsAnswer($)
601
602      return next(e)
603    }
604    void play($, e.durationMs >= longTurnMs ? 'long-turn' : 'turn-done')
605
606    return next(e)
607  })
608}
609