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 +17 -6
- package/package.json +3 -3
- package/src/cli.js +2 -1
- package/src/hooks.js +161 -79
- package/src/player.js +5 -2
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
|
|
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
|
|
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
|
|
104
|
-
npx
|
|
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.
|
|
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": "
|
|
7
|
-
"vibe": "
|
|
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
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
|
-
//
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
const
|
|
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
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* uninstall) ends
|
|
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({
|
|
367
|
+
function stopDaemon({ keepSessions = false } = {}) {
|
|
366
368
|
const pid = readPid();
|
|
367
369
|
fs.rmSync(PID_FILE, { force: true });
|
|
368
|
-
if (!
|
|
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
|
-
|
|
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(
|
|
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:
|
|
470
|
-
*
|
|
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
|
|
474
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
579
|
-
// user - a denied tool that nothing
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
fs.rmSync(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
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
|
|
624
|
-
*
|
|
625
|
-
*
|
|
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
|
-
|
|
636
|
-
|
|
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
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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
|
|
656
|
-
|
|
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 (
|
|
738
|
+
if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
|
|
660
739
|
|
|
661
|
-
|
|
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
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
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
|
|
676
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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.
|