vibeaudio 0.15.0 → 0.16.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
@@ -45,6 +45,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
45
45
  * 🔔 **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.
46
46
  * 💓 **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%.
47
47
  * ✋ **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.
48
+ * 🔕 **Signals without the music.** `vibe --music off` keeps the done, failed and "needs you" chimes, banners and the state stream, and drops the music. For people who bring their own, or share an office. ([details](#signals-only-vibe---music-off))
48
49
  * 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf and Antigravity**; MCP for **Claude Desktop**; the wrapper (`vibe <command>`) for anything else.
49
50
 
50
51
  <details>
@@ -418,6 +419,22 @@ Removing them is one command:
418
419
  vibe --uninstall-hooks
419
420
  ```
420
421
 
422
+ ### Signals only: `vibe --music off`
423
+
424
+ VibeAudio does two jobs: music while an agent works, and signals (the done, failed and "needs you" chimes, banners, the stuck alert, `--events`). If you only want the second:
425
+
426
+ ```bash
427
+ vibe --music off # signals only
428
+ vibe --music on # music back, in the genre you had
429
+ vibe --music off --here # this project only
430
+ ```
431
+
432
+ It's a setting of its own, saved to `config.json` like genre and volume, so your genre is still there when you turn music back on. Like the others, a flag beats `VIBE_MUSIC=off`, which beats `--here`, which beats the global value, and installed hooks pick it up on the next prompt.
433
+
434
+ With music off, hooks still track every turn: the chimes, `--notify` banners, `--state`, `--events` and `--report` behave exactly as before, and no background player is started. The wrapper (`vibe npm test`) stays silent while it runs and chimes when a run that outlasted the grace window ends, so it becomes "tell me when it's done". MCP's `vibe_play` tells the model that music is off rather than claiming it started. `vibe --status` and `vibe --doctor` show the setting.
435
+
436
+ Two things differ from music on. The stuck heartbeat is a layer on the music, so with music off it plays once on its own, the same two soft beats, when a session crosses into stuck (you also get the `stuck` event and, with `--notify`, the banner). And turning music off while something is playing doesn't cut it: `vibe --stop` does, or it ends with the current turn.
437
+
421
438
  ### Know where your time goes: `vibe --report`
422
439
 
423
440
  Hooks log each finished turn — which project, how long, how it ended, and how much of it the agent spent blocked on a dialog of yours — to `~/.vibeaudio/history.jsonl`. `vibe --report` (or `--report 30`) totals it:
@@ -509,9 +526,20 @@ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with
509
526
 
510
527
  #### 📟 Status Bar & Desktop Integrations
511
528
 
512
- **tmux status bar** — show overall agent state (`waiting`, `stuck`, `working`, or `idle`):
529
+ **`vibe --statusline`** prints the same state as one plain line, with no `jq`, and **nothing at all when idle**, so a prompt segment disappears. It shows the most urgent state, with a count when several sessions are in it, and names the project when one is waiting on you:
530
+
531
+ ```
532
+ working
533
+ working ×2
534
+ stuck
535
+ waiting: api
536
+ ```
537
+
538
+ It is plain text on purpose: tmux and Starship do their own styling. It doesn't run `ps` and doesn't change when you're muted.
539
+
540
+ **tmux status bar:**
513
541
  ```bash
514
- set -g status-right '#(vibe --state | jq -r .status)'
542
+ set -g status-right '#(vibe --statusline)'
515
543
  ```
516
544
 
517
545
  **macOS speech alert** — announce out loud whenever an agent stops to ask for your input:
@@ -522,9 +550,13 @@ vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | whil
522
550
  **Starship prompt** — custom indicator in `~/.config/starship.toml`:
523
551
  ```toml
524
552
  [custom.vibe]
525
- command = "vibe --state | jq -r 'if .status != \"idle\" then \"🎧 \" + .status else \"\" end'"
553
+ command = "vibe --statusline"
526
554
  when = "command -v vibe >/dev/null"
555
+ format = "([🎧 $output ]($style))"
527
556
  ```
557
+ The parentheses matter: Starship hides a group whose variables are empty, so the 🎧 disappears with the text when idle. Put the icon in a `symbol` instead and it shows all the time. `vibe --statusline` takes about 40 ms, so running it on every prompt is fine.
558
+
559
+ *Run on tmux 3.x and Starship 1.26.0, macOS arm64, Node 24: both showed `working`, `working ×2` and `waiting: web` as sessions started and waited, and nothing when idle. Not run on Linux or Windows.*
528
560
 
529
561
  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.
530
562
 
@@ -698,6 +730,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
698
730
  | `--grace <ms>` | Silence window before music starts | `1500` |
699
731
  | `--here` | With a saved setting: this directory tree only, not everywhere | off |
700
732
  | `--seed <n>` | Force a specific arrangement | derived from the project directory |
733
+ | `--music <on\|off>` | `off` keeps the chimes, banners and events and drops the music. With no command after it, saves it as a setting. `--here` for one project | `on` |
701
734
  | `--whisper` | Quick preset: 15% volume (headphones / late night) | — |
702
735
  | `--quiet` | Quick preset: 25% volume (focus / open office) | — |
703
736
  | `--loud` | Quick preset: 75% volume (hear from across the room) | — |
@@ -709,6 +742,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
709
742
  | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
710
743
  | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
711
744
  | `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
745
+ | `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
712
746
  | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
713
747
  | `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
714
748
  | `--stop` | Stop the background player, then exit | — |
@@ -735,6 +769,7 @@ export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
735
769
  export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
736
770
  export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
737
771
  export VIBE_DISABLE=1 # Mute, without uninstalling anything
772
+ export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
738
773
  export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
739
774
  export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
740
775
  export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ const {
17
17
  normalizeVolume,
18
18
  loadConfig,
19
19
  saveConfig,
20
+ musicEnabled,
20
21
  projectSettings,
21
22
  saveProjectConfig,
22
23
  wavDurationMs,
@@ -68,6 +69,7 @@ Procedural focus music while your AI coding tools think.
68
69
  --grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
69
70
  --here With a saved setting: this directory only, not everywhere
70
71
  --seed <n> Force a specific arrangement (default: derived from the project directory)
72
+ --music <on|off> off = signals only: chimes, banners and events, no music (a saved setting)
71
73
  --whisper Preset: 15% volume (late night / headphones)
72
74
  --quiet Preset: 25% volume (focus / open office)
73
75
  --loud Preset: 75% volume (hear from across the room)
@@ -79,6 +81,7 @@ Procedural focus music while your AI coding tools think.
79
81
  --notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
80
82
  --report [days] How long you waited on agents, and where (default: 7 days)
81
83
  --state Print what every agent is doing as JSON: idle, working, stuck or waiting
84
+ --statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
82
85
  --events Stream agent state changes as JSON lines, until stopped
83
86
  --doctor Check the setup; each problem comes with its fix (exit 1 if any)
84
87
  --stop Stop the background player, then exit
@@ -110,6 +113,7 @@ Procedural focus music while your AI coding tools think.
110
113
  VIBE_VOLUME=<5-100> Override the saved volume for this shell
111
114
  VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
112
115
  VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
116
+ VIBE_MUSIC=<on|off> Override the saved music setting for this shell
113
117
  VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
114
118
  VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
115
119
  VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
@@ -136,6 +140,7 @@ const VALUE_FLAGS = new Set([
136
140
  "-v", "--volume",
137
141
  "-cv", "--chime-volume",
138
142
  "--grace",
143
+ "--music",
139
144
  "--seed",
140
145
  "--tools",
141
146
  "--event"
@@ -155,6 +160,7 @@ function parseArgs(argv) {
155
160
  const here = projectSettings(saved);
156
161
  const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
157
162
 
163
+ let music = musicEnabled(process.env, saved);
158
164
  let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
159
165
 
160
166
  let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
@@ -178,6 +184,7 @@ function parseArgs(argv) {
178
184
  let clearCacheFlag = false;
179
185
  let statusFlag = false;
180
186
  let stateFlag = false;
187
+ let statuslineFlag = false;
181
188
  let eventsFlag = false;
182
189
  let doctorFlag = false;
183
190
  let notifyFlag = null;
@@ -295,6 +302,24 @@ function parseArgs(argv) {
295
302
  continue;
296
303
  }
297
304
 
305
+ if (arg === "--music") {
306
+ const value = args[i + 1].toLowerCase();
307
+ if (value !== "on" && value !== "off") {
308
+ console.error(`\x1b[31m[vibeaudio] --music takes on or off, not '${args[i + 1]}'.\x1b[0m`);
309
+ process.exit(1);
310
+ }
311
+ music = value === "on";
312
+ typed.music = music;
313
+ i += 2;
314
+ continue;
315
+ }
316
+
317
+ if (arg === "--statusline") {
318
+ statuslineFlag = true;
319
+ i += 1;
320
+ continue;
321
+ }
322
+
298
323
  if (arg === "--state" || arg === "--events") {
299
324
  if (arg === "--state") stateFlag = true;
300
325
  else eventsFlag = true;
@@ -492,6 +517,8 @@ function parseArgs(argv) {
492
517
  clearCache: clearCacheFlag,
493
518
  status: statusFlag,
494
519
  state: stateFlag,
520
+ statusline: statuslineFlag,
521
+ music,
495
522
  events: eventsFlag,
496
523
  doctor: doctorFlag,
497
524
  notify: notifyFlag,
@@ -734,6 +761,7 @@ function printStatus() {
734
761
  : off(" default");
735
762
 
736
763
  console.log(`\x1b[1mSound\x1b[0m`);
764
+ console.log(` music ${musicEnabled() ? on("on") : "\x1b[33moff\x1b[0m"}${source("VIBE_MUSIC", "music")}${musicEnabled() ? "" : off(" signals only — vibe --music on")}`);
737
765
  console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
738
766
  console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
739
767
  console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
@@ -943,6 +971,7 @@ function doctorChecks() {
943
971
  if (mute !== null) add("warn", "Mute", `muted ${muteRemainingText(mute)}`, "vibe --unmute");
944
972
  if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
945
973
  if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
974
+ if (!musicEnabled()) add("ok", "Music", "off — chimes and banners only, by your setting (vibe --music on)");
946
975
 
947
976
  const pid = readDaemonPid(hooks.PID_FILE);
948
977
  if (pid === null) add("ok", "Background player", "not running");
@@ -1178,6 +1207,7 @@ function saveDefaults(typed, hereOnly = false) {
1178
1207
  if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
1179
1208
  if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
1180
1209
  if (typed.grace !== undefined) patch.grace = typed.grace;
1210
+ if (typed.music !== undefined) patch.music = typed.music;
1181
1211
 
1182
1212
  const { CONFIG_FILE } = require("./player");
1183
1213
  if (hereOnly) saveProjectConfig(patch);
@@ -1187,7 +1217,8 @@ function saveDefaults(typed, hereOnly = false) {
1187
1217
  genre: (v) => `genre ${v}`,
1188
1218
  volume: (v) => `volume ${v}%`,
1189
1219
  chimeVolume: (v) => `chime volume ${v}%`,
1190
- grace: (v) => `grace ${v}ms`
1220
+ grace: (v) => `grace ${v}ms`,
1221
+ music: (v) => `music ${v ? "on" : "off"}`
1191
1222
  };
1192
1223
  const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
1193
1224
  const where = hereOnly ? ` for ${process.cwd()}` : "";
@@ -1201,6 +1232,12 @@ function saveDefaults(typed, hereOnly = false) {
1201
1232
  if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
1202
1233
  console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
1203
1234
  }
1235
+ if (playing && patch.music === false) {
1236
+ console.log(` \x1b[90m${playing.genre || "The music"} is still playing — vibe --stop cuts it short.\x1b[0m`);
1237
+ }
1238
+ if (patch.music === false) {
1239
+ console.log(` \x1b[90mSignals only: chimes, banners, --events and --report work as before.\x1b[0m`);
1240
+ }
1204
1241
  if (playing && playing.follows && patch.volume !== undefined) {
1205
1242
  console.log(` \x1b[90mThe music playing now follows the new volume within a second.\x1b[0m`);
1206
1243
  }
@@ -1335,7 +1372,7 @@ function renderToFile(target, genre) {
1335
1372
  console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
1336
1373
  }
1337
1374
 
1338
- function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
1375
+ function runHookAction(action, { music = true, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
1339
1376
  const hooks = require("./hooks");
1340
1377
 
1341
1378
  if (plugin && action.startsWith("hook-")) {
@@ -1361,7 +1398,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
1361
1398
  // The payload names the session (so SessionEnd can tell this session's
1362
1399
  // music from another's) and the transcript (so an interrupt, which
1363
1400
  // fires no hook, can still stop it).
1364
- hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
1401
+ hooks.readPayload((raw) => hooks.hookStart(genre, volume, { music, reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
1365
1402
  return;
1366
1403
 
1367
1404
  case "hook-stop":
@@ -1388,7 +1425,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
1388
1425
  return;
1389
1426
 
1390
1427
  case "hook-resume":
1391
- hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive, follow: typed.volume === undefined }));
1428
+ hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { music, reactive, follow: typed.volume === undefined, chimeVolume, noChime }));
1392
1429
  return;
1393
1430
 
1394
1431
  case "hook-end":
@@ -1455,10 +1492,10 @@ function windowsCommandNeedsShell(command) {
1455
1492
  return !/[\\/]/.test(command);
1456
1493
  }
1457
1494
 
1458
- function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
1495
+ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false, music = true) {
1459
1496
  const player = new AudioPlayer();
1460
1497
  const hookDriven = hooksAlreadyCover(cmdArgs);
1461
- const hud = !noHud && !hookDriven ? new TerminalHud(genre) : null;
1498
+ const hud = !noHud && !hookDriven && music ? new TerminalHud(genre) : null;
1462
1499
  const startTime = Date.now();
1463
1500
  let musicStarted = false;
1464
1501
  let finished = false;
@@ -1486,8 +1523,8 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
1486
1523
 
1487
1524
  // Grace window before triggering audio (silences fast commands)
1488
1525
  const graceTimer = hookDriven ? null : setTimeout(() => {
1489
- musicStarted = true;
1490
- player.start(genre, volume);
1526
+ musicStarted = true; // Set with music off too: the chime marks the end of a run that outlasted the grace window.
1527
+ if (music) player.start(genre, volume);
1491
1528
  if (hud) hud.start();
1492
1529
  }, grace);
1493
1530
 
@@ -1566,6 +1603,8 @@ async function run() {
1566
1603
  clearCache: shouldClear,
1567
1604
  status: showStatus,
1568
1605
  state: showState,
1606
+ statusline: showStatusline,
1607
+ music,
1569
1608
  events: followEvents,
1570
1609
  doctor: showDoctor,
1571
1610
  notify: notifyChange,
@@ -1596,7 +1635,7 @@ async function run() {
1596
1635
 
1597
1636
  if (hookAction) {
1598
1637
  try {
1599
- return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
1638
+ return runHookAction(hookAction, { music, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
1600
1639
  } catch (e) {
1601
1640
  // Settings problems are the user's to fix — report them, don't stack-trace.
1602
1641
  console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
@@ -1667,6 +1706,14 @@ async function run() {
1667
1706
  return;
1668
1707
  }
1669
1708
 
1709
+ // Idle prints nothing, not even a newline, so a prompt segment disappears.
1710
+ if (showStatusline) {
1711
+ const hooks = require("./hooks");
1712
+ const line = hooks.formatStatusline(hooks.agentState());
1713
+ if (line) console.log(line);
1714
+ return;
1715
+ }
1716
+
1670
1717
  if (followEvents) {
1671
1718
  process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
1672
1719
  require("./hooks").followEvents();
@@ -1736,10 +1783,10 @@ async function run() {
1736
1783
  }
1737
1784
  }
1738
1785
 
1739
- return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud);
1786
+ return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud, music);
1740
1787
  }
1741
1788
 
1742
- executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
1789
+ executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud, music);
1743
1790
  }
1744
1791
 
1745
1792
  module.exports = {
package/src/hooks.js CHANGED
@@ -755,6 +755,20 @@ function agentState(sessions = listSessions()) {
755
755
  return { v: 1, status, sessions: list };
756
756
  }
757
757
 
758
+ /**
759
+ * `vibe --statusline`: the state as one plain line for a tmux segment or a
760
+ * shell prompt, and nothing at all when idle so the segment disappears. The
761
+ * most urgent status, with a count when more than one session is in it; a
762
+ * waiting session names its project, since that is the one you have to go to.
763
+ * Plain text because tmux and Starship style it themselves.
764
+ */
765
+ function formatStatusline({ status, sessions }) {
766
+ if (status === "idle") return "";
767
+ const active = sessions.filter((s) => s.state === status);
768
+ const project = status === "waiting" && active[0].project ? `: ${projectName(active[0].project)}` : "";
769
+ return `${status}${project}${active.length > 1 ? ` ×${active.length}` : ""}`;
770
+ }
771
+
758
772
  const EVENTS_MAX_BYTES = 256 * 1024;
759
773
 
760
774
  /**
@@ -973,7 +987,7 @@ function spawnDaemon(genre, volume, reactive, follow = false) {
973
987
  * always did, so a genre change lands on the next prompt. With one working the
974
988
  * music carries on: restarting it would cut every other session's stream.
975
989
  */
976
- function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
990
+ function hookStart(genre, volume, { reactive = false, turn = null, follow = false, music = true } = {}) {
977
991
  if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
978
992
  if (turn && turn.continues) return null; // A later model call in a turn already playing.
979
993
  const id = sessionId(turn && turn.session);
@@ -985,6 +999,10 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
985
999
 
986
1000
  stopDaemon({ keepSessions: true });
987
1001
  fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
1002
+ // Signals only: the session is tracked (chimes, events and the report read
1003
+ // it) but there is no music to start. Stopping first means a switch to off
1004
+ // lands on the next prompt rather than playing until the turn ends.
1005
+ if (!music) return null;
988
1006
  return spawnDaemon(genre, volume, reactive, follow);
989
1007
  }
990
1008
 
@@ -1050,9 +1068,12 @@ function notifyEnabled(env = process.env, config = loadConfig()) {
1050
1068
  }
1051
1069
 
1052
1070
  /** The project a hook fired in: the payload's (see payloadProject), else ours. */
1071
+ function projectName(dir) {
1072
+ return path.basename(String(dir).replace(/[\\/]+$/, "")) || "a session";
1073
+ }
1074
+
1053
1075
  function sessionLabel(payload, cwd = process.cwd()) {
1054
- const dir = payloadProject(payload) || cwd;
1055
- return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
1076
+ return projectName(payloadProject(payload) || cwd);
1056
1077
  }
1057
1078
 
1058
1079
  /**
@@ -1183,7 +1204,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
1183
1204
  * one needing approval, can resume early - after the chime already did its
1184
1205
  * job. Match on tool_input as well if that ever shows up in practice.
1185
1206
  */
1186
- function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
1207
+ function hookResume(raw, genre, volume, { reactive = false, follow = false, music = true, chimeVolume = null, noChime = false } = {}) {
1187
1208
  const payload = parsePayload(raw);
1188
1209
  const id = sessionId(payloadSession(payload));
1189
1210
  const session = readSession(id);
@@ -1197,7 +1218,13 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
1197
1218
  const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
1198
1219
  const next = { ...session, recent };
1199
1220
  const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
1200
- if (crossing === "stuck") notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
1221
+ if (crossing === "stuck") {
1222
+ notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
1223
+ // With music on the daemon adds the heartbeat; with it off there is no
1224
+ // loop to put one under, so the pulse plays once on its own. Detached: the
1225
+ // agent waits on this hook.
1226
+ if (!music && !noChime) new AudioPlayer().stop({ playChime: true, outcome: "stuck", volume, chimeVolume, detach: true });
1227
+ }
1201
1228
 
1202
1229
  // An empty key is a wait that named nothing (a Notification): the next tool
1203
1230
  // to finish is the first sign of work carrying on.
@@ -1212,7 +1239,7 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
1212
1239
  writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
1213
1240
  emitEvent("resumed", id, session.project);
1214
1241
  if (crossing) emitEvent(crossing, id, session.project);
1215
- if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
1242
+ if (music && !daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
1216
1243
  return true;
1217
1244
  }
1218
1245
 
@@ -1565,6 +1592,7 @@ module.exports = {
1565
1592
  isStuck,
1566
1593
  STUCK_WINDOW,
1567
1594
  agentState,
1595
+ formatStatusline,
1568
1596
  followEvents,
1569
1597
  EVENTS_FILE,
1570
1598
  hookEnd,
package/src/mcp.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  const readline = require("readline");
9
- const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
9
+ const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig } = require("./player");
10
10
  const pkg = require("../package.json");
11
11
 
12
12
  // A desktop client that crashes never sends vibe_stop, so playback needs its
@@ -148,6 +148,15 @@ function handleMessage(player, msg) {
148
148
  const volume = normalizeVolume(args.volume,
149
149
  normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
150
150
 
151
+ // A deliberate setting, like a mute: report it as one, and start nothing.
152
+ if (!musicEnabled()) {
153
+ return {
154
+ jsonrpc: "2.0",
155
+ id,
156
+ result: { content: [{ type: "text", text: "The user has turned VibeAudio's music off (signals only), so nothing will play. This is deliberate and not an error — do not try again or suggest fixes; they will turn it back on when they want music." }] }
157
+ };
158
+ }
159
+
151
160
  const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
152
161
  // start() returns false for three unrelated reasons, and the model
153
162
  // relays whatever we say here to the user. Reporting a deliberate mute
@@ -204,6 +213,7 @@ function handleMessage(player, msg) {
204
213
  // Without this, "isPlaying: false" while muted reads as a bug
205
214
  // worth investigating rather than a choice the user made.
206
215
  muted: playbackDisabled(),
216
+ musicOff: !musicEnabled(),
207
217
  genre: player.genre,
208
218
  currentTier: player.currentTier,
209
219
  uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
package/src/player.js CHANGED
@@ -18,7 +18,7 @@ const { generateRainLoop } = require("./synth/rain");
18
18
  const { generateOceanLoop } = require("./synth/ocean");
19
19
  const { generatePianoLoop } = require("./synth/piano");
20
20
  const { generateJazzLoop } = require("./synth/jazz");
21
- const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
21
+ const { generateSuccessChime, generateFailureChime, generateAttentionChime, generateStuckChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
22
22
  const { hashString } = require("./synth/generator");
23
23
  const { addTension } = require("./synth/tension");
24
24
  const pkg = require("../package.json");
@@ -430,6 +430,19 @@ function playbackDisabled() {
430
430
  return muteState() !== null;
431
431
  }
432
432
 
433
+ /**
434
+ * Whether music plays at all, as opposed to `playbackDisabled()`, which
435
+ * silences everything including the chimes. Some people want only the
436
+ * signals (done, failed, needs you), so this is a setting of its own rather
437
+ * than a genre: `genre` stays what it was for when music comes back on.
438
+ * Env beats this directory's saved value beats the global one; a flag is
439
+ * resolved by parseArgs() and handed to whoever acts on it.
440
+ */
441
+ function musicEnabled(env = process.env, config = loadConfig()) {
442
+ const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
443
+ return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
444
+ }
445
+
433
446
  /**
434
447
  * `minutes` of 0 means indefinite, which the user has to ask for explicitly.
435
448
  */
@@ -652,7 +665,8 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0,
652
665
  const CHIMES = {
653
666
  success: generateSuccessChime,
654
667
  failure: generateFailureChime,
655
- attention: generateAttentionChime
668
+ attention: generateAttentionChime,
669
+ stuck: generateStuckChime
656
670
  };
657
671
 
658
672
  /**
@@ -965,6 +979,7 @@ module.exports = {
965
979
  bakedGain,
966
980
  applyGain,
967
981
  playbackDisabled,
982
+ musicEnabled,
968
983
  resolveGenre,
969
984
  isKnownGenre,
970
985
  normalizeVolume,
@@ -143,11 +143,32 @@ function generateAttentionChime(durationSec = 1.3) {
143
143
  return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
144
144
  }
145
145
 
146
+ /**
147
+ * "Looks stuck", for someone with the music off, who has no loop for the
148
+ * heartbeat to sit under. It is that same heartbeat on its own - two lub-dubs
149
+ * on A, a resting pulse - so it means what the layer means: not an outcome and
150
+ * not a question, just something that has not changed for a while. Quiet, and
151
+ * low enough to read as a pulse rather than a tone.
152
+ */
153
+ function generateStuckChime(durationSec = 1.8) {
154
+ const { thump, pulseFreq, BEAT_S, DUB_DELAY_S } = require("./tension");
155
+ const f = pulseFreq("A minor");
156
+ const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
157
+ const out = new Float64Array(totalSamples);
158
+ for (let i = 0; i < totalSamples; i++) {
159
+ const t = i / SAMPLE_RATE;
160
+ const local = t % BEAT_S;
161
+ out[i] = 0.3 * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
162
+ }
163
+ return createWavBuffer({ left: out, right: out, sampleRate: SAMPLE_RATE });
164
+ }
165
+
146
166
  module.exports = {
147
167
  DEFAULT_CHIME_KEY,
148
168
  SUCCESS_CHIME_NOTES,
149
169
  generateChime: generateSuccessChime,
150
170
  generateSuccessChime,
151
171
  generateFailureChime,
152
- generateAttentionChime
172
+ generateAttentionChime,
173
+ generateStuckChime
153
174
  };
@@ -64,4 +64,4 @@ function addTension(wav, key) {
64
64
  return out;
65
65
  }
66
66
 
67
- module.exports = { addTension, pulseFreq };
67
+ module.exports = { addTension, pulseFreq, thump, BEAT_S, DUB_DELAY_S };