vibeaudio 0.14.2 → 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 +21 -18
- package/package.json +1 -1
- package/src/cli.js +6 -6
- package/src/hooks.js +106 -32
- package/src/interactive.js +1 -0
- package/src/mcp.js +2 -2
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,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
|
|
|
45
45
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
46
46
|
* 💓 **A heartbeat when an agent looks stuck.** If 4 of a session's last 8 tool calls fail — the test-edit-test loop that goes nowhere — a soft pulse on the music's tonic joins the music until things start passing again. It's rare by design: on 4,497 real turns it fired in under 1%.
|
|
47
47
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
48
|
-
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code and
|
|
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.
|
|
49
49
|
|
|
50
50
|
<details>
|
|
51
51
|
<summary><b>And the quieter four</b> — silence on fast commands, several sessions at once, exit codes, the HUD</summary>
|
|
@@ -241,7 +241,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
|
|
|
241
241
|
|
|
242
242
|
## 🪝 Agent Hooks (no wrapper needed)
|
|
243
243
|
|
|
244
|
-
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code and
|
|
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.
|
|
245
245
|
|
|
246
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.
|
|
247
247
|
|
|
@@ -254,12 +254,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
|
|
|
254
254
|
vibe --install-hooks --dry-run # show what would change, write nothing
|
|
255
255
|
```
|
|
256
256
|
|
|
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.
|
|
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.
|
|
258
258
|
|
|
259
259
|
That's it — run your agent normally, with no `vibe` prefix.
|
|
260
260
|
|
|
261
261
|
<details>
|
|
262
|
-
<summary><b>Which file and which events, per agent</b> —
|
|
262
|
+
<summary><b>Which file and which events, per agent</b> — nine dialects, all written for you</summary>
|
|
263
263
|
|
|
264
264
|
Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
|
|
265
265
|
|
|
@@ -273,6 +273,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
273
273
|
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
274
274
|
| **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
275
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)) |
|
|
276
277
|
|
|
277
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:
|
|
278
279
|
|
|
@@ -283,12 +284,13 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
283
284
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
284
285
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
285
286
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
287
|
+
| **Antigravity** | — | — | `Stop` ending in an error or the step limit | — |
|
|
286
288
|
| **Cursor, Grok, Windsurf** | — | — | — | — |
|
|
287
289
|
|
|
288
290
|
</details>
|
|
289
291
|
|
|
290
292
|
<details>
|
|
291
|
-
<summary><b>
|
|
293
|
+
<summary><b>Seven things that differ per agent</b> — only the first one needs anything from you</summary>
|
|
292
294
|
|
|
293
295
|
|
|
294
296
|
| | |
|
|
@@ -296,7 +298,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
296
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. |
|
|
297
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. |
|
|
298
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. |
|
|
299
|
-
| **
|
|
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. |
|
|
300
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. |
|
|
301
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. |
|
|
302
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`. |
|
|
@@ -323,9 +326,9 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
323
326
|
|
|
324
327
|
</details>
|
|
325
328
|
|
|
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
|
|
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.
|
|
327
330
|
|
|
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-
|
|
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).)
|
|
329
332
|
|
|
330
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.
|
|
331
334
|
|
|
@@ -448,7 +451,7 @@ The worst stretch of agent work is twenty minutes of the same thing failing whil
|
|
|
448
451
|
|
|
449
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.
|
|
450
453
|
|
|
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
|
|
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.
|
|
452
455
|
|
|
453
456
|
</details>
|
|
454
457
|
|
|
@@ -487,7 +490,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
487
490
|
|
|
488
491
|
### Build on it: `vibe --state` and `vibe --events`
|
|
489
492
|
|
|
490
|
-
The hard part of VibeAudio isn't the music. It's turning
|
|
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.
|
|
491
494
|
|
|
492
495
|
```bash
|
|
493
496
|
vibe --state
|
|
@@ -525,11 +528,11 @@ when = "command -v vibe >/dev/null"
|
|
|
525
528
|
|
|
526
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.
|
|
527
530
|
|
|
528
|
-
## 🖥️ Everything Else (Claude Desktop,
|
|
531
|
+
## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
|
|
529
532
|
|
|
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)
|
|
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.
|
|
531
534
|
|
|
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
|
|
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.**
|
|
533
536
|
|
|
534
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:
|
|
535
538
|
|
|
@@ -555,7 +558,7 @@ Where the file lives:
|
|
|
555
558
|
| Tool | Config file |
|
|
556
559
|
| :--- | :--- |
|
|
557
560
|
| **Claude Desktop** (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
558
|
-
| **
|
|
561
|
+
| **VS Code, Zed, …** | that app's own MCP settings — same JSON shape |
|
|
559
562
|
|
|
560
563
|
Restart the app afterwards.
|
|
561
564
|
|
|
@@ -714,7 +717,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
714
717
|
| `--clear-cache` | Delete all cached audio, then exit | — |
|
|
715
718
|
| `--mcp` | Run as an MCP stdio server for desktop apps | — |
|
|
716
719
|
| `--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 |
|
|
720
|
+
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` | auto-detect |
|
|
718
721
|
| `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
|
|
719
722
|
| `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
|
|
720
723
|
| `--uninstall-hooks` | Remove the hooks again, from every agent | — |
|
|
@@ -874,8 +877,8 @@ Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects
|
|
|
874
877
|
|
|
875
878
|
| Leftover | Why, and how to remove it |
|
|
876
879
|
| :--- | :--- |
|
|
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
|
|
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-
|
|
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). |
|
|
879
882
|
|
|
880
883
|
Apart from those two, the three commands above remove everything VibeAudio writes.
|
|
881
884
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
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",
|
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)
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
|
1357
|
+
for (const [event, entries] of hookEntries(settings[key], t)) {
|
|
1293
1358
|
if (!Array.isArray(entries)) {
|
|
1294
1359
|
throw new Error(
|
|
1295
|
-
`${file} has
|
|
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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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(
|
|
1364
|
-
} else if (
|
|
1365
|
-
const kept =
|
|
1366
|
-
if (kept.length)
|
|
1367
|
-
else delete
|
|
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(
|
|
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(
|
|
1449
|
+
setHook(hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
|
|
1379
1450
|
}
|
|
1380
|
-
if (ev.failure) setHook(
|
|
1381
|
-
if (ev.end) setHook(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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)
|
|
1468
|
-
else delete
|
|
1540
|
+
if (kept.length) hooks[event] = kept;
|
|
1541
|
+
else delete hooks[event];
|
|
1469
1542
|
}
|
|
1470
1543
|
|
|
1471
|
-
if (Object.keys(
|
|
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,
|
package/src/interactive.js
CHANGED
|
@@ -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,
|
|
4
|
-
* Gemini CLI).
|
|
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
|
|