vibeaudio 0.16.2 → 0.17.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
@@ -630,9 +630,11 @@ VIBE_VOLUME = "25"
630
630
  Restart the app afterwards — this one is read from the environment the server was launched with, so unlike the saved default it can't change under a running client.
631
631
 
632
632
  #### Exposed MCP Tools:
633
- * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`).
633
+ * `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`; `project_dir`: the workspace's absolute path, optional).
634
634
  * `vibe_stop`: Stop music and play the completion chime (`outcome`: `success` or `failure`).
635
- * `vibe_status`: Return current playback state and active tier.
635
+ * `vibe_status`: Return current playback state and active tier, and which project the arrangement comes from.
636
+
637
+ **Which project it sounds like.** Hooks and the wrapper run inside your project, so each repo has its own arrangement. An MCP server is started once by the app, so it asks: a client that supports MCP *roots* (Claude Code does) reports its open folder, and any client's model can pass `project_dir`. That project's own settings apply too: a genre saved with `vibe --genre jazz --here` plays for that project over MCP as well. With neither, it uses the folder the app launched the server in, as before; `VIBE_SEED` still overrides all of it. `vibe_status` shows the `project` and its `projectSource` (`project_dir`, `roots`, `cwd` or `VIBE_SEED`).
636
638
 
637
639
  Playback stops automatically if the desktop client disconnects, and caps out after 15 minutes — which also covers the likelier case of a model that started the music and never called `vibe_stop`.
638
640
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.16.2",
3
+ "version": "0.17.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",
package/src/mcp.js CHANGED
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  const readline = require("readline");
9
- const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig } = require("./player");
9
+ const path = require("path");
10
+ const { fileURLToPath } = require("url");
11
+ const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig, projectSettings } = require("./player");
10
12
  const pkg = require("../package.json");
11
13
 
12
14
  // A desktop client that crashes never sends vibe_stop, so playback needs its
@@ -26,6 +28,64 @@ const annotate = (title, { readOnly, idempotent }) => ({
26
28
 
27
29
  const OUTCOMES = ["success", "failure"];
28
30
 
31
+ /**
32
+ * Which project this conversation is about. Hooks and the wrapper know it (they
33
+ * run in it); an MCP server is launched once by the client, in a directory the
34
+ * client chose, so it has to be told. Two sources, in order:
35
+ * 1. `project_dir`, an argument the model passes when it has a workspace.
36
+ * 2. The client's roots (`roots/list`), the protocol's own answer.
37
+ * With neither, the server's cwd, which is what it always used.
38
+ * The value is only hashed and matched against keys in our own config file; it
39
+ * is never opened, so it is not a file-access question - but it comes from the
40
+ * client, so it must be a plain absolute path of sane length.
41
+ */
42
+ const MAX_PROJECT_DIR = 1024;
43
+ function cleanProjectDir(value) {
44
+ if (typeof value !== "string") return null;
45
+ const v = value.trim();
46
+ if (!v || v.length > MAX_PROJECT_DIR || v.includes("\0") || !path.isAbsolute(v)) return null;
47
+ return path.resolve(v);
48
+ }
49
+
50
+ // A root is a file: URI. Anything else (a remote workspace) is not a path here.
51
+ function rootDirs(roots) {
52
+ const dirs = [];
53
+ for (const root of Array.isArray(roots) ? roots : []) {
54
+ try {
55
+ const uri = root && typeof root.uri === "string" ? root.uri : "";
56
+ if (!uri.startsWith("file:")) continue;
57
+ const dir = cleanProjectDir(fileURLToPath(uri));
58
+ if (dir) dirs.push(dir);
59
+ } catch (e) { /* a malformed URI is skipped, not fatal */ }
60
+ }
61
+ return dirs;
62
+ }
63
+
64
+ /** { dir, source }: what to seed from, and why - vibe_status reports the second. */
65
+ function resolveProject(args, session) {
66
+ const explicit = cleanProjectDir(args && args.project_dir);
67
+ let dir = null;
68
+ let source = "cwd";
69
+ if (explicit) { dir = explicit; source = "project_dir"; }
70
+ else if (session && session.roots.length) { dir = session.roots[0]; source = "roots"; }
71
+ // VIBE_SEED outranks every directory (projectSeed reads it); say so.
72
+ if (process.env.VIBE_SEED && !isNaN(parseInt(process.env.VIBE_SEED, 10))) source = "VIBE_SEED";
73
+ return { dir, source };
74
+ }
75
+
76
+ // What the client has told us about itself. `send` writes a request to it.
77
+ function newSession(send = null) {
78
+ return { supportsRoots: false, roots: [], rootsRequest: null, send, nextId: 1 };
79
+ }
80
+
81
+ // Ask for the roots. Our own id space ("vibeaudio-roots-N") cannot collide with
82
+ // the client's, and a reply is matched by it.
83
+ function requestRoots(session) {
84
+ if (!session || !session.supportsRoots || !session.send) return;
85
+ session.rootsRequest = `vibeaudio-roots-${session.nextId++}`;
86
+ session.send({ jsonrpc: "2.0", id: session.rootsRequest, method: "roots/list" });
87
+ }
88
+
29
89
  const TOOLS = [
30
90
  {
31
91
  name: "vibe_play",
@@ -49,6 +109,13 @@ const TOOLS = [
49
109
  description: "Playback volume from 5 to 100 (default: 40)",
50
110
  minimum: 5,
51
111
  maximum: 100
112
+ },
113
+ project_dir: {
114
+ type: "string",
115
+ description:
116
+ "Absolute path of the active workspace, if there is one. Omit it when you " +
117
+ "have no workspace - do not guess a path. It makes this project sound like " +
118
+ "itself and applies the genre and volume the user saved for it."
52
119
  }
53
120
  }
54
121
  }
@@ -90,10 +157,22 @@ const TOOLS = [
90
157
  }
91
158
  ];
92
159
 
93
- function handleMessage(player, msg) {
160
+ function handleMessage(player, msg, session = null) {
94
161
  const { id, method, params } = msg;
95
162
 
163
+ // The client's answer to our roots/list: it has a result, not a method.
164
+ if (method === undefined && session && id !== undefined && id === session.rootsRequest) {
165
+ session.rootsRequest = null;
166
+ session.roots = rootDirs(msg.result && msg.result.roots);
167
+ return null;
168
+ }
169
+ // Any other response is an answer to something we asked, not a request: it
170
+ // gets no reply (a "method not found" for it would be sent to the client).
171
+ if (method === undefined && (msg.result !== undefined || msg.error !== undefined)) return null;
172
+
96
173
  if (method === "initialize") {
174
+ // roots is a client capability: only a client that declares it can be asked.
175
+ if (session) session.supportsRoots = Boolean(params && params.capabilities && params.capabilities.roots);
97
176
  return {
98
177
  jsonrpc: "2.0",
99
178
  id,
@@ -117,13 +196,22 @@ function handleMessage(player, msg) {
117
196
  "and you are ready to hand back a result.\n" +
118
197
  "Do not use it for quick answers - starting and stopping music around a " +
119
198
  "one-second reply is worse than silence. Leave the genre and volume alone " +
120
- "unless the user asks; they are the user's preference, not yours."
199
+ "unless the user asks; they are the user's preference, not yours.\n" +
200
+ "If you have a workspace, pass its absolute path as vibe_play's project_dir so " +
201
+ "this project sounds like itself; omit it when you have none."
121
202
  }
122
203
  };
123
204
  }
124
205
 
125
206
  if (method === "notifications/initialized") {
126
- // Client acknowledgment - no response needed
207
+ // Client acknowledgment - no response needed, but this is the first moment
208
+ // a request of ours is allowed, so ask which project it has open.
209
+ requestRoots(session);
210
+ return null;
211
+ }
212
+
213
+ if (method === "notifications/roots/list_changed") {
214
+ requestRoots(session);
127
215
  return null;
128
216
  }
129
217
 
@@ -153,10 +241,16 @@ function handleMessage(player, msg) {
153
241
  // saved file here is what makes `vibe --genre jazz` mean jazz in a
154
242
  // desktop client too, rather than only in the terminal.
155
243
  const saved = loadConfig();
244
+ const project = resolveProject(args, session);
245
+ const dir = project.dir || process.cwd();
246
+ // What `vibe --here` saved for this project sits between the environment
247
+ // and the machine-wide default, as it does for the CLI and the hooks.
248
+ const here = projectSettings(saved, dir);
249
+ const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
156
250
  // The argument comes from the client: only a string counts. A number or
157
251
  // object here used to reach `.toLowerCase()` and fail the whole call.
158
252
  const asked = typeof args.genre === "string" ? args.genre.trim() : "";
159
- const requested = String(asked || process.env.VIBE_GENRE || saved.genre || "lofi");
253
+ const requested = String(asked || process.env.VIBE_GENRE || setting("genre") || "lofi");
160
254
  // An unknown genre already fell back to lofi inside the generator, but
161
255
  // player.genre kept the name nobody implements - so the model told the
162
256
  // user it was playing something that does not exist.
@@ -165,10 +259,10 @@ function handleMessage(player, msg) {
165
259
  // as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
166
260
  // the player as NaN.
167
261
  const volume = normalizeVolume(args.volume,
168
- normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
262
+ normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.4)));
169
263
 
170
264
  // A deliberate setting, like a mute: report it as one, and start nothing.
171
- if (!musicEnabled()) {
265
+ if (!musicEnabled(process.env, saved, dir)) {
172
266
  return {
173
267
  jsonrpc: "2.0",
174
268
  id,
@@ -176,7 +270,7 @@ function handleMessage(player, msg) {
176
270
  };
177
271
  }
178
272
 
179
- const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
273
+ const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS, project: project.dir });
180
274
  // start() returns false for three unrelated reasons, and the model
181
275
  // relays whatever we say here to the user. Reporting a deliberate mute
182
276
  // as a missing audio player sends them debugging their sound stack.
@@ -232,6 +326,7 @@ function handleMessage(player, msg) {
232
326
  }
233
327
 
234
328
  if (name === "vibe_status") {
329
+ const statusProject = resolveProject({}, session);
235
330
  return {
236
331
  jsonrpc: "2.0",
237
332
  id,
@@ -244,7 +339,13 @@ function handleMessage(player, msg) {
244
339
  // Without this, "isPlaying: false" while muted reads as a bug
245
340
  // worth investigating rather than a choice the user made.
246
341
  muted: playbackDisabled(),
247
- musicOff: !musicEnabled(),
342
+ musicOff: !musicEnabled(process.env, loadConfig(), statusProject.dir || process.cwd()),
343
+ // Which project the next vibe_play seeds from and how we learned
344
+ // it (project_dir | roots | cwd | VIBE_SEED), and, while music
345
+ // plays, the one it is playing for.
346
+ project: statusProject.dir || process.cwd(),
347
+ projectSource: statusProject.source,
348
+ playingProject: player.isPlaying ? (player.projectDir || process.cwd()) : null,
248
349
  genre: player.genre,
249
350
  currentTier: player.currentTier,
250
351
  uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
@@ -281,6 +382,8 @@ function handleMessage(player, msg) {
281
382
 
282
383
  function startMcpServer() {
283
384
  const player = new AudioPlayer();
385
+ const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
386
+ const session = newSession(send);
284
387
 
285
388
  // In stdio MCP mode, stderr is used for logging, stdout is strictly reserved for JSON-RPC
286
389
  process.stderr.write(`[vibeaudio] MCP Server running on stdio (v${pkg.version})\n`);
@@ -306,7 +409,7 @@ function startMcpServer() {
306
409
 
307
410
  let response;
308
411
  try {
309
- response = handleMessage(player, msg);
412
+ response = handleMessage(player, msg, session);
310
413
  } catch (err) {
311
414
  process.stderr.write(`[vibeaudio] Handler error: ${err.message}\n`);
312
415
  // A request (one with an id) must always get a reply. Swallowing the
@@ -316,7 +419,7 @@ function startMcpServer() {
316
419
  }
317
420
  }
318
421
 
319
- if (response) process.stdout.write(JSON.stringify(response) + "\n");
422
+ if (response) send(response);
320
423
  });
321
424
 
322
425
  // Client disconnected - never leave audio looping behind.
@@ -336,4 +439,4 @@ function startMcpServer() {
336
439
  });
337
440
  }
338
441
 
339
- module.exports = { startMcpServer, handleMessage, TOOLS };
442
+ module.exports = { startMcpServer, handleMessage, TOOLS, newSession, resolveProject, cleanProjectDir, rootDirs };
package/src/player.js CHANGED
@@ -438,8 +438,8 @@ function playbackDisabled() {
438
438
  * Env beats this directory's saved value beats the global one; a flag is
439
439
  * resolved by parseArgs() and handed to whoever acts on it.
440
440
  */
441
- function musicEnabled(env = process.env, config = loadConfig()) {
442
- const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
441
+ function musicEnabled(env = process.env, config = loadConfig(), cwd = process.cwd()) {
442
+ const raw = env.VIBE_MUSIC ?? projectSettings(config, cwd).music ?? config.music ?? "on";
443
443
  return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
444
444
  }
445
445
 
@@ -751,6 +751,7 @@ class AudioPlayer {
751
751
  this.genre = "lofi";
752
752
  this.volume = 0.42;
753
753
  this.seed = projectSeed();
754
+ this.projectDir = null; // Set when a client names the project (MCP); null means the cwd.
754
755
  this.intensity = null;
755
756
  this.minTier = null;
756
757
  this.tension = null;
@@ -764,12 +765,13 @@ class AudioPlayer {
764
765
  * Returns true if playback actually started. Restarts when called with
765
766
  * different settings while playing, so a genre switch is not silently dropped.
766
767
  */
767
- start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
768
+ start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null, project = null } = {}) {
768
769
  const resolved = resolveGenre(genre);
769
770
  const targetVolume = Math.max(0.05, Math.min(1.0, volume));
770
771
 
771
772
  if (this.isPlaying) {
772
- if (resolved === this.genre && targetVolume === this.volume) return false;
773
+ // A different project is a different arrangement, so it restarts too.
774
+ if (resolved === this.genre && targetVolume === this.volume && (project || null) === this.projectDir) return false;
773
775
  this.stop({ playChime: false });
774
776
  }
775
777
 
@@ -784,7 +786,8 @@ class AudioPlayer {
784
786
  this.startTime = Date.now();
785
787
  this.genre = resolved;
786
788
  this.volume = targetVolume;
787
- this.seed = projectSeed();
789
+ this.projectDir = project || null;
790
+ this.seed = projectSeed(project || process.cwd());
788
791
  this.bar = 0; // Every run opens on the project's own bar.
789
792
  this.intensity = intensity;
790
793
  this.minTier = minTier;