vibeaudio 0.25.1 → 0.26.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
@@ -160,7 +160,7 @@ vibe npm test
160
160
  vibe claude -p "explain this repo"
161
161
  ```
162
162
 
163
- **Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found. If something seems broken, `vibe --doctor` checks it and prints the fix for each problem.
163
+ **Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found. Its first line says why the music is silent or playing right now (`silent: muted for 42 more min (vibe --unmute)`, `playing: lofi @ 40%, 2 sessions working (api, web)`). If something seems broken, `vibe --doctor` checks it and prints the fix for each problem.
164
164
 
165
165
  > **Which one you want:** the wrapper plays music for as long as the wrapped process lives. That's exactly right for a command that exits when its work is done — and wrong for an interactive REPL like `claude`, where the process stays alive while you read and type, so the music never stops. Hooks know when the agent is actually thinking; the wrapper can only time the process.
166
166
 
@@ -300,7 +300,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
300
300
  | :--- | :--- |
301
301
  | **Five agents tell you when they're waiting on you** | When a permission dialog opens the music stops rather than sounding busy while the agent is stuck on you, and it resumes once the thing you answered has run. Claude Code covers the terminal, desktop app and IDEs alike, plus MCP servers asking for input. Copilot CLI's own `PermissionRequest` fires before *every* permission check — dialog or not — so VibeAudio listens for its permission-prompt notification instead. Cursor and Grok have no such event that's been verified, so they keep playing through a prompt. |
302
302
  | **Claude Code turns that never reach `Stop` still end the music** | An API error or rate limit ends the turn with `StopFailure` instead, which plays the failure chime. Interrupting (Esc, or the stop button in the desktop app) fires no hook at all, so the background player watches the session transcript for Claude Code's interrupt entry and stops silently within half a second — whether the agent was writing or running a tool. A prompt that another hook blocks (a token-saving proxy, say) never reaches `Stop` either, so the same watcher ends the music on Claude Code's "prompt blocked" entry — or never starts it, when the blocker was quicker than we were. Closing the session mid-turn stops it too — but only if that session started the music, so closing an idle terminal never silences another one. |
303
- | **Codex asks you to trust the hook once** | Codex keeps a per-hook trust hash in `~/.codex/config.toml` and won't run a hook it hasn't been told to trust, so the install isn't live until you approve each one the first time it fires. |
303
+ | **Codex asks you to trust the hook once** | Codex keeps a per-hook trust hash in `~/.codex/config.toml` and won't run a hook it hasn't been told to trust, so the install isn't live until you approve each one. The CLI asks the first time each fires; the Codex desktop app shows **Review hooks** in the composer, where **Allow all** approves them in one go. Both write the same records, so approving in one covers the other. |
304
304
  | **Cursor and Antigravity say how a turn ended** | Cursor's stop event reports whether the turn completed, aborted or errored, and Antigravity's gives a termination reason, so an error plays the failure chime. Claude Code and Qwen Code send a separate `StopFailure` for an API error. 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. |
305
305
  | <a id="antigravity-hooks"></a>**Antigravity never gets a pre-tool hook** | Its pre-tool hook has to approve or deny every tool call — one that answers nothing blocks the tool — so VibeAudio installs none, and `--reactive` does nothing there. It has no prompt event either: the start event fires before every model call, and VibeAudio starts the music on the first one of each prompt only. Its hooks live in `~/.gemini/config/hooks.json` under a name of their own, so your other named hooks there are never touched. Verified with the `agy` CLI; the desktop app reads the same file by Antigravity's own docs but hasn't been checked live. |
306
306
  | **Upgraded, and a new feature isn't there?** | Hooks are written into your agent's config once, at install time, and an upgrade doesn't touch them. When a new version listens for more events (0.13.1 added Codex's `Interrupt` and `SessionEnd`), `vibe --doctor` and `vibe --status` name what's missing, and `vibe --install-hooks` adds it. |
@@ -334,6 +334,8 @@ Sessions with no id in their payload share a single slot, so they behave as one.
334
334
 
335
335
  > **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-vs-code-via-mcp).)
336
336
 
337
+ > **So is the Codex desktop app** (the Codex side of the ChatGPT app): it runs the same Codex engine on the same `~/.codex/hooks.json`, so `--install-hooks` covers it with nothing extra. Approve the hooks once from its **Review hooks** prompt. Its stop button ends the music silently, as Codex's `Interrupt` does in the CLI.
338
+
337
339
  > **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.
338
340
 
339
341
  ### 🔔 Desktop banners: `vibe --notify`
@@ -429,6 +431,12 @@ The optional [`/vibeaudio` mod](#vibeaudio-instant-controls-and-a-pane-claude-co
429
431
 
430
432
  `/vibe` is a prompt, so Claude has to read it, run a command and answer: it costs a turn and a few tokens, and it waits for the current turn to end. The optional **VibeAudio mod** runs inside Claude Code instead, so `/vibeaudio mute` takes effect at once, even while Claude is working, and costs no tokens.
431
433
 
434
+ <p align="center">
435
+ <a href="https://kiril6.github.io/vibeaudio/#band-video"><img src="docs/video/band-poster.jpg" alt="The VibeAudio band above the Claude Code prompt" width="49%"></a>
436
+ <a href="https://kiril6.github.io/vibeaudio/#band-video"><img src="docs/video/pane.jpg" alt="The VibeAudio pane: now playing, sound, alerts and share" width="49%"></a>
437
+ <br><sub>▶ <a href="https://kiril6.github.io/vibeaudio/#band-video">Watch the 43-second demo</a> of the band and the pane (frames from the demo video, with sound on the site).</sub>
438
+ </p>
439
+
432
440
  ```text
433
441
  claude plugin marketplace add kiril6/vibeaudio
434
442
  claude plugin install vibeaudio-mod@vibeaudio
@@ -864,7 +872,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
864
872
  | `--no-hud` | Disable terminal window/tab title animation | `false` |
865
873
  | `--preview <genre>` | Play one loop of a genre and exit | — |
866
874
  | `--render [file]` | Write this project's music to a `.wav` and exit (full scale, ignores `--volume`) | `vibeaudio-<genre>.wav` |
867
- | `--status` | Show what's installed, running and detected, then exit | — |
875
+ | `--status` | Say why the music is silent or playing, then show what's installed, running and detected, and exit | — |
868
876
  | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
869
877
  | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
870
878
  | `--speak` / `--speak-always` / `--no-speak` | Say which project finished, failed or needs you, after the chime: only with two or more sessions going (`--speak`), or every time (`--speak-always`). Saved to `config.json` | off |
@@ -872,6 +880,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
872
880
  | `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
873
881
  | `--card [--out <path>]` | With `--report`: the totals as a shareable image - PNG on macOS, SVG anywhere - saved to `~/.vibeaudio/card.png`, replaced each run | off |
874
882
  | `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
883
+ | `--why` | Why the music is playing or silent, as one plain line (the first line of `--status`) | — |
875
884
  | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
876
885
  | `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
877
886
  | `--dashboard` | Serve a read-only live view of every agent on this machine at `http://127.0.0.1:4747`, and open it. `--port <n>` and `--no-open` adjust it | — |
@@ -935,6 +944,8 @@ The expiry is deliberate too. A call is a bounded thing; a mute you forget about
935
944
 
936
945
  ## 🔧 Troubleshooting
937
946
 
947
+ **Start with `vibe --status`.** Its first line answers "why is it silent?" or "why is it still playing?" in one sentence, with the command that changes it: a mute and how long it has left, `VIBE_DISABLE` in your shell, no audio player, music turned off (globally, for this project, or by `VIBE_MUSIC`), a session waiting on you, or simply no agent working right now.
948
+
938
949
  **No sound at all**
939
950
  Check that a player exists for your platform (see [Requirements](#-requirements)). VibeAudio prints a notice to stderr when it can't find one. On Linux: `sudo apt install pulseaudio-utils` (or `ffmpeg` / `alsa-utils`).
940
951
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.25.1",
3
+ "version": "0.26.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
@@ -41,6 +41,9 @@ const HOOK_ACTIONS = [
41
41
  ];
42
42
 
43
43
  function printHelp() {
44
+ // Both callers process.exit() next. On macOS a piped stdout is async, so Node 18/20
45
+ // dropped everything past the 8 KB pipe buffer (CI on #77). Blocking writes flush first.
46
+ process.stdout._handle?.setBlocking?.(true);
44
47
  console.log(`
45
48
  \x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}
46
49
  Procedural focus music while your AI coding tools think.
@@ -80,7 +83,8 @@ Procedural focus music while your AI coding tools think.
80
83
  --no-hud Disable terminal window/tab title animation
81
84
  --preview <genre> Play one loop of a genre and exit
82
85
  --render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
83
- --status Show what is installed, running and detected, then exit
86
+ --status Say why the music is silent or playing, then show what is
87
+ installed, running and detected, and exit
84
88
  --notify | --no-notify Also show a desktop banner naming the project when a turn finishes, fails, needs you or looks stuck (macOS and Linux; off by default)
85
89
  --speak | --no-speak Also say which project finished, failed or needs you, after the chime - with two or more sessions going (off by default)
86
90
  --speak-always Like --speak, but every time, even with one session
@@ -95,6 +99,7 @@ Procedural focus music while your AI coding tools think.
95
99
  --port <n> With --dashboard: the port (default ${require("./dashboard").DEFAULT_PORT}, else a free one)
96
100
  --no-open With --dashboard: do not open the browser
97
101
  --statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
102
+ --why One plain line: why the music is playing or silent (the first line of --status)
98
103
  --events Stream agent state changes as JSON lines, until stopped
99
104
  --doctor Check the setup; each problem comes with its fix (exit 1 if any)
100
105
  --stop Stop the background player, then exit
@@ -234,6 +239,7 @@ function parseArgs(argv) {
234
239
  let statusFlag = false;
235
240
  let stateFlag = false;
236
241
  let statuslineFlag = false;
242
+ let whyFlag = false;
237
243
  let eventsFlag = false;
238
244
  let doctorFlag = false;
239
245
  let notifyFlag = null;
@@ -418,6 +424,12 @@ function parseArgs(argv) {
418
424
  continue;
419
425
  }
420
426
 
427
+ if (arg === "--why") {
428
+ whyFlag = true;
429
+ i += 1;
430
+ continue;
431
+ }
432
+
421
433
  if (arg === "--state" || arg === "--events") {
422
434
  if (arg === "--state") stateFlag = true;
423
435
  else eventsFlag = true;
@@ -645,6 +657,7 @@ function parseArgs(argv) {
645
657
  status: statusFlag,
646
658
  state: stateFlag,
647
659
  statusline: statuslineFlag,
660
+ why: whyFlag,
648
661
  music,
649
662
  events: eventsFlag,
650
663
  doctor: doctorFlag,
@@ -869,21 +882,37 @@ function uninstallHookTargets() {
869
882
  * do". Every line is read from live state rather than assumed, because the
870
883
  * usual cause of confusion is a mismatch between config and what is running.
871
884
  */
885
+ // What hooks.explainMusic() needs, read once: the same sources the sections of
886
+ // --status read, so the one-line answer cannot disagree with them.
887
+ function musicFacts() {
888
+ const hooks = require("./hooks");
889
+ const { muteState, playbackDisabled, detectPlayer: detect } = require("./player");
890
+ const mute = muteState();
891
+ const daemon = hooks.daemonPlaying();
892
+ return {
893
+ disabledByEnv: mute === null && playbackDisabled(),
894
+ mute: mute === null ? null : mute.until ? Math.max(1, Math.round((mute.until - Date.now()) / 60000)) : Infinity,
895
+ backend: Boolean(detect()),
896
+ daemon,
897
+ genre: genreNow(),
898
+ volume: daemon ? (daemon.follows ? Math.round(volumeNow() * 100) : daemon.volume) : null,
899
+ music: musicEnabled(),
900
+ musicSource: process.env.VIBE_MUSIC ? "env" : projectSettings(loadConfig()).music !== undefined ? "project" : "",
901
+ sessions: hooks.agentState().sessions
902
+ };
903
+ }
904
+
872
905
  function printStatus() {
873
906
  const hooks = require("./hooks");
874
- const { playbackDisabled, detectPlayer: detect, CACHE_ROOT } = require("./player");
907
+ const { detectPlayer: detect, CACHE_ROOT } = require("./player");
875
908
  const on = (s) => `\x1b[32m${s}\x1b[0m`;
876
909
  const off = (s) => `\x1b[90m${s}\x1b[0m`;
877
910
 
878
911
  console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}\n`);
879
912
 
880
- const { muteState, muteRemainingText } = require("./player");
881
- const mute = muteState();
882
- if (mute !== null) {
883
- console.log(`\x1b[33m🔇 Muted ${muteRemainingText(mute)}\x1b[0m \x1b[90m(since ${mute.since})\x1b[0m\n`);
884
- } else if (playbackDisabled()) {
885
- console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
886
- }
913
+ // The answer first (#76): everything below is the detail behind it.
914
+ const now = hooks.explainMusic(musicFacts());
915
+ console.log(`\x1b[1mNow\x1b[0m ${now.playing ? on(now.text) : `\x1b[33m${now.text}\x1b[0m`}\n`);
887
916
 
888
917
  // What will actually play, and which of the three possible sources decided
889
918
  // it - the usual confusion is a saved default quietly beaten by an export.
@@ -1817,6 +1846,7 @@ async function run() {
1817
1846
  status: showStatus,
1818
1847
  state: showState,
1819
1848
  statusline: showStatusline,
1849
+ why: showWhy,
1820
1850
  music,
1821
1851
  events: followEvents,
1822
1852
  doctor: showDoctor,
@@ -1991,6 +2021,13 @@ async function run() {
1991
2021
  return;
1992
2022
  }
1993
2023
 
2024
+ // --status's first line without the rest, plain: for the mod's pane and band (#60), a prompt segment, a script.
2025
+ if (showWhy) {
2026
+ const hooks = require("./hooks");
2027
+ console.log(hooks.explainMusic(musicFacts()).text);
2028
+ return;
2029
+ }
2030
+
1994
2031
  if (dashboard) {
1995
2032
  const { startDashboard } = require("./dashboard");
1996
2033
  let server;
package/src/hooks.js CHANGED
@@ -266,7 +266,7 @@ const TARGETS = {
266
266
  seed: () => ({}),
267
267
  // Codex records a trusted_hash per hook in config.toml and asks before
268
268
  // running one it has not seen, so the install is not live until approved.
269
- note: "Codex asks you to trust each new hook the first time it fires — approve each one once."
269
+ note: "Codex asks you to trust each new hook once — in the desktop app via Review hooks in the composer (Allow all), in the CLI the first time each fires."
270
270
  },
271
271
  cursor: {
272
272
  name: "Cursor",
@@ -805,6 +805,50 @@ function formatStatusline({ status, sessions }) {
805
805
  return `${status}${project}${active.length > 1 ? ` ×${active.length}` : ""}`;
806
806
  }
807
807
 
808
+ /**
809
+ * "Why is it silent?" / "why is it still playing?" as one line, the first of
810
+ * --status (#76). Pure over what --status gathers (`musicFacts()` in cli.js),
811
+ * so the order is the test: what silences everything (VIBE_DISABLE, a mute)
812
+ * outranks what silences only the music, and a running player outranks every
813
+ * reason it might not be running. Each silent line names the command that
814
+ * changes it. `mute` is the minutes left, Infinity for an open-ended mute,
815
+ * null when not muted; `musicSource` is "env", "project" or "" (saved or default).
816
+ */
817
+ function explainMusic({ disabledByEnv, mute, backend, daemon, genre, volume, music, musicSource, sessions }) {
818
+ const names = (list) => {
819
+ const all = [...new Set(list.map((s) => (s.project ? projectName(s.project) : null)).filter(Boolean))];
820
+ return all.length > 3 ? `${all.slice(0, 3).join(", ")} +${all.length - 3}` : all.join(", ");
821
+ };
822
+ const counted = (list, word) => {
823
+ const n = names(list);
824
+ return `${list.length} session${list.length === 1 ? "" : "s"} ${word}${n ? ` (${n})` : ""}`;
825
+ };
826
+ const silent = (text) => ({ playing: false, text: `silent: ${text}` });
827
+ const working = sessions.filter((s) => s.state !== "waiting");
828
+ const waiting = sessions.filter((s) => s.state === "waiting");
829
+
830
+ if (disabledByEnv) return silent("VIBE_DISABLE is set in this shell (unset it to hear music)");
831
+ if (mute != null) return silent(`muted ${mute === Infinity ? "until you unmute" : `for ${mute} more min`} (vibe --unmute)`);
832
+ if (!backend) return silent("no audio player found on this machine (vibe --doctor)");
833
+ if (daemon) {
834
+ const stuck = sessions.filter((s) => s.state === "stuck");
835
+ const level = volume === null ? "" : ` @ ${volume}%`;
836
+ return {
837
+ playing: true,
838
+ text: `playing: ${daemon.genre || genre}${level}, ${working.length ? counted(working, "working") : "finishing a turn"}` +
839
+ `${stuck.length ? `; heartbeat on, ${names(stuck) || "a session"} keeps failing` : ""}`
840
+ };
841
+ }
842
+ if (waiting.length && !working.length) return silent(`${counted(waiting, "waiting on you")}; music resumes when you answer`);
843
+ if (!music) {
844
+ // The fix has to beat whatever turned it off: a saved global does not outrank an export or --here.
845
+ const [where, fix] = { env: [" by VIBE_MUSIC in this shell", "unset VIBE_MUSIC"], project: [" for this project", "vibe --music on --here"] }[musicSource] || ["", "vibe --music on"];
846
+ return silent(`music is off${where}; chimes still play (${fix})`);
847
+ }
848
+ if (working.length) return silent(`${counted(working, "working")} but the player is not running (vibe --doctor)`);
849
+ return silent("no agent is working; music starts with your next prompt");
850
+ }
851
+
808
852
  const EVENTS_MAX_BYTES = 256 * 1024;
809
853
 
810
854
  /**
@@ -1781,6 +1825,7 @@ module.exports = {
1781
1825
  STUCK_WINDOW,
1782
1826
  agentState,
1783
1827
  formatStatusline,
1828
+ explainMusic,
1784
1829
  followEvents,
1785
1830
  EVENTS_FILE,
1786
1831
  hookEnd,