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

A Claude Code plugin that plays sounds from your Jinglebox workspace when something happens in a session.
| Event | When it plays |
|---|---|
turn-done | Claude finished and its answer waits for you |
needs-answer | Claude waits on you: a question, a plan to approve, a permission prompt, or an answer that ends on a question |
long-turn | a turn longer than 60 s finished (instead of turn-done) |
tests-passed / tests-failed | a test command run by Claude passed or failed (artisan test, pest, phpunit, pytest, jest, vitest, go test, cargo test, npm test…) |
pr-created | glab 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).
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.~/.claude/settings.json: ``json "permissions": { "allow": [ "mcp__jinglebox__get-sound", "mcp__jinglebox__search-sounds", "mcp__jinglebox__get-workspace", "mcp__jinglebox__list-workspaces" ] } ``bash claude plugin marketplace add lamberttraccard/jinglebox-marketplace claude plugin install jinglebox@jinglebox ` Or try it without installing: claude --plugin-dir ./plugins/jinglebox`./jinglebox workspace <slug>./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.
/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.
| Setting | Default | Meaning |
|---|---|---|
workspace | empty | the workspace slug; empty to pick it with /jinglebox workspace |
mcp_server | jinglebox | the Jinglebox MCP server's name, as /mcp lists it |
long_turn_seconds | 60 | a turn longer than this plays long-turn |
random_max_seconds | 4 | a random pick only takes sounds this short |
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.$.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.get-sound, are cached in the plugin's store and played with $.audio.play. Random picks draw from search-sounds, refreshed once a day./jinglebox debug runs each step and says which one fails.
/mcp, reconnect jinglebox, and try again.MIT, see LICENSE.
hooks/register.ts 609 lines1import 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