vibeaudio 0.14.2 → 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
@@ -8,7 +8,7 @@
8
8
 
9
9
  > **Procedural focus music while your AI coding tools think.**
10
10
  > Every project gets its own arrangement. Zero dependencies, zero audio files.
11
- > Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Aider — and any terminal command.
11
+ > Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Antigravity, Aider — and any terminal command.
12
12
 
13
13
  **[Install](#-install)** · **[Agent hooks](#-agent-hooks-no-wrapper-needed)** · **[Genres](#-music-genres)** · **[Flags](#-options--flags)** · **[Troubleshooting](#-troubleshooting)** · **[Uninstall](#-uninstall)**
14
14
 
@@ -45,7 +45,8 @@ 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
- * 🔌 **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.
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))
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>
51
52
  <summary><b>And the quieter four</b> — silence on fast commands, several sessions at once, exit codes, the HUD</summary>
@@ -241,7 +242,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
241
242
 
242
243
  ## 🪝 Agent Hooks (no wrapper needed)
243
244
 
244
- > **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code and Windsurf.** Everything else uses the [wrapper or MCP](#-everything-else-claude-desktop-antigravity-via-mcp) instead.
245
+ > **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code, Windsurf and Antigravity.** Everything else uses the [wrapper or MCP](#-everything-else-claude-desktop-vs-code-via-mcp) instead.
245
246
 
246
247
  Wrapping (`vibe claude`) infers "the AI is thinking" from how long the process runs. Hooks know for certain — so music starts the moment you submit a prompt and stops the moment the agent finishes, with no grace-window guessing and no aliases.
247
248
 
@@ -254,12 +255,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
254
255
  vibe --install-hooks --dry-run # show what would change, write nothing
255
256
  ```
256
257
 
257
- **It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
258
+ **It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
258
259
 
259
260
  That's it — run your agent normally, with no `vibe` prefix.
260
261
 
261
262
  <details>
262
- <summary><b>Which file and which events, per agent</b> — eight dialects, all written for you</summary>
263
+ <summary><b>Which file and which events, per agent</b> — nine dialects, all written for you</summary>
263
264
 
264
265
  Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
265
266
 
@@ -273,6 +274,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
273
274
  | **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
274
275
  | **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
275
276
  | **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
277
+ | **Antigravity** | `~/.gemini/config/hooks.json`, under its own `vibeaudio` name | `PreInvocation` (the first of each prompt) | `Stop` | — never ([why](#antigravity-hooks)) |
276
278
 
277
279
  Where an agent reports more than start and stop, VibeAudio listens for that too — and only where the event was confirmed against the tool itself:
278
280
 
@@ -283,12 +285,13 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
283
285
  | **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
284
286
  | **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
285
287
  | **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
288
+ | **Antigravity** | — | — | `Stop` ending in an error or the step limit | — |
286
289
  | **Cursor, Grok, Windsurf** | — | — | — | — |
287
290
 
288
291
  </details>
289
292
 
290
293
  <details>
291
- <summary><b>Six things that differ per agent</b> — only the first one needs anything from you</summary>
294
+ <summary><b>Seven things that differ per agent</b> — only the first one needs anything from you</summary>
292
295
 
293
296
 
294
297
  | | |
@@ -296,7 +299,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
296
299
  | **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. |
297
300
  | **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. |
298
301
  | **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. |
299
- | **Only Cursor can play the failure chime** | Its stop event reports whether the turn completed, aborted or errored. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
302
+ | **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. |
303
+ | <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. |
300
304
  | **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. |
301
305
  | **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `CODEX_HOME` the way the agents do, so hooks (and `/vibe`) go where your agent actually reads them. Set the variable in the shell you run `vibe --install-hooks` from. |
302
306
  | **Grok and Copilot CLI get a file of their own** | Each reads every `*.json` in its `hooks/` directory, so VibeAudio writes `vibeaudio.json` rather than merging into anyone else's — which makes uninstalling it a delete, and leaves no backup file behind. Copilot's honours `COPILOT_HOME`. |
@@ -323,9 +327,9 @@ Sessions with no id in their payload share a single slot, so they behave as one.
323
327
 
324
328
  </details>
325
329
 
326
- > **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or Windsurf session does the same hasn't been checked, so start a new session there to be sure.
330
+ > **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code, Windsurf or Antigravity session does the same hasn't been checked, so start a new session there to be sure.
327
331
 
328
- > **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-antigravity-via-mcp).)
332
+ > **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).)
329
333
 
330
334
  > **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.
331
335
 
@@ -415,6 +419,22 @@ Removing them is one command:
415
419
  vibe --uninstall-hooks
416
420
  ```
417
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
+
418
438
  ### Know where your time goes: `vibe --report`
419
439
 
420
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:
@@ -448,7 +468,7 @@ The worst stretch of agent work is twenty minutes of the same thing failing whil
448
468
 
449
469
  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.
450
470
 
451
- 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.
471
+ It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok, Windsurf and Antigravity don't (Antigravity reports a tool error, but not a failing command), so for them the music never adds the heartbeat.
452
472
 
453
473
  </details>
454
474
 
@@ -487,7 +507,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
487
507
 
488
508
  ### Build on it: `vibe --state` and `vibe --events`
489
509
 
490
- 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.
510
+ The hard part of VibeAudio isn't the music. It's turning nine 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.
491
511
 
492
512
  ```bash
493
513
  vibe --state
@@ -506,9 +526,20 @@ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with
506
526
 
507
527
  #### 📟 Status Bar & Desktop Integrations
508
528
 
509
- **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:**
510
541
  ```bash
511
- set -g status-right '#(vibe --state | jq -r .status)'
542
+ set -g status-right '#(vibe --statusline)'
512
543
  ```
513
544
 
514
545
  **macOS speech alert** — announce out loud whenever an agent stops to ask for your input:
@@ -519,17 +550,21 @@ vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | whil
519
550
  **Starship prompt** — custom indicator in `~/.config/starship.toml`:
520
551
  ```toml
521
552
  [custom.vibe]
522
- command = "vibe --state | jq -r 'if .status != \"idle\" then \"🎧 \" + .status else \"\" end'"
553
+ command = "vibe --statusline"
523
554
  when = "command -v vibe >/dev/null"
555
+ format = "([🎧 $output ]($style))"
524
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.*
525
560
 
526
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.
527
562
 
528
- ## 🖥️ Everything Else (Claude Desktop, Antigravity… via MCP)
563
+ ## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
529
564
 
530
- **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.
565
+ **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) [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) and [Antigravity](https://antigravity.google/docs/hooks) have hook systems, and `--install-hooks` writes to all nine** — 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.
531
566
 
532
- > **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the eight agents above, use [hooks](#-agent-hooks-no-wrapper-needed) instead.**
567
+ > **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the nine agents above, use [hooks](#-agent-hooks-no-wrapper-needed) instead.**
533
568
 
534
569
  Use an **absolute path**, not the bare `vibe` command: GUI apps launched from Finder don't inherit your shell's `PATH`, and version managers like `fnm` or `nvm` put `vibe` on a per-shell path that won't resolve. Print yours with:
535
570
 
@@ -555,7 +590,7 @@ Where the file lives:
555
590
  | Tool | Config file |
556
591
  | :--- | :--- |
557
592
  | **Claude Desktop** (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
558
- | **Antigravity, VS Code, Zed, …** | that app's own MCP settings — same JSON shape |
593
+ | **VS Code, Zed, …** | that app's own MCP settings — same JSON shape |
559
594
 
560
595
  Restart the app afterwards.
561
596
 
@@ -695,6 +730,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
695
730
  | `--grace <ms>` | Silence window before music starts | `1500` |
696
731
  | `--here` | With a saved setting: this directory tree only, not everywhere | off |
697
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` |
698
734
  | `--whisper` | Quick preset: 15% volume (headphones / late night) | — |
699
735
  | `--quiet` | Quick preset: 25% volume (focus / open office) | — |
700
736
  | `--loud` | Quick preset: 75% volume (hear from across the room) | — |
@@ -706,6 +742,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
706
742
  | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
707
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 |
708
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 | — |
709
746
  | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
710
747
  | `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
711
748
  | `--stop` | Stop the background player, then exit | — |
@@ -714,7 +751,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
714
751
  | `--clear-cache` | Delete all cached audio, then exit | — |
715
752
  | `--mcp` | Run as an MCP stdio server for desktop apps | — |
716
753
  | `--install-hooks` | Wire music into your agent's hooks (no wrapper needed) | — |
717
- | `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` | auto-detect |
754
+ | `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` | auto-detect |
718
755
  | `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
719
756
  | `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
720
757
  | `--uninstall-hooks` | Remove the hooks again, from every agent | — |
@@ -732,6 +769,7 @@ export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
732
769
  export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
733
770
  export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
734
771
  export VIBE_DISABLE=1 # Mute, without uninstalling anything
772
+ export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
735
773
  export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
736
774
  export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
737
775
  export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
@@ -874,8 +912,8 @@ Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects
874
912
 
875
913
  | Leftover | Why, and how to remove it |
876
914
  | :--- | :--- |
877
- | `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code and Windsurf. A safety net we won't delete for you; `rm` them once you're happy the real files are correct, and `vibe --uninstall-hooks` prints the path of every one it finds. (Grok and Copilot CLI leave nothing: their files are ours alone, so uninstall deletes them outright.) |
878
- | `vibeaudio` entries in other apps' MCP configs | VibeAudio never edits those files, so it can't clean them either. Drop the entry from [whichever config you added it to](#-everything-else-claude-desktop-antigravity-via-mcp). |
915
+ | `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code, Windsurf and Antigravity (if you had a `hooks.json` there). A safety net we won't delete for you; `rm` them once you're happy the real files are correct, and `vibe --uninstall-hooks` prints the path of every one it finds. (Grok and Copilot CLI leave nothing: their files are ours alone, so uninstall deletes them outright.) |
916
+ | `vibeaudio` entries in other apps' MCP configs | VibeAudio never edits those files, so it can't clean them either. Drop the entry from [whichever config you added it to](#-everything-else-claude-desktop-vs-code-via-mcp). |
879
917
 
880
918
  Apart from those two, the three commands above remove everything VibeAudio writes.
881
919
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.14.2",
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
@@ -87,7 +90,7 @@ Procedural focus music while your AI coding tools think.
87
90
  --clear-cache Delete cached audio, then exit
88
91
  --mcp Run as Model Context Protocol (MCP) server for Desktop apps
89
92
  --install-hooks Wire music into your agent's hooks (no wrapper needed)
90
- --tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf (auto-detect)
93
+ --tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity (auto-detect)
91
94
  --reactive With --install-hooks: intensity follows the tool in use
92
95
  --dry-run With --install-hooks: show what would change, write nothing
93
96
  --uninstall-hooks Remove the hooks again, from every agent
@@ -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,
@@ -604,7 +631,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
604
631
  }
605
632
  console.log(` ${events.start.padEnd(19)}→ music starts (${genre} @ ${Math.round(volume * 100)}%)`);
606
633
  console.log(` ${events.stop.padEnd(19)}→ music stops + success chime`);
607
- if (reactive) {
634
+ if (result.reactive) { // off for a target with no pre-tool event
608
635
  console.log(` ${events.tool.padEnd(19)}→ intensity follows the tool in use (reactive mode)`);
609
636
  }
610
637
  if (events.wait) console.log(` ${events.wait[0].padEnd(19)}→ music pauses + "your turn" chime`);
@@ -648,7 +675,7 @@ function printHookPlan({ file, backup, name, before, after, id }, t) {
648
675
  const ours = (text) => {
649
676
  const map = new Map();
650
677
  if (!text) return map;
651
- for (const [event, entries] of hooks.hookEntries(JSON.parse(text).hooks, t)) {
678
+ for (const [event, entries] of hooks.hookEntries(hooks.hooksOf(JSON.parse(text), t), t)) {
652
679
  const mine = entries.filter((e) => hooks.isVibeHook(e, id));
653
680
  if (mine.length) map.set(event, JSON.stringify(mine));
654
681
  }
@@ -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");
@@ -1118,8 +1147,8 @@ function readVibeHooks(file, t = null) {
1118
1147
  try {
1119
1148
  const settings = JSON.parse(fs.readFileSync(file, "utf8"));
1120
1149
  const out = [];
1121
- for (const [event, entries] of Object.entries(settings.hooks || {})) {
1122
- for (const entry of entries || []) {
1150
+ for (const [event, entries] of Object.entries(require("./hooks").hooksOf(settings, t) || {})) {
1151
+ for (const entry of Array.isArray(entries) ? entries : []) {
1123
1152
  for (const command of commands(entry)) {
1124
1153
  if (VIBE_HOOK_FLAG.test(command || "")) out.push({ event, command });
1125
1154
  }
@@ -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":
@@ -1430,7 +1467,7 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
1430
1467
  if (!fs.existsSync(file)) return false;
1431
1468
 
1432
1469
  const settings = JSON.parse(fs.readFileSync(file, "utf8"));
1433
- return Object.values(settings.hooks || {}).some((entries) =>
1470
+ return Object.values(hooks.hooksOf(settings, hooks.TARGETS[id]) || {}).some((entries) =>
1434
1471
  // Not `.some(hooks.isVibeHook)` - Array.some would pass the index as the
1435
1472
  // target id and every lookup would throw.
1436
1473
  (entries || []).some((entry) => hooks.isVibeHook(entry, id))
@@ -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
@@ -106,9 +106,16 @@ function payloadToolName(raw) {
106
106
  return String(payload.tool_name || payload.toolName || byEvent);
107
107
  }
108
108
 
109
- // Windsurf calls the conversation `trajectory_id`.
109
+ // Windsurf calls the conversation `trajectory_id`; Antigravity, `conversationId`.
110
110
  function payloadSession(payload) {
111
- return payload.session_id || payload.trajectory_id;
111
+ return payload.session_id || payload.trajectory_id || payload.conversationId;
112
+ }
113
+
114
+ /** The project a hook fired in: `cwd` for most agents, Antigravity's first workspace. */
115
+ function payloadProject(payload) {
116
+ if (typeof payload.cwd === "string" && payload.cwd) return payload.cwd;
117
+ const ws = Array.isArray(payload.workspacePaths) ? payload.workspacePaths[0] : null;
118
+ return typeof ws === "string" && ws ? ws : null;
112
119
  }
113
120
 
114
121
  /**
@@ -346,6 +353,36 @@ const TARGETS = {
346
353
  seed: () => ({}),
347
354
  liveReload: false,
348
355
  note: "Windsurf's hooks do not load in Restricted Mode, and whether an open session reloads them is not documented — start a new one to be sure."
356
+ },
357
+ antigravity: {
358
+ name: "Antigravity",
359
+ cmd: "agy",
360
+ // Verified live with the agy CLI 1.1.25 (a logging hook in this file, two
361
+ // turns): ~/.gemini/config/ is Antigravity's global customization root,
362
+ // and its hooks.json holds *named* hooks - {"<name>": {"<Event>": [...]}} -
363
+ // so ours live under a key of their own (hooksKey) and nobody else's are
364
+ // touched. Handlers are flat for these two events; timeout in seconds.
365
+ // - start: there is no prompt event. PreInvocation fires before every model
366
+ // call with invocationNum counting from 0 per turn; newTurn() marks the
367
+ // later ones `continues` and hookStart ignores them.
368
+ // - stop: Stop, with terminationReason (outcomeFromPayload).
369
+ // - no tool slot, deliberately: PreToolUse must answer with a decision,
370
+ // and a hook that printed none was seen to DENY the tool, while "allow"
371
+ // would bypass the user's permission prompts. So --reactive cannot apply.
372
+ // - no wait, failure, resume or end: there is no permission or session-end
373
+ // event, and PostToolUse.error stayed empty for a failing command.
374
+ // Not verified: the desktop app reading this file (2.19.1 was running and
375
+ // fired nothing, but was idle), and what Esc fires.
376
+ file: () => path.join(os.homedir(), ".gemini", "config", "hooks.json"),
377
+ // ~/.gemini is Gemini CLI's too; this directory is Antigravity's alone.
378
+ configDir: () => path.join(os.homedir(), ".gemini", "antigravity"),
379
+ hooksKey: "vibeaudio",
380
+ events: { start: "PreInvocation", stop: "Stop" },
381
+ entry: (command) => ({ type: "command", command, timeout: 5 }),
382
+ commands: (entry) => (entry.hooks ? entry.hooks.map((h) => h.command) : entry.command ? [entry.command] : []),
383
+ seed: () => ({}),
384
+ liveReload: false,
385
+ note: "Not checked whether an open Antigravity session reloads hooks — start a new one to be sure. Reactive mode does not apply: Antigravity's pre-tool hook must approve or deny every tool, so VibeAudio installs none."
349
386
  }
350
387
  };
351
388
 
@@ -580,7 +617,17 @@ function newTurn(raw) {
580
617
  const payload = parsePayload(raw);
581
618
  const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
582
619
  const offset = transcript ? fileSize(transcript) : 0;
583
- return { session: String(payloadSession(payload) || ""), project: payload.cwd || process.cwd(), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
620
+ return {
621
+ session: String(payloadSession(payload) || ""),
622
+ project: payloadProject(payload) || process.cwd(),
623
+ transcript,
624
+ offset,
625
+ blocked: blockedJustBefore(transcript, offset),
626
+ // Antigravity has no prompt event: its start is PreInvocation, which fires
627
+ // before every model call and counts them from 0 within the turn. Only the
628
+ // first one is a new turn; the rest would restart the music mid-turn.
629
+ continues: Number(payload.invocationNum) > 0
630
+ };
584
631
  }
585
632
 
586
633
  const BLOCK_LOOKBACK_MS = 3000;
@@ -708,6 +755,20 @@ function agentState(sessions = listSessions()) {
708
755
  return { v: 1, status, sessions: list };
709
756
  }
710
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
+
711
772
  const EVENTS_MAX_BYTES = 256 * 1024;
712
773
 
713
774
  /**
@@ -926,8 +987,9 @@ function spawnDaemon(genre, volume, reactive, follow = false) {
926
987
  * always did, so a genre change lands on the next prompt. With one working the
927
988
  * music carries on: restarting it would cut every other session's stream.
928
989
  */
929
- function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
990
+ function hookStart(genre, volume, { reactive = false, turn = null, follow = false, music = true } = {}) {
930
991
  if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
992
+ if (turn && turn.continues) return null; // A later model call in a turn already playing.
931
993
  const id = sessionId(turn && turn.session);
932
994
  // Before the spawn: the daemon reads it on startup.
933
995
  const project = (turn && turn.project) || process.cwd();
@@ -937,6 +999,10 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
937
999
 
938
1000
  stopDaemon({ keepSessions: true });
939
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;
940
1006
  return spawnDaemon(genre, volume, reactive, follow);
941
1007
  }
942
1008
 
@@ -952,7 +1018,10 @@ function outcomeFromPayload(raw) {
952
1018
  const payload = parsePayload(raw);
953
1019
  if (payload.hook_event_name === "StopFailure") return "failure";
954
1020
  const status = String(payload.status || "").toLowerCase();
955
- return status === "error" || status === "aborted" ? "failure" : "success";
1021
+ if (status === "error" || status === "aborted") return "failure";
1022
+ // Antigravity's Stop: NO_TOOL_CALL is the normal end (seen live; its docs
1023
+ // say model_stop). An error or the step limit is a turn that did not finish.
1024
+ return /error|max_steps/i.test(String(payload.terminationReason || "")) ? "failure" : "success";
956
1025
  }
957
1026
 
958
1027
  /**
@@ -998,10 +1067,13 @@ function notifyEnabled(env = process.env, config = loadConfig()) {
998
1067
  return ["1", "true", "on", "yes"].includes(raw);
999
1068
  }
1000
1069
 
1001
- /** The project a hook fired in: the payload's cwd (Claude, Codex, Gemini), else ours. */
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
+
1002
1075
  function sessionLabel(payload, cwd = process.cwd()) {
1003
- const dir = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : cwd;
1004
- return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
1076
+ return projectName(payloadProject(payload) || cwd);
1005
1077
  }
1006
1078
 
1007
1079
  /**
@@ -1044,14 +1116,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
1044
1116
  const session = readSession(id);
1045
1117
  const tracked = session !== null;
1046
1118
  fs.rmSync(sessionFile(id), { force: true });
1047
- if (tracked) emitEvent("finished", id, parsePayload(raw).cwd || session.project, { outcome });
1119
+ if (tracked) emitEvent("finished", id, payloadProject(parsePayload(raw)) || session.project, { outcome });
1048
1120
  if (session && Number.isFinite(session.started)) {
1049
1121
  const now = Date.now();
1050
1122
  // A turn that ends while still paused for you has been blocked since then.
1051
1123
  const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
1052
1124
  // ponytail: assumes a playback backend exists; a machine with none hears no music either.
1053
1125
  const chimed = !noChime && !playbackDisabled();
1054
- recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
1126
+ recordTurn({ project: payloadProject(parsePayload(raw)) || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
1055
1127
  }
1056
1128
 
1057
1129
  // The music is every working session's, so it ends with the last of them.
@@ -1132,7 +1204,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
1132
1204
  * one needing approval, can resume early - after the chime already did its
1133
1205
  * job. Match on tool_input as well if that ever shows up in practice.
1134
1206
  */
1135
- 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 } = {}) {
1136
1208
  const payload = parsePayload(raw);
1137
1209
  const id = sessionId(payloadSession(payload));
1138
1210
  const session = readSession(id);
@@ -1146,7 +1218,13 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
1146
1218
  const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
1147
1219
  const next = { ...session, recent };
1148
1220
  const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
1149
- 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
+ }
1150
1228
 
1151
1229
  // An empty key is a wait that named nothing (a Notification): the next tool
1152
1230
  // to finish is the first sign of work carrying on.
@@ -1161,7 +1239,7 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
1161
1239
  writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
1162
1240
  emitEvent("resumed", id, session.project);
1163
1241
  if (crossing) emitEvent(crossing, id, session.project);
1164
- if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
1242
+ if (music && !daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
1165
1243
  return true;
1166
1244
  }
1167
1245
 
@@ -1244,6 +1322,19 @@ function setHook(hooks, event, command, id) {
1244
1322
  hooks[event] = kept;
1245
1323
  }
1246
1324
 
1325
+ /**
1326
+ * The object holding a target's events inside its config file: `hooks` for
1327
+ * every agent but Antigravity, whose file is a map of named hooks and keeps
1328
+ * ours under a name of their own (`hooksKey`).
1329
+ */
1330
+ function hooksKey(t) {
1331
+ return (t && t.hooksKey) || "hooks";
1332
+ }
1333
+
1334
+ function hooksOf(settings, t) {
1335
+ return settings ? settings[hooksKey(t)] : undefined;
1336
+ }
1337
+
1247
1338
  /** [event, entries] for every event in a hooks object, skipping settings keys. */
1248
1339
  function hookEntries(hooks, t) {
1249
1340
  const configKeys = (t && t.configKeys) || [];
@@ -1253,7 +1344,7 @@ function hookEntries(hooks, t) {
1253
1344
  function readVibeEntryCount(file, id) {
1254
1345
  try {
1255
1346
  const { settings } = loadSettings(file, target(id));
1256
- return hookEntries(settings.hooks, target(id)).reduce(
1347
+ return hookEntries(hooksOf(settings, target(id)), target(id)).reduce(
1257
1348
  (n, [, entries]) => n + entries.filter((e) => isVibeHook(e, id)).length,
1258
1349
  0
1259
1350
  );
@@ -1285,14 +1376,15 @@ function loadSettings(file, t = null) {
1285
1376
  if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
1286
1377
  throw new Error(`${file} does not contain a JSON object — refusing to overwrite it.`);
1287
1378
  }
1288
- if (settings.hooks !== undefined) {
1289
- if (settings.hooks === null || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
1290
- throw new Error(`${file} has a "hooks" key that is not an object — refusing to overwrite it.`);
1379
+ const key = hooksKey(t);
1380
+ if (settings[key] !== undefined) {
1381
+ if (settings[key] === null || typeof settings[key] !== "object" || Array.isArray(settings[key])) {
1382
+ throw new Error(`${file} has a "${key}" key that is not an object — refusing to overwrite it.`);
1291
1383
  }
1292
- for (const [event, entries] of hookEntries(settings.hooks, t)) {
1384
+ for (const [event, entries] of hookEntries(settings[key], t)) {
1293
1385
  if (!Array.isArray(entries)) {
1294
1386
  throw new Error(
1295
- `${file} has hooks.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
1387
+ `${file} has ${key}.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
1296
1388
  `not an array of entries — refusing to overwrite it.`
1297
1389
  );
1298
1390
  }
@@ -1354,33 +1446,39 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
1354
1446
  }
1355
1447
 
1356
1448
  const ev = t.events;
1357
- settings.hooks = settings.hooks || {};
1358
- setHook(settings.hooks, ev.start, hookCommand("--hook-start", genre, volume, reactive), id);
1359
- setHook(settings.hooks, ev.stop, hookCommand("--hook-stop", genre, volume), id);
1449
+ // Reactive needs a pre-tool event we can listen on without deciding for the
1450
+ // agent; where there is none (Antigravity) it is off, not carried as a flag
1451
+ // that does nothing and that --status would then report.
1452
+ reactive = reactive && Boolean(ev.tool);
1453
+ const key = hooksKey(t);
1454
+ settings[key] = settings[key] || {};
1455
+ const hooks = settings[key];
1456
+ setHook(hooks, ev.start, hookCommand("--hook-start", genre, volume, reactive), id);
1457
+ setHook(hooks, ev.stop, hookCommand("--hook-stop", genre, volume), id);
1360
1458
 
1361
1459
  // Only reactive mode needs per-tool-call signalling.
1362
1460
  if (reactive) {
1363
- setHook(settings.hooks, ev.tool, hookCommand("--hook-tool", genre, volume), id);
1364
- } else if (settings.hooks[ev.tool]) {
1365
- const kept = settings.hooks[ev.tool].filter((entry) => !isVibeHook(entry, id));
1366
- if (kept.length) settings.hooks[ev.tool] = kept;
1367
- else delete settings.hooks[ev.tool];
1461
+ setHook(hooks, ev.tool, hookCommand("--hook-tool", genre, volume), id);
1462
+ } else if (ev.tool && hooks[ev.tool]) {
1463
+ const kept = hooks[ev.tool].filter((entry) => !isVibeHook(entry, id));
1464
+ if (kept.length) hooks[ev.tool] = kept;
1465
+ else delete hooks[ev.tool];
1368
1466
  }
1369
1467
 
1370
1468
  for (const event of ev.wait || []) {
1371
- setHook(settings.hooks, event, hookCommand("--hook-wait", genre, volume), id);
1469
+ setHook(hooks, event, hookCommand("--hook-wait", genre, volume), id);
1372
1470
  }
1373
1471
  // The agent awaits a resume before the next tool's permission check, so a
1374
1472
  // resume can never land after the next wait.
1375
1473
  // ponytail: one ~40ms node start per tool call; a shell-side existence
1376
1474
  // check on the waiting file would skip it if that ever shows.
1377
1475
  for (const event of ev.resume || []) {
1378
- setHook(settings.hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
1476
+ setHook(hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
1379
1477
  }
1380
- if (ev.failure) setHook(settings.hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
1381
- if (ev.end) setHook(settings.hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
1478
+ if (ev.failure) setHook(hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
1479
+ if (ev.end) setHook(hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
1382
1480
  // An interrupt ends the turn the same silent way a closed session does.
1383
- if (ev.interrupt) setHook(settings.hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
1481
+ if (ev.interrupt) setHook(hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
1384
1482
 
1385
1483
  const after = `${JSON.stringify(settings, null, 2)}\n`;
1386
1484
  if (!dryRun) fs.writeFileSync(file, after);
@@ -1457,18 +1555,20 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
1457
1555
  }
1458
1556
 
1459
1557
  const { settings } = loadSettings(file, t);
1460
- if (!settings.hooks) return { file, removed: 0, id };
1558
+ const key = hooksKey(t);
1559
+ const hooks = settings[key];
1560
+ if (!hooks) return { file, removed: 0, id };
1461
1561
 
1462
1562
  let removed = 0;
1463
- for (const [event, entries] of hookEntries(settings.hooks, t)) {
1563
+ for (const [event, entries] of hookEntries(hooks, t)) {
1464
1564
  const kept = entries.filter((entry) => !isVibeHook(entry, id));
1465
1565
  removed += entries.length - kept.length;
1466
1566
 
1467
- if (kept.length) settings.hooks[event] = kept;
1468
- else delete settings.hooks[event];
1567
+ if (kept.length) hooks[event] = kept;
1568
+ else delete hooks[event];
1469
1569
  }
1470
1570
 
1471
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
1571
+ if (Object.keys(hooks).length === 0) delete settings[key];
1472
1572
  fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
1473
1573
  return { file, removed, id };
1474
1574
  }
@@ -1492,6 +1592,7 @@ module.exports = {
1492
1592
  isStuck,
1493
1593
  STUCK_WINDOW,
1494
1594
  agentState,
1595
+ formatStatusline,
1495
1596
  followEvents,
1496
1597
  EVENTS_FILE,
1497
1598
  hookEnd,
@@ -1513,6 +1614,7 @@ module.exports = {
1513
1614
  isVibeHook,
1514
1615
  userHooksInstalled,
1515
1616
  hookEntries,
1617
+ hooksOf,
1516
1618
  installSlashCommand,
1517
1619
  uninstallSlashCommand,
1518
1620
  VIBE_HOOK_FLAG,
@@ -36,6 +36,7 @@ const AI_TOOLS = [
36
36
  { name: "GitHub Copilot CLI", cmd: ["copilot"], check: "copilot", hookTarget: "copilot" },
37
37
  { name: "Qwen Code", cmd: ["qwen"], check: "qwen", hookTarget: "qwen" },
38
38
  { name: "Windsurf", cmd: ["windsurf"], check: "windsurf", hookTarget: "windsurf" },
39
+ { name: "Antigravity CLI", cmd: ["agy"], check: "agy", hookTarget: "antigravity" },
39
40
  { name: "Aider", cmd: ["aider"], check: "aider" },
40
41
  { name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
41
42
  { name: "Custom command...", cmd: null }
package/src/mcp.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Model Context Protocol (MCP) Stdio Server
3
- * Connects VibeAudio to clients with no hook system (Claude Desktop, Antigravity,
4
- * Gemini CLI). Claude Code, Codex and Cursor have hooks — use those instead.
3
+ * Connects VibeAudio to clients with no hook system (Claude Desktop, VS Code,
4
+ * older Gemini CLI releases). Agents with hooks (see TARGETS in hooks.js) should use those.
5
5
  * Zero dependencies - Pure Node.js JSON-RPC 2.0 over Stdio
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 };