vibeaudio 0.5.0 → 0.6.1

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/src/cli.js CHANGED
@@ -14,6 +14,10 @@ const {
14
14
  resolveGenre,
15
15
  isKnownGenre,
16
16
  normalizeVolume,
17
+ loadConfig,
18
+ saveConfig,
19
+ projectSettings,
20
+ saveProjectConfig,
17
21
  wavDurationMs,
18
22
  AVAILABLE_GENRES
19
23
  } = require("./player");
@@ -44,6 +48,7 @@ Procedural focus music while your AI coding tools think.
44
48
 
45
49
  \x1b[1mEXAMPLES:\x1b[0m
46
50
  vibe --install-hooks \x1b[90m# best for interactive agents — music follows thinking\x1b[0m
51
+ vibe --genre jazz --volume 25 \x1b[90m# save your default — reaches hooks, wrapper and MCP\x1b[0m
47
52
  vibe --install-hooks --tools codex \x1b[90m# wire up one agent instead of every one found\x1b[0m
48
53
  vibe npm test \x1b[90m# wrap any command that exits when it's done\x1b[0m
49
54
  vibe claude -p "explain this"
@@ -51,12 +56,14 @@ Procedural focus music while your AI coding tools think.
51
56
  vibe --genre 8bit sleep 5
52
57
  vibe --volume 30 npm test
53
58
  vibe --preview jazz
59
+ vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
54
60
 
55
61
  \x1b[1mOPTIONS:\x1b[0m
56
62
  -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, random
57
63
  -v, --volume <5-100> Set playback volume (default: 40)
58
64
  -cv, --chime-volume <5-100> Set independent completion chime volume
59
65
  --grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
66
+ --here With a saved setting: this directory only, not everywhere
60
67
  --seed <n> Force a specific arrangement (default: derived from the project directory)
61
68
  --whisper Preset: 15% volume (late night / headphones)
62
69
  --quiet Preset: 25% volume (focus / open office)
@@ -78,11 +85,22 @@ Procedural focus music while your AI coding tools think.
78
85
  -h, --help Show this help message
79
86
  --version Show version
80
87
 
88
+ \x1b[1mSETTINGS:\x1b[0m
89
+ A genre or volume with no command after it is saved as your default:
90
+
91
+ vibe --genre zen \x1b[90m# applies on the next prompt, nothing to reinstall\x1b[0m
92
+ vibe --genre zen --here \x1b[90m# this project only — the default stays as it was\x1b[0m
93
+
94
+ Saved to ~/.vibeaudio/config.json and read by the wrapper, your agent hooks
95
+ and the MCP server alike. A flag beats an environment variable beats a
96
+ \x1b[1m--here\x1b[0m setting beats the global one, so a one-off
97
+ \x1b[1mvibe --genre 8bit npm test\x1b[0m stays a one-off.
98
+
81
99
  \x1b[1mENVIRONMENT:\x1b[0m
82
- VIBE_GENRE=<name> Set persistent default genre (e.g. export VIBE_GENRE=jazz)
83
- VIBE_VOLUME=<5-100> Set persistent default volume (e.g. export VIBE_VOLUME=25)
84
- VIBE_CHIME_VOLUME=<5-100> Set persistent chime volume (e.g. export VIBE_CHIME_VOLUME=60)
85
- VIBE_GRACE_MS=<ms> Set persistent grace window in ms (e.g. export VIBE_GRACE_MS=3000)
100
+ VIBE_GENRE=<name> Override the saved genre for this shell
101
+ VIBE_VOLUME=<5-100> Override the saved volume for this shell
102
+ VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
103
+ VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
86
104
  VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
87
105
  VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
88
106
  `);
@@ -105,13 +123,33 @@ const VALUE_FLAGS = new Set([
105
123
 
106
124
  function parseArgs(argv) {
107
125
  const args = argv.slice(2);
108
- let genre = (process.env.VIBE_GENRE || "lofi").toLowerCase();
109
126
 
110
- let volume = normalizeVolume(process.env.VIBE_VOLUME, 0.40);
111
- let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, null);
127
+ // Flag beats env var beats saved config beats the built-in default. The
128
+ // saved config is what makes "set my genre" have one answer instead of three
129
+ // - see loadConfig() in player.js - and it sits below the env var so a shell
130
+ // that exports one keeps winning, as it always did.
131
+ const saved = loadConfig();
132
+ // A setting saved for this directory sits between the environment and the
133
+ // global default: `--here` is a deliberate choice about one project, so it
134
+ // outranks the machine-wide one, and an export still outranks both.
135
+ const here = projectSettings(saved);
136
+ const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
137
+
138
+ let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
139
+
140
+ let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
141
+ let chimeVolume = normalizeVolume(process.env.VIBE_CHIME_VOLUME, normalizeVolume(setting("chimeVolume"), null));
112
142
 
113
143
  const envGrace = process.env.VIBE_GRACE_MS ? parseInt(process.env.VIBE_GRACE_MS, 10) : NaN;
114
- let grace = !isNaN(envGrace) ? Math.max(0, envGrace) : DEFAULT_GRACE_PERIOD_MS;
144
+ let grace = !isNaN(envGrace) ? Math.max(0, envGrace)
145
+ : Number.isFinite(setting("grace")) ? Math.max(0, setting("grace"))
146
+ : DEFAULT_GRACE_PERIOD_MS;
147
+
148
+ // What the user typed on THIS command line, as opposed to what was inherited
149
+ // from the environment or the saved file. `vibe --genre jazz` with nothing
150
+ // after it is a request to change the default, and this is how run() tells
151
+ // that apart from a genre that merely happens to be in effect.
152
+ const typed = {};
115
153
 
116
154
  let noChime = false;
117
155
  let noHud = false;
@@ -124,6 +162,7 @@ function parseArgs(argv) {
124
162
  let hookAction = null;
125
163
  let mcp = false;
126
164
  let reactive = false;
165
+ let here_flag = false;
127
166
  let dryRun = false;
128
167
  let tools = null;
129
168
  let cmdArgs = [];
@@ -150,6 +189,7 @@ function parseArgs(argv) {
150
189
  if (arg === "-g" || arg === "--genre") {
151
190
  if (i + 1 < args.length) {
152
191
  genre = args[i + 1].toLowerCase();
192
+ typed.genre = genre;
153
193
  i += 2;
154
194
  continue;
155
195
  }
@@ -158,6 +198,7 @@ function parseArgs(argv) {
158
198
  if (arg === "-v" || arg === "--volume") {
159
199
  if (i + 1 < args.length) {
160
200
  volume = normalizeVolume(args[i + 1], volume);
201
+ typed.volume = volume;
161
202
  i += 2;
162
203
  continue;
163
204
  }
@@ -166,6 +207,7 @@ function parseArgs(argv) {
166
207
  if (arg === "-cv" || arg === "--chime-volume") {
167
208
  if (i + 1 < args.length) {
168
209
  chimeVolume = normalizeVolume(args[i + 1], chimeVolume);
210
+ typed.chimeVolume = chimeVolume;
169
211
  i += 2;
170
212
  continue;
171
213
  }
@@ -176,6 +218,7 @@ function parseArgs(argv) {
176
218
  const parsed = parseInt(args[i + 1], 10);
177
219
  if (!isNaN(parsed)) {
178
220
  grace = Math.max(0, parsed);
221
+ typed.grace = grace;
179
222
  }
180
223
  i += 2;
181
224
  continue;
@@ -239,6 +282,12 @@ function parseArgs(argv) {
239
282
  continue;
240
283
  }
241
284
 
285
+ if (arg === "--here") {
286
+ here_flag = true;
287
+ i += 1;
288
+ continue;
289
+ }
290
+
242
291
  if (arg === "--reactive") {
243
292
  reactive = true;
244
293
  i += 1;
@@ -259,18 +308,21 @@ function parseArgs(argv) {
259
308
 
260
309
  if (arg === "--whisper") {
261
310
  volume = 0.15;
311
+ typed.volume = volume;
262
312
  i += 1;
263
313
  continue;
264
314
  }
265
315
 
266
316
  if (arg === "--quiet") {
267
317
  volume = 0.25;
318
+ typed.volume = volume;
268
319
  i += 1;
269
320
  continue;
270
321
  }
271
322
 
272
323
  if (arg === "--loud") {
273
324
  volume = 0.75;
325
+ typed.volume = volume;
274
326
  i += 1;
275
327
  continue;
276
328
  }
@@ -313,11 +365,22 @@ function parseArgs(argv) {
313
365
  }
314
366
 
315
367
  if (!isKnownGenre(genre)) {
368
+ // A typo that would be SAVED is a different thing from a typo on a run:
369
+ // falling back to lofi here would quietly overwrite the default the user
370
+ // already had, on the strength of a misspelling. Refuse and change nothing.
371
+ if (typed.genre !== undefined && cmdArgs.length === 0) {
372
+ console.error(
373
+ `\x1b[31m[vibeaudio] Unknown genre '${genre}' — nothing saved.\x1b[0m\n` +
374
+ ` Available: ${AVAILABLE_GENRES.join(", ")}, random`
375
+ );
376
+ process.exit(1);
377
+ }
316
378
  console.error(
317
379
  `\x1b[33m[vibeaudio] Unknown genre '${genre}' — falling back to lofi.\x1b[0m\n` +
318
380
  ` Available: ${AVAILABLE_GENRES.join(", ")}, random`
319
381
  );
320
382
  genre = "lofi";
383
+ if (typed.genre) typed.genre = "lofi";
321
384
  }
322
385
 
323
386
  return {
@@ -338,6 +401,8 @@ function parseArgs(argv) {
338
401
  reactive,
339
402
  dryRun,
340
403
  tools,
404
+ typed,
405
+ hereOnly: here_flag,
341
406
  cmdArgs
342
407
  };
343
408
  }
@@ -382,9 +447,44 @@ function resolveTargets(explicit) {
382
447
  * The one install path. Both --install-hooks and the menu's hooks branch call
383
448
  * this, so the two entry points can't drift in what they write or report.
384
449
  */
385
- function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
450
+ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed = {}) {
386
451
  const hooks = require("./hooks");
452
+
453
+ // Migrating off a pinned install must not change what is playing: those
454
+ // entries carry the settings the user has actually been hearing, while the
455
+ // config file may not exist yet - so an untyped reinstall inherits from the
456
+ // entries it is about to replace.
457
+ //
458
+ // A pin fills a gap and never overrides. Once a setting is saved, somebody
459
+ // chose it, and a legacy pin left in some other agent's config is the older
460
+ // fact of the two - without this, installing for every detected agent lets
461
+ // four untouched installs outvote the genre the user picked a minute ago.
462
+ const saved = loadConfig();
463
+ const inherited = [];
464
+ const gap = (key) => typed[key] === undefined && saved[key] === undefined;
465
+
466
+ if (gap("genre") || gap("volume")) {
467
+ const pinned = pinnedSettingsFor(ids);
468
+ if (gap("genre") && pinned.genre && pinned.genre !== genre) {
469
+ genre = pinned.genre;
470
+ inherited.push(`genre ${genre}`);
471
+ }
472
+ if (gap("volume") && pinned.volume !== null && pinned.volume !== volume) {
473
+ volume = pinned.volume;
474
+ inherited.push(`volume ${Math.round(volume * 100)}%`);
475
+ }
476
+ }
477
+
387
478
  if (dryRun) console.log(`\x1b[1mDry run\x1b[0m — nothing below is written.\n`);
479
+ if (inherited.length) {
480
+ console.log(`\x1b[90mKeeping your current ${inherited.join(" and ")} — pass --genre/--volume to change it.\x1b[0m`);
481
+ }
482
+
483
+ // The hook entries no longer carry the genre and volume, so this is where
484
+ // they live. Saved here rather than inside installHooks(), which stays a
485
+ // pure config edit that tests can point at a throwaway file - the same
486
+ // reason the slash command and stopDaemon() are driven from this layer.
487
+ if (!dryRun) saveConfig({ genre, volume: Math.round(volume * 100) });
388
488
 
389
489
  for (const id of ids) {
390
490
  const result = hooks.installHooks(genre, volume, null, { reactive, id, dryRun });
@@ -424,6 +524,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false) {
424
524
  // verified for each, so an open session picks this up on its next prompt.
425
525
  const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
426
526
  if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
527
+ console.log(` \x1b[90mChange the sound any time with: vibe --genre <name> --volume <n> — no reinstall.\x1b[0m`);
427
528
  if (ids.length > 1) {
428
529
  console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
429
530
  }
@@ -515,9 +616,24 @@ function printStatus() {
515
616
  console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
516
617
  }
517
618
 
619
+ // What will actually play, and which of the three possible sources decided
620
+ // it - the usual confusion is a saved default quietly beaten by an export.
621
+ const saved = loadConfig();
622
+ const here = projectSettings(saved);
623
+ const source = (env, key) =>
624
+ process.env[env] ? off(` from ${env}`)
625
+ : here[key] !== undefined ? off(" saved for this project")
626
+ : saved[key] !== undefined ? off(" saved")
627
+ : off(" default");
628
+
629
+ console.log(`\x1b[1mSound\x1b[0m`);
630
+ console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
631
+ console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
632
+ console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
633
+
518
634
  // Audio backend
519
635
  const backend = detect();
520
- console.log(`\x1b[1mAudio\x1b[0m`);
636
+ console.log(`\n\x1b[1mAudio\x1b[0m`);
521
637
  console.log(backend
522
638
  ? ` player ${on(backend.cmd)}${backend.volume ? "" : off(" (no volume support — gain is baked into the file)")}`
523
639
  : ` player \x1b[31mnone found — VibeAudio runs silently\x1b[0m`);
@@ -539,28 +655,47 @@ function printStatus() {
539
655
  const why = detected.includes(id)
540
656
  ? `${off("not installed")} — run: vibe --install-hooks`
541
657
  : `${off("not installed")} ${off("(not found on this machine)")}`;
542
- console.log(` ${t.name.padEnd(13)}${why}`);
658
+ console.log(` ${t.name.padEnd(20)}${why}`);
543
659
  continue;
544
660
  }
545
661
 
546
662
  console.log(` ${t.name}`);
547
663
  for (const { event, command } of installed) {
664
+ // A baked genre means an install from before settings moved into the
665
+ // config file: worth showing, because it still overrides that file.
548
666
  const g = /--genre (\S+)/.exec(command);
549
667
  const vol = /--volume (\d+)/.exec(command);
550
- const extra = g ? ` ${g[1]} @ ${vol ? vol[1] : "?"}%${/--reactive/.test(command) ? ", reactive" : ""}` : "";
668
+ const parts = [];
669
+ if (g) parts.push(`pinned ${g[1]} @ ${vol ? vol[1] : "?"}%`);
670
+ if (/--reactive/.test(command)) parts.push("reactive");
671
+ const extra = parts.length ? ` ${parts.join(", ")}` : "";
551
672
  console.log(` ${on("✔")} ${extra ? event.padEnd(19) : event}${extra ? off(extra) : ""}`);
552
673
  }
674
+ if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
675
+ console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
676
+ }
553
677
  }
554
678
 
555
679
  // Background player
556
680
  console.log(`\n\x1b[1mBackground player\x1b[0m`);
557
681
  const pid = readDaemonPid(hooks.PID_FILE);
682
+ const playing = hooks.daemonPlaying();
558
683
  if (pid === null) {
559
684
  console.log(` ${off("not running")}`);
560
- } else if (!hooks.isOurDaemon(pid)) {
685
+ } else if (!playing) {
561
686
  console.log(` ${off(`stale pid file (${pid} is not ours) — cleared on the next prompt`)}`);
562
687
  } else {
563
- console.log(` ${on(`running`)} pid ${pid}${off(" stop it with: vibe --stop")}`);
688
+ // What it was started with, which is not necessarily what is saved: a
689
+ // daemon keeps its settings until the next prompt replaces it, and
690
+ // "I changed the genre and nothing happened" is that gap.
691
+ const now = playing.genre
692
+ ? ` ${on(playing.genre)}${playing.volume === null ? "" : on(` @ ${playing.volume}%`)}${playing.reactive ? off(", reactive") : ""}`
693
+ : "";
694
+ console.log(` ${on("playing")}${now}${off(` pid ${pid}`)}`);
695
+ if (playing.genre && playing.genre !== genreNow()) {
696
+ console.log(` ${off(`your saved default is ${genreNow()} — this one keeps playing until the next prompt`)}`);
697
+ }
698
+ console.log(` ${off("stop it with: vibe --stop")}`);
564
699
  }
565
700
 
566
701
  // Which tools are actually here, and what each one can use
@@ -576,6 +711,24 @@ function printStatus() {
576
711
  console.log();
577
712
  }
578
713
 
714
+ /**
715
+ * The effective genre and volume, resolved the same way parseArgs does it, so
716
+ * --status reports what would actually play rather than a second opinion.
717
+ */
718
+ function settingNow(key) {
719
+ const config = loadConfig();
720
+ const here = projectSettings(config);
721
+ return here[key] !== undefined ? here[key] : config[key];
722
+ }
723
+
724
+ function genreNow() {
725
+ return (process.env.VIBE_GENRE || settingNow("genre") || "lofi").toLowerCase();
726
+ }
727
+
728
+ function volumeNow() {
729
+ return normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(settingNow("volume"), 0.40));
730
+ }
731
+
579
732
  function dirSize(dir) {
580
733
  try {
581
734
  let total = 0;
@@ -646,6 +799,107 @@ function detectAiTools(hooked = new Set()) {
646
799
  return CANDIDATES.filter((c) => isInstalled(c.cmd));
647
800
  }
648
801
 
802
+ /**
803
+ * `vibe --genre jazz` with no command after it. That used to open the launcher
804
+ * menu, which then asked for a genre and used its own answer - the most
805
+ * natural way to phrase the request did something else entirely, and the
806
+ * README had to warn about it. It saves the default instead.
807
+ */
808
+ function saveDefaults(typed, hereOnly = false) {
809
+ const patch = {};
810
+ if (typed.genre !== undefined) patch.genre = typed.genre;
811
+ if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
812
+ if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
813
+ if (typed.grace !== undefined) patch.grace = typed.grace;
814
+
815
+ const { CONFIG_FILE } = require("./player");
816
+ if (hereOnly) saveProjectConfig(patch);
817
+ else saveConfig(patch);
818
+
819
+ const label = {
820
+ genre: (v) => `genre ${v}`,
821
+ volume: (v) => `volume ${v}%`,
822
+ chimeVolume: (v) => `chime volume ${v}%`,
823
+ grace: (v) => `grace ${v}ms`
824
+ };
825
+ const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
826
+ const where = hereOnly ? ` for ${process.cwd()}` : "";
827
+ console.log(`\x1b[32m✔ Saved: ${changed}${where}\x1b[0m \x1b[90m(${CONFIG_FILE})\x1b[0m`);
828
+ console.log(` Applies to the wrapper, your agent hooks and MCP — on the next prompt.`);
829
+ if (hereOnly) console.log(` \x1b[90mThis directory and everything under it. Drop --here to change the default everywhere.\x1b[0m`);
830
+
831
+ // Music already in the air is the one thing this does not reach, and
832
+ // silence about it reads as the setting having done nothing at all.
833
+ const playing = require("./hooks").daemonPlaying();
834
+ if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
835
+ console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
836
+ }
837
+
838
+ // An install from before the settings moved out of the hook command line
839
+ // still carries its own genre, and a flag beats this file. Saying nothing
840
+ // would leave the user with a saved default that audibly does nothing.
841
+ const pinned = pinnedHookSettings();
842
+ if (pinned.length) {
843
+ console.log(
844
+ `\n\x1b[33m ${pinned.join(", ")}: hooks installed by an older version pin their own settings.\x1b[0m\n` +
845
+ ` Run \x1b[1mvibe --install-hooks\x1b[0m once to have them follow this file instead.`
846
+ );
847
+ }
848
+ if (typed.genre !== undefined) console.log(`\n Audition it: vibe --preview ${typed.genre}`);
849
+ console.log();
850
+ }
851
+
852
+ /**
853
+ * Hook targets whose installed entries still carry a baked --genre/--volume.
854
+ */
855
+ /**
856
+ * What an install from before the config file pinned, for the first of `ids`
857
+ * that pinned anything.
858
+ *
859
+ * A reinstall is how a user moves off that older scheme, and it must not be
860
+ * the moment their music changes: the settings frozen into those entries are
861
+ * the ones they have actually been listening to, and the config file may hold
862
+ * nothing but an untouched default. Adopted only for what the user did not
863
+ * type on this command line - naming a genre still means that genre.
864
+ */
865
+ function pinnedSettingsFor(ids) {
866
+ const hooks = require("./hooks");
867
+ const genres = new Set();
868
+ const volumes = new Set();
869
+
870
+ for (const id of ids) {
871
+ const t = hooks.TARGETS[id];
872
+ if (!t) continue;
873
+ for (const { command } of readVibeHooks(t.file(), t)) {
874
+ const genre = /--genre (\S+)/.exec(command);
875
+ const volume = /--volume (\d+)/.exec(command);
876
+ if (genre && isKnownGenre(genre[1])) genres.add(genre[1]);
877
+ if (volume) volumes.add(normalizeVolume(volume[1], null));
878
+ }
879
+ }
880
+
881
+ // Only when they agree. The saved config is one value for the machine, so
882
+ // two agents pinned to different genres have no single answer to inherit -
883
+ // and taking whichever was found first would let an untouched install for
884
+ // some other tool silently overwrite the genre the user actually chose.
885
+ return {
886
+ genre: genres.size === 1 ? [...genres][0] : null,
887
+ volume: volumes.size === 1 ? [...volumes][0] : null
888
+ };
889
+ }
890
+
891
+ function pinnedHookSettings() {
892
+ const hooks = require("./hooks");
893
+ const names = [];
894
+ for (const id of Object.keys(hooks.TARGETS)) {
895
+ const t = hooks.TARGETS[id];
896
+ if (readVibeHooks(t.file(), t).some(({ command }) => /--genre |--volume /.test(command))) {
897
+ names.push(t.name);
898
+ }
899
+ }
900
+ return names;
901
+ }
902
+
649
903
  function signalExitCode(signal) {
650
904
  return 128 + (os.constants.signals[signal] || 0);
651
905
  }
@@ -670,7 +924,7 @@ function previewGenre(genre, volume) {
670
924
  spawnSync(backend.cmd, backend.args(audioFile, volume), { stdio: "ignore" });
671
925
  }
672
926
 
673
- function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun }) {
927
+ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
674
928
  const hooks = require("./hooks");
675
929
 
676
930
  switch (action) {
@@ -715,7 +969,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
715
969
  return;
716
970
 
717
971
  case "install-hooks":
718
- return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun);
972
+ return installHookTargets(resolveTargets(tools), genre, volume, reactive, dryRun, typed);
719
973
 
720
974
  case "uninstall-hooks":
721
975
  return uninstallHookTargets();
@@ -877,6 +1131,8 @@ async function run() {
877
1131
  reactive,
878
1132
  dryRun,
879
1133
  tools,
1134
+ typed,
1135
+ hereOnly,
880
1136
  cmdArgs
881
1137
  } = parseArgs(process.argv);
882
1138
 
@@ -890,7 +1146,7 @@ async function run() {
890
1146
 
891
1147
  if (hookAction) {
892
1148
  try {
893
- return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun });
1149
+ return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed });
894
1150
  } catch (e) {
895
1151
  // Settings problems are the user's to fix — report them, don't stack-trace.
896
1152
  console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
@@ -951,6 +1207,15 @@ async function run() {
951
1207
  return previewGenre(preview, volume);
952
1208
  }
953
1209
 
1210
+ // Settings typed with no command to run: save them, don't open the launcher.
1211
+ if (cmdArgs.length === 0 && Object.keys(typed).length > 0) {
1212
+ return saveDefaults(typed, hereOnly);
1213
+ }
1214
+
1215
+ if (hereOnly) {
1216
+ console.error("\x1b[33m[vibeaudio] --here only applies when saving a setting; ignoring it.\x1b[0m");
1217
+ }
1218
+
954
1219
  if (cmdArgs.length === 0) {
955
1220
  if (!process.stdin.isTTY) {
956
1221
  printHelp();
@@ -979,7 +1244,8 @@ async function run() {
979
1244
  // Either way, say so and still launch the tool the user asked for -
980
1245
  // they came here to start an agent, not to configure one.
981
1246
  try {
982
- installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive);
1247
+ installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive, false,
1248
+ { genre: selection.genre, volume: chosenVol });
983
1249
  } catch (err) {
984
1250
  console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
985
1251
  }
package/src/hooks.js CHANGED
@@ -345,19 +345,49 @@ function readPid() {
345
345
  * ponytail: posix only. Windows has no cheap command-line lookup, so the pid
346
346
  * is trusted there as before; revisit if hooks see real Windows use.
347
347
  */
348
- function isOurDaemon(pid) {
349
- if (process.platform === "win32") return true;
348
+ function daemonArgv(pid) {
349
+ if (process.platform === "win32") return null;
350
350
  try {
351
- const out = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
351
+ return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
352
352
  encoding: "utf8",
353
353
  stdio: ["ignore", "pipe", "ignore"]
354
354
  });
355
- return out.includes("vibeaudio") && out.includes("--daemon");
356
355
  } catch (e) {
357
- return false; // No such process, or ps unavailable - either way, do not kill.
356
+ return null; // No such process, or ps unavailable.
358
357
  }
359
358
  }
360
359
 
360
+ function isOurDaemon(pid) {
361
+ if (process.platform === "win32") return true;
362
+ const out = daemonArgv(pid);
363
+ // Failing closed: no argv means no kill.
364
+ return out !== null && out.includes("vibeaudio") && out.includes("--daemon");
365
+ }
366
+
367
+ /**
368
+ * What the background player is playing right now, or null if nothing is.
369
+ *
370
+ * Read from the daemon's own command line rather than a file it would have to
371
+ * keep in step - spawnDaemon() puts the settings there already, and a process
372
+ * that dies without cleanup cannot leave a stale answer behind. It is the
373
+ * settings *in flight*, which is the question a running daemon raises: a
374
+ * genre saved a moment ago does not reach it until the next prompt.
375
+ */
376
+ function daemonPlaying() {
377
+ const pid = readPid();
378
+ if (pid === null || !isOurDaemon(pid)) return null;
379
+
380
+ const argv = daemonArgv(pid) || "";
381
+ const genre = /--genre (\S+)/.exec(argv);
382
+ const volume = /--volume (\d+)/.exec(argv);
383
+ return {
384
+ pid,
385
+ genre: genre ? genre[1] : null,
386
+ volume: volume ? Number(volume[1]) : null,
387
+ reactive: /--reactive/.test(argv)
388
+ };
389
+ }
390
+
361
391
  /**
362
392
  * Stops the player. `keepSessions` leaves the session files alone: the hooks
363
393
  * use it because they decide per session what is still going on. Every other
@@ -781,13 +811,24 @@ function shellQuote(value) {
781
811
  return `'${String(value).replace(/'/g, "'\\''")}'`;
782
812
  }
783
813
 
814
+ /**
815
+ * Genre and volume are deliberately NOT written into the command line.
816
+ *
817
+ * They used to be, and that is what made "change the genre" mean "re-run the
818
+ * installer": a value frozen into seven hook entries at install time, which no
819
+ * later `vibe --genre jazz` and no exported variable could reach. Each hook
820
+ * fires as a fresh process and parses its own arguments, so leaving them out
821
+ * means it reads the saved config (see loadConfig() in player.js) on every
822
+ * prompt - and a genre change lands on the next one, with nothing reinstalled.
823
+ *
824
+ * `--reactive` stays, because it is not a setting the hook reads: it decides
825
+ * which hooks exist at all, and the PreToolUse entry is written or removed to
826
+ * match. A daemon respawned by a resume has to know it too.
827
+ */
784
828
  function hookCommand(flag, genre, volume, reactive = false) {
785
829
  const base = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} ${flag}`;
786
- // Resuming respawns the daemon, so it needs the same settings as starting.
787
- if (flag !== "--hook-start" && flag !== "--hook-resume") return base;
788
-
789
- const start = `${base} --genre ${genre} --volume ${Math.round(volume * 100)}`;
790
- return reactive ? `${start} --reactive` : start;
830
+ if (!reactive) return base;
831
+ return flag === "--hook-start" || flag === "--hook-resume" ? `${base} --reactive` : base;
791
832
  }
792
833
 
793
834
  const VIBE_HOOK_FLAG = /--hook-(start|stop|tool|wait|resume|end)\b/;
@@ -973,10 +1014,10 @@ single short sentence. Do nothing else.
973
1014
  - \`mute\` or \`mute <minutes>\`: \`${cli} --mute <minutes>\`
974
1015
  - \`unmute\`: \`${cli} --unmute\`
975
1016
  - \`stop\`: \`${cli} --stop\`
976
- - \`genre <name>\` or \`volume <5-100>\`: first run \`${cli} --status\` and read
977
- Claude Code's current genre, volume, and whether it says "reactive". Then run
978
- \`${cli} --install-hooks --tools claude --genre <genre> --volume <volume>\`,
979
- adding \`--reactive\` if it was reactive, and changing only what was asked.
1017
+ - \`genre <name>\`: \`${cli} --genre <name>\`
1018
+ - \`volume <5-100>\`: \`${cli} --volume <n>\`
1019
+ Both save the user's default and reach the hooks on the next prompt - there
1020
+ is nothing to reinstall, so do not run --install-hooks for these.
980
1021
  Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
981
1022
 
982
1023
  For anything else, show the user the list above instead of running a command.
@@ -1046,6 +1087,7 @@ module.exports = {
1046
1087
  readIntensity,
1047
1088
  stopDaemon,
1048
1089
  isOurDaemon,
1090
+ daemonPlaying,
1049
1091
  installHooks,
1050
1092
  ephemeralInstallReason,
1051
1093
  uninstallHooks,