vibeaudio 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -11
- package/package.json +2 -2
- package/src/cli.js +49 -13
- package/src/hooks.js +154 -17
- package/src/player.js +15 -10
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
> **In a hurry?** `npm i -g vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
|
|
14
14
|
>
|
|
15
|
-
> **Claude Code
|
|
15
|
+
> **Only Claude Code or Codex?** Skip npm. Inside a Claude Code session: `/plugin marketplace add kiril6/vibeaudio`, then `/plugin install vibeaudio@vibeaudio`. For Codex: `codex plugin marketplace add kiril6/vibeaudio`, then `codex plugin add vibeaudio@vibeaudio` ([details](#as-a-plugin-claude-code-and-codex)).
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
|
|
21
21
|
AI coding agents take 15–45 seconds to reason, read files and write code. Staring at a blank cursor feels slow; tabbing away means checking back to see if it's done. VibeAudio fills that gap with music while the agent thinks, and a chime when your output is ready to read.
|
|
22
22
|
|
|
23
|
+
It is built as **calm technology**, in the sense of Mark Weiser and John Seely Brown's *Designing Calm Technology* (Xerox PARC, 1995): information that stays at the edge of your attention and only comes to the front when it matters. You stop noticing the music, but you notice when it changes.
|
|
24
|
+
|
|
25
|
+
| Principle | In VibeAudio |
|
|
26
|
+
| :--- | :--- |
|
|
27
|
+
| Inform without demanding attention | The music sits in the background. What you notice is it *stopping*. |
|
|
28
|
+
| Come to the front only when it matters | Chimes for done, failed and "needs you" only. The music doesn't react to every tool call unless you ask it to ([reactive mode](#reactive-mode-opt-in)). |
|
|
29
|
+
| Make the background informative | Layers build as a turn runs long, and a heartbeat joins when an agent keeps failing. That fires in under 1% of turns, by design. |
|
|
30
|
+
| Calm, not alarming | "Needs you" is a soft rising interval, not an alarm. A mute ends on its own after an hour. |
|
|
31
|
+
|
|
23
32
|
**How it behaves:**
|
|
24
33
|
|
|
25
34
|
* 🧮 **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code — no audio assets, no npm dependencies, no `node-gyp`.
|
|
@@ -251,8 +260,8 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
251
260
|
|
|
252
261
|
| | File | Music starts | Music stops + chime | Reactive (opt-in) |
|
|
253
262
|
| :--- | :--- | :--- | :--- | :--- |
|
|
254
|
-
| **Claude Code** | `~/.claude/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
255
|
-
| **Codex** | `~/.codex/hooks.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
263
|
+
| **Claude Code** | `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
264
|
+
| **Codex** | `~/.codex/hooks.json` (or `$CODEX_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
256
265
|
| **Cursor** | `~/.cursor/hooks.json` | `beforeSubmitPrompt` | `stop` | `preToolUse` |
|
|
257
266
|
| **Grok** | `~/.grok/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
258
267
|
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
@@ -265,7 +274,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
265
274
|
| | Music pauses + "your turn" chime | Music resumes | Failure chime (API error) | Session closes mid-turn (silent) |
|
|
266
275
|
| :--- | :--- | :--- | :--- | :--- |
|
|
267
276
|
| **Claude Code** | `PermissionRequest`, `Elicitation` | `PostToolUse`, `PostToolUseFailure`, `ElicitationResult` | `StopFailure` | `SessionEnd` |
|
|
268
|
-
| **Codex** | `PermissionRequest` | `PostToolUse` | — |
|
|
277
|
+
| **Codex** | `PermissionRequest` | `PostToolUse` | — | `SessionEnd`, and `Interrupt` (Esc) |
|
|
269
278
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
270
279
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
271
280
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
@@ -283,6 +292,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
283
292
|
| **Claude Code turns that never reach `Stop` still end the music** | An API error or rate limit ends the turn with `StopFailure` instead, which plays the failure chime. Interrupting (Esc, or the stop button in the desktop app) fires no hook at all, so the background player watches the session transcript for Claude Code's interrupt entry and stops silently within half a second — whether the agent was writing or running a tool. A prompt that another hook blocks (a token-saving proxy, say) never reaches `Stop` either, so the same watcher ends the music on Claude Code's "prompt blocked" entry — or never starts it, when the blocker was quicker than we were. Closing the session mid-turn stops it too — but only if that session started the music, so closing an idle terminal never silences another one. |
|
|
284
293
|
| **Codex asks you to trust the hook once** | Codex keeps a per-hook trust hash in `~/.codex/config.toml` and won't run a hook it hasn't been told to trust, so the install isn't live until you approve each one the first time it fires. |
|
|
285
294
|
| **Only Cursor can play the failure chime** | Its stop event reports whether the turn completed, aborted or errored. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
|
|
295
|
+
| **Upgraded, and a new feature isn't there?** | Hooks are written into your agent's config once, at install time, and an upgrade doesn't touch them. When a new version listens for more events (0.13.1 added Codex's `Interrupt` and `SessionEnd`), `vibe --doctor` and `vibe --status` name what's missing, and `vibe --install-hooks` adds it. |
|
|
296
|
+
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `CODEX_HOME` the way the agents do, so hooks (and `/vibe`) go where your agent actually reads them. Set the variable in the shell you run `vibe --install-hooks` from. |
|
|
286
297
|
| **Grok and Copilot CLI get a file of their own** | Each reads every `*.json` in its `hooks/` directory, so VibeAudio writes `vibeaudio.json` rather than merging into anyone else's — which makes uninstalling it a delete, and leaves no backup file behind. Copilot's honours `COPILOT_HOME`. |
|
|
287
298
|
|
|
288
299
|
</details>
|
|
@@ -309,29 +320,45 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
309
320
|
|
|
310
321
|
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or Windsurf session does the same hasn't been checked, so start a new session there to be sure.
|
|
311
322
|
|
|
312
|
-
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json
|
|
323
|
+
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-antigravity-via-mcp).)
|
|
313
324
|
|
|
314
325
|
> **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers — deliberate, not a limitation being worked around.
|
|
315
326
|
|
|
316
|
-
### As a Claude Code
|
|
327
|
+
### As a plugin (Claude Code and Codex)
|
|
328
|
+
|
|
329
|
+
If Claude Code or Codex is the agent you use, the plugin installs the same hooks with no npm step. Inside a Claude Code session, type:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
/plugin marketplace add kiril6/vibeaudio
|
|
333
|
+
/plugin install vibeaudio@vibeaudio
|
|
334
|
+
```
|
|
317
335
|
|
|
318
|
-
|
|
336
|
+
Or the same from a terminal:
|
|
319
337
|
|
|
320
338
|
```bash
|
|
321
339
|
claude plugin marketplace add kiril6/vibeaudio
|
|
322
340
|
claude plugin install vibeaudio@vibeaudio
|
|
323
341
|
```
|
|
324
342
|
|
|
325
|
-
|
|
343
|
+
For Codex:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
codex plugin marketplace add kiril6/vibeaudio
|
|
347
|
+
codex plugin add vibeaudio@vibeaudio
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Codex asks you to approve the plugin's hooks once, as it does for any hook. Update with `codex plugin marketplace upgrade vibeaudio`; remove with `codex plugin remove vibeaudio@vibeaudio`.
|
|
351
|
+
|
|
352
|
+
The plugin needs Node 18+ on your `PATH` (the hooks run `node`). In Claude Code it also adds `/vibeaudio:vibe`, the plugin's spelling of [`/vibe`](#vibe-inside-claude-code). Update with `claude plugin update vibeaudio@vibeaudio`; remove with `claude plugin uninstall vibeaudio@vibeaudio` (music already playing stops on its own within 15 minutes, or run `/vibeaudio:vibe stop` first).
|
|
326
353
|
|
|
327
354
|
- **Settings are the same file.** `vibe --genre jazz` and the rest work as always, but the `vibe` command comes from `npm i -g vibeaudio`; the plugin alone puts nothing on your `PATH`. Use `/vibeaudio:vibe genre jazz` instead.
|
|
328
355
|
- **Both at once is safe.** If `--install-hooks` has also been run, the plugin's hooks step aside and the installed ones play, so there's no double chime. Remove the installed hooks first if you want the plugin to own it.
|
|
329
|
-
- **Claude Code
|
|
356
|
+
- **Claude Code and Codex.** Cursor, Gemini and the others still use `vibe --install-hooks`. Copilot CLI and Qwen Code can load the same plugin and it carries their events, but neither has been run with it yet.
|
|
330
357
|
- **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
|
|
331
358
|
|
|
332
359
|
### `/vibe` inside Claude Code
|
|
333
360
|
|
|
334
|
-
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`), so you can control the music without leaving the session:
|
|
361
|
+
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`, or under `$CLAUDE_CONFIG_DIR`), so you can control the music without leaving the session:
|
|
335
362
|
|
|
336
363
|
```text
|
|
337
364
|
/vibe what's installed and playing
|
|
@@ -455,7 +482,7 @@ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiti
|
|
|
455
482
|
{"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
|
|
456
483
|
```
|
|
457
484
|
|
|
458
|
-
Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended
|
|
485
|
+
Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended` (with `reason` when `vibe --stop` or `--uninstall-hooks` ended it). Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field. Some examples:
|
|
459
486
|
|
|
460
487
|
```bash
|
|
461
488
|
# tmux: show the state in the status bar
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node test/test-synth.js",
|
|
12
12
|
"start": "node bin/vibeaudio.js",
|
|
13
|
-
"version": "node -e \"const
|
|
13
|
+
"version": "node -e \"const fs=require('fs'),v=require('./package.json').version;for(const f of ['.claude-plugin/plugin.json','.codex-plugin/plugin.json']){const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version=v;fs.writeFileSync(f,JSON.stringify(j,null,2)+'\\n')}\" && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
|
|
14
14
|
"postinstall": "node scripts/postinstall.js"
|
|
15
15
|
},
|
|
16
16
|
"publishConfig": {
|
package/src/cli.js
CHANGED
|
@@ -137,7 +137,8 @@ const VALUE_FLAGS = new Set([
|
|
|
137
137
|
"-cv", "--chime-volume",
|
|
138
138
|
"--grace",
|
|
139
139
|
"--seed",
|
|
140
|
-
"--tools"
|
|
140
|
+
"--tools",
|
|
141
|
+
"--event"
|
|
141
142
|
]);
|
|
142
143
|
|
|
143
144
|
function parseArgs(argv) {
|
|
@@ -189,6 +190,7 @@ function parseArgs(argv) {
|
|
|
189
190
|
let reactive = false;
|
|
190
191
|
let followVolume = false;
|
|
191
192
|
let plugin = false;
|
|
193
|
+
let hookEvent = null;
|
|
192
194
|
let here_flag = false;
|
|
193
195
|
let dryRun = false;
|
|
194
196
|
let tools = null;
|
|
@@ -381,6 +383,14 @@ function parseArgs(argv) {
|
|
|
381
383
|
continue;
|
|
382
384
|
}
|
|
383
385
|
|
|
386
|
+
// Internal, also the plugin's: which event this hook entry is for, so a
|
|
387
|
+
// hook can ignore events the agent running it does not use that way.
|
|
388
|
+
if (arg === "--event") {
|
|
389
|
+
hookEvent = args[i + 1] || null;
|
|
390
|
+
i += 2;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
384
394
|
if (arg === "--dry-run") {
|
|
385
395
|
dryRun = true;
|
|
386
396
|
i += 1;
|
|
@@ -494,6 +504,7 @@ function parseArgs(argv) {
|
|
|
494
504
|
reactive,
|
|
495
505
|
followVolume,
|
|
496
506
|
plugin,
|
|
507
|
+
hookEvent,
|
|
497
508
|
dryRun,
|
|
498
509
|
tools,
|
|
499
510
|
typed,
|
|
@@ -600,6 +611,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
|
|
|
600
611
|
if (events.resume) console.log(` ${events.resume[0].padEnd(19)}→ music resumes once you've answered`);
|
|
601
612
|
if (events.failure) console.log(` ${events.failure.padEnd(19)}→ music stops + failure chime (API error)`);
|
|
602
613
|
if (events.end) console.log(` ${events.end.padEnd(19)}→ music stops if that session started it`);
|
|
614
|
+
if (events.interrupt) console.log(` ${events.interrupt.padEnd(19)}→ music stops silently when you interrupt`);
|
|
603
615
|
|
|
604
616
|
if (id === "claude") {
|
|
605
617
|
const slash = hooks.installSlashCommand({ dryRun });
|
|
@@ -687,7 +699,7 @@ function uninstallHookTargets() {
|
|
|
687
699
|
// `npm rm -g` right after this would take away the only thing that could
|
|
688
700
|
// stop it. Done here rather than in uninstallHooks() so that function stays
|
|
689
701
|
// a pure config edit for tests.
|
|
690
|
-
if (hooks.stopDaemon()) console.log(` Stopped the background player that was still running.`);
|
|
702
|
+
if (hooks.stopDaemon({ reason: "uninstall" })) console.log(` Stopped the background player that was still running.`);
|
|
691
703
|
}
|
|
692
704
|
|
|
693
705
|
/**
|
|
@@ -770,6 +782,10 @@ function printStatus() {
|
|
|
770
782
|
if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
|
|
771
783
|
console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
|
|
772
784
|
}
|
|
785
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
786
|
+
if (absent.length) {
|
|
787
|
+
console.log(` \x1b[33m+ ${absent.join(", ")} added since this install — vibe --install-hooks picks them up\x1b[0m`);
|
|
788
|
+
}
|
|
773
789
|
}
|
|
774
790
|
|
|
775
791
|
// Background player
|
|
@@ -883,8 +899,11 @@ function doctorChecks() {
|
|
|
883
899
|
const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
884
900
|
for (const p of tokens.slice(0, 2)) if (!fs.existsSync(p)) missing.add(p);
|
|
885
901
|
}
|
|
902
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
886
903
|
if (missing.size) {
|
|
887
904
|
add("fail", label, `points at a file that no longer exists: ${[...missing].join(", ")}`, "vibe --install-hooks");
|
|
905
|
+
} else if (absent.length) {
|
|
906
|
+
add("warn", label, `installed by an older version, missing ${absent.join(", ")}`, "vibe --install-hooks");
|
|
888
907
|
} else if (installed.some(({ command }) => /--genre /.test(command))) {
|
|
889
908
|
add("warn", label, "pinned to a genre/volume by an older install, which overrides your saved settings", "vibe --install-hooks");
|
|
890
909
|
} else {
|
|
@@ -895,15 +914,20 @@ function doctorChecks() {
|
|
|
895
914
|
// config.toml under "<file>:<event>:<group>:<index>".
|
|
896
915
|
if (id === "codex") {
|
|
897
916
|
let toml = "";
|
|
898
|
-
try { toml = fs.readFileSync(path.join(
|
|
917
|
+
try { toml = fs.readFileSync(path.join(hooks.codexHome(), "config.toml"), "utf8"); } catch (e) { /* none yet */ }
|
|
899
918
|
const pending = [];
|
|
900
919
|
try {
|
|
901
920
|
const file = t.file();
|
|
921
|
+
// Codex canonicalizes CODEX_HOME before keying (find_codex_home()), so
|
|
922
|
+
// a symlinked one - /tmp on macOS - is recorded under its real path.
|
|
923
|
+
let real = file;
|
|
924
|
+
try { real = fs.realpathSync(file); } catch (e) { /* checked below */ }
|
|
902
925
|
for (const [event, entries] of Object.entries(JSON.parse(fs.readFileSync(file, "utf8")).hooks || {})) {
|
|
903
926
|
(entries || []).forEach((entry, g) => {
|
|
904
927
|
t.commands(entry).forEach((command, i) => {
|
|
905
|
-
const
|
|
906
|
-
|
|
928
|
+
const suffix = `:${event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()}:${g}:${i}`;
|
|
929
|
+
const trusted = [file, real].some((f) => toml.includes(`"${f}${suffix}"`));
|
|
930
|
+
if (hooks.VIBE_HOOK_FLAG.test(command || "") && !trusted) pending.push(event);
|
|
907
931
|
});
|
|
908
932
|
});
|
|
909
933
|
}
|
|
@@ -1311,13 +1335,23 @@ function renderToFile(target, genre) {
|
|
|
1311
1335
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1312
1336
|
}
|
|
1313
1337
|
|
|
1314
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed = {} }) {
|
|
1338
|
+
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
|
|
1315
1339
|
const hooks = require("./hooks");
|
|
1316
1340
|
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1341
|
+
if (plugin && action.startsWith("hook-")) {
|
|
1342
|
+
// One plugin, four agents (PLUGIN_AGENTS). Each check is against the
|
|
1343
|
+
// agent actually running this hook, never Claude Code's by default.
|
|
1344
|
+
const agent = hooks.pluginAgent();
|
|
1345
|
+
// The plugin's hooks and --install-hooks' hooks are the same events: with
|
|
1346
|
+
// both present every prompt would restart the music and every turn chime
|
|
1347
|
+
// twice. The installed ones win because they carry the user's --reactive.
|
|
1348
|
+
if (hooks.userHooksInstalled(null, agent)) return;
|
|
1349
|
+
// The plugin's file carries every agent's events; this one may not use
|
|
1350
|
+
// this event this way. Copilot fires PermissionRequest before every
|
|
1351
|
+
// permission check, dialog or not, so taking it as a wait there would
|
|
1352
|
+
// pause the music on every tool call.
|
|
1353
|
+
if (hookEvent && !hooks.expectedEvents(agent).includes(hookEvent)) return;
|
|
1354
|
+
}
|
|
1321
1355
|
|
|
1322
1356
|
switch (action) {
|
|
1323
1357
|
case "daemon":
|
|
@@ -1544,6 +1578,7 @@ async function run() {
|
|
|
1544
1578
|
reactive,
|
|
1545
1579
|
followVolume,
|
|
1546
1580
|
plugin,
|
|
1581
|
+
hookEvent,
|
|
1547
1582
|
dryRun,
|
|
1548
1583
|
tools,
|
|
1549
1584
|
typed,
|
|
@@ -1561,7 +1596,7 @@ async function run() {
|
|
|
1561
1596
|
|
|
1562
1597
|
if (hookAction) {
|
|
1563
1598
|
try {
|
|
1564
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed });
|
|
1599
|
+
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
|
|
1565
1600
|
} catch (e) {
|
|
1566
1601
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1567
1602
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -1587,8 +1622,9 @@ async function run() {
|
|
|
1587
1622
|
const minutes = muteMinutes === null ? DEFAULT_MUTE_MINUTES : muteMinutes;
|
|
1588
1623
|
const state = setMuted(true, minutes);
|
|
1589
1624
|
// Muting has to silence what is playing right now, not just the next
|
|
1590
|
-
// prompt - the whole point is that a call is already ringing.
|
|
1591
|
-
|
|
1625
|
+
// prompt - the whole point is that a call is already ringing. The turns
|
|
1626
|
+
// in flight are kept: they still finish, log and show in --state.
|
|
1627
|
+
const stopped = require("./hooks").stopDaemon({ keepSessions: true });
|
|
1592
1628
|
|
|
1593
1629
|
console.log(`\x1b[33m🔇 Muted ${muteRemainingText(state)}.\x1b[0m`);
|
|
1594
1630
|
if (stopped) console.log(` Stopped the player that was running.`);
|
package/src/hooks.js
CHANGED
|
@@ -153,15 +153,35 @@ const MAX_DAEMON_MS = 15 * 60 * 1000;
|
|
|
153
153
|
* - failure: a turn ending in error *instead of* the stop event
|
|
154
154
|
* - end: the session closing, which can cut a turn off before stop
|
|
155
155
|
*
|
|
156
|
+
* - interrupt: the user stopped the turn (Esc), a silent end like `end`
|
|
157
|
+
*
|
|
156
158
|
* `seed` is the root object to write when the file does not exist yet. Cursor
|
|
157
159
|
* and Copilot require a schema version; Codex rejects unknown root keys
|
|
158
160
|
* outright, so nothing may be added there beyond `hooks`.
|
|
159
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Where Claude Code and Codex keep their config. Both move with an env var,
|
|
164
|
+
* and a user who sets one has hooks read from there only: writing to the
|
|
165
|
+
* default path left VibeAudio installed in a file nothing read, and silent.
|
|
166
|
+
* Claude Code: `CLAUDE_CONFIG_DIR ?? ~/.claude`, NFC-normalized (cn() in
|
|
167
|
+
* 2.1.195's bundle; settings.json and commands/ both live under it).
|
|
168
|
+
* Codex: `CODEX_HOME`, empty treated as unset, else `~/.codex`
|
|
169
|
+
* (find_codex_home(), 0.160 source). Empty is unset for both here, so an
|
|
170
|
+
* exported-but-blank variable cannot turn the path relative.
|
|
171
|
+
*/
|
|
172
|
+
function claudeConfigDir() {
|
|
173
|
+
return (process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")).normalize("NFC");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codexHome() {
|
|
177
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
const TARGETS = {
|
|
161
181
|
claude: {
|
|
162
182
|
name: "Claude Code",
|
|
163
183
|
cmd: "claude",
|
|
164
|
-
file: () => path.join(
|
|
184
|
+
file: () => path.join(claudeConfigDir(), "settings.json"),
|
|
165
185
|
// - wait: PermissionRequest fires when the dialog is shown in the terminal,
|
|
166
186
|
// the SDK (desktop app, IDEs) and print mode alike - Notification's
|
|
167
187
|
// permission_prompt is raised by the terminal UI alone, after 6s idle.
|
|
@@ -182,19 +202,27 @@ const TARGETS = {
|
|
|
182
202
|
codex: {
|
|
183
203
|
name: "Codex",
|
|
184
204
|
cmd: "codex",
|
|
185
|
-
file: () => path.join(
|
|
205
|
+
file: () => path.join(codexHome(), "hooks.json"),
|
|
186
206
|
// PermissionRequest runs only when Codex is about to ask for approval,
|
|
187
|
-
// with tool_name, and is present in 0.125's binary.
|
|
188
|
-
//
|
|
189
|
-
//
|
|
207
|
+
// with tool_name, and is present in 0.125's binary. SessionEnd arrived in
|
|
208
|
+
// 0.145 and Interrupt in 0.150 (source of each release tag). Listing them
|
|
209
|
+
// is safe on older versions: HookEventsToml has never denied unknown
|
|
210
|
+
// fields (checked 0.125 through 0.160), so an older Codex ignores an
|
|
211
|
+
// event it does not know - only the file's root is strict. Interrupt runs
|
|
212
|
+
// on the turn-abort path, not the one that runs Stop, with session_id,
|
|
213
|
+
// cwd and transcript_path.
|
|
190
214
|
events: {
|
|
191
215
|
start: "UserPromptSubmit",
|
|
192
216
|
stop: "Stop",
|
|
193
217
|
tool: "PreToolUse",
|
|
194
218
|
wait: ["PermissionRequest"],
|
|
195
|
-
resume: ["PostToolUse"]
|
|
219
|
+
resume: ["PostToolUse"],
|
|
220
|
+
interrupt: "Interrupt",
|
|
221
|
+
end: "SessionEnd"
|
|
196
222
|
},
|
|
197
|
-
|
|
223
|
+
// Codex caps SessionEnd and Interrupt hooks at 3s (SESSION_END_MAX_TIMEOUT_SEC,
|
|
224
|
+
// discovery.rs) and warns on every run when one asks for more.
|
|
225
|
+
entry: (command, event) => ({ hooks: [{ type: "command", command, timeout: event === "SessionEnd" || event === "Interrupt" ? 3 : 5 }] }),
|
|
198
226
|
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
199
227
|
seed: () => ({}),
|
|
200
228
|
// Codex records a trusted_hash per hook in config.toml and asks before
|
|
@@ -346,6 +374,94 @@ function detectTargets() {
|
|
|
346
374
|
});
|
|
347
375
|
}
|
|
348
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Every event an install by this version writes for a target, `tool` aside
|
|
379
|
+
* (reactive mode only). A hook file is written once and outlives upgrades,
|
|
380
|
+
* so an event added in a later version - Codex's Interrupt and SessionEnd in
|
|
381
|
+
* 0.13.1 - never reaches someone who installed before it unless they
|
|
382
|
+
* reinstall. `--doctor` and `--status` compare against this to say so.
|
|
383
|
+
*/
|
|
384
|
+
function expectedEvents(id) {
|
|
385
|
+
const ev = TARGETS[id].events;
|
|
386
|
+
return [ev.start, ev.stop, ...(ev.wait || []), ...(ev.resume || []), ev.failure, ev.end, ev.interrupt].filter(Boolean);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The agents that load this repository as a plugin, and the hooks file each
|
|
391
|
+
* one reads. Copilot CLI loads `.claude-plugin/` manifests and Qwen Code
|
|
392
|
+
* converts the plugin on install (copying the folder, substituting
|
|
393
|
+
* CLAUDE_PLUGIN_ROOT), so both read Claude Code's hooks/hooks.json. Codex
|
|
394
|
+
* 0.160 would too, but Claude Code's validator rejects an event it does not
|
|
395
|
+
* know ("hooks.Interrupt: Invalid key in record") and then loads none of the
|
|
396
|
+
* file, so Codex's Interrupt cannot live there: `.codex-plugin/plugin.json`,
|
|
397
|
+
* which Codex reads before `.claude-plugin/` and Claude Code never reads,
|
|
398
|
+
* points Codex at a file of its own. Gemini CLI has its own extension format
|
|
399
|
+
* and is not one of these.
|
|
400
|
+
*/
|
|
401
|
+
const PLUGIN_FILES = {
|
|
402
|
+
"hooks/hooks.json": ["claude", "copilot", "qwen"],
|
|
403
|
+
"hooks/codex.json": ["codex"]
|
|
404
|
+
};
|
|
405
|
+
const PLUGIN_AGENTS = Object.values(PLUGIN_FILES).flat();
|
|
406
|
+
|
|
407
|
+
const SLOT_ACTION = { start: "hook-start", stop: "hook-stop", failure: "hook-stop", wait: "hook-wait", resume: "hook-resume", end: "hook-end", interrupt: "hook-end" };
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Every event the given agents fire, with the one action it runs. One file
|
|
411
|
+
* can serve several agents, so it carries the union; an event an agent does
|
|
412
|
+
* not use there is dropped at runtime by `--event` (see runHookAction).
|
|
413
|
+
* Throws if two agents would need different actions for one event name,
|
|
414
|
+
* since the file could not serve both.
|
|
415
|
+
*/
|
|
416
|
+
function pluginHookEvents(agents) {
|
|
417
|
+
const actions = new Map();
|
|
418
|
+
for (const id of agents) {
|
|
419
|
+
for (const [slot, value] of Object.entries(TARGETS[id].events)) {
|
|
420
|
+
if (!SLOT_ACTION[slot]) continue; // `tool` is reactive mode, an --install-hooks option
|
|
421
|
+
for (const event of [].concat(value)) {
|
|
422
|
+
const prior = actions.get(event);
|
|
423
|
+
if (prior && prior !== SLOT_ACTION[slot]) throw new Error(`${event}: ${prior} for one agent, ${SLOT_ACTION[slot]} for ${id}`);
|
|
424
|
+
actions.set(event, SLOT_ACTION[slot]);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return actions;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A plugin hooks file (see PLUGIN_FILES), generated so it cannot drift from
|
|
433
|
+
* TARGETS. Agents sharing a file share an entry shape, so the first one's is
|
|
434
|
+
* used. `script` is the CLI's path as the host spells it: Gemini's extension
|
|
435
|
+
* substitutes ${extensionPath} itself, the others leave CLAUDE_PLUGIN_ROOT to
|
|
436
|
+
* the shell.
|
|
437
|
+
*/
|
|
438
|
+
function pluginHooksFile(agents, script = "${CLAUDE_PLUGIN_ROOT}/bin/vibeaudio.js") {
|
|
439
|
+
const hooks = {};
|
|
440
|
+
for (const [event, action] of pluginHookEvents(agents)) {
|
|
441
|
+
hooks[event] = [TARGETS[agents[0]].entry(`node "${script}" --${action} --plugin --event ${event}`, event)];
|
|
442
|
+
}
|
|
443
|
+
return { hooks };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Which agent is running a plugin hook, from the environment each one sets:
|
|
448
|
+
* Copilot CLI sets COPILOT_PLUGIN_ROOT (its changelog), Qwen Code sets
|
|
449
|
+
* QWEN_PROJECT_DIR for every hook (hookRunner.ts), Gemini CLI sets
|
|
450
|
+
* GEMINI_PROJECT_DIR for its extension's hooks (hookRunner.ts), and Codex sets
|
|
451
|
+
* PLUGIN_ROOT (discovery.rs). Claude Code sets none of these, only
|
|
452
|
+
* CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA / CLAUDE_PROJECT_DIR (2.1.195's
|
|
453
|
+
* bundle). Copilot also sets PLUGIN_ROOT, so it is checked first.
|
|
454
|
+
* ponytail: a variable exported in the user's own shell would leak into
|
|
455
|
+
* hooks and misattribute them; none of these names is one a user sets.
|
|
456
|
+
*/
|
|
457
|
+
function pluginAgent(env = process.env) {
|
|
458
|
+
if (env.COPILOT_PLUGIN_ROOT) return "copilot";
|
|
459
|
+
if (env.QWEN_PROJECT_DIR) return "qwen";
|
|
460
|
+
if (env.GEMINI_PROJECT_DIR) return "gemini"; // after Qwen, a fork that sets it too
|
|
461
|
+
if (env.PLUGIN_ROOT) return "codex";
|
|
462
|
+
return "claude";
|
|
463
|
+
}
|
|
464
|
+
|
|
349
465
|
function settingsPath() {
|
|
350
466
|
return TARGETS.claude.file();
|
|
351
467
|
}
|
|
@@ -418,14 +534,25 @@ function daemonPlaying() {
|
|
|
418
534
|
|
|
419
535
|
/**
|
|
420
536
|
* Stops the player. `keepSessions` leaves the session files alone: the hooks
|
|
421
|
-
* use it because they decide per session what is still going on
|
|
422
|
-
*
|
|
423
|
-
*
|
|
537
|
+
* use it because they decide per session what is still going on, and --mute
|
|
538
|
+
* because a mute is about sound - the turns are still in flight, and should
|
|
539
|
+
* still reach --state, --events and --report. --stop and uninstall end
|
|
540
|
+
* everything, or a later tool call would resume music nobody is waiting for;
|
|
541
|
+
* each session dropped gets an `ended` event with that `reason`, or an
|
|
542
|
+
* --events consumer would show it working forever (#24).
|
|
424
543
|
*/
|
|
425
|
-
function stopDaemon({ keepSessions = false } = {}) {
|
|
544
|
+
function stopDaemon({ keepSessions = false, reason = "stop" } = {}) {
|
|
426
545
|
const pid = readPid();
|
|
427
546
|
fs.rmSync(PID_FILE, { force: true });
|
|
428
|
-
if (!keepSessions)
|
|
547
|
+
if (!keepSessions) {
|
|
548
|
+
// Removed before its event, so each line's status is what it left behind
|
|
549
|
+
// and the last one reads idle.
|
|
550
|
+
for (const s of listSessions()) {
|
|
551
|
+
fs.rmSync(sessionFile(s.id), { force: true });
|
|
552
|
+
emitEvent("ended", s.id, s.project, { reason });
|
|
553
|
+
}
|
|
554
|
+
fs.rmSync(SESSIONS_DIR, { recursive: true, force: true });
|
|
555
|
+
}
|
|
429
556
|
if (pid === null || !isOurDaemon(pid)) return false;
|
|
430
557
|
|
|
431
558
|
try {
|
|
@@ -1047,7 +1174,7 @@ function hookEnd(raw) {
|
|
|
1047
1174
|
if (!session) return false;
|
|
1048
1175
|
|
|
1049
1176
|
fs.rmSync(sessionFile(id), { force: true });
|
|
1050
|
-
emitEvent("ended", id, session.project);
|
|
1177
|
+
emitEvent(parsePayload(raw).hook_event_name === "Interrupt" ? "interrupted" : "ended", id, session.project);
|
|
1051
1178
|
if (working(listSessions()).length === 0) {
|
|
1052
1179
|
stopDaemon({ keepSessions: true });
|
|
1053
1180
|
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
@@ -1107,7 +1234,7 @@ function setHook(hooks, event, command, id) {
|
|
|
1107
1234
|
// keeps the existing entries at their original index, which is what Codex
|
|
1108
1235
|
// keys its per-hook trust records by.
|
|
1109
1236
|
const kept = (hooks[event] || []).filter((entry) => !isVibeHook(entry, id));
|
|
1110
|
-
kept.push(target(id).entry(command));
|
|
1237
|
+
kept.push(target(id).entry(command, event));
|
|
1111
1238
|
hooks[event] = kept;
|
|
1112
1239
|
}
|
|
1113
1240
|
|
|
@@ -1130,8 +1257,8 @@ function readVibeEntryCount(file, id) {
|
|
|
1130
1257
|
}
|
|
1131
1258
|
|
|
1132
1259
|
/** True when --install-hooks has already written our entries for Claude Code. */
|
|
1133
|
-
function userHooksInstalled(file = null) {
|
|
1134
|
-
return readVibeEntryCount(file || TARGETS.
|
|
1260
|
+
function userHooksInstalled(file = null, id = "claude") {
|
|
1261
|
+
return readVibeEntryCount(file || TARGETS[id].file(), id) > 0;
|
|
1135
1262
|
}
|
|
1136
1263
|
|
|
1137
1264
|
function loadSettings(file, t = null) {
|
|
@@ -1246,6 +1373,8 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1246
1373
|
}
|
|
1247
1374
|
if (ev.failure) setHook(settings.hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
|
|
1248
1375
|
if (ev.end) setHook(settings.hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
|
|
1376
|
+
// An interrupt ends the turn the same silent way a closed session does.
|
|
1377
|
+
if (ev.interrupt) setHook(settings.hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
|
|
1249
1378
|
|
|
1250
1379
|
const after = `${JSON.stringify(settings, null, 2)}\n`;
|
|
1251
1380
|
if (!dryRun) fs.writeFileSync(file, after);
|
|
@@ -1261,7 +1390,7 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1261
1390
|
const SLASH_MARK = "<!-- vibeaudio:slash-command -->";
|
|
1262
1391
|
|
|
1263
1392
|
function slashCommandFile() {
|
|
1264
|
-
return path.join(
|
|
1393
|
+
return path.join(claudeConfigDir(), "commands", "vibe.md");
|
|
1265
1394
|
}
|
|
1266
1395
|
|
|
1267
1396
|
function slashCommandText() {
|
|
@@ -1340,6 +1469,14 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
|
|
|
1340
1469
|
|
|
1341
1470
|
module.exports = {
|
|
1342
1471
|
shellQuote,
|
|
1472
|
+
claudeConfigDir,
|
|
1473
|
+
codexHome,
|
|
1474
|
+
expectedEvents,
|
|
1475
|
+
PLUGIN_FILES,
|
|
1476
|
+
PLUGIN_AGENTS,
|
|
1477
|
+
pluginHookEvents,
|
|
1478
|
+
pluginHooksFile,
|
|
1479
|
+
pluginAgent,
|
|
1343
1480
|
runDaemon,
|
|
1344
1481
|
hookStart,
|
|
1345
1482
|
hookStop,
|
package/src/player.js
CHANGED
|
@@ -23,7 +23,10 @@ const { hashString } = require("./synth/generator");
|
|
|
23
23
|
const { addTension } = require("./synth/tension");
|
|
24
24
|
const pkg = require("../package.json");
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// VIBE_CACHE_DIR is internal: the suite points it at a temp directory, so it
|
|
27
|
+
// neither races live hooks pruning the real cache nor evicts a real project's
|
|
28
|
+
// audio (#38).
|
|
29
|
+
const CACHE_ROOT = process.env.VIBE_CACHE_DIR || path.join(os.homedir(), ".vibeaudio", "cache");
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* The cache key has to change whenever the audio would, or existing users keep
|
|
@@ -189,21 +192,23 @@ function ensureCacheDir() {
|
|
|
189
192
|
*/
|
|
190
193
|
function pruneStaleCache() {
|
|
191
194
|
try {
|
|
192
|
-
|
|
193
|
-
const full = path.join(CACHE_ROOT, entry.name);
|
|
194
|
-
if (entry.isDirectory() && /^v\d/.test(entry.name) && entry.name !== path.basename(CACHE_DIR)) {
|
|
195
|
-
fs.rmSync(full, { recursive: true, force: true });
|
|
196
|
-
} else if (entry.isFile() && entry.name.endsWith(".wav")) {
|
|
197
|
-
fs.rmSync(full, { force: true });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
195
|
+
removeCacheEntries(path.basename(CACHE_DIR));
|
|
200
196
|
} catch (e) {
|
|
201
197
|
// Pruning is best-effort; a stale cache is not worth failing a run over.
|
|
202
198
|
}
|
|
203
199
|
}
|
|
204
200
|
|
|
201
|
+
// Only what we write - key directories, and loose WAVs from before keys - and
|
|
202
|
+
// never the root itself: VIBE_CACHE_DIR may name a directory holding more.
|
|
203
|
+
function removeCacheEntries(keep = null) {
|
|
204
|
+
for (const entry of fs.readdirSync(CACHE_ROOT, { withFileTypes: true })) {
|
|
205
|
+
const ours = entry.isDirectory() ? /^v\d/.test(entry.name) && entry.name !== keep : entry.isFile() && entry.name.endsWith(".wav");
|
|
206
|
+
if (ours) fs.rmSync(path.join(CACHE_ROOT, entry.name), { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
205
210
|
function clearCache() {
|
|
206
|
-
fs.
|
|
211
|
+
if (fs.existsSync(CACHE_ROOT)) removeCacheEntries();
|
|
207
212
|
return CACHE_ROOT;
|
|
208
213
|
}
|
|
209
214
|
|