vibeaudio 0.13.1 → 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 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 only?** Skip npm: `claude plugin marketplace add kiril6/vibeaudio`, then `claude plugin install vibeaudio@vibeaudio` ([details](#as-a-claude-code-plugin)).
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
 
@@ -324,20 +324,36 @@ Sessions with no id in their payload share a single slot, so they behave as one.
324
324
 
325
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.
326
326
 
327
- ### As a Claude Code plugin
327
+ ### As a plugin (Claude Code and Codex)
328
328
 
329
- If Claude Code is the only agent you use, the plugin installs the same hooks with no npm step:
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
+ ```
335
+
336
+ Or the same from a terminal:
330
337
 
331
338
  ```bash
332
339
  claude plugin marketplace add kiril6/vibeaudio
333
340
  claude plugin install vibeaudio@vibeaudio
334
341
  ```
335
342
 
336
- It needs Node 18+ on your `PATH` (the hooks run `node`), and 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).
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).
337
353
 
338
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.
339
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.
340
- - **Claude Code only.** Codex, Cursor, Gemini and the others still use `vibe --install-hooks`.
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.
341
357
  - **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
342
358
 
343
359
  ### `/vibe` inside Claude Code
@@ -466,7 +482,7 @@ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiti
466
482
  {"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
467
483
  ```
468
484
 
469
- Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended`. 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:
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:
470
486
 
471
487
  ```bash
472
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.13.1",
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 f='.claude-plugin/plugin.json',j=require('./'+f);j.version=require('./package.json').version;require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
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,
@@ -688,7 +699,7 @@ function uninstallHookTargets() {
688
699
  // `npm rm -g` right after this would take away the only thing that could
689
700
  // stop it. Done here rather than in uninstallHooks() so that function stays
690
701
  // a pure config edit for tests.
691
- 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.`);
692
703
  }
693
704
 
694
705
  /**
@@ -1324,13 +1335,23 @@ function renderToFile(target, genre) {
1324
1335
  console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
1325
1336
  }
1326
1337
 
1327
- 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 = {} }) {
1328
1339
  const hooks = require("./hooks");
1329
1340
 
1330
- // The plugin's hooks and --install-hooks' hooks are the same events: with both
1331
- // present every prompt would restart the music and every turn chime twice.
1332
- // The installed ones win because they carry the user's --reactive choice.
1333
- if (plugin && action.startsWith("hook-") && hooks.userHooksInstalled()) return;
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
+ }
1334
1355
 
1335
1356
  switch (action) {
1336
1357
  case "daemon":
@@ -1557,6 +1578,7 @@ async function run() {
1557
1578
  reactive,
1558
1579
  followVolume,
1559
1580
  plugin,
1581
+ hookEvent,
1560
1582
  dryRun,
1561
1583
  tools,
1562
1584
  typed,
@@ -1574,7 +1596,7 @@ async function run() {
1574
1596
 
1575
1597
  if (hookAction) {
1576
1598
  try {
1577
- 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 });
1578
1600
  } catch (e) {
1579
1601
  // Settings problems are the user's to fix — report them, don't stack-trace.
1580
1602
  console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
@@ -1600,8 +1622,9 @@ async function run() {
1600
1622
  const minutes = muteMinutes === null ? DEFAULT_MUTE_MINUTES : muteMinutes;
1601
1623
  const state = setMuted(true, minutes);
1602
1624
  // Muting has to silence what is playing right now, not just the next
1603
- // prompt - the whole point is that a call is already ringing.
1604
- const stopped = require("./hooks").stopDaemon();
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 });
1605
1628
 
1606
1629
  console.log(`\x1b[33m🔇 Muted ${muteRemainingText(state)}.\x1b[0m`);
1607
1630
  if (stopped) console.log(` Stopped the player that was running.`);
package/src/hooks.js CHANGED
@@ -220,7 +220,9 @@ const TARGETS = {
220
220
  interrupt: "Interrupt",
221
221
  end: "SessionEnd"
222
222
  },
223
- entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
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 }] }),
224
226
  commands: (entry) => (entry.hooks || []).map((h) => h.command),
225
227
  seed: () => ({}),
226
228
  // Codex records a trusted_hash per hook in config.toml and asks before
@@ -384,6 +386,82 @@ function expectedEvents(id) {
384
386
  return [ev.start, ev.stop, ...(ev.wait || []), ...(ev.resume || []), ev.failure, ev.end, ev.interrupt].filter(Boolean);
385
387
  }
386
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
+
387
465
  function settingsPath() {
388
466
  return TARGETS.claude.file();
389
467
  }
@@ -456,14 +534,25 @@ function daemonPlaying() {
456
534
 
457
535
  /**
458
536
  * Stops the player. `keepSessions` leaves the session files alone: the hooks
459
- * use it because they decide per session what is still going on. Every other
460
- * stop (--stop, --mute, uninstall) ends everything, or a later tool call would
461
- * resume music nobody is waiting for.
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).
462
543
  */
463
- function stopDaemon({ keepSessions = false } = {}) {
544
+ function stopDaemon({ keepSessions = false, reason = "stop" } = {}) {
464
545
  const pid = readPid();
465
546
  fs.rmSync(PID_FILE, { force: true });
466
- if (!keepSessions) fs.rmSync(SESSIONS_DIR, { recursive: true, force: true });
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
+ }
467
556
  if (pid === null || !isOurDaemon(pid)) return false;
468
557
 
469
558
  try {
@@ -1145,7 +1234,7 @@ function setHook(hooks, event, command, id) {
1145
1234
  // keeps the existing entries at their original index, which is what Codex
1146
1235
  // keys its per-hook trust records by.
1147
1236
  const kept = (hooks[event] || []).filter((entry) => !isVibeHook(entry, id));
1148
- kept.push(target(id).entry(command));
1237
+ kept.push(target(id).entry(command, event));
1149
1238
  hooks[event] = kept;
1150
1239
  }
1151
1240
 
@@ -1168,8 +1257,8 @@ function readVibeEntryCount(file, id) {
1168
1257
  }
1169
1258
 
1170
1259
  /** True when --install-hooks has already written our entries for Claude Code. */
1171
- function userHooksInstalled(file = null) {
1172
- return readVibeEntryCount(file || TARGETS.claude.file(), "claude") > 0;
1260
+ function userHooksInstalled(file = null, id = "claude") {
1261
+ return readVibeEntryCount(file || TARGETS[id].file(), id) > 0;
1173
1262
  }
1174
1263
 
1175
1264
  function loadSettings(file, t = null) {
@@ -1383,6 +1472,11 @@ module.exports = {
1383
1472
  claudeConfigDir,
1384
1473
  codexHome,
1385
1474
  expectedEvents,
1475
+ PLUGIN_FILES,
1476
+ PLUGIN_AGENTS,
1477
+ pluginHookEvents,
1478
+ pluginHooksFile,
1479
+ pluginAgent,
1386
1480
  runDaemon,
1387
1481
  hookStart,
1388
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
- const CACHE_ROOT = path.join(os.homedir(), ".vibeaudio", "cache");
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
- for (const entry of fs.readdirSync(CACHE_ROOT, { withFileTypes: true })) {
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.rmSync(CACHE_ROOT, { recursive: true, force: true });
211
+ if (fs.existsSync(CACHE_ROOT)) removeCacheEntries();
207
212
  return CACHE_ROOT;
208
213
  }
209
214