vibeaudio 0.14.1 โ†’ 0.15.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
@@ -1,10 +1,14 @@
1
1
  # ๐ŸŽง VibeAudio
2
2
 
3
+ <p align="center">
4
+ <img src="docs/og.png?v=2" alt="VibeAudio โ€” Focus music while your AI codes" width="720">
5
+ </p>
6
+
3
7
  [![test](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml/badge.svg)](https://github.com/kiril6/vibeaudio/actions/workflows/test.yml) [![npm](https://img.shields.io/npm/v/vibeaudio)](https://www.npmjs.com/package/vibeaudio) [![downloads](https://img.shields.io/npm/d18m/vibeaudio?label=downloads)](https://npm-stat.com/charts.html?package=vibeaudio) [![downloads/month](https://img.shields.io/npm/dm/vibeaudio)](https://npm-stat.com/charts.html?package=vibeaudio)
4
8
 
5
9
  > **Procedural focus music while your AI coding tools think.**
6
10
  > Every project gets its own arrangement. Zero dependencies, zero audio files.
7
- > 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.
8
12
 
9
13
  **[Install](#-install)** ยท **[Agent hooks](#-agent-hooks-no-wrapper-needed)** ยท **[Genres](#-music-genres)** ยท **[Flags](#-options--flags)** ยท **[Troubleshooting](#-troubleshooting)** ยท **[Uninstall](#-uninstall)**
10
14
 
@@ -31,7 +35,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
31
35
 
32
36
  **How it behaves:**
33
37
 
34
- * ๐Ÿงฎ **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code โ€” no audio assets, no npm dependencies, no `node-gyp`.
38
+ * ๐Ÿงฎ **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code โ€” no audio assets, no npm dependencies, no install scripts, no `node-gyp`.
35
39
  * ๐Ÿƒ **Light on battery.** Each loop is rendered once and cached. While music plays, your OS's own audio player does the work. With hooks, nothing of VibeAudio's keeps running between turns.
36
40
  * ๐ŸŽผ **A different arrangement per project.** Your working directory seeds the progression, bass line and melody, so each repo has its own sound and keeps it.
37
41
  * ๐Ÿ“ˆ **Escalating layers.** Tier 1 (0โ€“15s) gentle intro โ†’ Tier 2 (15โ€“45s) main groove โ†’ Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
@@ -41,7 +45,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
41
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.
42
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%.
43
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.
44
- * ๐Ÿ”Œ **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
+ * ๐Ÿ”Œ **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.
45
49
 
46
50
  <details>
47
51
  <summary><b>And the quieter four</b> โ€” silence on fast commands, several sessions at once, exit codes, the HUD</summary>
@@ -49,7 +53,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
49
53
  * ๐Ÿ›ก๏ธ **A grace window.** Fast commands stay 100% silent โ€” music starts only past 1.5s (`--grace`).
50
54
  * ๐ŸชŸ **Several sessions, one soundtrack.** Run as many terminals of the same agent as you like: the music plays while *any* of them is working, and each finishes with its own chime. One session ending, pausing for a permission dialog or being interrupted never cuts off another that's still going.
51
55
  * ๐Ÿงฎ **Honest exit codes.** Your command's status passes straight through (`130` on `Ctrl+C`), so `vibe claude && next-step` behaves exactly as it would without the wrapper.
52
- * ๐ŸŒŠ **Terminal title HUD.** A live ASCII wave and elapsed timer in the window title, where it can't corrupt a full-screen TUI.
56
+ * ๐ŸŒŠ **Terminal title HUD.** A live ASCII wave and elapsed timer in the window title (`[ โ™ซ โ–ƒโ–…โ–†โ–‡โ–ˆโ–‡โ–†โ–ƒ lofi (0:24) ]`), where it can't corrupt a full-screen TUI.
53
57
 
54
58
  </details>
55
59
 
@@ -237,7 +241,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
237
241
 
238
242
  ## ๐Ÿช Agent Hooks (no wrapper needed)
239
243
 
240
- > **`--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.
244
+ > **`--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.
241
245
 
242
246
  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.
243
247
 
@@ -250,12 +254,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
250
254
  vibe --install-hooks --dry-run # show what would change, write nothing
251
255
  ```
252
256
 
253
- **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.
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,antigravity` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
254
258
 
255
259
  That's it โ€” run your agent normally, with no `vibe` prefix.
256
260
 
257
261
  <details>
258
- <summary><b>Which file and which events, per agent</b> โ€” eight dialects, all written for you</summary>
262
+ <summary><b>Which file and which events, per agent</b> โ€” nine dialects, all written for you</summary>
259
263
 
260
264
  Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
261
265
 
@@ -269,6 +273,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
269
273
  | **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
270
274
  | **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
271
275
  | **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
276
+ | **Antigravity** | `~/.gemini/config/hooks.json`, under its own `vibeaudio` name | `PreInvocation` (the first of each prompt) | `Stop` | โ€” never ([why](#antigravity-hooks)) |
272
277
 
273
278
  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:
274
279
 
@@ -279,12 +284,13 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
279
284
  | **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | โ€” | `SessionEnd` |
280
285
  | **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | โ€” | `SessionEnd` |
281
286
  | **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
287
+ | **Antigravity** | โ€” | โ€” | `Stop` ending in an error or the step limit | โ€” |
282
288
  | **Cursor, Grok, Windsurf** | โ€” | โ€” | โ€” | โ€” |
283
289
 
284
290
  </details>
285
291
 
286
292
  <details>
287
- <summary><b>Six things that differ per agent</b> โ€” only the first one needs anything from you</summary>
293
+ <summary><b>Seven things that differ per agent</b> โ€” only the first one needs anything from you</summary>
288
294
 
289
295
 
290
296
  | | |
@@ -292,7 +298,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
292
298
  | **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. |
293
299
  | **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. |
294
300
  | **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. |
295
- | **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. |
301
+ | **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. |
302
+ | <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. |
296
303
  | **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. |
297
304
  | **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. |
298
305
  | **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`. |
@@ -319,9 +326,9 @@ Sessions with no id in their payload share a single slot, so they behave as one.
319
326
 
320
327
  </details>
321
328
 
322
- > **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.
329
+ > **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.
323
330
 
324
- > **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).)
331
+ > **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).)
325
332
 
326
333
  > **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.
327
334
 
@@ -352,11 +359,16 @@ Codex asks you to approve the plugin's hooks once, as it does for any hook. Upda
352
359
 
353
360
  The plugin needs Node 18+ on your `PATH` (the hooks run `node`). In Claude Code it also adds `/vibeaudio:vibe`, the plugin's spelling of [`/vibe`](#vibe-inside-claude-code). Update with `claude plugin update vibeaudio@vibeaudio`; remove with `claude plugin uninstall vibeaudio@vibeaudio` (music already playing stops on its own within 15 minutes, or run `/vibeaudio:vibe stop` first).
354
361
 
362
+ <details>
363
+ <summary><b>Plugin details & coexistence with npm</b> โ€” settings file, coexistence, and limitations</summary>
364
+
355
365
  - **Settings are the same file.** `vibe --genre jazz` and the rest work as always, but the `vibe` command comes from `npm i -g vibeaudio`; the plugin alone puts nothing on your `PATH`. Use `/vibeaudio:vibe genre jazz` instead.
356
366
  - **Both at once is safe.** If `--install-hooks` has also been run, the plugin's hooks step aside and the installed ones play, so there's no double chime. Remove the installed hooks first if you want the plugin to own it.
357
367
  - **Claude Code and Codex.** Cursor, Gemini and the others still use `vibe --install-hooks`. Copilot CLI and Qwen Code can load the same plugin and it carries their events, but neither has been run with it yet.
358
368
  - **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
359
369
 
370
+ </details>
371
+
360
372
  ### `/vibe` inside Claude Code
361
373
 
362
374
  Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`, or under `$CLAUDE_CONFIG_DIR`), so you can control the music without leaving the session:
@@ -419,8 +431,13 @@ Hooks log each finished turn โ€” which project, how long, how it ended, and how
419
431
  32s with a chime 1m 50s without (muted or --no-chime)
420
432
  ```
421
433
 
434
+ <details>
435
+ <summary><b>How the report metrics & return times are calculated</b></summary>
436
+
422
437
  plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. "Back to it" is the other direction: how long a finished turn sat before your next prompt in the same session โ€” gaps over 30 minutes count as breaks and are left out. Once both sides have five turns, it is split by whether a chime announced the turn, so you can see what the chime is worth to you. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged โ€” a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
423
438
 
439
+ </details>
440
+
424
441
  ### The chime is in the music's key
425
442
 
426
443
  The success chime is the tonic chord of whatever key your genre sits in โ€” C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone โ€” so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
@@ -429,9 +446,14 @@ The success chime is the tonic chord of whatever key your genre sits in โ€” C ma
429
446
 
430
447
  The worst stretch of agent work is twenty minutes of the same thing failing while the music says all is well. So when **4 of a session's last 8 tool calls fail**, a soft heartbeat โ€” two low beats about once a second, on the tonic of your genre โ€” joins the music at the next loop boundary, and leaves once enough calls succeed. With `--notify` on, you also get one banner when the session crosses the line ("api: looks stuck"), not one per failure.
431
448
 
449
+ <details>
450
+ <summary><b>How the stuck threshold was calibrated (4,497 turns analyzed)</b></summary>
451
+
432
452
  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.
433
453
 
434
- 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.
454
+ 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.
455
+
456
+ </details>
435
457
 
436
458
  ### Reactive mode (opt-in)
437
459
 
@@ -468,7 +490,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
468
490
 
469
491
  ### Build on it: `vibe --state` and `vibe --events`
470
492
 
471
- 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.
493
+ 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.
472
494
 
473
495
  ```bash
474
496
  vibe --state
@@ -483,23 +505,34 @@ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiti
483
505
  {"v":1,"at":1791026130000,"event":"waiting","session":"โ€ฆ","project":"/work/api","tool":"Bash","status":"waiting"}
484
506
  ```
485
507
 
486
- Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended` (with `reason` when `vibe --stop` or `--uninstall-hooks` ended it). Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field. Some examples:
508
+ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended` (with `reason` when `vibe --stop` or `--uninstall-hooks` ended it). Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field.
487
509
 
510
+ #### ๐Ÿ“Ÿ Status Bar & Desktop Integrations
511
+
512
+ **tmux status bar** โ€” show overall agent state (`waiting`, `stuck`, `working`, or `idle`):
488
513
  ```bash
489
- # tmux: show the state in the status bar
490
514
  set -g status-right '#(vibe --state | jq -r .status)'
515
+ ```
491
516
 
492
- # macOS: say it out loud when any agent needs you
517
+ **macOS speech alert** โ€” announce out loud whenever an agent stops to ask for your input:
518
+ ```bash
493
519
  vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | while read p; do say "$(basename "$p") needs you"; done
494
520
  ```
495
521
 
522
+ **Starship prompt** โ€” custom indicator in `~/.config/starship.toml`:
523
+ ```toml
524
+ [custom.vibe]
525
+ command = "vibe --state | jq -r 'if .status != \"idle\" then \"๐ŸŽง \" + .status else \"\" end'"
526
+ when = "command -v vibe >/dev/null"
527
+ ```
528
+
496
529
  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.
497
530
 
498
- ## ๐Ÿ–ฅ๏ธ Everything Else (Claude Desktop, Antigravityโ€ฆ via MCP)
531
+ ## ๐Ÿ–ฅ๏ธ Everything Else (Claude Desktop, VS Codeโ€ฆ via MCP)
499
532
 
500
- **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.
533
+ **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.
501
534
 
502
- > **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.**
535
+ > **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.**
503
536
 
504
537
  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:
505
538
 
@@ -525,7 +558,7 @@ Where the file lives:
525
558
  | Tool | Config file |
526
559
  | :--- | :--- |
527
560
  | **Claude Desktop** (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
528
- | **Antigravity, VS Code, Zed, โ€ฆ** | that app's own MCP settings โ€” same JSON shape |
561
+ | **VS Code, Zed, โ€ฆ** | that app's own MCP settings โ€” same JSON shape |
529
562
 
530
563
  Restart the app afterwards.
531
564
 
@@ -575,7 +608,7 @@ Playback stops automatically if the desktop client disconnects, and caps out aft
575
608
 
576
609
  ## ๐ŸŽจ Music Genres
577
610
 
578
- VibeAudio includes **8 procedural music styles** synthesized entirely in code:
611
+ VibeAudio includes **10 procedural sound styles** synthesized entirely in code:
579
612
 
580
613
  | Genre | Style | Vibe |
581
614
  | :--- | :--- | :--- |
@@ -591,7 +624,8 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
591
624
  | `ocean` | ๐ŸŒŠ **Ocean** | Low surf on a slow swell that rises and drains โ€” **no melody at all** |
592
625
  | `random` | ๐ŸŽฒ **Shuffle Mode** | Picks a surprise genre for the run โ€” **never `drone`, `rain` or `ocean`** |
593
626
 
594
- **Aliases also work**, so you can ask for a genre the way you'd say it โ€” `vibe --preview chill` is `lofi`:
627
+ <details>
628
+ <summary><b>Genre aliases</b> โ€” you can ask for a genre the way you'd say it (<code>chill</code>, <code>retrowave</code>, <code>bossa</code>, <code>ambient</code>โ€ฆ)</summary>
595
629
 
596
630
  | Canonical | Also accepted |
597
631
  | :--- | :--- |
@@ -608,6 +642,8 @@ VibeAudio includes **8 procedural music styles** synthesized entirely in code:
608
642
 
609
643
  Matching is case-insensitive, so `BOSSA` works too.
610
644
 
645
+ </details>
646
+
611
647
  > **If any melody distracts you, use `drone`.** Every other genre plays something โ€” notes, a progression, a bass line โ€” and some people can't read while that happens. `drone` holds one low tone under a slow-breathing noise bed and never moves: closer to a fan or rainfall than to music. Tiers add weight rather than movement. If you want the fan-and-rainfall idea literally, `rain` and `ocean` are synthesized the same way (seeded noise, no recorded files): rain adds more droplets as the turn runs longer, ocean adds a second swell and then foam.
612
648
  >
613
649
  > **`piano` is the gentler version of that idea.** It still plays notes โ€” two in eight seconds at tier 1 โ€” but they're single struck tones with silence between them and nothing running underneath. Higher tiers fill the gaps rather than adding a groove. Try it before `drone` if you want *something* there.
@@ -681,7 +717,7 @@ The full order, highest first: **a flag** โ†’ **an environment variable** โ†’ **
681
717
  | `--clear-cache` | Delete all cached audio, then exit | โ€” |
682
718
  | `--mcp` | Run as an MCP stdio server for desktop apps | โ€” |
683
719
  | `--install-hooks` | Wire music into your agent's hooks (no wrapper needed) | โ€” |
684
- | `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` | auto-detect |
720
+ | `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` | auto-detect |
685
721
  | `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
686
722
  | `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
687
723
  | `--uninstall-hooks` | Remove the hooks again, from every agent | โ€” |
@@ -747,7 +783,9 @@ It shouldn't. The cache key includes a hash of the synth sources, so changing a
747
783
  vibe --clear-cache
748
784
  ```
749
785
 
750
- **Silent when the agent runs on another machine over SSH**
786
+ <details>
787
+ <summary><b>Silent when the agent runs on another machine over SSH (socket forwarding)</b></summary>
788
+
751
789
  Sound plays on the machine where the agent runs, and a remote server usually has no sound card โ€” so VibeAudio finds no player and stays quiet. You can hear it locally by forwarding a PulseAudio socket through the SSH connection: the remote `paplay` sends the audio back over SSH to your own speakers.
752
790
 
753
791
  This needs a PulseAudio-compatible sound server on **your local machine**: a Linux desktop (PipeWire and PulseAudio both provide one) or Windows with WSLg. macOS has none built in, so this route doesn't apply there.
@@ -769,6 +807,8 @@ This needs a PulseAudio-compatible sound server on **your local machine**: a Lin
769
807
 
770
808
  If the second connection fails with the socket "already in use", an earlier session left it behind: `rm /tmp/vibe-pulse.sock` on the server, or set `StreamLocalBindUnlink yes` in the server's `sshd_config`. If `paplay` says access denied, your local PulseAudio requires its cookie โ€” copy `~/.config/pulse/cookie` to the same path on the server.
771
809
 
810
+ </details>
811
+
772
812
  **Volume flag does nothing**
773
813
  Shouldn't happen any more โ€” where the player can't attenuate (`aplay`, PowerShell), the gain is baked into the audio instead. A `--volume` change reaches music already playing within a second on macOS hooks (it eases there), at the next loop boundary elsewhere, and in the wrapper or MCP on their next run; `vibe --status` shows the volume in effect and what set it.
774
814
 
@@ -837,8 +877,8 @@ Step 3 reclaims disk โ€” the audio cache, pruned to the 3 most recent projects
837
877
 
838
878
  | Leftover | Why, and how to remove it |
839
879
  | :--- | :--- |
840
- | `*.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.) |
841
- | `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). |
880
+ | `*.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.) |
881
+ | `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). |
842
882
 
843
883
  Apart from those two, the three commands above remove everything VibeAudio writes.
844
884
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think โ€” a different arrangement per project.",
5
5
  "bin": {
6
6
  "vibeaudio": "bin/vibeaudio.js",
@@ -10,8 +10,7 @@
10
10
  "scripts": {
11
11
  "test": "node test/test-synth.js",
12
12
  "start": "node bin/vibeaudio.js",
13
- "version": "node -e \"const fs=require('fs'),v=require('./package.json').version;for(const f of ['.claude-plugin/plugin.json','.codex-plugin/plugin.json']){const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version=v;fs.writeFileSync(f,JSON.stringify(j,null,2)+'\\n')}\" && git add .claude-plugin/plugin.json .codex-plugin/plugin.json",
14
- "postinstall": "node scripts/postinstall.js"
13
+ "version": "node -e \"const fs=require('fs'),v=require('./package.json').version;for(const f of ['.claude-plugin/plugin.json','.codex-plugin/plugin.json']){const j=JSON.parse(fs.readFileSync(f,'utf8'));j.version=v;fs.writeFileSync(f,JSON.stringify(j,null,2)+'\\n')}\" && git add .claude-plugin/plugin.json .codex-plugin/plugin.json"
15
14
  },
16
15
  "publishConfig": {
17
16
  "registry": "https://registry.npmjs.org/",
@@ -23,7 +22,6 @@
23
22
  "files": [
24
23
  "bin/",
25
24
  "src/",
26
- "scripts/postinstall.js",
27
25
  "README.md",
28
26
  "LICENSE"
29
27
  ],
package/src/cli.js CHANGED
@@ -87,7 +87,7 @@ Procedural focus music while your AI coding tools think.
87
87
  --clear-cache Delete cached audio, then exit
88
88
  --mcp Run as Model Context Protocol (MCP) server for Desktop apps
89
89
  --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)
90
+ --tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity (auto-detect)
91
91
  --reactive With --install-hooks: intensity follows the tool in use
92
92
  --dry-run With --install-hooks: show what would change, write nothing
93
93
  --uninstall-hooks Remove the hooks again, from every agent
@@ -604,7 +604,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
604
604
  }
605
605
  console.log(` ${events.start.padEnd(19)}โ†’ music starts (${genre} @ ${Math.round(volume * 100)}%)`);
606
606
  console.log(` ${events.stop.padEnd(19)}โ†’ music stops + success chime`);
607
- if (reactive) {
607
+ if (result.reactive) { // off for a target with no pre-tool event
608
608
  console.log(` ${events.tool.padEnd(19)}โ†’ intensity follows the tool in use (reactive mode)`);
609
609
  }
610
610
  if (events.wait) console.log(` ${events.wait[0].padEnd(19)}โ†’ music pauses + "your turn" chime`);
@@ -648,7 +648,7 @@ function printHookPlan({ file, backup, name, before, after, id }, t) {
648
648
  const ours = (text) => {
649
649
  const map = new Map();
650
650
  if (!text) return map;
651
- for (const [event, entries] of hooks.hookEntries(JSON.parse(text).hooks, t)) {
651
+ for (const [event, entries] of hooks.hookEntries(hooks.hooksOf(JSON.parse(text), t), t)) {
652
652
  const mine = entries.filter((e) => hooks.isVibeHook(e, id));
653
653
  if (mine.length) map.set(event, JSON.stringify(mine));
654
654
  }
@@ -1118,8 +1118,8 @@ function readVibeHooks(file, t = null) {
1118
1118
  try {
1119
1119
  const settings = JSON.parse(fs.readFileSync(file, "utf8"));
1120
1120
  const out = [];
1121
- for (const [event, entries] of Object.entries(settings.hooks || {})) {
1122
- for (const entry of entries || []) {
1121
+ for (const [event, entries] of Object.entries(require("./hooks").hooksOf(settings, t) || {})) {
1122
+ for (const entry of Array.isArray(entries) ? entries : []) {
1123
1123
  for (const command of commands(entry)) {
1124
1124
  if (VIBE_HOOK_FLAG.test(command || "")) out.push({ event, command });
1125
1125
  }
@@ -1430,7 +1430,7 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
1430
1430
  if (!fs.existsSync(file)) return false;
1431
1431
 
1432
1432
  const settings = JSON.parse(fs.readFileSync(file, "utf8"));
1433
- return Object.values(settings.hooks || {}).some((entries) =>
1433
+ return Object.values(hooks.hooksOf(settings, hooks.TARGETS[id]) || {}).some((entries) =>
1434
1434
  // Not `.some(hooks.isVibeHook)` - Array.some would pass the index as the
1435
1435
  // target id and every lookup would throw.
1436
1436
  (entries || []).some((entry) => hooks.isVibeHook(entry, id))
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;
@@ -928,6 +975,7 @@ function spawnDaemon(genre, volume, reactive, follow = false) {
928
975
  */
929
976
  function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
930
977
  if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
978
+ if (turn && turn.continues) return null; // A later model call in a turn already playing.
931
979
  const id = sessionId(turn && turn.session);
932
980
  // Before the spawn: the daemon reads it on startup.
933
981
  const project = (turn && turn.project) || process.cwd();
@@ -952,7 +1000,10 @@ function outcomeFromPayload(raw) {
952
1000
  const payload = parsePayload(raw);
953
1001
  if (payload.hook_event_name === "StopFailure") return "failure";
954
1002
  const status = String(payload.status || "").toLowerCase();
955
- return status === "error" || status === "aborted" ? "failure" : "success";
1003
+ if (status === "error" || status === "aborted") return "failure";
1004
+ // Antigravity's Stop: NO_TOOL_CALL is the normal end (seen live; its docs
1005
+ // say model_stop). An error or the step limit is a turn that did not finish.
1006
+ return /error|max_steps/i.test(String(payload.terminationReason || "")) ? "failure" : "success";
956
1007
  }
957
1008
 
958
1009
  /**
@@ -998,9 +1049,9 @@ function notifyEnabled(env = process.env, config = loadConfig()) {
998
1049
  return ["1", "true", "on", "yes"].includes(raw);
999
1050
  }
1000
1051
 
1001
- /** The project a hook fired in: the payload's cwd (Claude, Codex, Gemini), else ours. */
1052
+ /** The project a hook fired in: the payload's (see payloadProject), else ours. */
1002
1053
  function sessionLabel(payload, cwd = process.cwd()) {
1003
- const dir = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : cwd;
1054
+ const dir = payloadProject(payload) || cwd;
1004
1055
  return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
1005
1056
  }
1006
1057
 
@@ -1044,14 +1095,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
1044
1095
  const session = readSession(id);
1045
1096
  const tracked = session !== null;
1046
1097
  fs.rmSync(sessionFile(id), { force: true });
1047
- if (tracked) emitEvent("finished", id, parsePayload(raw).cwd || session.project, { outcome });
1098
+ if (tracked) emitEvent("finished", id, payloadProject(parsePayload(raw)) || session.project, { outcome });
1048
1099
  if (session && Number.isFinite(session.started)) {
1049
1100
  const now = Date.now();
1050
1101
  // A turn that ends while still paused for you has been blocked since then.
1051
1102
  const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
1052
1103
  // ponytail: assumes a playback backend exists; a machine with none hears no music either.
1053
1104
  const chimed = !noChime && !playbackDisabled();
1054
- recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
1105
+ recordTurn({ project: payloadProject(parsePayload(raw)) || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
1055
1106
  }
1056
1107
 
1057
1108
  // The music is every working session's, so it ends with the last of them.
@@ -1244,6 +1295,19 @@ function setHook(hooks, event, command, id) {
1244
1295
  hooks[event] = kept;
1245
1296
  }
1246
1297
 
1298
+ /**
1299
+ * The object holding a target's events inside its config file: `hooks` for
1300
+ * every agent but Antigravity, whose file is a map of named hooks and keeps
1301
+ * ours under a name of their own (`hooksKey`).
1302
+ */
1303
+ function hooksKey(t) {
1304
+ return (t && t.hooksKey) || "hooks";
1305
+ }
1306
+
1307
+ function hooksOf(settings, t) {
1308
+ return settings ? settings[hooksKey(t)] : undefined;
1309
+ }
1310
+
1247
1311
  /** [event, entries] for every event in a hooks object, skipping settings keys. */
1248
1312
  function hookEntries(hooks, t) {
1249
1313
  const configKeys = (t && t.configKeys) || [];
@@ -1253,7 +1317,7 @@ function hookEntries(hooks, t) {
1253
1317
  function readVibeEntryCount(file, id) {
1254
1318
  try {
1255
1319
  const { settings } = loadSettings(file, target(id));
1256
- return hookEntries(settings.hooks, target(id)).reduce(
1320
+ return hookEntries(hooksOf(settings, target(id)), target(id)).reduce(
1257
1321
  (n, [, entries]) => n + entries.filter((e) => isVibeHook(e, id)).length,
1258
1322
  0
1259
1323
  );
@@ -1285,14 +1349,15 @@ function loadSettings(file, t = null) {
1285
1349
  if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
1286
1350
  throw new Error(`${file} does not contain a JSON object โ€” refusing to overwrite it.`);
1287
1351
  }
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.`);
1352
+ const key = hooksKey(t);
1353
+ if (settings[key] !== undefined) {
1354
+ if (settings[key] === null || typeof settings[key] !== "object" || Array.isArray(settings[key])) {
1355
+ throw new Error(`${file} has a "${key}" key that is not an object โ€” refusing to overwrite it.`);
1291
1356
  }
1292
- for (const [event, entries] of hookEntries(settings.hooks, t)) {
1357
+ for (const [event, entries] of hookEntries(settings[key], t)) {
1293
1358
  if (!Array.isArray(entries)) {
1294
1359
  throw new Error(
1295
- `${file} has hooks.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
1360
+ `${file} has ${key}.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
1296
1361
  `not an array of entries โ€” refusing to overwrite it.`
1297
1362
  );
1298
1363
  }
@@ -1354,33 +1419,39 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
1354
1419
  }
1355
1420
 
1356
1421
  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);
1422
+ // Reactive needs a pre-tool event we can listen on without deciding for the
1423
+ // agent; where there is none (Antigravity) it is off, not carried as a flag
1424
+ // that does nothing and that --status would then report.
1425
+ reactive = reactive && Boolean(ev.tool);
1426
+ const key = hooksKey(t);
1427
+ settings[key] = settings[key] || {};
1428
+ const hooks = settings[key];
1429
+ setHook(hooks, ev.start, hookCommand("--hook-start", genre, volume, reactive), id);
1430
+ setHook(hooks, ev.stop, hookCommand("--hook-stop", genre, volume), id);
1360
1431
 
1361
1432
  // Only reactive mode needs per-tool-call signalling.
1362
1433
  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];
1434
+ setHook(hooks, ev.tool, hookCommand("--hook-tool", genre, volume), id);
1435
+ } else if (ev.tool && hooks[ev.tool]) {
1436
+ const kept = hooks[ev.tool].filter((entry) => !isVibeHook(entry, id));
1437
+ if (kept.length) hooks[ev.tool] = kept;
1438
+ else delete hooks[ev.tool];
1368
1439
  }
1369
1440
 
1370
1441
  for (const event of ev.wait || []) {
1371
- setHook(settings.hooks, event, hookCommand("--hook-wait", genre, volume), id);
1442
+ setHook(hooks, event, hookCommand("--hook-wait", genre, volume), id);
1372
1443
  }
1373
1444
  // The agent awaits a resume before the next tool's permission check, so a
1374
1445
  // resume can never land after the next wait.
1375
1446
  // ponytail: one ~40ms node start per tool call; a shell-side existence
1376
1447
  // check on the waiting file would skip it if that ever shows.
1377
1448
  for (const event of ev.resume || []) {
1378
- setHook(settings.hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
1449
+ setHook(hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
1379
1450
  }
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);
1451
+ if (ev.failure) setHook(hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
1452
+ if (ev.end) setHook(hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
1382
1453
  // 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);
1454
+ if (ev.interrupt) setHook(hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
1384
1455
 
1385
1456
  const after = `${JSON.stringify(settings, null, 2)}\n`;
1386
1457
  if (!dryRun) fs.writeFileSync(file, after);
@@ -1457,18 +1528,20 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
1457
1528
  }
1458
1529
 
1459
1530
  const { settings } = loadSettings(file, t);
1460
- if (!settings.hooks) return { file, removed: 0, id };
1531
+ const key = hooksKey(t);
1532
+ const hooks = settings[key];
1533
+ if (!hooks) return { file, removed: 0, id };
1461
1534
 
1462
1535
  let removed = 0;
1463
- for (const [event, entries] of hookEntries(settings.hooks, t)) {
1536
+ for (const [event, entries] of hookEntries(hooks, t)) {
1464
1537
  const kept = entries.filter((entry) => !isVibeHook(entry, id));
1465
1538
  removed += entries.length - kept.length;
1466
1539
 
1467
- if (kept.length) settings.hooks[event] = kept;
1468
- else delete settings.hooks[event];
1540
+ if (kept.length) hooks[event] = kept;
1541
+ else delete hooks[event];
1469
1542
  }
1470
1543
 
1471
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
1544
+ if (Object.keys(hooks).length === 0) delete settings[key];
1472
1545
  fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
1473
1546
  return { file, removed, id };
1474
1547
  }
@@ -1513,6 +1586,7 @@ module.exports = {
1513
1586
  isVibeHook,
1514
1587
  userHooksInstalled,
1515
1588
  hookEntries,
1589
+ hooksOf,
1516
1590
  installSlashCommand,
1517
1591
  uninstallSlashCommand,
1518
1592
  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,7 +1,7 @@
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
 
@@ -1,50 +0,0 @@
1
- /**
2
- * The one line an install owes the user.
3
- *
4
- * `npm i -g vibeaudio` used to end in silence: nothing plays until hooks are
5
- * installed or a command is wrapped, so someone who installed it and then went
6
- * back to work heard nothing and concluded it was broken. This says what to
7
- * run next, once, at the only moment the user is definitely looking.
8
- *
9
- * It must never fail an install. Anything thrown here is npm's problem to
10
- * report and the user's to wonder about, so everything is wrapped and the exit
11
- * code is always 0 - a greeting is not worth a failed install.
12
- */
13
- // npm 7+ hides lifecycle-script stdout unless the script fails - measured on
14
- // npm 10, a global install printed only "added 1 package". The controlling
15
- // terminal is outside npm's pipe, so write there; stdout remains for when
16
- // there is no terminal (CI, Windows), where npm may or may not show it.
17
- function say(text) {
18
- const fs = require("fs");
19
- try {
20
- const fd = fs.openSync("/dev/tty", "w");
21
- fs.writeSync(fd, text + "\n");
22
- fs.closeSync(fd);
23
- } catch (e) {
24
- console.log(text);
25
- }
26
- }
27
-
28
- try {
29
- // Local installs are a dependency of something else; their user is not here
30
- // and not the one who would run `vibe`. CI and Docker set this too.
31
- // No isTTY check: npm pipes lifecycle output often enough that gating on it
32
- // would mean the hint never lands for the people who need it. A global
33
- // install is already the narrow case - a dependency install sets this false.
34
- if (process.env.npm_config_global === "true") {
35
- const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
36
- say(`
37
- ${c("1;36", "๐ŸŽง VibeAudio installed.")} Start here:
38
-
39
- ${c("1", "vibe")} ${c("90", "guided setup: pick your agent, genre and volume")}
40
-
41
- Or skip the menu:
42
-
43
- ${c("1", "vibe --install-hooks")} ${c("90", "music follows your agent's thinking โ€” Claude Code, Codex, Cursorโ€ฆ")}
44
- ${c("1", "vibe npm test")} ${c("90", "wrap any command that exits when it's done")}
45
- ${c("1", "vibe --preview jazz")} ${c("90", "hear a genre first")}
46
- `);
47
- }
48
- } catch (e) {
49
- // Deliberately silent: see above.
50
- }