vibeaudio 0.12.0 β†’ 0.13.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
@@ -11,6 +11,8 @@
11
11
  > **πŸ”Š [Listen to every genre β†’](https://kiril6.github.io/vibeaudio/)** β€” hear all 10 genres, the tier escalation, and the three chimes, rendered from the real synth.
12
12
 
13
13
  > **In a hurry?** `npm i -g vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
14
+ >
15
+ > **Claude Code only?** Skip npm: `claude plugin marketplace add kiril6/vibeaudio`, then `claude plugin install vibeaudio@vibeaudio` ([details](#as-a-claude-code-plugin)).
14
16
 
15
17
  ---
16
18
 
@@ -27,6 +29,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
27
29
  * πŸŽ›οΈ **Music that follows the work** ([reactive mode](#reactive-mode-opt-in), opt-in). Calm while the agent reads, fuller while it edits, busiest when it runs commands or hands work to sub-agents β€” you can hear *what* it's doing, not just for how long.
28
30
  * πŸŒ… **It fades, it doesn't cut.** On macOS the music eases in and out instead of starting and stopping mid-note, and `vibe --volume` reaches music that is already playing within a second.
29
31
  * πŸ”” **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.
32
+ * πŸ’“ **A heartbeat when an agent looks stuck.** If 4 of a session's last 8 tool calls fail β€” the test-edit-test loop that goes nowhere β€” a soft pulse on the music's tonic joins the music until things start passing again. It's rare by design: on 4,497 real turns it fired in under 1%.
30
33
  * βœ‹ **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.
31
34
  * πŸ”Œ **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code and Windsurf**; MCP for **Claude Desktop and Antigravity**; the wrapper (`vibe <command>`) for anything else.
32
35
 
@@ -310,6 +313,22 @@ Sessions with no id in their payload share a single slot, so they behave as one.
310
313
 
311
314
  > **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers β€” deliberate, not a limitation being worked around.
312
315
 
316
+ ### As a Claude Code plugin
317
+
318
+ If Claude Code is the only agent you use, the plugin installs the same hooks with no npm step:
319
+
320
+ ```bash
321
+ claude plugin marketplace add kiril6/vibeaudio
322
+ claude plugin install vibeaudio@vibeaudio
323
+ ```
324
+
325
+ It needs Node 18+ on your `PATH` (the hooks run `node`), and it also adds `/vibeaudio:vibe`, the plugin's spelling of [`/vibe`](#vibe-inside-claude-code). Update with `claude plugin update vibeaudio@vibeaudio`; remove with `claude plugin uninstall vibeaudio@vibeaudio` (music already playing stops on its own within 15 minutes, or run `/vibeaudio:vibe stop` first).
326
+
327
+ - **Settings are the same file.** `vibe --genre jazz` and the rest work as always, but the `vibe` command comes from `npm i -g vibeaudio`; the plugin alone puts nothing on your `PATH`. Use `/vibeaudio:vibe genre jazz` instead.
328
+ - **Both at once is safe.** If `--install-hooks` has also been run, the plugin's hooks step aside and the installed ones play, so there's no double chime. Remove the installed hooks first if you want the plugin to own it.
329
+ - **Claude Code only.** Codex, Cursor, Gemini and the others still use `vibe --install-hooks`.
330
+ - **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
331
+
313
332
  ### `/vibe` inside Claude Code
314
333
 
315
334
  Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`), so you can control the music without leaving the session:
@@ -368,14 +387,24 @@ Hooks log each finished turn β€” which project, how long, how it ended, and how
368
387
  You waited 3h 55m on agents, 7m 51s per turn
369
388
  Agents waited 5m 35s on you β€” permission dialogs and questions
370
389
  Typical turn 2m 57s median
390
+ Back to it 41s median from done to your next prompt, 27 times
391
+ 32s with a chime 1m 50s without (muted or --no-chime)
371
392
  ```
372
393
 
373
- plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged β€” a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
394
+ plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. "Back to it" is the other direction: how long a finished turn sat before your next prompt in the same session β€” gaps over 30 minutes count as breaks and are left out. Once both sides have five turns, it is split by whether a chime announced the turn, so you can see what the chime is worth to you. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged β€” a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
374
395
 
375
396
  ### The chime is in the music's key
376
397
 
377
398
  The success chime is the tonic chord of whatever key your genre sits in β€” C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone β€” so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
378
399
 
400
+ ### When an agent looks stuck
401
+
402
+ The worst stretch of agent work is twenty minutes of the same thing failing while the music says all is well. So when **4 of a session's last 8 tool calls fail**, a soft heartbeat β€” two low beats about once a second, on the tonic of your genre β€” joins the music at the next loop boundary, and leaves once enough calls succeed. With `--notify` on, you also get one banner when the session crosses the line ("api: looks stuck"), not one per failure.
403
+
404
+ The threshold was picked against 4,497 real Claude Code turns (46,032 tool calls). It fires in about 1% of turns, at around minute 3, and those turns typically ran for another 3 minutes β€” time you could have spent stepping in. "3 in a row" was rejected because the usual loop has a successful edit between every failing test run. Each new prompt starts with a clean slate, and an interrupt (Esc) never counts as a failure.
405
+
406
+ It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok and Windsurf don't, so for them the music never adds the heartbeat.
407
+
379
408
  ### Reactive mode (opt-in)
380
409
 
381
410
  ```bash
@@ -409,6 +438,35 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
409
438
 
410
439
  ---
411
440
 
441
+ ### Build on it: `vibe --state` and `vibe --events`
442
+
443
+ The hard part of VibeAudio isn't the music. It's turning eight agents' different hook events into one set of states that mean the same thing everywhere. The music is one consumer of those states, and anything else can be another: a smart light, a menu bar icon, a tmux status line, a Stream Deck key.
444
+
445
+ ```bash
446
+ vibe --state
447
+ # {"v":1,"status":"waiting","sessions":[{"session":"…","project":"/work/api","state":"waiting","since":1791026124771,"tool":"Bash"}]}
448
+ ```
449
+
450
+ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiting` (on you: a permission dialog or a question). `status` is the machine's overall state: the most urgent of its sessions, in the order `waiting` > `stuck` > `working` > `idle`. One session waiting on you matters more than three working.
451
+
452
+ `vibe --events` prints that snapshot once, then a JSON line for every change, as it happens:
453
+
454
+ ```json
455
+ {"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
456
+ ```
457
+
458
+ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended`. Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field. Some examples:
459
+
460
+ ```bash
461
+ # tmux: show the state in the status bar
462
+ set -g status-right '#(vibe --state | jq -r .status)'
463
+
464
+ # macOS: say it out loud when any agent needs you
465
+ vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | while read p; do say "$(basename "$p") needs you"; done
466
+ ```
467
+
468
+ The stream is a local file (`~/.vibeaudio/events.jsonl`, rotated at 256 KB), so it never leaves your machine. It keeps updating while you're muted, because a mute silences sound and a light isn't sound. The `v` field is the format version, and any breaking change will increment it.
469
+
412
470
  ## πŸ–₯️ Everything Else (Claude Desktop, Antigravity… via MCP)
413
471
 
414
472
  **Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code) and [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) have hook systems, and `--install-hooks` writes to all eight** β€” use [hooks](#-agent-hooks-no-wrapper-needed) there, they're strictly better. This section is for everything else. MCP is the way in: it's plain stdio JSON-RPC, so the setup is identical everywhere and only the config file differs.
@@ -587,6 +645,8 @@ The full order, highest first: **a flag** β†’ **an environment variable** β†’ **
587
645
  | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | β€” |
588
646
  | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
589
647
  | `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
648
+ | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | β€” |
649
+ | `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | β€” |
590
650
  | `--stop` | Stop the background player, then exit | β€” |
591
651
  | `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
592
652
  | `--unmute` | Resume normal playback, then exit | β€” |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think β€” a different arrangement per project.",
5
5
  "bin": {
6
6
  "vibeaudio": "bin/vibeaudio.js",
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "test": "node test/test-synth.js",
12
12
  "start": "node bin/vibeaudio.js",
13
+ "version": "node -e \"const f='.claude-plugin/plugin.json',j=require('./'+f);j.version=require('./package.json').version;require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
13
14
  "postinstall": "node scripts/postinstall.js"
14
15
  },
15
16
  "publishConfig": {
package/src/cli.js CHANGED
@@ -78,6 +78,8 @@ Procedural focus music while your AI coding tools think.
78
78
  --status Show what is installed, running and detected, then exit
79
79
  --notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
80
80
  --report [days] How long you waited on agents, and where (default: 7 days)
81
+ --state Print what every agent is doing as JSON: idle, working, stuck or waiting
82
+ --events Stream agent state changes as JSON lines, until stopped
81
83
  --doctor Check the setup; each problem comes with its fix (exit 1 if any)
82
84
  --stop Stop the background player, then exit
83
85
  --mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
@@ -174,6 +176,8 @@ function parseArgs(argv) {
174
176
  let render = null;
175
177
  let clearCacheFlag = false;
176
178
  let statusFlag = false;
179
+ let stateFlag = false;
180
+ let eventsFlag = false;
177
181
  let doctorFlag = false;
178
182
  let notifyFlag = null;
179
183
  let reportDays = null;
@@ -184,6 +188,7 @@ function parseArgs(argv) {
184
188
  let mcp = false;
185
189
  let reactive = false;
186
190
  let followVolume = false;
191
+ let plugin = false;
187
192
  let here_flag = false;
188
193
  let dryRun = false;
189
194
  let tools = null;
@@ -288,6 +293,13 @@ function parseArgs(argv) {
288
293
  continue;
289
294
  }
290
295
 
296
+ if (arg === "--state" || arg === "--events") {
297
+ if (arg === "--state") stateFlag = true;
298
+ else eventsFlag = true;
299
+ i += 1;
300
+ continue;
301
+ }
302
+
291
303
  if (arg === "--report") {
292
304
  // Optional window in days, like --mute's minutes: `vibe --report 30`.
293
305
  i += 1;
@@ -361,6 +373,14 @@ function parseArgs(argv) {
361
373
  continue;
362
374
  }
363
375
 
376
+ // Internal, set by the plugin's hooks.json: yield to hooks that
377
+ // --install-hooks already wrote, so both being present is not a double chime.
378
+ if (arg === "--plugin") {
379
+ plugin = true;
380
+ i += 1;
381
+ continue;
382
+ }
383
+
364
384
  if (arg === "--dry-run") {
365
385
  dryRun = true;
366
386
  i += 1;
@@ -461,6 +481,8 @@ function parseArgs(argv) {
461
481
  render,
462
482
  clearCache: clearCacheFlag,
463
483
  status: statusFlag,
484
+ state: stateFlag,
485
+ events: eventsFlag,
464
486
  doctor: doctorFlag,
465
487
  notify: notifyFlag,
466
488
  report: reportDays,
@@ -471,6 +493,7 @@ function parseArgs(argv) {
471
493
  mcp,
472
494
  reactive,
473
495
  followVolume,
496
+ plugin,
474
497
  dryRun,
475
498
  tools,
476
499
  typed,
@@ -926,10 +949,12 @@ function formatDuration(ms) {
926
949
  * The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
927
950
  * working time - a turn's length minus the stretches it spent blocked on a
928
951
  * dialog of yours - because the two are different complaints: one is the
929
- * agent being slow, the other is you being away.
952
+ * agent being slow, the other is you being away. "Back to it" is the other
953
+ * direction: how long a finished turn waited for your next prompt, with gaps
954
+ * over RETURN_WINDOW_MS read as breaks and left out.
930
955
  */
931
956
  function printReport(days, now = Date.now()) {
932
- const { readTurns } = require("./history");
957
+ const { readTurns, returnTimes } = require("./history");
933
958
  const turns = readTurns(days, now);
934
959
  const dim = (s) => `\x1b[90m${s}\x1b[0m`;
935
960
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -968,6 +993,19 @@ function printReport(days, now = Date.now()) {
968
993
  row("Typical turn", formatDuration(median), "median");
969
994
  row("Longest turn", formatDuration(longest.ms), label(longest));
970
995
 
996
+ // How fast you came back once a turn was done - the human half of the loop.
997
+ const middle = (xs) => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)];
998
+ const gaps = returnTimes(turns);
999
+ if (gaps.length) {
1000
+ row("Back to it", formatDuration(middle(gaps.map((g) => g.ms))), `median from done to your next prompt, ${gaps.length} ${gaps.length === 1 ? "time" : "times"}`);
1001
+ // Only once both sides have enough to mean something; three turns is an anecdote.
1002
+ const withChime = gaps.filter((g) => g.chimed === true).map((g) => g.ms);
1003
+ const without = gaps.filter((g) => g.chimed === false).map((g) => g.ms);
1004
+ if (withChime.length >= 5 && without.length >= 5) {
1005
+ row("", `${formatDuration(middle(withChime))} with a chime`, `${formatDuration(middle(without))} without (muted or --no-chime)`);
1006
+ }
1007
+ }
1008
+
971
1009
  const byProject = new Map();
972
1010
  for (const t of turns) {
973
1011
  const p = byProject.get(label(t)) || { ms: 0, n: 0 };
@@ -1273,9 +1311,14 @@ function renderToFile(target, genre) {
1273
1311
  console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
1274
1312
  }
1275
1313
 
1276
- function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed = {} }) {
1314
+ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed = {} }) {
1277
1315
  const hooks = require("./hooks");
1278
1316
 
1317
+ // The plugin's hooks and --install-hooks' hooks are the same events: with both
1318
+ // present every prompt would restart the music and every turn chime twice.
1319
+ // The installed ones win because they carry the user's --reactive choice.
1320
+ if (plugin && action.startsWith("hook-") && hooks.userHooksInstalled()) return;
1321
+
1279
1322
  switch (action) {
1280
1323
  case "daemon":
1281
1324
  return hooks.runDaemon(genre, volume, { reactive, volumeSource: followVolume ? volumeNow : null });
@@ -1488,6 +1531,8 @@ async function run() {
1488
1531
  render,
1489
1532
  clearCache: shouldClear,
1490
1533
  status: showStatus,
1534
+ state: showState,
1535
+ events: followEvents,
1491
1536
  doctor: showDoctor,
1492
1537
  notify: notifyChange,
1493
1538
  report: reportDays,
@@ -1498,6 +1543,7 @@ async function run() {
1498
1543
  mcp,
1499
1544
  reactive,
1500
1545
  followVolume,
1546
+ plugin,
1501
1547
  dryRun,
1502
1548
  tools,
1503
1549
  typed,
@@ -1515,7 +1561,7 @@ async function run() {
1515
1561
 
1516
1562
  if (hookAction) {
1517
1563
  try {
1518
- return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, tools, dryRun, typed });
1564
+ return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed });
1519
1565
  } catch (e) {
1520
1566
  // Settings problems are the user's to fix β€” report them, don't stack-trace.
1521
1567
  console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
@@ -1579,6 +1625,18 @@ async function run() {
1579
1625
  return printDoctor();
1580
1626
  }
1581
1627
 
1628
+ // Machine-readable, for lights, menu bars and status lines: see "Build on it" in the README.
1629
+ if (showState) {
1630
+ console.log(JSON.stringify(require("./hooks").agentState()));
1631
+ return;
1632
+ }
1633
+
1634
+ if (followEvents) {
1635
+ process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
1636
+ require("./hooks").followEvents();
1637
+ return;
1638
+ }
1639
+
1582
1640
  if (reportDays !== null) {
1583
1641
  return printReport(reportDays);
1584
1642
  }
package/src/history.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * A local log of finished turns - what `vibe --report` reads. One JSON object
3
3
  * per line in ~/.vibeaudio/history.jsonl: when the turn ended, which project,
4
- * how long it took, how much of that the agent spent blocked on you, and how
5
- * it ended. It never leaves the machine; VIBE_NO_HISTORY=1 turns it off.
4
+ * how long it took, how much of that the agent spent blocked on you, how it
5
+ * ended, which session it was and whether a chime announced it. It never
6
+ * leaves the machine; VIBE_NO_HISTORY=1 turns it off.
6
7
  */
7
8
 
8
9
  const fs = require("fs");
@@ -14,12 +15,12 @@ const MAX_BYTES = 1024 * 1024;
14
15
  const KEEP_LINES = 4000;
15
16
 
16
17
  /** Appends one turn. Never throws: a log is not worth failing a hook over. */
17
- function recordTurn({ project, ms, blockedMs = 0, outcome = "success", at = Date.now() }, file = HISTORY_FILE) {
18
+ function recordTurn({ project, ms, blockedMs = 0, outcome = "success", session, chimed, at = Date.now() }, file = HISTORY_FILE) {
18
19
  if (process.env.VIBE_NO_HISTORY) return false;
19
20
  if (!Number.isFinite(ms) || ms < 0) return false;
20
21
  try {
21
22
  fs.mkdirSync(path.dirname(file), { recursive: true });
22
- fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome })}\n`);
23
+ fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome, session, chimed })}\n`);
23
24
  // Bounded, so a year of use is still a small file. Rewritten rarely, from
24
25
  // the tail, so the newest turns are always the ones kept.
25
26
  if (fs.statSync(file).size > MAX_BYTES) {
@@ -54,4 +55,34 @@ function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
54
55
  return turns;
55
56
  }
56
57
 
57
- module.exports = { HISTORY_FILE, recordTurn, readTurns };
58
+ // A gap longer than this is a break - lunch, a meeting, the end of the day -
59
+ // not someone coming back to a finished turn, and would swamp the median.
60
+ const RETURN_WINDOW_MS = 30 * 60 * 1000;
61
+
62
+ /**
63
+ * How long each finished turn sat before the next prompt in the same session:
64
+ * the time it took you to come back. A turn's start is `at - ms`. Sessions,
65
+ * not projects, are what pair up - two agents in one repo interleave - so a
66
+ * line from before sessions were logged (or an `anon` one) falls back to its
67
+ * project. `chimed` is the finished turn's, since that is the chime you were
68
+ * answering.
69
+ */
70
+ function returnTimes(turns) {
71
+ const groups = new Map();
72
+ for (const t of turns) {
73
+ const key = t.session && t.session !== "anon" ? `s:${t.session}` : `p:${t.project}`;
74
+ if (!groups.has(key)) groups.set(key, []);
75
+ groups.get(key).push(t);
76
+ }
77
+ const gaps = [];
78
+ for (const list of groups.values()) {
79
+ list.sort((a, b) => a.at - b.at);
80
+ for (let i = 1; i < list.length; i++) {
81
+ const ms = list[i].at - list[i].ms - list[i - 1].at;
82
+ if (ms >= 0 && ms <= RETURN_WINDOW_MS) gaps.push({ ms, chimed: list[i - 1].chimed });
83
+ }
84
+ }
85
+ return gaps;
86
+ }
87
+
88
+ module.exports = { HISTORY_FILE, RETURN_WINDOW_MS, recordTurn, readTurns, returnTimes };
package/src/hooks.js CHANGED
@@ -25,6 +25,7 @@ const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
25
25
  // them is working; each holds where its transcript began and, while paused for
26
26
  // the user, what will resume it.
27
27
  const SESSIONS_DIR = path.join(STATE_DIR, "sessions");
28
+ const EVENTS_FILE = path.join(STATE_DIR, "events.jsonl");
28
29
  // Payloads that name no session share this one slot.
29
30
  const ANON_SESSION = "anon";
30
31
  const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
@@ -452,7 +453,7 @@ function newTurn(raw) {
452
453
  const payload = parsePayload(raw);
453
454
  const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
454
455
  const offset = transcript ? fileSize(transcript) : 0;
455
- return { session: String(payloadSession(payload) || ""), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
456
+ return { session: String(payloadSession(payload) || ""), project: payload.cwd || process.cwd(), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
456
457
  }
457
458
 
458
459
  const BLOCK_LOOKBACK_MS = 3000;
@@ -545,6 +546,102 @@ function listSessions() {
545
546
  // Working, as opposed to paused for the user.
546
547
  const working = (sessions) => sessions.filter((s) => s.waiting == null);
547
548
 
549
+ // "Stuck": STUCK_FAILURES of a session's last STUCK_WINDOW tool calls failed.
550
+ // Measured on 4,497 real Claude Code turns (46,032 tool calls, 3.8% failing):
551
+ // this fires in 0.9% of turns, at minute 3 by the median, and those turns ran
552
+ // a median 3.3 minutes more - time someone could have stepped in. "3 of 8"
553
+ // fired in 2.4%, and "3 in a row" misses the usual loop, where an edit that
554
+ // succeeds sits between every failing test run. Rare is the point: a signal
555
+ // that fires often is one people learn to tune out.
556
+ const STUCK_WINDOW = 8;
557
+ const STUCK_FAILURES = 4;
558
+ const isStuck = (session) => (session.recent || []).filter(Boolean).length >= STUCK_FAILURES;
559
+
560
+ /**
561
+ * The public face of the session files: what every agent on the machine is
562
+ * doing, in five words that mean the same thing whichever agent it is. The
563
+ * hard part of this project is mapping eight agents' hook events onto those
564
+ * states; the music is one consumer of them, and `vibe --state` / `--events`
565
+ * let anything else be another - a light, a menu bar, a tmux status line.
566
+ * `status` is the machine's: the most urgent of its sessions, since one
567
+ * session waiting on you matters more than three working.
568
+ */
569
+ const STATE_RANK = ["idle", "working", "stuck", "waiting"];
570
+ const sessionState = (s) => (s.waiting != null ? "waiting" : isStuck(s) ? "stuck" : "working");
571
+
572
+ function agentState(sessions = listSessions()) {
573
+ const list = sessions.map((s) => ({
574
+ session: s.id,
575
+ project: s.project || null,
576
+ state: sessionState(s),
577
+ since: s.started || null,
578
+ ...(s.waiting ? { tool: s.waiting } : {})
579
+ }));
580
+ const status = list.reduce((a, s) => (STATE_RANK.indexOf(s.state) > STATE_RANK.indexOf(a) ? s.state : a), "idle");
581
+ return { v: 1, status, sessions: list };
582
+ }
583
+
584
+ const EVENTS_MAX_BYTES = 256 * 1024;
585
+
586
+ /**
587
+ * Appends one transition to ~/.vibeaudio/events.jsonl, after the session file
588
+ * already reflects it, so `status` is the state the event left behind. Not
589
+ * gated by a mute: a mute silences sound, and a light watching this is not
590
+ * sound. Rotated to `.1` rather than trimmed in place, so a follower sees a
591
+ * fresh file instead of re-reading the lines a trim kept. Never throws.
592
+ */
593
+ function emitEvent(event, session, project, extra = {}) {
594
+ try {
595
+ fs.mkdirSync(STATE_DIR, { recursive: true });
596
+ try {
597
+ if (fs.statSync(EVENTS_FILE).size > EVENTS_MAX_BYTES) fs.renameSync(EVENTS_FILE, `${EVENTS_FILE}.1`);
598
+ } catch (e) { /* no file yet */ }
599
+ const line = { v: 1, at: Date.now(), event, session, project: project || null, ...extra, status: agentState().status };
600
+ fs.appendFileSync(EVENTS_FILE, `${JSON.stringify(line)}\n`); // One write under PIPE_BUF: appends do not interleave.
601
+ return true;
602
+ } catch (e) {
603
+ return false;
604
+ }
605
+ }
606
+
607
+ /**
608
+ * `vibe --events`: the current state as one line, then every event as it is
609
+ * written, until killed. Polled rather than fs.watch'd, which is unreliable on
610
+ * the platforms this runs on; a quarter second is well inside a turn.
611
+ */
612
+ function followEvents(write = (s) => process.stdout.write(s), pollMs = 250) {
613
+ write(`${JSON.stringify({ event: "state", at: Date.now(), ...agentState() })}\n`);
614
+ const stat = () => {
615
+ try {
616
+ const st = fs.statSync(EVENTS_FILE);
617
+ return { size: st.size, ino: st.ino };
618
+ } catch (e) {
619
+ return { size: 0, ino: null };
620
+ }
621
+ };
622
+ let { size: pos, ino } = stat();
623
+ let partial = "";
624
+ return setInterval(() => {
625
+ const now = stat();
626
+ if (now.ino !== ino || now.size < pos) {
627
+ ino = now.ino; // Rotated: the new file holds only what came after.
628
+ pos = 0;
629
+ partial = "";
630
+ }
631
+ if (now.size <= pos) return;
632
+ try {
633
+ const fd = fs.openSync(EVENTS_FILE, "r");
634
+ const buf = Buffer.alloc(now.size - pos);
635
+ fs.readSync(fd, buf, 0, buf.length, pos);
636
+ fs.closeSync(fd);
637
+ pos = now.size;
638
+ const lines = (partial + buf.toString("utf8")).split("\n");
639
+ partial = lines.pop();
640
+ for (const l of lines) if (l) write(`${l}\n`);
641
+ } catch (e) { /* removed between stat and open: next poll */ }
642
+ }, pollMs);
643
+ }
644
+
548
645
  // Claude Code's entry for Esc / the stop button: a user message whose text is
549
646
  // "[Request interrupted by user]" or "... for tool use]".
550
647
  const INTERRUPT_MARK = "[Request interrupted by user";
@@ -627,6 +724,7 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
627
724
  if (watchers.get(s.id)()) {
628
725
  fs.rmSync(sessionFile(s.id), { force: true });
629
726
  watchers.delete(s.id);
727
+ emitEvent("interrupted", s.id, s.project);
630
728
  }
631
729
  }
632
730
  return working(listSessions()).length > 0;
@@ -648,7 +746,9 @@ function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}
648
746
  // The same piece with more of it, as time escalation already does: two
649
747
  // sessions working is tier 2 at least, three or more is tier 3. Read at
650
748
  // each loop boundary, so it lands cleanly and eases off as they finish.
651
- minTier: () => Math.min(3, working(listSessions()).length)
749
+ minTier: () => Math.min(3, working(listSessions()).length),
750
+ // A heartbeat under the music while any working session looks stuck.
751
+ tension: () => working(listSessions()).some(isStuck)
652
752
  });
653
753
  if (!started) process.exit(0);
654
754
 
@@ -697,7 +797,9 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
697
797
  if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
698
798
  const id = sessionId(turn && turn.session);
699
799
  // Before the spawn: the daemon reads it on startup.
700
- writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
800
+ const project = (turn && turn.project) || process.cwd();
801
+ writeSession(id, { project, transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
802
+ emitEvent("started", id, project);
701
803
  if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
702
804
 
703
805
  stopDaemon({ keepSessions: true });
@@ -809,11 +911,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
809
911
  const session = readSession(id);
810
912
  const tracked = session !== null;
811
913
  fs.rmSync(sessionFile(id), { force: true });
914
+ if (tracked) emitEvent("finished", id, parsePayload(raw).cwd || session.project, { outcome });
812
915
  if (session && Number.isFinite(session.started)) {
813
916
  const now = Date.now();
814
917
  // A turn that ends while still paused for you has been blocked since then.
815
918
  const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
816
- recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, at: now });
919
+ // ponytail: assumes a playback backend exists; a machine with none hears no music either.
920
+ const chimed = !noChime && !playbackDisabled();
921
+ recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
817
922
  }
818
923
 
819
924
  // The music is every working session's, so it ends with the last of them.
@@ -873,6 +978,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
873
978
  if (!session || session.waiting != null) return false;
874
979
 
875
980
  writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
981
+ emitEvent("waiting", id, session.project, waitKey(raw) ? { tool: waitKey(raw) } : {});
876
982
  if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
877
983
  const tool = payloadToolName(raw);
878
984
  notify(raw, tool ? `needs you (${tool})` : "needs you");
@@ -894,15 +1000,34 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
894
1000
  * job. Match on tool_input as well if that ever shows up in practice.
895
1001
  */
896
1002
  function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
897
- const id = sessionId(payloadSession(parsePayload(raw)));
1003
+ const payload = parsePayload(raw);
1004
+ const id = sessionId(payloadSession(payload));
898
1005
  const session = readSession(id);
899
- if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
1006
+ if (!session) return false;
1007
+
1008
+ // Every tool call lands here, so this is where the stuck signal is kept.
1009
+ // An interrupt is the user stopping a tool, not the tool failing.
1010
+ // ponytail: two parallel tool calls in one session can each read, then write,
1011
+ // and one result is lost. A heuristic over eight calls survives that.
1012
+ const failed = payload.hook_event_name === "PostToolUseFailure" && !payload.is_interrupt;
1013
+ const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
1014
+ const next = { ...session, recent };
1015
+ const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
1016
+ if (crossing === "stuck") notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
1017
+
900
1018
  // An empty key is a wait that named nothing (a Notification): the next tool
901
1019
  // to finish is the first sign of work carrying on.
902
- if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
1020
+ const resumes = session.waiting != null && (session.waiting === "" || session.waiting === waitKey(raw));
1021
+ if (!resumes) {
1022
+ writeSession(id, next);
1023
+ if (crossing) emitEvent(crossing, id, session.project);
1024
+ return false; // Not waiting - the common case, on every tool call.
1025
+ }
903
1026
 
904
1027
  const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
905
- writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
1028
+ writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
1029
+ emitEvent("resumed", id, session.project);
1030
+ if (crossing) emitEvent(crossing, id, session.project);
906
1031
  if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
907
1032
  return true;
908
1033
  }
@@ -918,9 +1043,11 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
918
1043
  */
919
1044
  function hookEnd(raw) {
920
1045
  const id = sessionId(payloadSession(parsePayload(raw)));
921
- if (!readSession(id)) return false;
1046
+ const session = readSession(id);
1047
+ if (!session) return false;
922
1048
 
923
1049
  fs.rmSync(sessionFile(id), { force: true });
1050
+ emitEvent("ended", id, session.project);
924
1051
  if (working(listSessions()).length === 0) {
925
1052
  stopDaemon({ keepSessions: true });
926
1053
  fs.rmSync(INTENSITY_FILE, { force: true });
@@ -1002,6 +1129,11 @@ function readVibeEntryCount(file, id) {
1002
1129
  }
1003
1130
  }
1004
1131
 
1132
+ /** True when --install-hooks has already written our entries for Claude Code. */
1133
+ function userHooksInstalled(file = null) {
1134
+ return readVibeEntryCount(file || TARGETS.claude.file(), "claude") > 0;
1135
+ }
1136
+
1005
1137
  function loadSettings(file, t = null) {
1006
1138
  if (!fs.existsSync(file)) return { settings: t ? t.seed() : {}, raw: null };
1007
1139
  const raw = fs.readFileSync(file, "utf8");
@@ -1214,6 +1346,11 @@ module.exports = {
1214
1346
  hookTool,
1215
1347
  hookWait,
1216
1348
  hookResume,
1349
+ isStuck,
1350
+ STUCK_WINDOW,
1351
+ agentState,
1352
+ followEvents,
1353
+ EVENTS_FILE,
1217
1354
  hookEnd,
1218
1355
  newTurn,
1219
1356
  outcomeFromPayload,
@@ -1231,6 +1368,7 @@ module.exports = {
1231
1368
  uninstallHooks,
1232
1369
  settingsPath,
1233
1370
  isVibeHook,
1371
+ userHooksInstalled,
1234
1372
  hookEntries,
1235
1373
  installSlashCommand,
1236
1374
  uninstallSlashCommand,
package/src/player.js CHANGED
@@ -20,6 +20,7 @@ const { generatePianoLoop } = require("./synth/piano");
20
20
  const { generateJazzLoop } = require("./synth/jazz");
21
21
  const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
22
22
  const { hashString } = require("./synth/generator");
23
+ const { addTension } = require("./synth/tension");
23
24
  const pkg = require("../package.json");
24
25
 
25
26
  const CACHE_ROOT = path.join(os.homedir(), ".vibeaudio", "cache");
@@ -617,7 +618,7 @@ function writeCacheFileAtomic(filePath, buffer) {
617
618
  }
618
619
  }
619
620
 
620
- function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
621
+ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0, tension = false) {
621
622
  ensureCacheDir();
622
623
  const normalizedGenre = resolveGenre(genre);
623
624
  const safeTier = Math.max(1, Math.min(3, tier));
@@ -629,11 +630,14 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0)
629
630
  // into this opens on exactly the bar it has always opened on.
630
631
  const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
631
632
  const dir = seedDir(seed);
632
- const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${gainSuffix(gain)}.wav`);
633
+ // The stuck heartbeat is its own file, rendered only once an agent is stuck.
634
+ const tensionSuffix = tension ? "_x" : "";
635
+ const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${tensionSuffix}${gainSuffix(gain)}.wav`);
633
636
 
634
637
  if (!fs.existsSync(filePath)) {
635
638
  fs.mkdirSync(dir, { recursive: true });
636
- writeCacheFileAtomic(filePath, applyGain(generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar), gain));
639
+ const loop = generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar);
640
+ writeCacheFileAtomic(filePath, applyGain(tension ? addTension(loop, GENRE_KEYS[normalizedGenre]) : loop, gain));
637
641
  pruneSeedDirs();
638
642
  }
639
643
 
@@ -728,6 +732,7 @@ class AudioPlayer {
728
732
  this.seed = projectSeed();
729
733
  this.intensity = null;
730
734
  this.minTier = null;
735
+ this.tension = null;
731
736
  this.currentTier = 1;
732
737
  this.bar = 0;
733
738
  this.nextTimer = null;
@@ -738,7 +743,7 @@ class AudioPlayer {
738
743
  * Returns true if playback actually started. Restarts when called with
739
744
  * different settings while playing, so a genre switch is not silently dropped.
740
745
  */
741
- start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null } = {}) {
746
+ start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
742
747
  const resolved = resolveGenre(genre);
743
748
  const targetVolume = Math.max(0.05, Math.min(1.0, volume));
744
749
 
@@ -762,6 +767,7 @@ class AudioPlayer {
762
767
  this.bar = 0; // Every run opens on the project's own bar.
763
768
  this.intensity = intensity;
764
769
  this.minTier = minTier;
770
+ this.tension = tension;
765
771
 
766
772
  installExitHook();
767
773
  activePlayers.add(this);
@@ -800,7 +806,8 @@ class AudioPlayer {
800
806
  // never both, or the volume would be applied twice.
801
807
  const backend = detectPlayer();
802
808
  const gain = bakedGain(backend, this.volume);
803
- const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
809
+ // An agent that looks stuck adds a heartbeat, landing at this loop boundary.
810
+ const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar, Boolean(this.tension && this.tension()));
804
811
  // Advanced after the choice, so the bar that plays first is bar 0.
805
812
  this.bar = (this.bar + 1) % LOOP_BARS;
806
813
  if (!(helperUsable(backend) && this.playViaHelper(audioFile, backend))) this.spawnLoop(audioFile, backend);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The "stuck" layer: a soft heartbeat under the loop, played while an agent's
3
+ * recent tool calls keep failing. More of the same music would say "working
4
+ * hard", which is not the news; a pulse that was not there before changes the
5
+ * music's character, and a heartbeat is the most widely read tension cue there
6
+ * is. Pitched on the genre's tonic so it sits in the key rather than against it.
7
+ *
8
+ * Pure like every other synth module: a WAV buffer in, a new one out.
9
+ */
10
+
11
+ const { noteToFreq, SAMPLE_RATE } = require("./generator");
12
+
13
+ const BEAT_S = 1.1; // About 55 bpm: a resting heart, not a racing one.
14
+ const DUB_DELAY_S = 0.24;
15
+ const EDGE_S = 0.15; // Clear of the loop's boundary fades, so seams stay clean.
16
+ const LEVEL = 0.22;
17
+
18
+ // Laptop speakers roll off below ~120 Hz, and a pulse nobody hears is no
19
+ // signal, so the tonic sits between 110 and 220 Hz with a second harmonic.
20
+ function pulseFreq(key) {
21
+ const tonic = String(key || "A minor").split(" ")[0];
22
+ let f = noteToFreq(`${tonic}3`);
23
+ while (f > 220) f /= 2;
24
+ while (f < 110) f *= 2;
25
+ return f;
26
+ }
27
+
28
+ // One thump: a short pitch drop, like a soft kick, decaying in ~0.2s.
29
+ function thump(t, f) {
30
+ if (t < 0 || t > 0.35) return 0;
31
+ const phase = f * t + (f * 0.5 * 0.04) * (1 - Math.exp(-t / 0.04)); // f*1.5 falling to f
32
+ const env = Math.min(1, t / 0.004) * Math.exp(-t / 0.075);
33
+ return env * (Math.sin(2 * Math.PI * phase) + 0.45 * Math.sin(4 * Math.PI * phase));
34
+ }
35
+
36
+ /** Mixes the heartbeat into a canonical 16-bit WAV (mono or stereo) and returns a new buffer. */
37
+ function addTension(wav, key) {
38
+ if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF") {
39
+ throw new Error("addTension expects a canonical 44-byte-header WAV");
40
+ }
41
+ const channels = wav.readUInt16LE(22);
42
+ const rate = wav.readUInt32LE(24) || SAMPLE_RATE;
43
+ const frames = Math.floor((wav.length - 44) / (2 * channels));
44
+ const duration = frames / rate;
45
+ const f = pulseFreq(key);
46
+
47
+ // A whole number of beats per loop, so the pulse keeps time across loops.
48
+ const beats = Math.max(1, Math.round((duration - 2 * EDGE_S) / BEAT_S));
49
+ const period = (duration - 2 * EDGE_S) / beats;
50
+ const out = Buffer.from(wav);
51
+
52
+ for (let i = 0; i < frames; i++) {
53
+ const t = i / rate - EDGE_S;
54
+ if (t < 0 || t > duration - 2 * EDGE_S) continue;
55
+ const local = t % period;
56
+ const s = LEVEL * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
57
+ if (s === 0) continue;
58
+ for (let c = 0; c < channels; c++) {
59
+ const offset = 44 + (i * channels + c) * 2;
60
+ const mixed = Math.round(out.readInt16LE(offset) + s * 32767);
61
+ out.writeInt16LE(Math.max(-32768, Math.min(32767, mixed)), offset);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ module.exports = { addTension, pulseFreq };