vibeaudio 0.4.0 → 0.5.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
@@ -10,7 +10,7 @@
10
10
 
11
11
  > **🔊 [Listen to every genre →](https://kiril6.github.io/vibeaudio/)** — hear all 8 genres, the tier escalation, and the three chimes, rendered from the real synth.
12
12
 
13
- > **In a hurry?** `npm i -g github:kiril6/vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
13
+ > **In a hurry?** `npm i -g vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
14
14
 
15
15
  ---
16
16
 
@@ -26,6 +26,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
26
26
  * 📈 **Escalating layers.** Tier 1 (0–15s) gentle intro → Tier 2 (15–45s) main groove → Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
27
27
  * 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
28
28
  * ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
29
+ * 🪟 **Several sessions, one soundtrack.** Run as many terminals of the same agent as you like: the music plays while *any* of them is working, and each finishes with its own chime. One session ending, pausing for a permission dialog or being interrupted never cuts off another that's still going.
29
30
  * 🧮 **Honest exit codes.** Your command's status passes straight through (`130` on `Ctrl+C`), so `vibe claude && next-step` behaves exactly as it would without the wrapper.
30
31
  * 🌊 **Terminal title HUD.** A live ASCII wave and elapsed timer in the window title, where it can't corrupt a full-screen TUI.
31
32
  * 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI and Qwen Code**; MCP for **Claude Desktop and Antigravity**; the wrapper (`vibe <command>`) for anything else.
@@ -69,13 +70,11 @@ If no player is found, VibeAudio prints a one-line notice and runs your command
69
70
  One line. No clone, no build step, no dependencies to resolve:
70
71
 
71
72
  ```bash
72
- npm i -g github:kiril6/vibeaudio
73
+ npm i -g vibeaudio
73
74
  ```
74
75
 
75
76
  That puts `vibe` and `vibeaudio` on your `PATH`. Re-run the same command to update, or see [Uninstall](#-uninstall) to remove it cleanly.
76
77
 
77
- > **Note:** VibeAudio isn't on the npm registry yet, so plain `npx vibeaudio` won't resolve — use the `github:` form above.
78
-
79
78
  ### Then pick how it runs
80
79
 
81
80
  **Using Claude Code, Codex, Cursor or Grok interactively?** Install the hooks — this is the mode that actually tracks thinking:
@@ -100,8 +99,8 @@ vibe claude -p "explain this repo"
100
99
  ### Try it without installing
101
100
 
102
101
  ```bash
103
- npx github:kiril6/vibeaudio --preview jazz # hear a loop right now
104
- npx github:kiril6/vibeaudio sleep 8 # hear the wrapper: music, then the done chime
102
+ npx vibeaudio --preview jazz # hear a loop right now
103
+ npx vibeaudio sleep 8 # hear the wrapper: music, then the done chime
105
104
  ```
106
105
 
107
106
  > **Heard nothing?** Music only starts once the wrapped command has run longer than the 1.5s grace window — that's [the point](#-why), so quick commands stay silent. `sleep 8` is the reliable demo; something like `npm test` is silent if the tests finish fast or the command errors out immediately.
@@ -218,6 +217,18 @@ Six things differ per agent, and none of them need any action from you except th
218
217
  | **Only Cursor can play the failure chime** | Its stop event reports whether the turn completed, aborted or errored. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
219
218
  | **Grok and Copilot CLI get a file of their own** | Each reads every `*.json` in its `hooks/` directory, so VibeAudio writes `vibeaudio.json` rather than merging into anyone else's — which makes uninstalling it a delete, and leaves no backup file behind. Copilot's honours `COPILOT_HOME`. |
220
219
 
220
+ ### Several sessions at once
221
+
222
+ Every session of an agent is tracked separately, by the session id in its hook payload, so two terminals (or a terminal and the desktop app) share one soundtrack instead of fighting over it:
223
+
224
+ * **The music plays while any session is working.** A second prompt doesn't restart it, and it stops only when the last working session finishes.
225
+ * **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).
226
+ * **Every session gets its own chime.** A quick question that finishes while another agent is still busy chimes "done" and the music carries on underneath.
227
+ * **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.
228
+ * **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.
229
+
230
+ 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.
231
+
221
232
  > **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI or Qwen Code session does the same hasn't been checked, so start a new session there to be sure.
222
233
 
223
234
  > **One player is shared.** They all drive the same background player, so if you prompt two agents at once, the last prompt owns the music. One person, one set of speakers — deliberate, not a limitation being worked around.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
- "vibeaudio": "./bin/vibeaudio.js",
7
- "vibe": "./bin/vibeaudio.js"
6
+ "vibeaudio": "bin/vibeaudio.js",
7
+ "vibe": "bin/vibeaudio.js"
8
8
  },
9
9
  "main": "src/cli.js",
10
10
  "scripts": {
package/src/cli.js CHANGED
@@ -693,7 +693,8 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
693
693
  outcome: hooks.outcomeFromPayload(raw),
694
694
  volume,
695
695
  chimeVolume,
696
- noChime
696
+ noChime,
697
+ raw
697
698
  });
698
699
  });
699
700
  return;
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
  /**
@@ -357,15 +359,15 @@ function isOurDaemon(pid) {
357
359
  }
358
360
 
359
361
  /**
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.
362
+ * Stops the player. `keepSessions` leaves the session files alone: the hooks
363
+ * use it because they decide per session what is still going on. Every other
364
+ * stop (--stop, --mute, uninstall) ends everything, or a later tool call would
365
+ * resume music nobody is waiting for.
364
366
  */
365
- function stopDaemon({ pause = false } = {}) {
367
+ function stopDaemon({ keepSessions = false } = {}) {
366
368
  const pid = readPid();
367
369
  fs.rmSync(PID_FILE, { force: true });
368
- if (!pause) endTurnState();
370
+ if (!keepSessions) fs.rmSync(SESSIONS_DIR, { recursive: true, force: true });
369
371
  if (pid === null || !isOurDaemon(pid)) return false;
370
372
 
371
373
  try {
@@ -376,11 +378,6 @@ function stopDaemon({ pause = false } = {}) {
376
378
  }
377
379
  }
378
380
 
379
- function endTurnState() {
380
- fs.rmSync(WAITING_FILE, { force: true });
381
- fs.rmSync(TURN_FILE, { force: true });
382
- }
383
-
384
381
  function fileSize(file) {
385
382
  try {
386
383
  return fs.statSync(file).size;
@@ -400,14 +397,60 @@ function newTurn(raw) {
400
397
  return { session: String(payload.session_id || ""), transcript, offset: transcript ? fileSize(transcript) : 0 };
401
398
  }
402
399
 
403
- function readTurn() {
400
+ // The id ends up in a file name.
401
+ function sessionId(id) {
402
+ return String(id || ANON_SESSION).replace(/[^\w.-]/g, "_").slice(0, 100);
403
+ }
404
+
405
+ function sessionFile(id) {
406
+ return path.join(SESSIONS_DIR, `${id}.json`);
407
+ }
408
+
409
+ function readSession(id) {
404
410
  try {
405
- return JSON.parse(fs.readFileSync(TURN_FILE, "utf8"));
411
+ return JSON.parse(fs.readFileSync(sessionFile(id), "utf8"));
406
412
  } catch (e) {
407
413
  return null;
408
414
  }
409
415
  }
410
416
 
417
+ // Written beside the target and renamed into place: the daemon lists this
418
+ // directory every poll and must never read half a file.
419
+ function writeSession(id, data) {
420
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
421
+ const tmp = `${sessionFile(id)}.${process.pid}.tmp`;
422
+ fs.writeFileSync(tmp, JSON.stringify({ ...data, ts: Date.now() }));
423
+ fs.renameSync(tmp, sessionFile(id));
424
+ }
425
+
426
+ /**
427
+ * Every session with a turn in flight. One that has not been touched for
428
+ * MAX_DAEMON_MS is a crashed agent - it never sent Stop - and is dropped here,
429
+ * the same ceiling the daemon itself stops at.
430
+ */
431
+ function listSessions() {
432
+ let names;
433
+ try {
434
+ names = fs.readdirSync(SESSIONS_DIR);
435
+ } catch (e) {
436
+ return [];
437
+ }
438
+ const now = Date.now();
439
+ const sessions = [];
440
+ for (const name of names) {
441
+ if (!name.endsWith(".json")) continue;
442
+ const id = name.slice(0, -".json".length);
443
+ const data = readSession(id);
444
+ if (!data) continue;
445
+ if (now - (data.ts || 0) > MAX_DAEMON_MS) fs.rmSync(sessionFile(id), { force: true });
446
+ else sessions.push({ id, ...data });
447
+ }
448
+ return sessions;
449
+ }
450
+
451
+ // Working, as opposed to paused for the user.
452
+ const working = (sessions) => sessions.filter((s) => s.waiting == null);
453
+
411
454
  // Claude Code's entry for Esc / the stop button: a user message whose text is
412
455
  // "[Request interrupted by user]" or "... for tool use]".
413
456
  const INTERRUPT_MARK = "[Request interrupted by user";
@@ -466,29 +509,47 @@ function interruptWatcher(transcript, offset) {
466
509
  const INTERRUPT_POLL_MS = 500;
467
510
 
468
511
  /**
469
- * Internal mode: holds the audio open until told to stop. The player's own
470
- * loop timer keeps the event loop alive.
512
+ * Internal mode: plays while any session is working. The player's own loop
513
+ * timer keeps the event loop alive.
471
514
  */
472
515
  function runDaemon(genre, volume, { reactive = false } = {}) {
473
- const turn = readTurn();
474
- const interrupted = turn && turn.transcript ? interruptWatcher(turn.transcript, turn.offset) : null;
516
+ const watchers = new Map(); // session id -> its transcript poll
517
+
518
+ // Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
519
+ // this is the only way to learn of one) and says whether any is still working.
520
+ const sweep = () => {
521
+ const sessions = listSessions();
522
+ for (const id of watchers.keys()) if (!sessions.some((s) => s.id === id)) watchers.delete(id);
523
+ for (const s of sessions) {
524
+ if (!s.transcript) continue;
525
+ // Built from the offset taken at the prompt, so a daemon respawned mid-turn
526
+ // still sees an interrupt that came before it.
527
+ if (!watchers.has(s.id)) watchers.set(s.id, interruptWatcher(s.transcript, s.offset));
528
+ if (watchers.get(s.id)()) {
529
+ fs.rmSync(sessionFile(s.id), { force: true });
530
+ watchers.delete(s.id);
531
+ }
532
+ }
533
+ return working(listSessions()).length > 0;
534
+ };
475
535
 
476
536
  // Silent, like Ctrl+C in the wrapper: an interrupt is never reported as done.
477
- const endInterrupted = () => {
537
+ const endIdle = () => {
478
538
  if (readPid() === process.pid) {
479
539
  fs.rmSync(PID_FILE, { force: true });
480
- endTurnState();
481
540
  fs.rmSync(INTENSITY_FILE, { force: true });
482
541
  }
483
542
  process.exit(0);
484
543
  };
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();
544
+ if (!sweep()) endIdle(); // Already interrupted before this daemon started.
488
545
 
489
546
  const player = new AudioPlayer();
490
547
  const started = player.start(genre, volume, {
491
- intensity: reactive ? readIntensity : null
548
+ intensity: reactive ? readIntensity : null,
549
+ // The same piece with more of it, as time escalation already does: two
550
+ // sessions working is tier 2 at least, three or more is tier 3. Read at
551
+ // each loop boundary, so it lands cleanly and eases off as they finish.
552
+ minTier: () => Math.min(3, working(listSessions()).length)
492
553
  });
493
554
  if (!started) process.exit(0);
494
555
 
@@ -501,32 +562,46 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
501
562
  process.on("SIGINT", shutdown);
502
563
  setTimeout(shutdown, MAX_DAEMON_MS);
503
564
 
504
- if (interrupted) {
505
- setInterval(() => {
506
- if (!interrupted()) return;
507
- player.stop({ playChime: false });
508
- endInterrupted();
509
- }, INTERRUPT_POLL_MS);
510
- }
565
+ setInterval(() => {
566
+ if (sweep()) return;
567
+ player.stop({ playChime: false });
568
+ endIdle();
569
+ }, INTERRUPT_POLL_MS);
511
570
  }
512
571
 
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));
572
+ function daemonRunning() {
573
+ const pid = readPid();
574
+ return pid !== null && isOurDaemon(pid);
575
+ }
519
576
 
577
+ function spawnDaemon(genre, volume, reactive) {
520
578
  const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
521
579
  if (reactive) args.push("--reactive");
522
580
 
523
581
  const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
524
582
  child.unref();
525
583
 
584
+ fs.mkdirSync(STATE_DIR, { recursive: true });
526
585
  fs.writeFileSync(PID_FILE, String(child.pid));
527
586
  return child.pid;
528
587
  }
529
588
 
589
+ /**
590
+ * A prompt. With no other session working this replaces the player, as it
591
+ * always did, so a genre change lands on the next prompt. With one working the
592
+ * music carries on: restarting it would cut every other session's stream.
593
+ */
594
+ function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
595
+ const id = sessionId(turn && turn.session);
596
+ // Before the spawn: the daemon reads it on startup.
597
+ writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
598
+ if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
599
+
600
+ stopDaemon({ keepSessions: true });
601
+ fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
602
+ return spawnDaemon(genre, volume, reactive);
603
+ }
604
+
530
605
  /**
531
606
  * What the agent's stop event says about how the turn ended. Cursor reports
532
607
  * it in `status` ("completed", "aborted" or "error"). Claude Code's Stop
@@ -574,27 +649,25 @@ function readPayload(done, timeoutMs = 500) {
574
649
  setTimeout(finish, timeoutMs).unref();
575
650
  }
576
651
 
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.
652
+ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
653
+ const id = sessionId(parsePayload(raw).session_id);
654
+ // A turn that ends while paused for the user - a denied tool that nothing
655
+ // resumed after - still finished, so its session counts either way.
656
+ const tracked = readSession(id) !== null;
657
+ fs.rmSync(sessionFile(id), { force: true });
658
+
659
+ // The music is every working session's, so it ends with the last of them.
660
+ // Everyone else's carries on, and this session still gets its own chime.
661
+ const others = working(listSessions()).length > 0;
662
+ const wasPlaying = others ? false : stopDaemon({ keepSessions: true });
663
+ if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
664
+ if (!(tracked || wasPlaying) || noChime) return false;
665
+
666
+ // Chime plays in this short-lived hook process.
586
667
  new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
587
668
  return true;
588
669
  }
589
670
 
590
- function readWaiting() {
591
- try {
592
- return fs.readFileSync(WAITING_FILE, "utf8");
593
- } catch (e) {
594
- return null;
595
- }
596
- }
597
-
598
671
  /**
599
672
  * What a wait is waiting for, and what ends it: the tool's name for a
600
673
  * permission dialog and its PostToolUse, the server's name for an MCP
@@ -618,11 +691,12 @@ const WAIT_NOTIFICATIONS = new Set([
618
691
  * The agent is blocked on the user (a permission dialog, a question, a plan to
619
692
  * approve, an MCP server asking for input). Music that keeps playing says
620
693
  * "still working", which is the one thing that is not true, so it stops and
621
- * the attention chime asks instead.
694
+ * the attention chime asks instead - unless another session is still working,
695
+ * whose music is not this dialog's to end.
622
696
  *
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.
697
+ * Only for a turn in flight: after it has ended there is nobody to interrupt,
698
+ * and a second dialog while already waiting keeps the first wait rather than
699
+ * chiming twice.
626
700
  *
627
701
  * The chime is detached: the agent waits on this hook before showing the
628
702
  * dialog, and a chime played to completion here held it back for its length.
@@ -632,8 +706,12 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
632
706
  const type = payload.notification_type ?? payload.notificationType;
633
707
  if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
634
708
 
635
- if (!stopDaemon({ pause: true })) return false;
636
- fs.writeFileSync(WAITING_FILE, waitKey(raw));
709
+ const id = sessionId(payload.session_id);
710
+ const session = readSession(id);
711
+ if (!session || session.waiting != null) return false;
712
+
713
+ writeSession(id, { ...session, waiting: waitKey(raw) });
714
+ if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
637
715
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
638
716
  return true;
639
717
  }
@@ -643,22 +721,24 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
643
721
  * itself, so this is the first signal that work carries on after one - resume,
644
722
  * if it is the one being waited for.
645
723
  *
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.
724
+ * The session keeps its transcript offset from the prompt, so an approved tool
725
+ * interrupted with Esc, which still fires this PostToolUse, is seen by the
726
+ * daemon and must not bring the music back.
649
727
  *
650
728
  * ponytail: matching by name means two same-named tools in one parallel batch,
651
729
  * one needing approval, can resume early - after the chime already did its
652
730
  * job. Match on tool_input as well if that ever shows up in practice.
653
731
  */
654
732
  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.
733
+ const id = sessionId(parsePayload(raw).session_id);
734
+ const session = readSession(id);
735
+ if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
657
736
  // An empty key is a wait that named nothing (a Notification): the next tool
658
737
  // to finish is the first sign of work carrying on.
659
- if (waitingFor !== "" && waitingFor !== waitKey(raw)) return false;
738
+ if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
660
739
 
661
- hookStart(genre, volume, { reactive, turn: readTurn() || newTurn(raw) });
740
+ writeSession(id, { ...session, waiting: null });
741
+ if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
662
742
  return true;
663
743
  }
664
744
 
@@ -666,18 +746,20 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
666
746
  * The agent's session is over (quit, /clear, logout). A turn cut off by it
667
747
  * never reaches Stop. Silent: nothing finished.
668
748
  *
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.
749
+ * Only that session's own turn ends, so one terminal closing cannot silence
750
+ * another that is mid-turn. Fails closed on an unknown session: with none on
751
+ * record there is nothing of its to end, and the MAX_DAEMON_MS ceiling still
752
+ * applies.
673
753
  */
674
754
  function hookEnd(raw) {
675
- const turn = readTurn();
676
- const owner = turn && turn.session;
677
- if (!owner || owner !== String(parsePayload(raw).session_id || "")) return false;
755
+ const id = sessionId(parsePayload(raw).session_id);
756
+ if (!readSession(id)) return false;
678
757
 
679
- stopDaemon();
680
- fs.rmSync(INTENSITY_FILE, { force: true });
758
+ fs.rmSync(sessionFile(id), { force: true });
759
+ if (working(listSessions()).length === 0) {
760
+ stopDaemon({ keepSessions: true });
761
+ fs.rmSync(INTENSITY_FILE, { force: true });
762
+ }
681
763
  return true;
682
764
  }
683
765
 
package/src/player.js CHANGED
@@ -498,6 +498,7 @@ class AudioPlayer {
498
498
  this.volume = 0.42;
499
499
  this.seed = projectSeed();
500
500
  this.intensity = null;
501
+ this.minTier = null;
501
502
  this.currentTier = 1;
502
503
  this.nextTimer = null;
503
504
  this.watchdog = null;
@@ -507,7 +508,7 @@ class AudioPlayer {
507
508
  * Returns true if playback actually started. Restarts when called with
508
509
  * different settings while playing, so a genre switch is not silently dropped.
509
510
  */
510
- start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null } = {}) {
511
+ start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null } = {}) {
511
512
  const resolved = resolveGenre(genre);
512
513
  const targetVolume = Math.max(0.05, Math.min(1.0, volume));
513
514
 
@@ -529,6 +530,7 @@ class AudioPlayer {
529
530
  this.volume = targetVolume;
530
531
  this.seed = projectSeed();
531
532
  this.intensity = intensity;
533
+ this.minTier = minTier;
532
534
 
533
535
  installExitHook();
534
536
  activePlayers.add(this);
@@ -560,7 +562,8 @@ class AudioPlayer {
560
562
  // An intensity source (reactive mode) overrides this when it has a signal.
561
563
  const elapsed = Date.now() - this.startTime;
562
564
  const byTime = elapsed > TIER_3_AFTER_MS ? 3 : elapsed > TIER_2_AFTER_MS ? 2 : 1;
563
- this.currentTier = (this.intensity && this.intensity()) || byTime;
565
+ // A floor (several sessions at once) only ever raises the tier.
566
+ this.currentTier = Math.max((this.intensity && this.intensity()) || byTime, (this.minTier && this.minTier()) || 1);
564
567
 
565
568
  // Either the backend attenuates, or the file is rendered pre-attenuated -
566
569
  // never both, or the volume would be applied twice.