vibeaudio 0.16.0 → 0.16.2

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
@@ -4,7 +4,7 @@
4
4
  <img src="docs/og.png?v=2" alt="VibeAudio — Focus music while your AI codes" width="720">
5
5
  </p>
6
6
 
7
- [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/vibeaudio)](https://www.npmjs.com/package/vibeaudio) [![downloads](https://img.shields.io/npm/d18m/vibeaudio?label=downloads)](https://npm-stat.com/charts.html?package=vibeaudio) [![downloads/month](https://img.shields.io/npm/dm/vibeaudio)](https://npm-stat.com/charts.html?package=vibeaudio)
7
+ [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/vibeaudio)](https://www.npmjs.com/package/vibeaudio) [![license](https://img.shields.io/npm/l/vibeaudio)](LICENSE) [![node](https://img.shields.io/node/v/vibeaudio)](https://nodejs.org) [![downloads/month](https://img.shields.io/npm/dm/vibeaudio)](https://npm-stat.com/charts.html?package=vibeaudio) [![M8ven Score](https://m8ven.ai/badge/mcp/kiril6/vibeaudio)](https://m8ven.ai/mcp/kiril6/vibeaudio?s=readme)
8
8
 
9
9
  > **Procedural focus music while your AI coding tools think.**
10
10
  > Every project gets its own arrangement. Zero dependencies, zero audio files.
@@ -321,7 +321,7 @@ Every session of an agent is tracked separately, by the session id in its hook p
321
321
 
322
322
  * **More sessions, more music.** Two sessions working at once plays at least tier 2 and three or more plays tier 3 — the same piece with more layers, arriving at the next loop boundary and easing off as sessions finish. It only ever raises the tier, so it works alongside time escalation and [reactive mode](#reactive-mode-opt-in).
323
323
  * **Dialogs and Esc are per session.** A permission dialog in one terminal plays the "your turn" chime but only pauses the music once *every* session is waiting; Esc ends just that session's turn.
324
- * **Crashed agents can't hold it hostage.** A session that never sent `Stop` is dropped after 15 minutes, the same ceiling the background player stops at.
324
+ * **Crashed agents can't hold it hostage.** A session that goes 15 minutes without any hook (no tool call, no `Stop`) is taken for a crashed agent and dropped. A long turn that keeps working keeps its music.
325
325
 
326
326
  Sessions with no id in their payload share a single slot, so they behave as one. VibeAudio keeps one stream: per-session genres or several streams mixed together aren't supported.
327
327
 
@@ -499,7 +499,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
499
499
  * **Backs up first, once.** The first install copies your file alongside as `*.vibeaudio.bak`. Later installs leave it alone — re-copying would overwrite your real pre-VibeAudio config with a copy of VibeAudio's own last install.
500
500
  * **Reinstalling updates, never duplicates.** Uninstalling sweeps every supported agent and removes only VibeAudio's own entries.
501
501
  * **Refuses rather than clobbers.** Malformed JSON aborts the write; a temporary `npx` checkout is rejected outright, since the hook records an absolute path that npm's cache eviction would later delete.
502
- * **Can't run away.** The background player is capped at 15 minutes, so a missed stop event can't leave music looping.
502
+ * **Can't run away.** The background player stops once its sessions have been silent for 15 minutes (and after 4 hours at most), so a missed stop event can't leave music looping.
503
503
 
504
504
  </details>
505
505
 
@@ -921,10 +921,6 @@ Apart from those two, the three commands above remove everything VibeAudio write
921
921
 
922
922
  ---
923
923
 
924
- If VibeAudio is useful to you, consider giving it a ⭐ on [GitHub](https://github.com/kiril6/vibeaudio) — it helps others find it.
925
-
926
- ---
927
-
928
924
  ## 🤝 Contributing
929
925
 
930
926
  Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the ground rules (zero runtime dependencies, no build step, pure synth modules) and the dev loop. Found a bug? [Open an issue](https://github.com/kiril6/vibeaudio/issues/new) with your **OS**, **Node version**, and which audio player you have installed.
@@ -940,5 +936,13 @@ npm test
940
936
 
941
937
  ---
942
938
 
939
+ ## 💛 Supporting the Project
940
+
941
+ If VibeAudio is useful to you, consider giving it a ⭐ — it helps others find it.
942
+
943
+ Support development: <a href="https://ko-fi.com/K3K2X0ERJ"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi" align="absmiddle" /></a>
944
+
945
+ ---
946
+
943
947
  ## 📄 License
944
948
  MIT © 2026
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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",
package/src/hooks.js CHANGED
@@ -143,8 +143,13 @@ function hookTool() {
143
143
  setTimeout(commit, 500).unref();
144
144
  }
145
145
 
146
- // A lost Stop hook must not leave music looping forever.
147
- const MAX_DAEMON_MS = 15 * 60 * 1000;
146
+ // A lost Stop hook must not leave music looping forever. This is how long a
147
+ // session may go without a hook before it is taken for a crashed agent; every
148
+ // tool call refreshes it, so a long turn that keeps working keeps its music.
149
+ // VIBE_MAX_DAEMON_MS is internal, for the suite.
150
+ const MAX_DAEMON_MS = Number(process.env.VIBE_MAX_DAEMON_MS) || 15 * 60 * 1000;
151
+ // The daemon's own ceiling, whatever the sessions say: a backstop, not the rule.
152
+ const DAEMON_CEILING_MS = Math.max(MAX_DAEMON_MS, 4 * 60 * 60 * 1000);
148
153
 
149
154
  /**
150
155
  * The agents VibeAudio can wire itself into, and the few things that differ
@@ -519,7 +524,7 @@ function readPid() {
519
524
  * so an unverified kill would eventually SIGTERM an unrelated process.
520
525
  *
521
526
  * Failing closed is the safe direction here: a daemon we decline to kill stops
522
- * itself at MAX_DAEMON_MS, while killing a stranger's process has no such
527
+ * itself once no session is left (MAX_DAEMON_MS of silence), while killing a stranger's process has no such
523
528
  * ceiling.
524
529
  *
525
530
  * ponytail: posix only. Windows has no cheap command-line lookup, so the pid
@@ -694,8 +699,8 @@ function writeSession(id, data) {
694
699
 
695
700
  /**
696
701
  * Every session with a turn in flight. One that has not been touched for
697
- * MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here,
698
- * the same ceiling the daemon itself stops at.
702
+ * MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here;
703
+ * the daemon stops once none is left, with DAEMON_CEILING_MS as the backstop.
699
704
  */
700
705
  function listSessions() {
701
706
  let names;
@@ -947,7 +952,7 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
947
952
 
948
953
  process.on("SIGTERM", shutdown);
949
954
  process.on("SIGINT", shutdown);
950
- setTimeout(shutdown, MAX_DAEMON_MS);
955
+ setTimeout(shutdown, DAEMON_CEILING_MS);
951
956
 
952
957
  setInterval(() => {
953
958
  // The pid file names the one daemon that owns the speakers. Two prompts
@@ -3,19 +3,15 @@
3
3
  * Zero dependencies - Pure Node.js ANSI and Readline
4
4
  */
5
5
 
6
- const { execSync } = require("child_process");
6
+ const { spawnSync } = require("child_process");
7
7
  const readline = require("readline");
8
8
 
9
9
  // Windows has no `which`; without this every tool shows as missing there.
10
10
  const LOOKUP_CMD = process.platform === "win32" ? "where" : "which";
11
11
 
12
12
  function isInstalled(cmd) {
13
- try {
14
- execSync(`${LOOKUP_CMD} ${cmd}`, { stdio: "ignore" });
15
- return true;
16
- } catch (e) {
17
- return false;
18
- }
13
+ // An argument vector, not a command string: nothing in `cmd` reaches a shell.
14
+ return spawnSync(LOOKUP_CMD, [cmd], { stdio: "ignore" }).status === 0;
19
15
  }
20
16
 
21
17
  // Convenience only - the wrapper runs any command, and "Custom command..."
package/src/mcp.js CHANGED
@@ -13,6 +13,19 @@ const pkg = require("../package.json");
13
13
  // own ceiling rather than looping forever.
14
14
  const MAX_PLAYBACK_MS = 15 * 60 * 1000;
15
15
 
16
+ // All four hints on every tool, as the spec's ToolAnnotations defines them, so
17
+ // a host can tell what each one does before the model calls it. None touches
18
+ // anything beyond the local audio player and VibeAudio's own cache.
19
+ const annotate = (title, { readOnly, idempotent }) => ({
20
+ title,
21
+ readOnlyHint: readOnly,
22
+ destructiveHint: false,
23
+ idempotentHint: idempotent,
24
+ openWorldHint: false
25
+ });
26
+
27
+ const OUTCOMES = ["success", "failure"];
28
+
16
29
  const TOOLS = [
17
30
  {
18
31
  name: "vibe_play",
@@ -22,6 +35,7 @@ const TOOLS = [
22
35
  "long file edits, repeated tool calls, anything the user will wait through. " +
23
36
  "Always pair it with vibe_stop when the task resolves. Skip it for quick " +
24
37
  "answers: music around a one-second reply is worse than silence.",
38
+ annotations: annotate("Start focus music", { readOnly: false, idempotent: true }),
25
39
  inputSchema: {
26
40
  type: "object",
27
41
  properties: {
@@ -46,13 +60,14 @@ const TOOLS = [
46
60
  "task resolves and you are ready to hand back a result, including when it " +
47
61
  "failed - pass outcome 'failure' so the chime says so. Never leave music " +
48
62
  "playing after a vibe_play task is done.",
63
+ annotations: annotate("Stop focus music", { readOnly: false, idempotent: false }),
49
64
  inputSchema: {
50
65
  type: "object",
51
66
  properties: {
52
67
  outcome: {
53
68
  type: "string",
54
69
  description: "Resolution outcome: 'success' (ascending chime) or 'failure' (soft minor tone)",
55
- enum: ["success", "failure"]
70
+ enum: OUTCOMES
56
71
  },
57
72
  playChime: {
58
73
  type: "boolean",
@@ -67,6 +82,7 @@ const TOOLS = [
67
82
  "Check whether focus music is currently playing, and in which genre and " +
68
83
  "intensity tier. Use it to avoid starting a second track, or to confirm " +
69
84
  "nothing was left running.",
85
+ annotations: annotate("Check focus music", { readOnly: true, idempotent: true }),
70
86
  inputSchema: {
71
87
  type: "object",
72
88
  properties: {}
@@ -137,7 +153,10 @@ function handleMessage(player, msg) {
137
153
  // saved file here is what makes `vibe --genre jazz` mean jazz in a
138
154
  // desktop client too, rather than only in the terminal.
139
155
  const saved = loadConfig();
140
- const requested = args.genre || process.env.VIBE_GENRE || saved.genre || "lofi";
156
+ // The argument comes from the client: only a string counts. A number or
157
+ // object here used to reach `.toLowerCase()` and fail the whole call.
158
+ const asked = typeof args.genre === "string" ? args.genre.trim() : "";
159
+ const requested = String(asked || process.env.VIBE_GENRE || saved.genre || "lofi");
141
160
  // An unknown genre already fell back to lofi inside the generator, but
142
161
  // player.genre kept the name nobody implements - so the model told the
143
162
  // user it was playing something that does not exist.
@@ -161,9 +180,12 @@ function handleMessage(player, msg) {
161
180
  // start() returns false for three unrelated reasons, and the model
162
181
  // relays whatever we say here to the user. Reporting a deliberate mute
163
182
  // as a missing audio player sends them debugging their sound stack.
183
+ // What goes back to the model is client-supplied text, so echo a short
184
+ // plain version of it, not whatever was sent.
185
+ const shown = requested.replace(/[^\w.-]/g, "").slice(0, 24);
164
186
  const fallbackNote = genre === requested
165
187
  ? ""
166
- : ` (requested genre '${requested}' is not one of ${AVAILABLE_GENRES.join(", ")}, random)`;
188
+ : ` (requested genre '${shown}' is not one of ${AVAILABLE_GENRES.join(", ")}, random)`;
167
189
  const text = started
168
190
  ? `Started playing ${player.genre} procedural focus music at ${Math.round(volume * 100)}% volume.${fallbackNote}`
169
191
  : player.isPlaying
@@ -183,6 +205,15 @@ function handleMessage(player, msg) {
183
205
 
184
206
  if (name === "vibe_stop") {
185
207
  const outcome = args.outcome || "success";
208
+ // The schema's enum is a hint to the model, not a check: nothing stops a
209
+ // client sending anything, and this string is echoed back below.
210
+ if (!OUTCOMES.includes(outcome)) {
211
+ return {
212
+ jsonrpc: "2.0",
213
+ id,
214
+ result: { isError: true, content: [{ type: "text", text: `outcome must be one of: ${OUTCOMES.join(", ")}.` }] }
215
+ };
216
+ }
186
217
  const playChime = args.playChime !== false;
187
218
 
188
219
  const wasPlaying = player.stop({ playChime, outcome });
package/src/output.js CHANGED
@@ -45,7 +45,9 @@ function parseDefaultOutput(json) {
45
45
  function macOutput() {
46
46
  if (process.platform !== "darwin") return null;
47
47
  const run = (cmd, args) => {
48
- const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 });
48
+ // SIGKILL, not the default SIGTERM: a lookup stuck on audio hardware (a CI
49
+ // runner has none) can ignore TERM, and spawnSync then waits on it forever.
50
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000, killSignal: "SIGKILL" });
49
51
  return r.status === 0 ? r.stdout : null;
50
52
  };
51
53
  const settings = run("osascript", ["-e", "get volume settings"]);
package/src/player.js CHANGED
@@ -698,7 +698,9 @@ function chimeKeyFor(genre) {
698
698
 
699
699
  function getChimePath(outcome = "success", gain = 1, key = DEFAULT_CHIME_KEY) {
700
700
  ensureCacheDir();
701
- const kind = outcome === "error" ? "failure" : CHIMES[outcome] ? outcome : "success";
701
+ // Own keys only: `CHIMES["constructor"]` is truthy, and used to reach the
702
+ // generator call below as a function that is not one of ours.
703
+ const kind = outcome === "error" ? "failure" : Object.hasOwn(CHIMES, outcome) ? outcome : "success";
702
704
  // Only the success chime has keys, and the default keeps its original file
703
705
  // name so an upgrade does not leave a cached copy behind.
704
706
  const keyed = kind === "success" && key !== DEFAULT_CHIME_KEY && SUCCESS_CHIME_NOTES[key];
@@ -955,7 +957,7 @@ class AudioPlayer {
955
957
  .on("error", () => {})
956
958
  .unref();
957
959
  } else {
958
- spawnSync(backend.cmd, backend.args(chimeFile, clamped), { stdio: "ignore", timeout: 2500 });
960
+ spawnSync(backend.cmd, backend.args(chimeFile, clamped), { stdio: "ignore", timeout: 2500, killSignal: "SIGKILL" });
959
961
  }
960
962
  } catch (e) {
961
963
  // Ignore timeout