vibeaudio 0.4.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/hooks.js CHANGED
@@ -20,10 +20,12 @@ const { AudioPlayer } = require("./player");
20
20
  const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
21
21
  const PID_FILE = path.join(STATE_DIR, "daemon.pid");
22
22
  const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
23
- // Present while the music is paused mid-turn; holds what will resume it.
24
- const WAITING_FILE = path.join(STATE_DIR, "waiting");
25
- // The turn the music belongs to: its session, and where its transcript began.
26
- const TURN_FILE = path.join(STATE_DIR, "turn.json");
23
+ // One file per agent session with a turn in flight. The music plays while any of
24
+ // them is working; each holds where its transcript began and, while paused for
25
+ // the user, what will resume it.
26
+ const SESSIONS_DIR = path.join(STATE_DIR, "sessions");
27
+ // Payloads that name no session share this one slot.
28
+ const ANON_SESSION = "anon";
27
29
  const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
28
30
 
29
31
  /**
@@ -343,29 +345,59 @@ function readPid() {
343
345
  * ponytail: posix only. Windows has no cheap command-line lookup, so the pid
344
346
  * is trusted there as before; revisit if hooks see real Windows use.
345
347
  */
346
- function isOurDaemon(pid) {
347
- if (process.platform === "win32") return true;
348
+ function daemonArgv(pid) {
349
+ if (process.platform === "win32") return null;
348
350
  try {
349
- const out = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
351
+ return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
350
352
  encoding: "utf8",
351
353
  stdio: ["ignore", "pipe", "ignore"]
352
354
  });
353
- return out.includes("vibeaudio") && out.includes("--daemon");
354
355
  } catch (e) {
355
- return false; // No such process, or ps unavailable - either way, do not kill.
356
+ return null; // No such process, or ps unavailable.
356
357
  }
357
358
  }
358
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
+
359
391
  /**
360
- * `pause` keeps the turn's state (what resumes it, whose it is): only the hooks
361
- * pausing mid-turn pass it. Every other stop (new prompt, turn end, --stop,
362
- * uninstall) ends the turn, or a later tool call would resume music nobody is
363
- * waiting for.
392
+ * Stops the player. `keepSessions` leaves the session files alone: the hooks
393
+ * use it because they decide per session what is still going on. Every other
394
+ * stop (--stop, --mute, uninstall) ends everything, or a later tool call would
395
+ * resume music nobody is waiting for.
364
396
  */
365
- function stopDaemon({ pause = false } = {}) {
397
+ function stopDaemon({ keepSessions = false } = {}) {
366
398
  const pid = readPid();
367
399
  fs.rmSync(PID_FILE, { force: true });
368
- if (!pause) endTurnState();
400
+ if (!keepSessions) fs.rmSync(SESSIONS_DIR, { recursive: true, force: true });
369
401
  if (pid === null || !isOurDaemon(pid)) return false;
370
402
 
371
403
  try {
@@ -376,11 +408,6 @@ function stopDaemon({ pause = false } = {}) {
376
408
  }
377
409
  }
378
410
 
379
- function endTurnState() {
380
- fs.rmSync(WAITING_FILE, { force: true });
381
- fs.rmSync(TURN_FILE, { force: true });
382
- }
383
-
384
411
  function fileSize(file) {
385
412
  try {
386
413
  return fs.statSync(file).size;
@@ -400,14 +427,60 @@ function newTurn(raw) {
400
427
  return { session: String(payload.session_id || ""), transcript, offset: transcript ? fileSize(transcript) : 0 };
401
428
  }
402
429
 
403
- function readTurn() {
430
+ // The id ends up in a file name.
431
+ function sessionId(id) {
432
+ return String(id || ANON_SESSION).replace(/[^\w.-]/g, "_").slice(0, 100);
433
+ }
434
+
435
+ function sessionFile(id) {
436
+ return path.join(SESSIONS_DIR, `${id}.json`);
437
+ }
438
+
439
+ function readSession(id) {
404
440
  try {
405
- return JSON.parse(fs.readFileSync(TURN_FILE, "utf8"));
441
+ return JSON.parse(fs.readFileSync(sessionFile(id), "utf8"));
406
442
  } catch (e) {
407
443
  return null;
408
444
  }
409
445
  }
410
446
 
447
+ // Written beside the target and renamed into place: the daemon lists this
448
+ // directory every poll and must never read half a file.
449
+ function writeSession(id, data) {
450
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
451
+ const tmp = `${sessionFile(id)}.${process.pid}.tmp`;
452
+ fs.writeFileSync(tmp, JSON.stringify({ ...data, ts: Date.now() }));
453
+ fs.renameSync(tmp, sessionFile(id));
454
+ }
455
+
456
+ /**
457
+ * Every session with a turn in flight. One that has not been touched for
458
+ * MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here,
459
+ * the same ceiling the daemon itself stops at.
460
+ */
461
+ function listSessions() {
462
+ let names;
463
+ try {
464
+ names = fs.readdirSync(SESSIONS_DIR);
465
+ } catch (e) {
466
+ return [];
467
+ }
468
+ const now = Date.now();
469
+ const sessions = [];
470
+ for (const name of names) {
471
+ if (!name.endsWith(".json")) continue;
472
+ const id = name.slice(0, -".json".length);
473
+ const data = readSession(id);
474
+ if (!data) continue;
475
+ if (now - (data.ts || 0) > MAX_DAEMON_MS) fs.rmSync(sessionFile(id), { force: true });
476
+ else sessions.push({ id, ...data });
477
+ }
478
+ return sessions;
479
+ }
480
+
481
+ // Working, as opposed to paused for the user.
482
+ const working = (sessions) => sessions.filter((s) => s.waiting == null);
483
+
411
484
  // Claude Code's entry for Esc / the stop button: a user message whose text is
412
485
  // "[Request interrupted by user]" or "... for tool use]".
413
486
  const INTERRUPT_MARK = "[Request interrupted by user";
@@ -466,29 +539,47 @@ function interruptWatcher(transcript, offset) {
466
539
  const INTERRUPT_POLL_MS = 500;
467
540
 
468
541
  /**
469
- * Internal mode: holds the audio open until told to stop. The player's own
470
- * loop timer keeps the event loop alive.
542
+ * Internal mode: plays while any session is working. The player's own loop
543
+ * timer keeps the event loop alive.
471
544
  */
472
545
  function runDaemon(genre, volume, { reactive = false } = {}) {
473
- const turn = readTurn();
474
- const interrupted = turn && turn.transcript ? interruptWatcher(turn.transcript, turn.offset) : null;
546
+ const watchers = new Map(); // session id -> its transcript poll
547
+
548
+ // Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
549
+ // this is the only way to learn of one) and says whether any is still working.
550
+ const sweep = () => {
551
+ const sessions = listSessions();
552
+ for (const id of watchers.keys()) if (!sessions.some((s) => s.id === id)) watchers.delete(id);
553
+ for (const s of sessions) {
554
+ if (!s.transcript) continue;
555
+ // Built from the offset taken at the prompt, so a daemon respawned mid-turn
556
+ // still sees an interrupt that came before it.
557
+ if (!watchers.has(s.id)) watchers.set(s.id, interruptWatcher(s.transcript, s.offset));
558
+ if (watchers.get(s.id)()) {
559
+ fs.rmSync(sessionFile(s.id), { force: true });
560
+ watchers.delete(s.id);
561
+ }
562
+ }
563
+ return working(listSessions()).length > 0;
564
+ };
475
565
 
476
566
  // Silent, like Ctrl+C in the wrapper: an interrupt is never reported as done.
477
- const endInterrupted = () => {
567
+ const endIdle = () => {
478
568
  if (readPid() === process.pid) {
479
569
  fs.rmSync(PID_FILE, { force: true });
480
- endTurnState();
481
570
  fs.rmSync(INTENSITY_FILE, { force: true });
482
571
  }
483
572
  process.exit(0);
484
573
  };
485
- // Already interrupted before this daemon started - a resume respawned by the
486
- // PostToolUse that an interrupted tool still fires.
487
- if (interrupted && interrupted()) endInterrupted();
574
+ if (!sweep()) endIdle(); // Already interrupted before this daemon started.
488
575
 
489
576
  const player = new AudioPlayer();
490
577
  const started = player.start(genre, volume, {
491
- intensity: reactive ? readIntensity : null
578
+ intensity: reactive ? readIntensity : null,
579
+ // The same piece with more of it, as time escalation already does: two
580
+ // sessions working is tier 2 at least, three or more is tier 3. Read at
581
+ // each loop boundary, so it lands cleanly and eases off as they finish.
582
+ minTier: () => Math.min(3, working(listSessions()).length)
492
583
  });
493
584
  if (!started) process.exit(0);
494
585
 
@@ -501,32 +592,46 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
501
592
  process.on("SIGINT", shutdown);
502
593
  setTimeout(shutdown, MAX_DAEMON_MS);
503
594
 
504
- if (interrupted) {
505
- setInterval(() => {
506
- if (!interrupted()) return;
507
- player.stop({ playChime: false });
508
- endInterrupted();
509
- }, INTERRUPT_POLL_MS);
510
- }
595
+ setInterval(() => {
596
+ if (sweep()) return;
597
+ player.stop({ playChime: false });
598
+ endIdle();
599
+ }, INTERRUPT_POLL_MS);
511
600
  }
512
601
 
513
- function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
514
- stopDaemon(); // Single instance: a new prompt replaces the previous run
515
- fs.mkdirSync(STATE_DIR, { recursive: true });
516
- fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
517
- // Before the spawn: the daemon reads it on startup.
518
- if (turn) fs.writeFileSync(TURN_FILE, JSON.stringify(turn));
602
+ function daemonRunning() {
603
+ const pid = readPid();
604
+ return pid !== null && isOurDaemon(pid);
605
+ }
519
606
 
607
+ function spawnDaemon(genre, volume, reactive) {
520
608
  const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
521
609
  if (reactive) args.push("--reactive");
522
610
 
523
611
  const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
524
612
  child.unref();
525
613
 
614
+ fs.mkdirSync(STATE_DIR, { recursive: true });
526
615
  fs.writeFileSync(PID_FILE, String(child.pid));
527
616
  return child.pid;
528
617
  }
529
618
 
619
+ /**
620
+ * A prompt. With no other session working this replaces the player, as it
621
+ * always did, so a genre change lands on the next prompt. With one working the
622
+ * music carries on: restarting it would cut every other session's stream.
623
+ */
624
+ function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
625
+ const id = sessionId(turn && turn.session);
626
+ // Before the spawn: the daemon reads it on startup.
627
+ writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
628
+ if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
629
+
630
+ stopDaemon({ keepSessions: true });
631
+ fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
632
+ return spawnDaemon(genre, volume, reactive);
633
+ }
634
+
530
635
  /**
531
636
  * What the agent's stop event says about how the turn ended. Cursor reports
532
637
  * it in `status` ("completed", "aborted" or "error"). Claude Code's Stop
@@ -574,27 +679,25 @@ function readPayload(done, timeoutMs = 500) {
574
679
  setTimeout(finish, timeoutMs).unref();
575
680
  }
576
681
 
577
- function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false } = {}) {
578
- // Read before stopDaemon clears it. A turn that ends while paused for the
579
- // user - a denied tool that nothing resumed after - still finished.
580
- const wasWaiting = fs.existsSync(WAITING_FILE);
581
- const wasPlaying = stopDaemon();
582
- fs.rmSync(INTENSITY_FILE, { force: true });
583
- if (!(wasPlaying || wasWaiting) || noChime) return false;
584
-
585
- // Chime plays in this short-lived hook process, after the daemon is gone.
682
+ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
683
+ const id = sessionId(parsePayload(raw).session_id);
684
+ // A turn that ends while paused for the user - a denied tool that nothing
685
+ // resumed after - still finished, so its session counts either way.
686
+ const tracked = readSession(id) !== null;
687
+ fs.rmSync(sessionFile(id), { force: true });
688
+
689
+ // The music is every working session's, so it ends with the last of them.
690
+ // Everyone else's carries on, and this session still gets its own chime.
691
+ const others = working(listSessions()).length > 0;
692
+ const wasPlaying = others ? false : stopDaemon({ keepSessions: true });
693
+ if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
694
+ if (!(tracked || wasPlaying) || noChime) return false;
695
+
696
+ // Chime plays in this short-lived hook process.
586
697
  new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
587
698
  return true;
588
699
  }
589
700
 
590
- function readWaiting() {
591
- try {
592
- return fs.readFileSync(WAITING_FILE, "utf8");
593
- } catch (e) {
594
- return null;
595
- }
596
- }
597
-
598
701
  /**
599
702
  * What a wait is waiting for, and what ends it: the tool's name for a
600
703
  * permission dialog and its PostToolUse, the server's name for an MCP
@@ -618,11 +721,12 @@ const WAIT_NOTIFICATIONS = new Set([
618
721
  * The agent is blocked on the user (a permission dialog, a question, a plan to
619
722
  * approve, an MCP server asking for input). Music that keeps playing says
620
723
  * "still working", which is the one thing that is not true, so it stops and
621
- * the attention chime asks instead.
724
+ * the attention chime asks instead - unless another session is still working,
725
+ * whose music is not this dialog's to end.
622
726
  *
623
- * Only while music is playing: after the turn has ended there is nobody to
624
- * interrupt, and a second dialog while already waiting keeps the first wait
625
- * rather than chiming twice.
727
+ * Only for a turn in flight: after it has ended there is nobody to interrupt,
728
+ * and a second dialog while already waiting keeps the first wait rather than
729
+ * chiming twice.
626
730
  *
627
731
  * The chime is detached: the agent waits on this hook before showing the
628
732
  * dialog, and a chime played to completion here held it back for its length.
@@ -632,8 +736,12 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
632
736
  const type = payload.notification_type ?? payload.notificationType;
633
737
  if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
634
738
 
635
- if (!stopDaemon({ pause: true })) return false;
636
- fs.writeFileSync(WAITING_FILE, waitKey(raw));
739
+ const id = sessionId(payload.session_id);
740
+ const session = readSession(id);
741
+ if (!session || session.waiting != null) return false;
742
+
743
+ writeSession(id, { ...session, waiting: waitKey(raw) });
744
+ if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
637
745
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
638
746
  return true;
639
747
  }
@@ -643,22 +751,24 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
643
751
  * itself, so this is the first signal that work carries on after one - resume,
644
752
  * if it is the one being waited for.
645
753
  *
646
- * The turn is carried over rather than started afresh, so the new daemon keeps
647
- * watching the transcript from the prompt. An approved tool interrupted with
648
- * Esc still fires this PostToolUse, and must not bring the music back.
754
+ * The session keeps its transcript offset from the prompt, so an approved tool
755
+ * interrupted with Esc, which still fires this PostToolUse, is seen by the
756
+ * daemon and must not bring the music back.
649
757
  *
650
758
  * ponytail: matching by name means two same-named tools in one parallel batch,
651
759
  * one needing approval, can resume early - after the chime already did its
652
760
  * job. Match on tool_input as well if that ever shows up in practice.
653
761
  */
654
762
  function hookResume(raw, genre, volume, { reactive = false } = {}) {
655
- const waitingFor = readWaiting();
656
- if (waitingFor === null) return false; // Not waiting - the common case, on every tool call.
763
+ const id = sessionId(parsePayload(raw).session_id);
764
+ const session = readSession(id);
765
+ if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
657
766
  // An empty key is a wait that named nothing (a Notification): the next tool
658
767
  // to finish is the first sign of work carrying on.
659
- if (waitingFor !== "" && waitingFor !== waitKey(raw)) return false;
768
+ if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
660
769
 
661
- hookStart(genre, volume, { reactive, turn: readTurn() || newTurn(raw) });
770
+ writeSession(id, { ...session, waiting: null });
771
+ if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
662
772
  return true;
663
773
  }
664
774
 
@@ -666,18 +776,20 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
666
776
  * The agent's session is over (quit, /clear, logout). A turn cut off by it
667
777
  * never reaches Stop. Silent: nothing finished.
668
778
  *
669
- * Only the session that started the music may end it. SessionEnd also fires
670
- * for idle sessions, and one terminal closing must not silence another that is
671
- * mid-turn. Fails closed: with no recorded session there is no proof the music
672
- * is this session's, and the MAX_DAEMON_MS ceiling still applies.
779
+ * Only that session's own turn ends, so one terminal closing cannot silence
780
+ * another that is mid-turn. Fails closed on an unknown session: with none on
781
+ * record there is nothing of its to end, and the MAX_DAEMON_MS ceiling still
782
+ * applies.
673
783
  */
674
784
  function hookEnd(raw) {
675
- const turn = readTurn();
676
- const owner = turn && turn.session;
677
- if (!owner || owner !== String(parsePayload(raw).session_id || "")) return false;
785
+ const id = sessionId(parsePayload(raw).session_id);
786
+ if (!readSession(id)) return false;
678
787
 
679
- stopDaemon();
680
- fs.rmSync(INTENSITY_FILE, { force: true });
788
+ fs.rmSync(sessionFile(id), { force: true });
789
+ if (working(listSessions()).length === 0) {
790
+ stopDaemon({ keepSessions: true });
791
+ fs.rmSync(INTENSITY_FILE, { force: true });
792
+ }
681
793
  return true;
682
794
  }
683
795
 
@@ -699,13 +811,24 @@ function shellQuote(value) {
699
811
  return `'${String(value).replace(/'/g, "'\\''")}'`;
700
812
  }
701
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
+ */
702
828
  function hookCommand(flag, genre, volume, reactive = false) {
703
829
  const base = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} ${flag}`;
704
- // Resuming respawns the daemon, so it needs the same settings as starting.
705
- if (flag !== "--hook-start" && flag !== "--hook-resume") return base;
706
-
707
- const start = `${base} --genre ${genre} --volume ${Math.round(volume * 100)}`;
708
- return reactive ? `${start} --reactive` : start;
830
+ if (!reactive) return base;
831
+ return flag === "--hook-start" || flag === "--hook-resume" ? `${base} --reactive` : base;
709
832
  }
710
833
 
711
834
  const VIBE_HOOK_FLAG = /--hook-(start|stop|tool|wait|resume|end)\b/;
@@ -891,10 +1014,10 @@ single short sentence. Do nothing else.
891
1014
  - \`mute\` or \`mute <minutes>\`: \`${cli} --mute <minutes>\`
892
1015
  - \`unmute\`: \`${cli} --unmute\`
893
1016
  - \`stop\`: \`${cli} --stop\`
894
- - \`genre <name>\` or \`volume <5-100>\`: first run \`${cli} --status\` and read
895
- Claude Code's current genre, volume, and whether it says "reactive". Then run
896
- \`${cli} --install-hooks --tools claude --genre <genre> --volume <volume>\`,
897
- 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.
898
1021
  Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
899
1022
 
900
1023
  For anything else, show the user the list above instead of running a command.
@@ -964,6 +1087,7 @@ module.exports = {
964
1087
  readIntensity,
965
1088
  stopDaemon,
966
1089
  isOurDaemon,
1090
+ daemonPlaying,
967
1091
  installHooks,
968
1092
  ephemeralInstallReason,
969
1093
  uninstallHooks,
@@ -52,7 +52,48 @@ const GENRES = [
52
52
  { name: "🎲 Shuffle / Random", desc: "Picks a surprise vibe each time", id: "random" }
53
53
  ];
54
54
 
55
- function selectMenu(title, items, renderItem) {
55
+
56
+ /**
57
+ * One loop of a genre, started from inside the menu and replaced on each
58
+ * press. Non-blocking: the menu stays interactive while it plays, so you can
59
+ * page through the list and hear each one.
60
+ *
61
+ * The process is module-level rather than per-menu because there is only ever
62
+ * one pair of speakers - a second press has to silence the first, or two
63
+ * genres play over each other.
64
+ */
65
+ let previewChild = null;
66
+
67
+ function stopPreview() {
68
+ if (!previewChild) return;
69
+ try {
70
+ previewChild.kill();
71
+ } catch (e) {
72
+ // Already gone - the loop ended on its own.
73
+ }
74
+ previewChild = null;
75
+ }
76
+
77
+ function playPreview(genreId) {
78
+ const { spawn } = require("child_process");
79
+ const { detectPlayer, getAudioPath, resolveGenre, normalizeVolume, loadConfig, playbackDisabled } = require("./player");
80
+
81
+ stopPreview();
82
+ // A muted user pressing "p" is asking to hear something, the same as
83
+ // `--preview` is - but a mute set for a call should still hold. Silence is
84
+ // the safe read, and --status says why.
85
+ if (playbackDisabled()) return;
86
+
87
+ const backend = detectPlayer();
88
+ if (!backend) return;
89
+
90
+ const volume = normalizeVolume(loadConfig().volume, 0.4);
91
+ const file = getAudioPath(resolveGenre(genreId), 2);
92
+ previewChild = spawn(backend.cmd, backend.args(file, volume), { stdio: "ignore" });
93
+ previewChild.on("error", () => { previewChild = null; });
94
+ }
95
+
96
+ function selectMenu(title, items, renderItem, { onPreview = null } = {}) {
56
97
  return new Promise((resolve) => {
57
98
  let selected = 0;
58
99
  const stdin = process.stdin;
@@ -92,6 +133,15 @@ function selectMenu(title, items, renderItem) {
92
133
  process.exit(0);
93
134
  }
94
135
 
136
+ // Auditioning is the whole reason the genre list is hard to choose from:
137
+ // nine names and a one-line description each, and no way to hear any of
138
+ // them without leaving the menu. A loop renders in ~150ms, so this is
139
+ // just a keypress.
140
+ if (onPreview && (key === "p" || key === "P")) {
141
+ onPreview(items[selected]);
142
+ return;
143
+ }
144
+
95
145
  if (key === "\u001b[A") {
96
146
  // Up arrow
97
147
  selected = (selected - 1 + items.length) % items.length;
@@ -119,6 +169,7 @@ function selectMenu(title, items, renderItem) {
119
169
  }
120
170
 
121
171
  function cleanup() {
172
+ if (onPreview) stopPreview();
122
173
  stdin.removeListener("data", onData);
123
174
  stdin.setRawMode(false);
124
175
  stdin.pause();
@@ -299,9 +350,10 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
299
350
 
300
351
  // 3. Select Music Genre
301
352
  const selectedGenre = await selectMenu(
302
- "Choose your sound vibe:",
353
+ "Choose your sound vibe: \x1b[0m\x1b[90m(press p to hear the highlighted one)\x1b[0m",
303
354
  GENRES,
304
- (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`
355
+ (item, num) => `${num}. ${item.name} \x1b[90m— ${item.desc}\x1b[0m`,
356
+ { onPreview: (item) => playPreview(item.id) }
305
357
  );
306
358
 
307
359
  // 4. Select Volume Preset
@@ -335,4 +387,4 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
335
387
  };
336
388
  }
337
389
 
338
- module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, AI_TOOLS };
390
+ module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
package/src/mcp.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  const readline = require("readline");
9
- const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre } = require("./player");
9
+ const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
10
10
  const pkg = require("../package.json");
11
11
 
12
12
  // A desktop client that crashes never sends vibe_stop, so playback needs its
@@ -132,7 +132,12 @@ function handleMessage(player, msg) {
132
132
  const { name, arguments: args = {} } = params || {};
133
133
 
134
134
  if (name === "vibe_play") {
135
- const requested = args.genre || process.env.VIBE_GENRE || "lofi";
135
+ // Same precedence as the CLI: what the model asked for, then the
136
+ // environment, then the user's saved default, then lofi. Reading the
137
+ // saved file here is what makes `vibe --genre jazz` mean jazz in a
138
+ // desktop client too, rather than only in the terminal.
139
+ const saved = loadConfig();
140
+ const requested = args.genre || process.env.VIBE_GENRE || saved.genre || "lofi";
136
141
  // An unknown genre already fell back to lofi inside the generator, but
137
142
  // player.genre kept the name nobody implements - so the model told the
138
143
  // user it was playing something that does not exist.
@@ -140,7 +145,8 @@ function handleMessage(player, msg) {
140
145
  // Both the tool argument and the env default go through the same parser
141
146
  // as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
142
147
  // the player as NaN.
143
- const volume = normalizeVolume(args.volume, normalizeVolume(process.env.VIBE_VOLUME, 0.4));
148
+ const volume = normalizeVolume(args.volume,
149
+ normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
144
150
 
145
151
  const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
146
152
  // start() returns false for three unrelated reasons, and the model