Sound packs for the moments that need you: a prompt sent, a permission prompt, a long job done, a subagent back, a failed test, a git push, a compaction. Packs…

A Claude Code mod that plays a short sound at the moments that need you, and at no other time: Claude asks for your OK, a long job finishes, a test fails, a push goes through. Pick a Terran SCV, a Protoss zealot, an Age of Empires monk or Turkish villager, a Kirov airship, a Diablo II necromancer, Duke Nukem, Pickle Rick, Caleb from Blood, or the NASA radio. Part of claude-plugins.
Watch the teaser with sound (28 seconds).
Watch it with sound (33 seconds, a famous line from a different pack at each moment: peon, terran, protoss, red-alert, rick-and-morty, duke, aoe).
| Moment | When |
|---|---|
ordered | You send a prompt (an order given); every prompt by default, /sounds rest adds a wait |
needsYou | Claude waits for you: a permission prompt, a question for you or a plan to approve |
longDone | A turn that took 60 seconds or more is answered |
subagentStart | A subagent starts (a villager is created, from the aoe pack, whatever your pack) |
subagent | A subagent finishes |
failed | A test, build, lint or type check fails (a step such as npm test, pytest, tsc or scripts/check-x.sh exits with an error; text inside a heredoc never counts) |
pushed | git push goes through |
compacted | The conversation is compacted |
mcp | An MCP tool runs, at most every 5 minutes. Off until you turn it on (/sounds mcp aoe fires a trebuchet) |
At most one sound every 2.5 seconds across all your terminals, so a handful of subagents finishing together give one sound, not a choir. The same clip never plays twice in a row for a moment. A pack may leave a moment silent.
Hear the packs (41 seconds).
| Pack | What it sounds like | Source |
|---|---|---|
aoe | "Wololo", the horn, the villager's "Yes", and the taunts: "Start the game already!", "What age are you in?", "You played two hours to die like this?" at a failed test, "Raiding party!" on a push | Age of Empires II, under Microsoft's Game Content Usage Rules |
aoe-turk | The Turks: the villager (male and female), "Allah Allah!" and swords on a push, the victory fanfare after a long job; "Emrin?" (your order?), "Tamam" (OK), "Hazir" (ready), "Saldir!" (attack!) on a push, the job names when a subagent returns | Age of Empires II, under Microsoft's Game Content Usage Rules, via the openpeon pack by Yusuf Kinatas |
terran (default) | "SCV good to go, sir", "You want a piece of me, boy?", "Nuclear launch detected", "In the pipe, five by five" | StarCraft, Blizzard Entertainment |
protoss | "En Taro Adun", "You must construct additional pylons", "My life for Aiur!", "Power overwhelming" | StarCraft, Blizzard Entertainment |
red-alert | "Kirov reporting", "For mother Russia", "Your mind is clear", "Kaboom" | Command & Conquer: Red Alert 2, Electronic Arts |
peon | "Work work", "Zug zug", "Something need doing?", "Me not that kind of orc" | Warcraft III: Reign of Chaos, Blizzard Entertainment |
necromancer | "Raise skeleton" when a subagent returns, "Not enough mana", "I can't carry any more" at compaction | Diablo II: Lord of Destruction, Blizzard Entertainment |
diablo | The level up chime, the quest done fanfare, gold dropping, a town portal opening, an item breaking | Diablo II: Lord of Destruction, Blizzard Entertainment |
rick-and-morty | "Wubba lubba dub dub", "Show me what you got!", "I'm Pickle Rick!" on a push, the butter robot's "What is my purpose?" when a subagent returns, "Oh jeeze" at a failed test | Rick and Morty, Warner Bros. Discovery (Adult Swim), via the family-friendly openpeon pack by Mr3zee |
rick-and-morty-uncut | The swearing lines: "You son of a bitch, I'm in!", "Oh fuck me!", "Everybody fuck off!" on a push. Explicit language, never the default | Rick and Morty, Warner Bros. Discovery (Adult Swim), via the mature openpeon pack by Mr3zee |
meeseeks | Mr. Meeseeks: "Can do!", "I'm Mr. Meeseeks, look at me!" when a subagent returns, "All done!", "I can't take it anymore!" at a failed test | Rick and Morty, Warner Bros. Discovery (Adult Swim), via the openpeon pack by kasperhendriks |
duke | "Come get some", "Hail to the king, baby", "Groovy", "Ooh, that's gotta hurt" | Duke Nukem 3D, Gearbox Software |
blood | Caleb: "Let's go", "I won", "Boomstick", "Pathetic", the laugh | Blood (Monolith Productions, 1997) |
lich | The Lich and Kel'Thuzad: "As the master commands", "Thy bidding, master?", "Kneel before my Frost Nova!" on a push, "The Scourge will consume all!" at compaction | Warcraft III, Blizzard Entertainment, via the openpeon pack |
cabal | CABAL, the Nod AI: "Establishing battlefield control", "Primary objective achieved", "Reinforcements have arrived" when a subagent returns, "Construct more power plants" at compaction | Command & Conquer: Tiberian Sun, Electronic Arts, via the openpeon pack |
yuri | "Your thoughts are mine", "I foresaw this need", "Another pawn joins us" when a subagent returns, "I did not foresee this" at a failed test | Red Alert 2: Yuri's Revenge, Electronic Arts, via the openpeon pack |
abathur | The Zerg evolution master, in his own long winded way: evolution done for a long job, a help call when Claude waits | StarCraft II, Blizzard Entertainment, via the openpeon pack |
tauren | "Peace and patience, friend", "Fate smiles upon you", "For the Horde!" on a push, "My inventory is full" at compaction | World of Warcraft, Blizzard Entertainment, via the openpeon pack |
engineer | "Teleporter comin' right up", "Done and done", "You just ain't doin' it right" at a failed test, "Yeehaw!" on a push | Team Fortress 2, Valve, via the openpeon pack |
counterstrike | The radio calls: "Roger that", "Need backup!", "Area clear!", "Enemy spotted!", "Lock and load!" on a push | Counter-Strike, Valve, via the openpeon pack |
stronghold | The AI lords, a different one each time: Richard's "The lion roars", the Rat's "Plans worked perfectly", the Abbot's "My troops fail me", the Sheriff's "Not Father Christmas" | Stronghold Crusader, FireFly Studios, via the openpeon packs of the lords (for example the Abbot) |
sheogorath | The Prince of Madness: "Good choice", "I've been waiting for you", "Cheese for everyone", "Boring, boring, boring" at a failed test | The Elder Scrolls IV: Oblivion, Bethesda (ZeniMax), via the openpeon pack |
lebowski | The clean lines: "The Dude abides", "Phone's ringing, Dude", "That rug really tied the room together", "Over the line!", "Mark it zero!" | The Big Lebowski (1998), Universal Pictures, via the openpeon pack |
nasa | "The Eagle has landed", "And the liftoff!", "Okay Houston, we have had a problem here", a Quindar beep | NASA recordings on Wikimedia Commons, public domain |
Each pack's folder under sounds/ has a CREDITS.md with the source of every clip and who holds its rights. The game clips are the original files as shared on 101soundboards and in openpeon packs, unchanged except that WAV and Ogg originals are stored as M4A (smaller, and macOS afplay stops Ogg clips after about a second), and the short intro 101soundboards puts before some clips is cut where the site's own player skips it. They belong to their publishers: this plugin is free fan use, not licensed, not endorsed by any of them, and never sold. If you hold the rights to a pack and want it gone, open an issue and it goes. The game and show logos in the banner and films are trademarks of their owners, shown only to name the pack's source, on the same terms.
| Command | Effect |
|---|---|
/sounds | This terminal's pack, this project's pack, the default for new terminals and the settings, then what every moment plays, every command, and every pack at the bottom |
/sounds <pack> | Switch this terminal only |
/sounds default <pack> | Every terminal plays this pack: the open ones (in both accounts) from their next sound, and every new one. Project packs stay, and a terminal's own /sounds <pack> afterwards still wins there |
/sounds project <pack> | Every terminal in this folder, or a folder inside it, plays this pack, now and in every later session, in both accounts. One pack per project tells you by ear which one needs you |
/sounds project off, /sounds project | Take this folder's pack back, or see this project's pack and every project pack |
/sounds project clear | Remove every project pack at once; the answer lists what it removed |
/sounds <moment> <pack> | One moment plays another pack's clips, e.g. /sounds pushed aoe-turk: each push plays one of that pack's push clips |
/sounds <moment> <pack> <clip> | Always the same clip, e.g. /sounds pushed aoe-turk allah-allah |
/sounds <moment> <pack> <pack> | Mix two or more packs' clips for that moment |
/sounds <moment> random | Each time a clip from any pack |
/sounds <moment> off, /sounds <moment> reset | Silence one moment, or give it back to the terminal's pack |
/sounds <moment> | What that moment plays now and the names of its clips |
/sounds test [pack] [moment] | Play every sound of a pack once, or only one moment of it (/sounds test aoe-turk pushed), whatever the settings |
/sounds off, /sounds on | Mute, unmute |
/sounds away, /sounds always | Play only while no terminal or editor is the front app, or always (the default). The prompt answer still plays in away mode: you are at the keyboard |
/sounds night off, /sounds night on | Quiet hours: no clips from 23:00 to 07:00. Off by default |
/sounds rest <minutes> | How long after this terminal's last prompt sound the next prompt plays one; 0 (the default) plays every prompt |
Every change answers with one line saying what changed, and a mistyped pack or clip name gets the one it meant ("Did you mean aoe-turk?"). Moment names are typed in lowercase (/sounds needsyou); /sounds lists them, and a short word like push, fail or done gets the moment it means.
Install asks nothing. The first session shows one notice naming the pack and /sounds. Settings, moment rules included, are kept in the plugin's store, per Claude Code config directory.
A folder under ~/.config/findmutlu-sounds/packs/<name>/ (lowercase letters, digits and dashes) with your clips and a pack.json is a pack. It loads when a session starts and then works like the others (/sounds <name>, /sounds default <name>):
{
"label": "My office",
"ordered": ["yes-boss.m4a"],
"needsYou": ["knock.wav"],
"longDone": ["applause.mp3"],
"subagent": [],
"failed": ["sigh.wav"],
"pushed": ["cheer.m4a"],
"compacted": []
}
WAV, MP3, M4A and AIFF play. A built-in pack's name always means the built-in pack. Your clips stay on your machine: nothing is uploaded.
To add a pack to this plugin for everyone: a folder under sounds/ with the clips, a pack.json like the one above and a CREDITS.md naming the source and rights holder of every clip, then python3 scripts/packs.py, which writes the table in hooks/register.js (CI checks it is up to date).
lsappinfo, no permission needed).afplay underneath), never waiting for it, so nothing slows down. Answers its own /sounds command and no other. Writes three files in ~/.config/findmutlu-sounds/: last-played (the time of the last sound), so your terminals take turns instead of talking over each other, all-terminals.json (the pack and time of the last /sounds default) and projects.json (each project folder you gave a pack, and the pack). Every terminal reads them before a sound, and asks Claude Code which folder it runs in.tool.call (Bash, AskUserQuestion, ExitPlanMode) plays a sound and passes the call on; it never changes or blocks a call. prompt.submit, turn.complete and the notification, subagent and compaction events pass through unchanged.Needs Claude Code 2.1.287 or later (mods) on macOS. Tested on 2.1.294.
/plugin marketplace add mutlumehmet/claude-plugins
/plugin install findmutlu-sounds@mehmetmutlu
A mod runs inside Claude Code with your permissions. Read the code before you install any mod.
afplay. On Linux and Windows the mod loads and stays silent.failed knows check steps by name (npm, pnpm, yarn, make, cargo or go with test, build, lint, typecheck or check; pytest, jest, vitest, tsc, playwright, eslint; scripts named check-, test- or lint-), so a failing script with another name stays silent./sounds <pack>) resets when the plugin reloads; the default does not.Made by Mehmet Mutlu, part of claude-plugins. More of his work at mehmetmutlu.dev.
Code: MIT. Sounds: not covered by the MIT license. NASA clips are public domain; the game clips belong to their publishers, as each sounds/<pack>/CREDITS.md says.
hooks/register.js 1637 lines1// Sound packs for the moments that need you, and no others.
2//
3// moment when event
4// ordered you sent a prompt (after /sounds rest minutes) prompt.submit
5// needsYou Claude waits for you: a permission prompt, classic.Notification,
6// a question, a plan to approve tool.call (AskUserQuestion, ExitPlanMode)
7// longDone a turn of 60 s or more answered turn.complete (main loop)
8// subagentStart a subagent started classic.SubagentStart
9// subagent a subagent finished classic.SubagentStop
10// failed a test, build, lint or type check failed tool.call (Bash, isError; the step run, not heredoc text)
11// pushed git push went through tool.call (Bash)
12// compacted the conversation was compacted classic.PostCompact
13// mcp an MCP tool ran (off unless you turn it on) tool.call (mcp__*, rests 5 minutes)
14//
15// Each moment plays the terminal's pack unless /sounds <moment> gave it its own rule: other packs,
16// one clip, random or off (kept in the plugin store as settings.moments)
17//
18// Three kinds of pack:
19// file packs clips in ../sounds/<pack>/ (source and rights holder in each CREDITS.md)
20// voice packs lines the system voice speaks ($.audio.speak), so no audio file ships (none yet)
21// your packs folders under ~/.config/<this plugin's name>/packs/<pack>/ with a pack.json,
22// read at session start and played from their bytes
23//
24// Which pack plays: this terminal's (/sounds <pack>), else your default (/sounds default <pack>,
25// kept in the plugin store), else DEFAULT_PACK.
26
27const DEFAULT_PACK = 'terran'
28const LONG_TURN_MS = 60000
29const QUIET_FROM = 23
30const QUIET_TO = 7
31// One sound at a time: several subagents finishing together give one clip, not ten. The last
32// play time is shared by every terminal through a small file beside your packs folder
33const GAP_MS = 2500
34// Every prompt plays by default; /sounds rest <minutes> makes a prompt wait that long after this
35// terminal's last prompt clip, so typing away is not a clip every message
36const ORDERED_REST_MIN = 0
37const MAX_REST_MIN = 120
38
39// Front apps that mean you are already looking at Claude Code (away mode)
40const HERE_APPS = ['Terminal', 'iTerm2', 'Code', 'Visual Studio Code', 'Cursor', 'Ghostty', 'Warp', 'Claude']
41
42// Notification types that are only information, not a wait on you
43const QUIET_NOTIFICATIONS = ['idle_prompt', 'auth_success']
44
45// Checked against each step a command runs (heredoc bodies dropped), never against file text
46const CHECK_TOOL = /^(pytest|jest|vitest|tsc|playwright|eslint|mypy|ruff|shellcheck|biome)$/
47const CHECK_WORD = /^(test|tests|build|lint|typecheck|type-check|check|validate)(:.*)?$/
48const CHECK_RUNNER = /^(npm|pnpm|yarn|bun|npx|bunx|make|cargo|go|deno|gradle|mvn|swift|turbo|nx)$/
49const CHECK_SCRIPT = /(^|\/)(check|test|lint)[\w.-]*\.(sh|py|js|ts|mjs)$/
50const PUSH_COMMAND = /\bgit\s+push\b/
51// Tools that stop and wait for your answer
52const ASKING_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
53
54const MOMENTS = ['ordered', 'needsYou', 'longDone', 'subagentStart', 'subagent', 'failed', 'pushed', 'compacted', 'mcp']
55
56// What each moment means, for /sounds and its replies
57const MOMENT_INFO = {
58 ordered: 'you send a prompt',
59 needsYou: 'Claude waits for your OK or answer',
60 longDone: 'a turn of 60 s or more finishes',
61 subagentStart: 'a subagent starts',
62 subagent: 'a subagent finishes',
63 failed: 'a test, build or lint fails',
64 pushed: 'git push goes through',
65 compacted: 'the conversation is compacted',
66 mcp: 'an MCP tool runs (at most every 5 minutes)',
67}
68
69// Moments few packs carry start with a rule of their own: a villager is made when a subagent
70// starts; the MCP trebuchet is off until you turn it on (MCP tools run often)
71const DEFAULT_MOMENTS = { subagentStart: { packs: ['aoe'] }, mcp: { off: true } }
72
73// Moment names are typed in lowercase (/sounds needsyou)
74const MOMENT_BY_WORD = Object.fromEntries(MOMENTS.map((m) => [m.toLowerCase(), m]))
75
76// Words people reach for first, mapped to the moment they mean
77const MOMENT_ALIASES = {
78 push: 'pushed', fail: 'failed', fails: 'failed', error: 'failed', done: 'longDone', long: 'longDone',
79 start: 'subagentStart', spawn: 'subagentStart', agent: 'subagent', compact: 'compacted',
80 prompt: 'ordered', order: 'ordered', wait: 'needsYou', ask: 'needsYou', permission: 'needsYou',
81}
82const MCP_REST_MS = 5 * 60000
83
84// FILE_PACKS_START (generated from sounds/*/pack.json by scripts/packs.py; edit those, not this)
85const FILE_PACKS = {
86 "abathur": {
87 "label": "StarCraft II: Abathur, the Zerg evolution master (fan use)",
88 "ordered": [
89 "thanks-00.m4a",
90 "thanks-01.m4a"
91 ],
92 "needsYou": [
93 "idle.m4a",
94 "help-me.m4a"
95 ],
96 "longDone": [
97 "evolution-00.m4a",
98 "evolution-01.m4a"
99 ],
100 "subagentStart": [],
101 "subagent": [
102 "brutalisk.m4a",
103 "thanks-02.m4a"
104 ],
105 "failed": [
106 "units-lost.m4a"
107 ],
108 "pushed": [
109 "positive.m4a",
110 "generic.m4a"
111 ],
112 "compacted": [
113 "brutalisk-reminder.m4a"
114 ],
115 "mcp": []
116 },
117 "aoe": {
118 "label": "Age of Empires II: wololo, the horn and the taunts (Microsoft Game Content Usage Rules)",
119 "ordered": [
120 "yes.mp3",
121 "start-the-game-already.mp3",
122 "roggan.mp3"
123 ],
124 "needsYou": [
125 "conversion-warning.mp3",
126 "what-age-are-you-in.mp3",
127 "wait-for-my-signal.mp3"
128 ],
129 "longDone": [
130 "drum-fanfare.mp3",
131 "long-time-no-siege.mp3",
132 "good-to-be-the-king.mp3",
133 "nice-town.mp3"
134 ],
135 "subagentStart": [
136 "villager-created.mp3",
137 "military-created.mp3"
138 ],
139 "subagent": [
140 "horn.mp3",
141 "sssh-ho.mp3",
142 "oooh.mp3"
143 ],
144 "failed": [
145 "herb-laugh.mp3",
146 "no.mp3",
147 "two-hours-to-die.mp3",
148 "the-wonder-no.mp3",
149 "being-rushed.mp3"
150 ],
151 "pushed": [
152 "viking-horn.mp3",
153 "converted.mp3",
154 "raiding-party.mp3"
155 ],
156 "compacted": [
157 "wololo.mp3",
158 "give-me-your-extra-resources.mp3"
159 ],
160 "mcp": [
161 "trebuchet-fire.mp3",
162 "mangonel-fire.mp3"
163 ]
164 },
165 "aoe-turk": {
166 "label": "Age of Empires II: the Turks, villagers male and female, the war cry, swords and fanfares (Microsoft Game Content Usage Rules)",
167 "ordered": [
168 "tamam.m4a",
169 "tamam-f.m4a",
170 "evet.m4a",
171 "evet-f.m4a",
172 "dogru.m4a",
173 "dogru-f.m4a",
174 "yapacagim.m4a",
175 "yapacagim-f.m4a"
176 ],
177 "needsYou": [
178 "efendim.m4a",
179 "efendim-f.m4a",
180 "emrin.m4a",
181 "emrin-f.m4a",
182 "evet-question.m4a",
183 "evet-question-f.m4a"
184 ],
185 "longDone": [
186 "hazir.m4a",
187 "hazir-f.m4a",
188 "victory.mp3",
189 "wonder.mp3"
190 ],
191 "subagentStart": [],
192 "subagent": [
193 "oduncu.m4a",
194 "oduncu-f.m4a",
195 "ciftci.m4a",
196 "ciftci-f.m4a",
197 "madenci.m4a",
198 "madenci-f.m4a",
199 "balikci.m4a",
200 "balikci-f.m4a",
201 "avci.m4a",
202 "avci-f.m4a",
203 "seyis.m4a",
204 "seyis-f.m4a"
205 ],
206 "failed": [
207 "villager-death.m4a",
208 "villager-death-f.m4a",
209 "tamirci.m4a",
210 "tamirci-f.m4a",
211 "sword-hit.mp3"
212 ],
213 "pushed": [
214 "saldir.m4a",
215 "saldir-f.m4a",
216 "allah-allah.mp3",
217 "ileri.mp3",
218 "sword-clash.mp3"
219 ],
220 "compacted": [
221 "usta.m4a",
222 "usta-f.m4a",
223 "castle.mp3"
224 ],
225 "mcp": []
226 },
227 "blood": {
228 "label": "Blood: Caleb laughs, quotes and boomsticks (fan use)",
229 "ordered": [
230 "lets-go.mp3",
231 "alright.mp3"
232 ],
233 "needsYou": [
234 "come-out.mp3",
235 "play-with-me.mp3"
236 ],
237 "longDone": [
238 "i-won.mp3",
239 "did-it.mp3"
240 ],
241 "subagentStart": [],
242 "subagent": [
243 "laughing.mp3",
244 "yeah.mp3"
245 ],
246 "failed": [
247 "all-dead.mp3",
248 "rip.mp3",
249 "pathetic.mp3"
250 ],
251 "pushed": [
252 "boomstick.mp3",
253 "napalm.mp3"
254 ],
255 "compacted": [
256 "live-again.mp3",
257 "nevermore.mp3"
258 ],
259 "mcp": []
260 },
261 "cabal": {
262 "label": "Tiberian Sun: CABAL, the Nod AI (fan use)",
263 "ordered": [
264 "establishing-battlefield-control.m4a",
265 "building.m4a",
266 "please-stand-by.m4a"
267 ],
268 "needsYou": [
269 "incoming-transmission.m4a",
270 "select-target.m4a",
271 "new-construction-options.m4a"
272 ],
273 "longDone": [
274 "primary-objective-achieved.m4a",
275 "mission-accomplished.m4a"
276 ],
277 "subagentStart": [],
278 "subagent": [
279 "reinforcements-have-arrived.m4a",
280 "objective-complete.m4a"
281 ],
282 "failed": [
283 "base-perimeter-breached.m4a",
284 "critical-unit-lost.m4a",
285 "you-make-this-easy.m4a"
286 ],
287 "pushed": [
288 "congratulations-on-your-success.m4a",
289 "prepare-for-decimation.m4a"
290 ],
291 "compacted": [
292 "construct-more-power-plants.m4a",
293 "low-power.m4a",
294 "silos-needed.m4a"
295 ],
296 "mcp": []
297 },
298 "counterstrike": {
299 "label": "Counter-Strike: the radio calls (fan use)",
300 "ordered": [
301 "lets-go.m4a",
302 "roger-that.m4a",
303 "affirmative.m4a"
304 ],
305 "needsYou": [
306 "need-backup.m4a",
307 "report-in.m4a"
308 ],
309 "longDone": [
310 "area-clear.m4a"
311 ],
312 "subagentStart": [],
313 "subagent": [
314 "moving-out.m4a",
315 "you-take-the-point.m4a"
316 ],
317 "failed": [
318 "negative.m4a",
319 "enemy-spotted.m4a"
320 ],
321 "pushed": [
322 "lock-and-load.m4a"
323 ],
324 "compacted": [
325 "fall-back.m4a",
326 "stick-together.m4a",
327 "hold-your-position.m4a"
328 ],
329 "mcp": []
330 },
331 "diablo": {
332 "label": "Diablo II effects: level up, quest done, gold, a portal opens (Blizzard, fan use)",
333 "ordered": [
334 "potion-drink.mp3",
335 "select.mp3"
336 ],
337 "needsYou": [
338 "hostile.mp3",
339 "waypoint.mp3"
340 ],
341 "longDone": [
342 "level-up.mp3",
343 "quest-done.mp3"
344 ],
345 "subagentStart": [],
346 "subagent": [
347 "gold.mp3",
348 "rune.mp3"
349 ],
350 "failed": [
351 "item-broken.mp3",
352 "glass-break.mp3"
353 ],
354 "pushed": [
355 "portal-open.mp3",
356 "cairn-success.mp3"
357 ],
358 "compacted": [
359 "identify.mp3",
360 "scroll.mp3"
361 ],
362 "mcp": []
363 },
364 "duke": {
365 "label": "Duke Nukem 3D: come get some, hail to the king, baby (fan use)",
366 "ordered": [
367 "come-get-some.mp3",
368 "im-going-in.mp3"
369 ],
370 "needsYou": [
371 "waiting-for-christmas.mp3",
372 "you-wanna-dance.mp3"
373 ],
374 "longDone": [
375 "damn-im-good.mp3",
376 "piece-of-cake.mp3"
377 ],
378 "subagentStart": [],
379 "subagent": [
380 "groovy.mp3",
381 "back-to-work.mp3"
382 ],
383 "failed": [
384 "game-over.mp3",
385 "this-sucks.mp3",
386 "gotta-hurt.mp3"
387 ],
388 "pushed": [
389 "hail-to-the-king.mp3",
390 "bubble-gum.mp3"
391 ],
392 "compacted": [
393 "much-better.mp3",
394 "needed-that.mp3"
395 ],
396 "mcp": []
397 },
398 "engineer": {
399 "label": "Team Fortress 2: the Engineer (fan use)",
400 "ordered": [
401 "teleporter-comin-right-up.mp3",
402 "buildin-a-sentry.mp3",
403 "lets-do-this-texas-style.mp3"
404 ],
405 "needsYou": [
406 "not-on-auto-pilot.mp3",
407 "help-me.mp3"
408 ],
409 "longDone": [
410 "done-and-done.mp3",
411 "knife-fight-in-a-phonebooth.mp3"
412 ],
413 "subagentStart": [],
414 "subagent": [
415 "another-satisfied-customer.mp3",
416 "nice-work.mp3"
417 ],
418 "failed": [
419 "you-just-aint-doin-it-right.mp3",
420 "sentry-down.mp3",
421 "told-ya-dont-touch.mp3"
422 ],
423 "pushed": [
424 "yeehaw.mp3",
425 "move-em-out.mp3"
426 ],
427 "compacted": [
428 "good-night-irene.mp3",
429 "now-ive-seen-everything.mp3"
430 ],
431 "mcp": []
432 },
433 "lebowski": {
434 "label": "The Big Lebowski: the Dude, Walter, Donny and friends, clean lines (fan use)",
435 "ordered": [
436 "the-dude-abides.mp3",
437 "far-out.mp3",
438 "no-problemo.mp3"
439 ],
440 "needsYou": [
441 "phones-ringing-dude.mp3",
442 "whats-your-point.mp3",
443 "certain-things-have-come-to-light.mp3"
444 ],
445 "longDone": [
446 "rug-tied-the-room-together.mp3",
447 "goodnight-sweet-prince.mp3"
448 ],
449 "subagentStart": [],
450 "subagent": [
451 "i-like-your-style.mp3",
452 "ive-got-information.mp3"
453 ],
454 "failed": [
455 "over-the-line.mp3",
456 "mark-it-zero.mp3",
457 "world-of-pain.mp3",
458 "this-aggression-will-not-stand.mp3"
459 ],
460 "pushed": [
461 "way-to-go-donny.mp3",
462 "wave-of-the-future.mp3"
463 ],
464 "compacted": [
465 "just-take-it-easy.mp3",
466 "strikes-and-gutters.mp3"
467 ],
468 "mcp": []
469 },
470 "lich": {
471 "label": "Warcraft III: the Lich and Kel'Thuzad (fan use)",
472 "ordered": [
473 "as-the-master-commands.m4a",
474 "i-shall-obey.m4a",
475 "direct-my-hatred.m4a"
476 ],
477 "needsYou": [
478 "thy-bidding-master.m4a",
479 "what-is-thy-will.m4a",
480 "i-await-your-command.m4a"
481 ],
482 "longDone": [
483 "thy-will-be-done.m4a",
484 "the-ancient-evil-survives.m4a"
485 ],
486 "subagentStart": [],
487 "subagent": [
488 "for-the-lich-king.m4a",
489 "it-is-destined.m4a"
490 ],
491 "failed": [
492 "aaugh.m4a",
493 "do-not-try-my-patience.m4a"
494 ],
495 "pushed": [
496 "kneel-before-my-frost-nova.m4a",
497 "dread-lord-not-drug-lord.m4a"
498 ],
499 "compacted": [
500 "the-scourge-will-consume-all.m4a",
501 "bone-to-pick.m4a"
502 ],
503 "mcp": []
504 },
505 "meeseeks": {
506 "label": "Rick and Morty: Mr. Meeseeks, look at me! (fan use)",
507 "ordered": [
508 "can-do.mp3",
509 "can-do-2.mp3",
510 "ooh-okay.mp3",
511 "okay.mp3"
512 ],
513 "needsYou": [
514 "what-do-you-got.mp3",
515 "excuse-me.mp3",
516 "back-to-the-task.mp3"
517 ],
518 "longDone": [
519 "all-done.mp3",
520 "exist-this-long.mp3"
521 ],
522 "subagentStart": [],
523 "subagent": [
524 "im-mr-meeseeks.mp3",
525 "look-at-me.mp3",
526 "fulfill-my-purpose.mp3",
527 "yes-sirree.mp3"
528 ],
529 "failed": [
530 "cant-take-it.mp3",
531 "frustrating.mp3",
532 "doesnt-work-like-that.mp3"
533 ],
534 "pushed": [
535 "ooh-yeah.mp3",
536 "yes-maam.mp3"
537 ],
538 "compacted": [
539 "gotta-relax.mp3",
540 "let-me-try.mp3"
541 ],
542 "mcp": []
543 },
544 "nasa": {
545 "label": "NASA radio: the Eagle has landed, liftoff, we have had a problem (public domain)",
546 "ordered": [
547 "roger-roll.m4a",
548 "quindar-beep.m4a"
549 ],
550 "needsYou": [
551 "houston-discovery.m4a",
552 "how-do-you-read.m4a"
553 ],
554 "longDone": [
555 "eagle-landed.m4a",
556 "wheels-stop.m4a"
557 ],
558 "subagentStart": [],
559 "subagent": [
560 "go-for-deploy.m4a",
561 "go-at-throttle-up.m4a"
562 ],
563 "failed": [
564 "weve-had-a-problem.m4a"
565 ],
566 "pushed": [
567 "liftoff.m4a",
568 "liftoff-41d.m4a"
569 ],
570 "compacted": [
571 "vector-transfer.m4a"
572 ],
573 "mcp": []
574 },
575 "necromancer": {
576 "label": "Diablo II Necromancer: raise skeleton, not enough mana, I cannot carry any more (Blizzard, fan use)",
577 "ordered": [
578 "ok.mp3",
579 "yes.mp3",
580 "lets-go.mp3"
581 ],
582 "needsYou": [
583 "need-help.mp3",
584 "yes-question.mp3",
585 "what-else.mp3"
586 ],
587 "longDone": [
588 "diablo-killed.mp3",
589 "baal-defeated.mp3",
590 "andariel-done.mp3"
591 ],
592 "subagentStart": [],
593 "subagent": [
594 "raise-skeleton.mp3",
595 "golem.mp3",
596 "revive.mp3"
597 ],
598 "failed": [
599 "i-cant.mp3",
600 "oops.mp3",
601 "not-enough-mana.mp3"
602 ],
603 "pushed": [
604 "time-to-die.mp3",
605 "meet-your-fate.mp3",
606 "corpse-explosion.mp3"
607 ],
608 "compacted": [
609 "cant-carry.mp3",
610 "need-mana.mp3"
611 ],
612 "mcp": []
613 },
614 "peon": {
615 "label": "Warcraft III peon: work work, zug zug, something need doing? (Blizzard, fan use)",
616 "ordered": [
617 "work-work.mp3",
618 "okey-dokey.mp3",
619 "i-can-do-that.mp3",
620 "be-happy-to.mp3"
621 ],
622 "needsYou": [
623 "something-need-doing.mp3",
624 "ready-to-work.mp3",
625 "what-do-you-want.mp3"
626 ],
627 "longDone": [
628 "zug-zug.mp3"
629 ],
630 "subagentStart": [],
631 "subagent": [
632 "me-busy.mp3",
633 "yes.mp3",
634 "ok.mp3"
635 ],
636 "failed": [
637 "splat.mp3",
638 "tummy-feels-funny.mp3",
639 "not-that-kind-of-orc.mp3"
640 ],
641 "pushed": [
642 "kill-them.mp3",
643 "why-not.mp3"
644 ],
645 "compacted": [
646 "leave-me-alone.mp3",
647 "no-time-for-play.mp3"
648 ],
649 "mcp": []
650 },
651 "protoss": {
652 "label": "StarCraft Protoss: construct additional pylons, my life for Aiur (Blizzard, fan use)",
653 "ordered": [
654 "adun-toridas.mp3"
655 ],
656 "needsYou": [
657 "transmission.mp3"
658 ],
659 "longDone": [
660 "upgrade-complete.mp3",
661 "research.mp3"
662 ],
663 "subagentStart": [],
664 "subagent": [
665 "upgrade.mp3",
666 "systems-functional.mp3"
667 ],
668 "failed": [
669 "pylons.mp3",
670 "pylons-2.mp3"
671 ],
672 "pushed": [
673 "life-for-aiur.mp3",
674 "power-overwhelming.mp3"
675 ],
676 "compacted": [
677 "vespene-gas.mp3"
678 ],
679 "mcp": []
680 },
681 "red-alert": {
682 "label": "Red Alert 2: Kirov reporting, for mother Russia (EA, fan use)",
683 "ordered": [
684 "acknowledged.mp3",
685 "yes-commander.mp3",
686 "can-do.mp3"
687 ],
688 "needsYou": [
689 "commander.mp3",
690 "give-me-a-job.mp3",
691 "anytime-boss.mp3"
692 ],
693 "longDone": [
694 "kirov-reporting.mp3",
695 "cha-ching.mp3"
696 ],
697 "subagentStart": [],
698 "subagent": [
699 "unit-reporting.mp3",
700 "agent-ready.mp3"
701 ],
702 "failed": [
703 "mommy.mp3",
704 "bail-out.mp3"
705 ],
706 "pushed": [
707 "kaboom.mp3",
708 "for-mother-russia.mp3"
709 ],
710 "compacted": [
711 "your-mind-is-clear.mp3",
712 "deconstructing.mp3"
713 ],
714 "mcp": []
715 },
716 "rick-and-morty": {
717 "label": "Rick and Morty: family-friendly lines from Rick and the crew (fan use)",
718 "ordered": [
719 "wubba-lubba-dub-dub.mp3",
720 "here-we-go.mp3",
721 "time-for-action.mp3",
722 "twenty-minute-adventure.mp3"
723 ],
724 "needsYou": [
725 "show-me-what-you-got.mp3",
726 "yes-or-no.mp3",
727 "hey-morty.mp3"
728 ],
729 "longDone": [
730 "pickle-rick.mp3",
731 "pure-luck.mp3"
732 ],
733 "subagentStart": [],
734 "subagent": [
735 "what-is-my-purpose.mp3",
736 "everything-you-want.mp3",
737 "oh-yeah.mp3"
738 ],
739 "failed": [
740 "oh-jeeze.mp3",
741 "oh-man.mp3",
742 "not-in-control.mp3"
743 ],
744 "pushed": [
745 "im-pickle-rick.mp3",
746 "my-plan-all-along.mp3"
747 ],
748 "compacted": [
749 "brain-functionality.mp3",
750 "super-intelligence.mp3"
751 ],
752 "mcp": []
753 },
754 "rick-and-morty-uncut": {
755 "label": "Rick and Morty, uncut: the swearing lines (explicit language, fan use)",
756 "ordered": [
757 "son-of-a-bitch-im-in.mp3",
758 "gonna-fucking-do-it.mp3",
759 "doctor-who.mp3"
760 ],
761 "needsYou": [
762 "who-the-fuck-are-you.mp3",
763 "seeds-up-your-butt.mp3"
764 ],
765 "longDone": [
766 "having-a-party.mp3"
767 ],
768 "subagentStart": [],
769 "subagent": [
770 "god-damn-it.mp3"
771 ],
772 "failed": [
773 "oh-fuck-me.mp3",
774 "fuck-the-government.mp3"
775 ],
776 "pushed": [
777 "everybody-fuck-off.mp3",
778 "dick-move.mp3"
779 ],
780 "compacted": [
781 "traumatized-for-breakfast.mp3",
782 "take-a-shit.mp3"
783 ],
784 "mcp": []
785 },
786 "sheogorath": {
787 "label": "The Elder Scrolls IV: Sheogorath, Prince of Madness (fan use)",
788 "ordered": [
789 "good-choice.m4a",
790 "i-like-it.m4a",
791 "dance-sing-smile.m4a"
792 ],
793 "needsYou": [
794 "ive-been-waiting-for-you.m4a",
795 "need-a-champion.m4a"
796 ],
797 "longDone": [
798 "cheese-for-everyone.m4a"
799 ],
800 "subagentStart": [],
801 "subagent": [
802 "congratulations.m4a",
803 "but-youre-not-done-yet.m4a"
804 ],
805 "failed": [
806 "boring-boring-boring.m4a",
807 "absolutely-not.m4a",
808 "how-rude.m4a"
809 ],
810 "pushed": [
811 "prince-of-madness.m4a",
812 "dont-get-too-comfortable.m4a"
813 ],
814 "compacted": [
815 "a-little-busy-here.m4a",
816 "niggling-little-details.m4a"
817 ],
818 "mcp": []
819 },
820 "stronghold": {
821 "label": "Stronghold Crusader: the AI lords (fan use)",
822 "ordered": [
823 "richard-i-march.mp3",
824 "abbot-agreed.mp3",
825 "marshal-off-we-go.mp3",
826 "philip-on-my-honor.mp3"
827 ],
828 "needsYou": [
829 "abbot-send-help.mp3",
830 "sheriff-help-surrounded.mp3"
831 ],
832 "longDone": [
833 "richard-sound-the-fanfare.mp3",
834 "abbot-victory-for-me.mp3",
835 "pig-no-one-beats-the-pig.mp3"
836 ],
837 "subagentStart": [],
838 "subagent": [
839 "rat-plans-worked-perfectly.mp3",
840 "saladin-another-has-fallen.mp3",
841 "emir-this-is-easy.mp3"
842 ],
843 "failed": [
844 "abbot-my-troops-fail-me.mp3",
845 "emir-what-a-disaster.mp3",
846 "marshal-bad-tactics.mp3"
847 ],
848 "pushed": [
849 "richard-the-lion-roars.mp3",
850 "wolf-i-win-you-lose.mp3",
851 "sheriff-sweet-victory.mp3"
852 ],
853 "compacted": [
854 "sheriff-not-father-christmas.mp3",
855 "richard-lean-times.mp3"
856 ],
857 "mcp": []
858 },
859 "tauren": {
860 "label": "World of Warcraft: the Tauren (fan use)",
861 "ordered": [
862 "peace-and-patience.m4a",
863 "indeed.m4a",
864 "it-is-so.m4a"
865 ],
866 "needsYou": [
867 "i-need-assistance.m4a",
868 "wont-you-help.m4a"
869 ],
870 "longDone": [
871 "fate-smiles-upon-you.m4a",
872 "our-ancestors-be-praised.m4a"
873 ],
874 "subagentStart": [],
875 "subagent": [
876 "well-done.m4a",
877 "pleasure-doing-business.m4a"
878 ],
879 "failed": [
880 "impermissible.m4a",
881 "i-cannot-do-that.m4a"
882 ],
883 "pushed": [
884 "for-the-horde.m4a",
885 "ride-the-winds.m4a"
886 ],
887 "compacted": [
888 "my-inventory-is-full.m4a",
889 "i-cannot-carry-more.m4a",
890 "moo.m4a"
891 ],
892 "mcp": []
893 },
894 "terran": {
895 "label": "StarCraft Terran: SCV good to go, nuclear launch detected (Blizzard, fan use)",
896 "ordered": [
897 "scv-good-to-go.mp3",
898 "jacked-up.mp3"
899 ],
900 "needsYou": [
901 "piece-of-me.mp3",
902 "supply-depots.mp3"
903 ],
904 "longDone": [
905 "battlecruiser-operational.mp3"
906 ],
907 "subagentStart": [],
908 "subagent": [
909 "research-complete.mp3",
910 "upgrade-complete.mp3"
911 ],
912 "failed": [
913 "nuclear-launch.mp3",
914 "base-under-attack.mp3"
915 ],
916 "pushed": [
917 "in-the-pipe.mp3"
918 ],
919 "compacted": [
920 "not-enough-minerals.mp3",
921 "vespene-gas.mp3"
922 ],
923 "mcp": []
924 },
925 "yuri": {
926 "label": "Red Alert 2: Yuri's Revenge: Yuri (fan use)",
927 "ordered": [
928 "your-thoughts-are-mine.m4a",
929 "a-trivial-matter.m4a",
930 "yes-i-know.m4a"
931 ],
932 "needsYou": [
933 "i-foresaw-this-need.m4a",
934 "share-your-thoughts.m4a",
935 "something-to-think-about.m4a"
936 ],
937 "longDone": [
938 "yes-my-exquisite-mind.m4a",
939 "they-will-obey.m4a"
940 ],
941 "subagentStart": [],
942 "subagent": [
943 "another-pawn-joins-us.m4a",
944 "he-shall-serve-me-well.m4a"
945 ],
946 "failed": [
947 "i-did-not-foresee-this.m4a",
948 "this-could-be-a-problem.m4a"
949 ],
950 "pushed": [
951 "there-is-only-one-yuri.m4a",
952 "yield-to-me.m4a"
953 ],
954 "compacted": [
955 "all-in-the-mind.m4a",
956 "i-cannot-be-overcome.m4a"
957 ],
958 "mcp": []
959 }
960}
961// FILE_PACKS_END
962
963const VOICE_PACKS = {}
964
965// Module variables are this terminal's: each terminal is its own process. A plugin reload
966// starts them over, which only costs this terminal's /sounds <pack> choice.
967// pack and packAt: this terminal's /sounds <pack>; all: the last /sounds default <pack> of any terminal;
968// project: the folder this session runs in and the pack /sounds project gave it (or its parent)
969const here = { pack: undefined, packAt: -1, all: undefined, cwd: '', project: undefined, playedAt: 0, orderedAt: 0, yourPacks: {}, lastClip: {} }
970
971const DEFAULTS = { isOn: true, defaultPack: DEFAULT_PACK, mode: 'always', isNightQuiet: false, restMin: ORDERED_REST_MIN }
972
973export function register(on) {
974 on('session.start', async ($, e, next) => {
975 await $.command.register({
976 name: 'sounds',
977 description: 'Sound packs for the moments that need you: /sounds to see every moment and command, /sounds <pack>, /sounds <moment> <pack>, test',
978 argumentHint: '[<pack>|default <pack>|<moment> <pack> [clip]|<moment> random|off|reset|on|off|test [pack] [moment]]',
979 })
980 here.yourPacks = await loadYourPacks($)
981 // Said once, so the way to change the pack is never a secret
982 try {
983 if (!(await $.store.get('welcomed'))) {
984 await $.store.set('welcomed', true)
985 const settings = await readSettings($)
986 $.ui.toast('Sounds: ' + settings.defaultPack + ' pack. Type /sounds for the others')
987 }
988 } catch {
989 // A hint is never worth an error line
990 }
991 return next(e)
992 })
993
994 on('command.run', { command: 'sounds' }, async ($, e) => {
995 const args = String(e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
996 const settings = await readSettings($)
997 const current = currentPack(settings)
998
999 if (args[0] === 'test') {
1000 // /sounds test [pack] [moment], either may be left out
1001 const words = args.slice(1)
1002 const moment = MOMENT_BY_WORD[words[words.length - 1]]
1003 if (moment) words.pop()
1004 const name = words[0] ?? current
1005 if (!packOf(name)) {
1006 const meant = closestPack(name)
1007 return { text: 'No pack named ' + name + (meant ? '. Did you mean ' + meant + '? /sounds test ' + meant + (moment ? ' ' + moment.toLowerCase() : '') : '\n' + listing()) }
1008 }
1009 const count = await playAll($, name, moment)
1010 return { text: 'Played ' + count + ' sounds of ' + name + (moment ? ' for ' + moment : '') }
1011 }
1012 // No word: where things stand, then every command and pack
1013 if (!args.length) return { text: describe(settings) }
1014 // /sounds <moment> ...: that moment's own rule
1015 if (MOMENT_BY_WORD[args[0]]) return momentCommand($, settings, MOMENT_BY_WORD[args[0]], args.slice(1))
1016 // A change answers with one line saying what changed, not the whole list again
1017 if (packOf(args[0]) && args.length === 1) {
1018 here.pack = args[0]
1019 here.packAt = await $.clock.now()
1020 const def = defaultOf(settings)
1021 return {
1022 text: 'This terminal now plays ' + args[0] + ': ' + packOf(args[0]).label +
1023 (args[0] === def ? '' : '\nOther terminals keep theirs (/sounds default ' + args[0] + ' switches them all)'),
1024 }
1025 }
1026 if (args[0] === 'default' && !packOf(args[1])) {
1027 const meant = args[1] && closestPack(args[1])
1028 if (meant) return { text: 'No pack named ' + args[1] + '. Did you mean ' + meant + '? /sounds default ' + meant }
1029 return { text: (args[1] ? 'No pack named ' + args[1] : 'Which pack? /sounds default <pack>') + '\n' + listing() }
1030 }
1031 // /sounds project [<pack>|off]: every terminal in this folder, or one below it
1032 if (args[0] === 'project') return projectCommand($, args.slice(1))
1033 // /sounds default <pack>: every open terminal, of both accounts, and every new one
1034 if (args[0] === 'default' && args.length === 2) {
1035 const name = args[1]
1036 if (current === name && defaultOf(settings) === name) return { text: 'Default is already ' + name + ': ' + packOf(name).label }
1037 const at = await $.clock.now()
1038 here.all = { pack: name, at }
1039 here.pack = undefined
1040 here.packAt = -1
1041 await $.fs.write(await allFile($), JSON.stringify(here.all)).catch(() => {})
1042 await $.store.set('settings', { ...settings, defaultPack: name, defaultAt: at })
1043 const kept = Object.keys(await readProjects($)).length
1044 return {
1045 text: 'Default changed: every terminal, open or new, now plays ' + name + ': ' + packOf(name).label +
1046 (kept ? '\nProject packs stay (/sounds project to see them)' : ''),
1047 }
1048 }
1049 const change = CHANGES.find((c) => c.matches(args))
1050 if (!change && args[0] === 'rest') return { text: 'How many minutes? /sounds rest <0 to ' + MAX_REST_MIN + '> (0: every prompt). Now: ' + restText(settings) }
1051 // A moment name mistyped or shortened (push, needsyu) gets the moment it meant
1052 const moment = !change && !packOf(args[0]) && closestMoment(args[0])
1053 if (moment) {
1054 return { text: 'No moment named ' + args[0] + '. Did you mean ' + moment + '? /sounds ' + [moment.toLowerCase(), ...args.slice(1)].join(' ') }
1055 }
1056 if (!change) {
1057 // A typo of a pack name (aeo-turk) gets the pack it meant, not just the list
1058 const last = args[args.length - 1]
1059 const meant = !packOf(last) && closestPack(last)
1060 if (meant) return { text: 'No pack named ' + last + '. Did you mean ' + meant + '? /sounds ' + (args[0] === 'default' ? 'default ' : '') + meant }
1061 return { text: 'Unknown: ' + args.join(' ') + '\n' + commands() + '\n' + listing() }
1062 }
1063 const updated = change.apply({ ...settings }, args)
1064 await $.store.set('settings', updated)
1065 return { text: change.said(updated, args, settings) }
1066 })
1067
1068 // An order given: the unit answers. You are at the keyboard, so away mode does not apply
1069 on('prompt.submit', async ($, e, next) => {
1070 // The rest counts from this terminal's last prompt clip only: a clip of another moment, or of
1071 // another terminal, does not silence the next prompt
1072 const settings = await readSettings($).catch(() => DEFAULTS)
1073 const now = await $.clock.now()
1074 if (here.orderedAt && now - here.orderedAt < restOf(settings) * 60000) return next(e)
1075 if (await cue($, 'ordered', { isPresent: true })) here.orderedAt = now
1076 return next(e)
1077 })
1078
1079 on('classic.Notification', async ($, e, next) => {
1080 if (!QUIET_NOTIFICATIONS.includes(e.notification_type)) await cue($, 'needsYou')
1081 return next(e)
1082 })
1083
1084 on('classic.SubagentStart', async ($, e, next) => {
1085 await cue($, 'subagentStart').catch(() => {})
1086 return next(e)
1087 })
1088
1089 on('classic.SubagentStop', async ($, e, next) => {
1090 await cue($, 'subagent')
1091 return next(e)
1092 })
1093
1094 on('classic.PostCompact', async ($, e, next) => {
1095 await cue($, 'compacted')
1096 return next(e)
1097 })
1098
1099 on('turn.complete', async ($, e, next) => {
1100 const out = await next(e)
1101 if (!e.agentId && e.reason === 'answer' && e.durationMs >= LONG_TURN_MS) await cue($, 'longDone')
1102 return out
1103 })
1104
1105 // One hook for the tools: a plugin gets one hook per event
1106 on('tool.call', async ($, e, next) => {
1107 // A question waits for you, so the sound goes before the answer, not after it
1108 if (ASKING_TOOLS.includes(e.tool)) {
1109 await cue($, 'needsYou')
1110 return next(e)
1111 }
1112 if (String(e.tool).startsWith('mcp__')) {
1113 const out = await next(e)
1114 if (out.deny === undefined && !out.isError) await cue($, 'mcp', { restMs: MCP_REST_MS })
1115 return out
1116 }
1117 if (e.tool !== 'Bash') return next(e)
1118 const out = await next(e)
1119 const command = String(e.command ?? '')
1120 if (out.deny === undefined) {
1121 if (out.isError && runsCheck(command)) await cue($, 'failed')
1122 else if (!out.isError && PUSH_COMMAND.test(command)) await cue($, 'pushed')
1123 }
1124 return out
1125 })
1126}
1127
1128// Plays one sound of the moment when the settings allow it; never delays or breaks the event
1129async function cue($, moment, opts = {}) {
1130 try {
1131 const settings = await readSettings($)
1132 if (!settings.isOn) return
1133 const choices = choicesFor(settings, moment)
1134 if (!choices.length) return
1135 const now = await $.clock.now()
1136 const last = Math.max(here.playedAt, await sharedPlayedAt($))
1137 if (now - last < GAP_MS) return
1138 if (settings.isNightQuiet && isQuietHour(new Date(now).getHours())) return
1139 if (!opts.isPresent && settings.mode === 'away' && (await isHere($))) return
1140 here.playedAt = now
1141 await sharePlayedAt($, now)
1142 // Not awaited: the sound plays while the session goes on
1143 const [name, item] = pick(choices.map((c) => c.pack + '/' + c.item), here.lastClip, moment, now).split(/\/(.*)/s)
1144 sound($, packOf(name), item).catch(() => {})
1145 return true
1146 } catch {
1147 // A sound is never worth an error line
1148 }
1149 return false
1150}
1151
1152// One sound of a pack: a clip of the plugin's, a line to speak, or a file of yours
1153async function sound($, pack, item) {
1154 if (pack.kind === 'voice') {
1155 try {
1156 await $.audio.speak(item, { voice: pack.voice })
1157 } catch {
1158 // The voice is not installed here: the default voice says it
1159 await $.audio.speak(item)
1160 }
1161 } else if (pack.kind === 'yours') {
1162 const { base64 } = await $.fs.read(pack.dir + '/' + item, { as: 'bytes' })
1163 await $.audio.play({ base64, mime: mimeOf(item) })
1164 } else {
1165 await $.audio.play({ asset: 'sounds/' + pack.name + '/' + item })
1166 }
1167}
1168
1169// A clip of the moment, never the one this terminal played last for it
1170function pick(choices, lastClip, key, now) {
1171 let i = Math.floor(now / 1000) % choices.length
1172 if (choices.length > 1 && choices[i] === lastClip[key]) i = (i + 1) % choices.length
1173 lastClip[key] = choices[i]
1174 return choices[i]
1175}
1176
1177// The file every terminal reads and writes the last play time through
1178async function playedAtFile($) {
1179 return (await $.env.get('HOME')) + '/.config/' + $.plugin.name + '/last-played'
1180}
1181
1182// The last /sounds default, shared by every terminal of both accounts beside the last play time
1183async function allFile($) {
1184 return (await $.env.get('HOME')) + '/.config/' + $.plugin.name + '/all-terminals.json'
1185}
1186
1187async function readAll($) {
1188 try {
1189 const all = JSON.parse(await $.fs.read(await allFile($)))
1190 return all && typeof all.pack === 'string' && Number.isFinite(all.at) ? all : undefined
1191 } catch {
1192 // Never used, or unreadable: each terminal keeps its own pack
1193 return undefined
1194 }
1195}
1196
1197// Packs per project folder, { "<folder>": "<pack>" }, shared by both accounts like the files above
1198async function projectsFile($) {
1199 return (await $.env.get('HOME')) + '/.config/' + $.plugin.name + '/projects.json'
1200}