vibeaudio 0.14.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -21
- package/package.json +1 -1
- package/src/cli.js +64 -17
- package/src/hooks.js +139 -37
- package/src/interactive.js +1 -0
- package/src/mcp.js +13 -3
- package/src/player.js +17 -2
- package/src/synth/chime.js +22 -1
- package/src/synth/tension.js +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
> **Procedural focus music while your AI coding tools think.**
|
|
10
10
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
11
|
-
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Aider — and any terminal command.
|
|
11
|
+
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Antigravity, Aider — and any terminal command.
|
|
12
12
|
|
|
13
13
|
**[Install](#-install)** · **[Agent hooks](#-agent-hooks-no-wrapper-needed)** · **[Genres](#-music-genres)** · **[Flags](#-options--flags)** · **[Troubleshooting](#-troubleshooting)** · **[Uninstall](#-uninstall)**
|
|
14
14
|
|
|
@@ -45,7 +45,8 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
|
|
|
45
45
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
46
46
|
* 💓 **A heartbeat when an agent looks stuck.** If 4 of a session's last 8 tool calls fail — the test-edit-test loop that goes nowhere — a soft pulse on the music's tonic joins the music until things start passing again. It's rare by design: on 4,497 real turns it fired in under 1%.
|
|
47
47
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
48
|
-
*
|
|
48
|
+
* 🔕 **Signals without the music.** `vibe --music off` keeps the done, failed and "needs you" chimes, banners and the state stream, and drops the music. For people who bring their own, or share an office. ([details](#signals-only-vibe---music-off))
|
|
49
|
+
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf and Antigravity**; MCP for **Claude Desktop**; the wrapper (`vibe <command>`) for anything else.
|
|
49
50
|
|
|
50
51
|
<details>
|
|
51
52
|
<summary><b>And the quieter four</b> — silence on fast commands, several sessions at once, exit codes, the HUD</summary>
|
|
@@ -241,7 +242,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
|
|
|
241
242
|
|
|
242
243
|
## 🪝 Agent Hooks (no wrapper needed)
|
|
243
244
|
|
|
244
|
-
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code and
|
|
245
|
+
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code, Windsurf and Antigravity.** Everything else uses the [wrapper or MCP](#-everything-else-claude-desktop-vs-code-via-mcp) instead.
|
|
245
246
|
|
|
246
247
|
Wrapping (`vibe claude`) infers "the AI is thinking" from how long the process runs. Hooks know for certain — so music starts the moment you submit a prompt and stops the moment the agent finishes, with no grace-window guessing and no aliases.
|
|
247
248
|
|
|
@@ -254,12 +255,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
|
|
|
254
255
|
vibe --install-hooks --dry-run # show what would change, write nothing
|
|
255
256
|
```
|
|
256
257
|
|
|
257
|
-
**It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
|
|
258
|
+
**It auto-detects.** With no `--tools`, VibeAudio wires up each supported agent it finds on your machine — one counts as present when its config directory exists or its CLI is on your `PATH`. `--tools claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
|
|
258
259
|
|
|
259
260
|
That's it — run your agent normally, with no `vibe` prefix.
|
|
260
261
|
|
|
261
262
|
<details>
|
|
262
|
-
<summary><b>Which file and which events, per agent</b> —
|
|
263
|
+
<summary><b>Which file and which events, per agent</b> — nine dialects, all written for you</summary>
|
|
263
264
|
|
|
264
265
|
Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
|
|
265
266
|
|
|
@@ -273,6 +274,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
273
274
|
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
274
275
|
| **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
275
276
|
| **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
|
|
277
|
+
| **Antigravity** | `~/.gemini/config/hooks.json`, under its own `vibeaudio` name | `PreInvocation` (the first of each prompt) | `Stop` | — never ([why](#antigravity-hooks)) |
|
|
276
278
|
|
|
277
279
|
Where an agent reports more than start and stop, VibeAudio listens for that too — and only where the event was confirmed against the tool itself:
|
|
278
280
|
|
|
@@ -283,12 +285,13 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
283
285
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
284
286
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
285
287
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
288
|
+
| **Antigravity** | — | — | `Stop` ending in an error or the step limit | — |
|
|
286
289
|
| **Cursor, Grok, Windsurf** | — | — | — | — |
|
|
287
290
|
|
|
288
291
|
</details>
|
|
289
292
|
|
|
290
293
|
<details>
|
|
291
|
-
<summary><b>
|
|
294
|
+
<summary><b>Seven things that differ per agent</b> — only the first one needs anything from you</summary>
|
|
292
295
|
|
|
293
296
|
|
|
294
297
|
| | |
|
|
@@ -296,7 +299,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
296
299
|
| **Five agents tell you when they're waiting on you** | When a permission dialog opens the music stops rather than sounding busy while the agent is stuck on you, and it resumes once the thing you answered has run. Claude Code covers the terminal, desktop app and IDEs alike, plus MCP servers asking for input. Copilot CLI's own `PermissionRequest` fires before *every* permission check — dialog or not — so VibeAudio listens for its permission-prompt notification instead. Cursor and Grok have no such event that's been verified, so they keep playing through a prompt. |
|
|
297
300
|
| **Claude Code turns that never reach `Stop` still end the music** | An API error or rate limit ends the turn with `StopFailure` instead, which plays the failure chime. Interrupting (Esc, or the stop button in the desktop app) fires no hook at all, so the background player watches the session transcript for Claude Code's interrupt entry and stops silently within half a second — whether the agent was writing or running a tool. A prompt that another hook blocks (a token-saving proxy, say) never reaches `Stop` either, so the same watcher ends the music on Claude Code's "prompt blocked" entry — or never starts it, when the blocker was quicker than we were. Closing the session mid-turn stops it too — but only if that session started the music, so closing an idle terminal never silences another one. |
|
|
298
301
|
| **Codex asks you to trust the hook once** | Codex keeps a per-hook trust hash in `~/.codex/config.toml` and won't run a hook it hasn't been told to trust, so the install isn't live until you approve each one the first time it fires. |
|
|
299
|
-
| **
|
|
302
|
+
| **Cursor and Antigravity say how a turn ended** | Cursor's stop event reports whether the turn completed, aborted or errored, and Antigravity's gives a termination reason, so an error plays the failure chime. Claude Code and Qwen Code send a separate `StopFailure` for an API error. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
|
|
303
|
+
| <a id="antigravity-hooks"></a>**Antigravity never gets a pre-tool hook** | Its pre-tool hook has to approve or deny every tool call — one that answers nothing blocks the tool — so VibeAudio installs none, and `--reactive` does nothing there. It has no prompt event either: the start event fires before every model call, and VibeAudio starts the music on the first one of each prompt only. Its hooks live in `~/.gemini/config/hooks.json` under a name of their own, so your other named hooks there are never touched. Verified with the `agy` CLI; the desktop app reads the same file by Antigravity's own docs but hasn't been checked live. |
|
|
300
304
|
| **Upgraded, and a new feature isn't there?** | Hooks are written into your agent's config once, at install time, and an upgrade doesn't touch them. When a new version listens for more events (0.13.1 added Codex's `Interrupt` and `SessionEnd`), `vibe --doctor` and `vibe --status` name what's missing, and `vibe --install-hooks` adds it. |
|
|
301
305
|
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `CODEX_HOME` the way the agents do, so hooks (and `/vibe`) go where your agent actually reads them. Set the variable in the shell you run `vibe --install-hooks` from. |
|
|
302
306
|
| **Grok and Copilot CLI get a file of their own** | Each reads every `*.json` in its `hooks/` directory, so VibeAudio writes `vibeaudio.json` rather than merging into anyone else's — which makes uninstalling it a delete, and leaves no backup file behind. Copilot's honours `COPILOT_HOME`. |
|
|
@@ -323,9 +327,9 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
323
327
|
|
|
324
328
|
</details>
|
|
325
329
|
|
|
326
|
-
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or
|
|
330
|
+
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code, Windsurf or Antigravity session does the same hasn't been checked, so start a new session there to be sure.
|
|
327
331
|
|
|
328
|
-
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-
|
|
332
|
+
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-vs-code-via-mcp).)
|
|
329
333
|
|
|
330
334
|
> **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers — deliberate, not a limitation being worked around.
|
|
331
335
|
|
|
@@ -415,6 +419,22 @@ Removing them is one command:
|
|
|
415
419
|
vibe --uninstall-hooks
|
|
416
420
|
```
|
|
417
421
|
|
|
422
|
+
### Signals only: `vibe --music off`
|
|
423
|
+
|
|
424
|
+
VibeAudio does two jobs: music while an agent works, and signals (the done, failed and "needs you" chimes, banners, the stuck alert, `--events`). If you only want the second:
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
vibe --music off # signals only
|
|
428
|
+
vibe --music on # music back, in the genre you had
|
|
429
|
+
vibe --music off --here # this project only
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
It's a setting of its own, saved to `config.json` like genre and volume, so your genre is still there when you turn music back on. Like the others, a flag beats `VIBE_MUSIC=off`, which beats `--here`, which beats the global value, and installed hooks pick it up on the next prompt.
|
|
433
|
+
|
|
434
|
+
With music off, hooks still track every turn: the chimes, `--notify` banners, `--state`, `--events` and `--report` behave exactly as before, and no background player is started. The wrapper (`vibe npm test`) stays silent while it runs and chimes when a run that outlasted the grace window ends, so it becomes "tell me when it's done". MCP's `vibe_play` tells the model that music is off rather than claiming it started. `vibe --status` and `vibe --doctor` show the setting.
|
|
435
|
+
|
|
436
|
+
Two things differ from music on. The stuck heartbeat is a layer on the music, so with music off it plays once on its own, the same two soft beats, when a session crosses into stuck (you also get the `stuck` event and, with `--notify`, the banner). And turning music off while something is playing doesn't cut it: `vibe --stop` does, or it ends with the current turn.
|
|
437
|
+
|
|
418
438
|
### Know where your time goes: `vibe --report`
|
|
419
439
|
|
|
420
440
|
Hooks log each finished turn — which project, how long, how it ended, and how much of it the agent spent blocked on a dialog of yours — to `~/.vibeaudio/history.jsonl`. `vibe --report` (or `--report 30`) totals it:
|
|
@@ -448,7 +468,7 @@ The worst stretch of agent work is twenty minutes of the same thing failing whil
|
|
|
448
468
|
|
|
449
469
|
The threshold was picked against 4,497 real Claude Code turns (46,032 tool calls). It fires in about 1% of turns, at around minute 3, and those turns typically ran for another 3 minutes — time you could have spent stepping in. "3 in a row" was rejected because the usual loop has a successful edit between every failing test run. Each new prompt starts with a clean slate, and an interrupt (Esc) never counts as a failure.
|
|
450
470
|
|
|
451
|
-
It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok and
|
|
471
|
+
It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok, Windsurf and Antigravity don't (Antigravity reports a tool error, but not a failing command), so for them the music never adds the heartbeat.
|
|
452
472
|
|
|
453
473
|
</details>
|
|
454
474
|
|
|
@@ -487,7 +507,7 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
487
507
|
|
|
488
508
|
### Build on it: `vibe --state` and `vibe --events`
|
|
489
509
|
|
|
490
|
-
The hard part of VibeAudio isn't the music. It's turning
|
|
510
|
+
The hard part of VibeAudio isn't the music. It's turning nine agents' different hook events into one set of states that mean the same thing everywhere. The music is one consumer of those states, and anything else can be another: a smart light, a menu bar icon, a tmux status line, a Stream Deck key.
|
|
491
511
|
|
|
492
512
|
```bash
|
|
493
513
|
vibe --state
|
|
@@ -506,9 +526,20 @@ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with
|
|
|
506
526
|
|
|
507
527
|
#### 📟 Status Bar & Desktop Integrations
|
|
508
528
|
|
|
509
|
-
|
|
529
|
+
**`vibe --statusline`** prints the same state as one plain line, with no `jq`, and **nothing at all when idle**, so a prompt segment disappears. It shows the most urgent state, with a count when several sessions are in it, and names the project when one is waiting on you:
|
|
530
|
+
|
|
531
|
+
```
|
|
532
|
+
working
|
|
533
|
+
working ×2
|
|
534
|
+
stuck
|
|
535
|
+
waiting: api
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
It is plain text on purpose: tmux and Starship do their own styling. It doesn't run `ps` and doesn't change when you're muted.
|
|
539
|
+
|
|
540
|
+
**tmux status bar:**
|
|
510
541
|
```bash
|
|
511
|
-
set -g status-right '#(vibe --
|
|
542
|
+
set -g status-right '#(vibe --statusline)'
|
|
512
543
|
```
|
|
513
544
|
|
|
514
545
|
**macOS speech alert** — announce out loud whenever an agent stops to ask for your input:
|
|
@@ -519,17 +550,21 @@ vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | whil
|
|
|
519
550
|
**Starship prompt** — custom indicator in `~/.config/starship.toml`:
|
|
520
551
|
```toml
|
|
521
552
|
[custom.vibe]
|
|
522
|
-
command = "vibe --
|
|
553
|
+
command = "vibe --statusline"
|
|
523
554
|
when = "command -v vibe >/dev/null"
|
|
555
|
+
format = "([🎧 $output ]($style))"
|
|
524
556
|
```
|
|
557
|
+
The parentheses matter: Starship hides a group whose variables are empty, so the 🎧 disappears with the text when idle. Put the icon in a `symbol` instead and it shows all the time. `vibe --statusline` takes about 40 ms, so running it on every prompt is fine.
|
|
558
|
+
|
|
559
|
+
*Run on tmux 3.x and Starship 1.26.0, macOS arm64, Node 24: both showed `working`, `working ×2` and `waiting: web` as sessions started and waited, and nothing when idle. Not run on Linux or Windows.*
|
|
525
560
|
|
|
526
561
|
The stream is a local file (`~/.vibeaudio/events.jsonl`, rotated at 256 KB), so it never leaves your machine. It keeps updating while you're muted, because a mute silences sound and a light isn't sound. The `v` field is the format version, and any breaking change will increment it.
|
|
527
562
|
|
|
528
|
-
## 🖥️ Everything Else (Claude Desktop,
|
|
563
|
+
## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
|
|
529
564
|
|
|
530
|
-
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code)
|
|
565
|
+
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code) [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) and [Antigravity](https://antigravity.google/docs/hooks) have hook systems, and `--install-hooks` writes to all nine** — use [hooks](#-agent-hooks-no-wrapper-needed) there, they're strictly better. This section is for everything else. MCP is the way in: it's plain stdio JSON-RPC, so the setup is identical everywhere and only the config file differs.
|
|
531
566
|
|
|
532
|
-
> **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the
|
|
567
|
+
> **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the nine agents above, use [hooks](#-agent-hooks-no-wrapper-needed) instead.**
|
|
533
568
|
|
|
534
569
|
Use an **absolute path**, not the bare `vibe` command: GUI apps launched from Finder don't inherit your shell's `PATH`, and version managers like `fnm` or `nvm` put `vibe` on a per-shell path that won't resolve. Print yours with:
|
|
535
570
|
|
|
@@ -555,7 +590,7 @@ Where the file lives:
|
|
|
555
590
|
| Tool | Config file |
|
|
556
591
|
| :--- | :--- |
|
|
557
592
|
| **Claude Desktop** (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
558
|
-
| **
|
|
593
|
+
| **VS Code, Zed, …** | that app's own MCP settings — same JSON shape |
|
|
559
594
|
|
|
560
595
|
Restart the app afterwards.
|
|
561
596
|
|
|
@@ -695,6 +730,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
695
730
|
| `--grace <ms>` | Silence window before music starts | `1500` |
|
|
696
731
|
| `--here` | With a saved setting: this directory tree only, not everywhere | off |
|
|
697
732
|
| `--seed <n>` | Force a specific arrangement | derived from the project directory |
|
|
733
|
+
| `--music <on\|off>` | `off` keeps the chimes, banners and events and drops the music. With no command after it, saves it as a setting. `--here` for one project | `on` |
|
|
698
734
|
| `--whisper` | Quick preset: 15% volume (headphones / late night) | — |
|
|
699
735
|
| `--quiet` | Quick preset: 25% volume (focus / open office) | — |
|
|
700
736
|
| `--loud` | Quick preset: 75% volume (hear from across the room) | — |
|
|
@@ -706,6 +742,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
706
742
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
707
743
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
708
744
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
745
|
+
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
|
|
709
746
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
710
747
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
711
748
|
| `--stop` | Stop the background player, then exit | — |
|
|
@@ -714,7 +751,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
714
751
|
| `--clear-cache` | Delete all cached audio, then exit | — |
|
|
715
752
|
| `--mcp` | Run as an MCP stdio server for desktop apps | — |
|
|
716
753
|
| `--install-hooks` | Wire music into your agent's hooks (no wrapper needed) | — |
|
|
717
|
-
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` | auto-detect |
|
|
754
|
+
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity` | auto-detect |
|
|
718
755
|
| `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
|
|
719
756
|
| `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
|
|
720
757
|
| `--uninstall-hooks` | Remove the hooks again, from every agent | — |
|
|
@@ -732,6 +769,7 @@ export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
|
|
|
732
769
|
export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
|
|
733
770
|
export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
|
|
734
771
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
772
|
+
export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
|
|
735
773
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
736
774
|
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
737
775
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
@@ -874,8 +912,8 @@ Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects
|
|
|
874
912
|
|
|
875
913
|
| Leftover | Why, and how to remove it |
|
|
876
914
|
| :--- | :--- |
|
|
877
|
-
| `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code and
|
|
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-
|
|
915
|
+
| `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code, Windsurf and Antigravity (if you had a `hooks.json` there). A safety net we won't delete for you; `rm` them once you're happy the real files are correct, and `vibe --uninstall-hooks` prints the path of every one it finds. (Grok and Copilot CLI leave nothing: their files are ours alone, so uninstall deletes them outright.) |
|
|
916
|
+
| `vibeaudio` entries in other apps' MCP configs | VibeAudio never edits those files, so it can't clean them either. Drop the entry from [whichever config you added it to](#-everything-else-claude-desktop-vs-code-via-mcp). |
|
|
879
917
|
|
|
880
918
|
Apart from those two, the three commands above remove everything VibeAudio writes.
|
|
881
919
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
package/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
normalizeVolume,
|
|
18
18
|
loadConfig,
|
|
19
19
|
saveConfig,
|
|
20
|
+
musicEnabled,
|
|
20
21
|
projectSettings,
|
|
21
22
|
saveProjectConfig,
|
|
22
23
|
wavDurationMs,
|
|
@@ -68,6 +69,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
68
69
|
--grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
|
|
69
70
|
--here With a saved setting: this directory only, not everywhere
|
|
70
71
|
--seed <n> Force a specific arrangement (default: derived from the project directory)
|
|
72
|
+
--music <on|off> off = signals only: chimes, banners and events, no music (a saved setting)
|
|
71
73
|
--whisper Preset: 15% volume (late night / headphones)
|
|
72
74
|
--quiet Preset: 25% volume (focus / open office)
|
|
73
75
|
--loud Preset: 75% volume (hear from across the room)
|
|
@@ -79,6 +81,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
79
81
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
80
82
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
81
83
|
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
84
|
+
--statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
|
|
82
85
|
--events Stream agent state changes as JSON lines, until stopped
|
|
83
86
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
84
87
|
--stop Stop the background player, then exit
|
|
@@ -87,7 +90,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
87
90
|
--clear-cache Delete cached audio, then exit
|
|
88
91
|
--mcp Run as Model Context Protocol (MCP) server for Desktop apps
|
|
89
92
|
--install-hooks Wire music into your agent's hooks (no wrapper needed)
|
|
90
|
-
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf (auto-detect)
|
|
93
|
+
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf,antigravity (auto-detect)
|
|
91
94
|
--reactive With --install-hooks: intensity follows the tool in use
|
|
92
95
|
--dry-run With --install-hooks: show what would change, write nothing
|
|
93
96
|
--uninstall-hooks Remove the hooks again, from every agent
|
|
@@ -110,6 +113,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
110
113
|
VIBE_VOLUME=<5-100> Override the saved volume for this shell
|
|
111
114
|
VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
|
|
112
115
|
VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
|
|
116
|
+
VIBE_MUSIC=<on|off> Override the saved music setting for this shell
|
|
113
117
|
VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
|
|
114
118
|
VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
|
|
115
119
|
VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
|
|
@@ -136,6 +140,7 @@ const VALUE_FLAGS = new Set([
|
|
|
136
140
|
"-v", "--volume",
|
|
137
141
|
"-cv", "--chime-volume",
|
|
138
142
|
"--grace",
|
|
143
|
+
"--music",
|
|
139
144
|
"--seed",
|
|
140
145
|
"--tools",
|
|
141
146
|
"--event"
|
|
@@ -155,6 +160,7 @@ function parseArgs(argv) {
|
|
|
155
160
|
const here = projectSettings(saved);
|
|
156
161
|
const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
|
|
157
162
|
|
|
163
|
+
let music = musicEnabled(process.env, saved);
|
|
158
164
|
let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
|
|
159
165
|
|
|
160
166
|
let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
|
|
@@ -178,6 +184,7 @@ function parseArgs(argv) {
|
|
|
178
184
|
let clearCacheFlag = false;
|
|
179
185
|
let statusFlag = false;
|
|
180
186
|
let stateFlag = false;
|
|
187
|
+
let statuslineFlag = false;
|
|
181
188
|
let eventsFlag = false;
|
|
182
189
|
let doctorFlag = false;
|
|
183
190
|
let notifyFlag = null;
|
|
@@ -295,6 +302,24 @@ function parseArgs(argv) {
|
|
|
295
302
|
continue;
|
|
296
303
|
}
|
|
297
304
|
|
|
305
|
+
if (arg === "--music") {
|
|
306
|
+
const value = args[i + 1].toLowerCase();
|
|
307
|
+
if (value !== "on" && value !== "off") {
|
|
308
|
+
console.error(`\x1b[31m[vibeaudio] --music takes on or off, not '${args[i + 1]}'.\x1b[0m`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
music = value === "on";
|
|
312
|
+
typed.music = music;
|
|
313
|
+
i += 2;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (arg === "--statusline") {
|
|
318
|
+
statuslineFlag = true;
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
298
323
|
if (arg === "--state" || arg === "--events") {
|
|
299
324
|
if (arg === "--state") stateFlag = true;
|
|
300
325
|
else eventsFlag = true;
|
|
@@ -492,6 +517,8 @@ function parseArgs(argv) {
|
|
|
492
517
|
clearCache: clearCacheFlag,
|
|
493
518
|
status: statusFlag,
|
|
494
519
|
state: stateFlag,
|
|
520
|
+
statusline: statuslineFlag,
|
|
521
|
+
music,
|
|
495
522
|
events: eventsFlag,
|
|
496
523
|
doctor: doctorFlag,
|
|
497
524
|
notify: notifyFlag,
|
|
@@ -604,7 +631,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
|
|
|
604
631
|
}
|
|
605
632
|
console.log(` ${events.start.padEnd(19)}→ music starts (${genre} @ ${Math.round(volume * 100)}%)`);
|
|
606
633
|
console.log(` ${events.stop.padEnd(19)}→ music stops + success chime`);
|
|
607
|
-
if (reactive) {
|
|
634
|
+
if (result.reactive) { // off for a target with no pre-tool event
|
|
608
635
|
console.log(` ${events.tool.padEnd(19)}→ intensity follows the tool in use (reactive mode)`);
|
|
609
636
|
}
|
|
610
637
|
if (events.wait) console.log(` ${events.wait[0].padEnd(19)}→ music pauses + "your turn" chime`);
|
|
@@ -648,7 +675,7 @@ function printHookPlan({ file, backup, name, before, after, id }, t) {
|
|
|
648
675
|
const ours = (text) => {
|
|
649
676
|
const map = new Map();
|
|
650
677
|
if (!text) return map;
|
|
651
|
-
for (const [event, entries] of hooks.hookEntries(JSON.parse(text)
|
|
678
|
+
for (const [event, entries] of hooks.hookEntries(hooks.hooksOf(JSON.parse(text), t), t)) {
|
|
652
679
|
const mine = entries.filter((e) => hooks.isVibeHook(e, id));
|
|
653
680
|
if (mine.length) map.set(event, JSON.stringify(mine));
|
|
654
681
|
}
|
|
@@ -734,6 +761,7 @@ function printStatus() {
|
|
|
734
761
|
: off(" default");
|
|
735
762
|
|
|
736
763
|
console.log(`\x1b[1mSound\x1b[0m`);
|
|
764
|
+
console.log(` music ${musicEnabled() ? on("on") : "\x1b[33moff\x1b[0m"}${source("VIBE_MUSIC", "music")}${musicEnabled() ? "" : off(" signals only — vibe --music on")}`);
|
|
737
765
|
console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
|
|
738
766
|
console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
|
|
739
767
|
console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
|
|
@@ -943,6 +971,7 @@ function doctorChecks() {
|
|
|
943
971
|
if (mute !== null) add("warn", "Mute", `muted ${muteRemainingText(mute)}`, "vibe --unmute");
|
|
944
972
|
if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
|
|
945
973
|
if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
|
|
974
|
+
if (!musicEnabled()) add("ok", "Music", "off — chimes and banners only, by your setting (vibe --music on)");
|
|
946
975
|
|
|
947
976
|
const pid = readDaemonPid(hooks.PID_FILE);
|
|
948
977
|
if (pid === null) add("ok", "Background player", "not running");
|
|
@@ -1118,8 +1147,8 @@ function readVibeHooks(file, t = null) {
|
|
|
1118
1147
|
try {
|
|
1119
1148
|
const settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1120
1149
|
const out = [];
|
|
1121
|
-
for (const [event, entries] of Object.entries(settings
|
|
1122
|
-
for (const entry of entries
|
|
1150
|
+
for (const [event, entries] of Object.entries(require("./hooks").hooksOf(settings, t) || {})) {
|
|
1151
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1123
1152
|
for (const command of commands(entry)) {
|
|
1124
1153
|
if (VIBE_HOOK_FLAG.test(command || "")) out.push({ event, command });
|
|
1125
1154
|
}
|
|
@@ -1178,6 +1207,7 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1178
1207
|
if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
|
|
1179
1208
|
if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
|
|
1180
1209
|
if (typed.grace !== undefined) patch.grace = typed.grace;
|
|
1210
|
+
if (typed.music !== undefined) patch.music = typed.music;
|
|
1181
1211
|
|
|
1182
1212
|
const { CONFIG_FILE } = require("./player");
|
|
1183
1213
|
if (hereOnly) saveProjectConfig(patch);
|
|
@@ -1187,7 +1217,8 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1187
1217
|
genre: (v) => `genre ${v}`,
|
|
1188
1218
|
volume: (v) => `volume ${v}%`,
|
|
1189
1219
|
chimeVolume: (v) => `chime volume ${v}%`,
|
|
1190
|
-
grace: (v) => `grace ${v}ms
|
|
1220
|
+
grace: (v) => `grace ${v}ms`,
|
|
1221
|
+
music: (v) => `music ${v ? "on" : "off"}`
|
|
1191
1222
|
};
|
|
1192
1223
|
const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
|
|
1193
1224
|
const where = hereOnly ? ` for ${process.cwd()}` : "";
|
|
@@ -1201,6 +1232,12 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1201
1232
|
if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
|
|
1202
1233
|
console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1203
1234
|
}
|
|
1235
|
+
if (playing && patch.music === false) {
|
|
1236
|
+
console.log(` \x1b[90m${playing.genre || "The music"} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1237
|
+
}
|
|
1238
|
+
if (patch.music === false) {
|
|
1239
|
+
console.log(` \x1b[90mSignals only: chimes, banners, --events and --report work as before.\x1b[0m`);
|
|
1240
|
+
}
|
|
1204
1241
|
if (playing && playing.follows && patch.volume !== undefined) {
|
|
1205
1242
|
console.log(` \x1b[90mThe music playing now follows the new volume within a second.\x1b[0m`);
|
|
1206
1243
|
}
|
|
@@ -1335,7 +1372,7 @@ function renderToFile(target, genre) {
|
|
|
1335
1372
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1336
1373
|
}
|
|
1337
1374
|
|
|
1338
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
|
|
1375
|
+
function runHookAction(action, { music = true, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
|
|
1339
1376
|
const hooks = require("./hooks");
|
|
1340
1377
|
|
|
1341
1378
|
if (plugin && action.startsWith("hook-")) {
|
|
@@ -1361,7 +1398,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1361
1398
|
// The payload names the session (so SessionEnd can tell this session's
|
|
1362
1399
|
// music from another's) and the transcript (so an interrupt, which
|
|
1363
1400
|
// fires no hook, can still stop it).
|
|
1364
|
-
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1401
|
+
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { music, reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1365
1402
|
return;
|
|
1366
1403
|
|
|
1367
1404
|
case "hook-stop":
|
|
@@ -1388,7 +1425,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1388
1425
|
return;
|
|
1389
1426
|
|
|
1390
1427
|
case "hook-resume":
|
|
1391
|
-
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive, follow: typed.volume === undefined }));
|
|
1428
|
+
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { music, reactive, follow: typed.volume === undefined, chimeVolume, noChime }));
|
|
1392
1429
|
return;
|
|
1393
1430
|
|
|
1394
1431
|
case "hook-end":
|
|
@@ -1430,7 +1467,7 @@ function hooksAlreadyCover(cmdArgs, settingsFile = null) {
|
|
|
1430
1467
|
if (!fs.existsSync(file)) return false;
|
|
1431
1468
|
|
|
1432
1469
|
const settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1433
|
-
return Object.values(settings.
|
|
1470
|
+
return Object.values(hooks.hooksOf(settings, hooks.TARGETS[id]) || {}).some((entries) =>
|
|
1434
1471
|
// Not `.some(hooks.isVibeHook)` - Array.some would pass the index as the
|
|
1435
1472
|
// target id and every lookup would throw.
|
|
1436
1473
|
(entries || []).some((entry) => hooks.isVibeHook(entry, id))
|
|
@@ -1455,10 +1492,10 @@ function windowsCommandNeedsShell(command) {
|
|
|
1455
1492
|
return !/[\\/]/.test(command);
|
|
1456
1493
|
}
|
|
1457
1494
|
|
|
1458
|
-
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
|
|
1495
|
+
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false, music = true) {
|
|
1459
1496
|
const player = new AudioPlayer();
|
|
1460
1497
|
const hookDriven = hooksAlreadyCover(cmdArgs);
|
|
1461
|
-
const hud = !noHud && !hookDriven ? new TerminalHud(genre) : null;
|
|
1498
|
+
const hud = !noHud && !hookDriven && music ? new TerminalHud(genre) : null;
|
|
1462
1499
|
const startTime = Date.now();
|
|
1463
1500
|
let musicStarted = false;
|
|
1464
1501
|
let finished = false;
|
|
@@ -1486,8 +1523,8 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
|
|
|
1486
1523
|
|
|
1487
1524
|
// Grace window before triggering audio (silences fast commands)
|
|
1488
1525
|
const graceTimer = hookDriven ? null : setTimeout(() => {
|
|
1489
|
-
musicStarted = true;
|
|
1490
|
-
player.start(genre, volume);
|
|
1526
|
+
musicStarted = true; // Set with music off too: the chime marks the end of a run that outlasted the grace window.
|
|
1527
|
+
if (music) player.start(genre, volume);
|
|
1491
1528
|
if (hud) hud.start();
|
|
1492
1529
|
}, grace);
|
|
1493
1530
|
|
|
@@ -1566,6 +1603,8 @@ async function run() {
|
|
|
1566
1603
|
clearCache: shouldClear,
|
|
1567
1604
|
status: showStatus,
|
|
1568
1605
|
state: showState,
|
|
1606
|
+
statusline: showStatusline,
|
|
1607
|
+
music,
|
|
1569
1608
|
events: followEvents,
|
|
1570
1609
|
doctor: showDoctor,
|
|
1571
1610
|
notify: notifyChange,
|
|
@@ -1596,7 +1635,7 @@ async function run() {
|
|
|
1596
1635
|
|
|
1597
1636
|
if (hookAction) {
|
|
1598
1637
|
try {
|
|
1599
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
|
|
1638
|
+
return runHookAction(hookAction, { music, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
|
|
1600
1639
|
} catch (e) {
|
|
1601
1640
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1602
1641
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -1667,6 +1706,14 @@ async function run() {
|
|
|
1667
1706
|
return;
|
|
1668
1707
|
}
|
|
1669
1708
|
|
|
1709
|
+
// Idle prints nothing, not even a newline, so a prompt segment disappears.
|
|
1710
|
+
if (showStatusline) {
|
|
1711
|
+
const hooks = require("./hooks");
|
|
1712
|
+
const line = hooks.formatStatusline(hooks.agentState());
|
|
1713
|
+
if (line) console.log(line);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1670
1717
|
if (followEvents) {
|
|
1671
1718
|
process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
|
|
1672
1719
|
require("./hooks").followEvents();
|
|
@@ -1736,10 +1783,10 @@ async function run() {
|
|
|
1736
1783
|
}
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1739
|
-
return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud);
|
|
1786
|
+
return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud, music);
|
|
1740
1787
|
}
|
|
1741
1788
|
|
|
1742
|
-
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
|
|
1789
|
+
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud, music);
|
|
1743
1790
|
}
|
|
1744
1791
|
|
|
1745
1792
|
module.exports = {
|
package/src/hooks.js
CHANGED
|
@@ -106,9 +106,16 @@ function payloadToolName(raw) {
|
|
|
106
106
|
return String(payload.tool_name || payload.toolName || byEvent);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// Windsurf calls the conversation `trajectory_id`.
|
|
109
|
+
// Windsurf calls the conversation `trajectory_id`; Antigravity, `conversationId`.
|
|
110
110
|
function payloadSession(payload) {
|
|
111
|
-
return payload.session_id || payload.trajectory_id;
|
|
111
|
+
return payload.session_id || payload.trajectory_id || payload.conversationId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The project a hook fired in: `cwd` for most agents, Antigravity's first workspace. */
|
|
115
|
+
function payloadProject(payload) {
|
|
116
|
+
if (typeof payload.cwd === "string" && payload.cwd) return payload.cwd;
|
|
117
|
+
const ws = Array.isArray(payload.workspacePaths) ? payload.workspacePaths[0] : null;
|
|
118
|
+
return typeof ws === "string" && ws ? ws : null;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
/**
|
|
@@ -346,6 +353,36 @@ const TARGETS = {
|
|
|
346
353
|
seed: () => ({}),
|
|
347
354
|
liveReload: false,
|
|
348
355
|
note: "Windsurf's hooks do not load in Restricted Mode, and whether an open session reloads them is not documented — start a new one to be sure."
|
|
356
|
+
},
|
|
357
|
+
antigravity: {
|
|
358
|
+
name: "Antigravity",
|
|
359
|
+
cmd: "agy",
|
|
360
|
+
// Verified live with the agy CLI 1.1.25 (a logging hook in this file, two
|
|
361
|
+
// turns): ~/.gemini/config/ is Antigravity's global customization root,
|
|
362
|
+
// and its hooks.json holds *named* hooks - {"<name>": {"<Event>": [...]}} -
|
|
363
|
+
// so ours live under a key of their own (hooksKey) and nobody else's are
|
|
364
|
+
// touched. Handlers are flat for these two events; timeout in seconds.
|
|
365
|
+
// - start: there is no prompt event. PreInvocation fires before every model
|
|
366
|
+
// call with invocationNum counting from 0 per turn; newTurn() marks the
|
|
367
|
+
// later ones `continues` and hookStart ignores them.
|
|
368
|
+
// - stop: Stop, with terminationReason (outcomeFromPayload).
|
|
369
|
+
// - no tool slot, deliberately: PreToolUse must answer with a decision,
|
|
370
|
+
// and a hook that printed none was seen to DENY the tool, while "allow"
|
|
371
|
+
// would bypass the user's permission prompts. So --reactive cannot apply.
|
|
372
|
+
// - no wait, failure, resume or end: there is no permission or session-end
|
|
373
|
+
// event, and PostToolUse.error stayed empty for a failing command.
|
|
374
|
+
// Not verified: the desktop app reading this file (2.19.1 was running and
|
|
375
|
+
// fired nothing, but was idle), and what Esc fires.
|
|
376
|
+
file: () => path.join(os.homedir(), ".gemini", "config", "hooks.json"),
|
|
377
|
+
// ~/.gemini is Gemini CLI's too; this directory is Antigravity's alone.
|
|
378
|
+
configDir: () => path.join(os.homedir(), ".gemini", "antigravity"),
|
|
379
|
+
hooksKey: "vibeaudio",
|
|
380
|
+
events: { start: "PreInvocation", stop: "Stop" },
|
|
381
|
+
entry: (command) => ({ type: "command", command, timeout: 5 }),
|
|
382
|
+
commands: (entry) => (entry.hooks ? entry.hooks.map((h) => h.command) : entry.command ? [entry.command] : []),
|
|
383
|
+
seed: () => ({}),
|
|
384
|
+
liveReload: false,
|
|
385
|
+
note: "Not checked whether an open Antigravity session reloads hooks — start a new one to be sure. Reactive mode does not apply: Antigravity's pre-tool hook must approve or deny every tool, so VibeAudio installs none."
|
|
349
386
|
}
|
|
350
387
|
};
|
|
351
388
|
|
|
@@ -580,7 +617,17 @@ function newTurn(raw) {
|
|
|
580
617
|
const payload = parsePayload(raw);
|
|
581
618
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
582
619
|
const offset = transcript ? fileSize(transcript) : 0;
|
|
583
|
-
return {
|
|
620
|
+
return {
|
|
621
|
+
session: String(payloadSession(payload) || ""),
|
|
622
|
+
project: payloadProject(payload) || process.cwd(),
|
|
623
|
+
transcript,
|
|
624
|
+
offset,
|
|
625
|
+
blocked: blockedJustBefore(transcript, offset),
|
|
626
|
+
// Antigravity has no prompt event: its start is PreInvocation, which fires
|
|
627
|
+
// before every model call and counts them from 0 within the turn. Only the
|
|
628
|
+
// first one is a new turn; the rest would restart the music mid-turn.
|
|
629
|
+
continues: Number(payload.invocationNum) > 0
|
|
630
|
+
};
|
|
584
631
|
}
|
|
585
632
|
|
|
586
633
|
const BLOCK_LOOKBACK_MS = 3000;
|
|
@@ -708,6 +755,20 @@ function agentState(sessions = listSessions()) {
|
|
|
708
755
|
return { v: 1, status, sessions: list };
|
|
709
756
|
}
|
|
710
757
|
|
|
758
|
+
/**
|
|
759
|
+
* `vibe --statusline`: the state as one plain line for a tmux segment or a
|
|
760
|
+
* shell prompt, and nothing at all when idle so the segment disappears. The
|
|
761
|
+
* most urgent status, with a count when more than one session is in it; a
|
|
762
|
+
* waiting session names its project, since that is the one you have to go to.
|
|
763
|
+
* Plain text because tmux and Starship style it themselves.
|
|
764
|
+
*/
|
|
765
|
+
function formatStatusline({ status, sessions }) {
|
|
766
|
+
if (status === "idle") return "";
|
|
767
|
+
const active = sessions.filter((s) => s.state === status);
|
|
768
|
+
const project = status === "waiting" && active[0].project ? `: ${projectName(active[0].project)}` : "";
|
|
769
|
+
return `${status}${project}${active.length > 1 ? ` ×${active.length}` : ""}`;
|
|
770
|
+
}
|
|
771
|
+
|
|
711
772
|
const EVENTS_MAX_BYTES = 256 * 1024;
|
|
712
773
|
|
|
713
774
|
/**
|
|
@@ -926,8 +987,9 @@ function spawnDaemon(genre, volume, reactive, follow = false) {
|
|
|
926
987
|
* always did, so a genre change lands on the next prompt. With one working the
|
|
927
988
|
* music carries on: restarting it would cut every other session's stream.
|
|
928
989
|
*/
|
|
929
|
-
function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
|
|
990
|
+
function hookStart(genre, volume, { reactive = false, turn = null, follow = false, music = true } = {}) {
|
|
930
991
|
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
992
|
+
if (turn && turn.continues) return null; // A later model call in a turn already playing.
|
|
931
993
|
const id = sessionId(turn && turn.session);
|
|
932
994
|
// Before the spawn: the daemon reads it on startup.
|
|
933
995
|
const project = (turn && turn.project) || process.cwd();
|
|
@@ -937,6 +999,10 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
|
|
|
937
999
|
|
|
938
1000
|
stopDaemon({ keepSessions: true });
|
|
939
1001
|
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
1002
|
+
// Signals only: the session is tracked (chimes, events and the report read
|
|
1003
|
+
// it) but there is no music to start. Stopping first means a switch to off
|
|
1004
|
+
// lands on the next prompt rather than playing until the turn ends.
|
|
1005
|
+
if (!music) return null;
|
|
940
1006
|
return spawnDaemon(genre, volume, reactive, follow);
|
|
941
1007
|
}
|
|
942
1008
|
|
|
@@ -952,7 +1018,10 @@ function outcomeFromPayload(raw) {
|
|
|
952
1018
|
const payload = parsePayload(raw);
|
|
953
1019
|
if (payload.hook_event_name === "StopFailure") return "failure";
|
|
954
1020
|
const status = String(payload.status || "").toLowerCase();
|
|
955
|
-
|
|
1021
|
+
if (status === "error" || status === "aborted") return "failure";
|
|
1022
|
+
// Antigravity's Stop: NO_TOOL_CALL is the normal end (seen live; its docs
|
|
1023
|
+
// say model_stop). An error or the step limit is a turn that did not finish.
|
|
1024
|
+
return /error|max_steps/i.test(String(payload.terminationReason || "")) ? "failure" : "success";
|
|
956
1025
|
}
|
|
957
1026
|
|
|
958
1027
|
/**
|
|
@@ -998,10 +1067,13 @@ function notifyEnabled(env = process.env, config = loadConfig()) {
|
|
|
998
1067
|
return ["1", "true", "on", "yes"].includes(raw);
|
|
999
1068
|
}
|
|
1000
1069
|
|
|
1001
|
-
/** The project a hook fired in: the payload's
|
|
1070
|
+
/** The project a hook fired in: the payload's (see payloadProject), else ours. */
|
|
1071
|
+
function projectName(dir) {
|
|
1072
|
+
return path.basename(String(dir).replace(/[\\/]+$/, "")) || "a session";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1002
1075
|
function sessionLabel(payload, cwd = process.cwd()) {
|
|
1003
|
-
|
|
1004
|
-
return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
|
|
1076
|
+
return projectName(payloadProject(payload) || cwd);
|
|
1005
1077
|
}
|
|
1006
1078
|
|
|
1007
1079
|
/**
|
|
@@ -1044,14 +1116,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
1044
1116
|
const session = readSession(id);
|
|
1045
1117
|
const tracked = session !== null;
|
|
1046
1118
|
fs.rmSync(sessionFile(id), { force: true });
|
|
1047
|
-
if (tracked) emitEvent("finished", id, parsePayload(raw)
|
|
1119
|
+
if (tracked) emitEvent("finished", id, payloadProject(parsePayload(raw)) || session.project, { outcome });
|
|
1048
1120
|
if (session && Number.isFinite(session.started)) {
|
|
1049
1121
|
const now = Date.now();
|
|
1050
1122
|
// A turn that ends while still paused for you has been blocked since then.
|
|
1051
1123
|
const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
|
|
1052
1124
|
// ponytail: assumes a playback backend exists; a machine with none hears no music either.
|
|
1053
1125
|
const chimed = !noChime && !playbackDisabled();
|
|
1054
|
-
recordTurn({ project: parsePayload(raw)
|
|
1126
|
+
recordTurn({ project: payloadProject(parsePayload(raw)) || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
|
|
1055
1127
|
}
|
|
1056
1128
|
|
|
1057
1129
|
// The music is every working session's, so it ends with the last of them.
|
|
@@ -1132,7 +1204,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
1132
1204
|
* one needing approval, can resume early - after the chime already did its
|
|
1133
1205
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
1134
1206
|
*/
|
|
1135
|
-
function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
|
|
1207
|
+
function hookResume(raw, genre, volume, { reactive = false, follow = false, music = true, chimeVolume = null, noChime = false } = {}) {
|
|
1136
1208
|
const payload = parsePayload(raw);
|
|
1137
1209
|
const id = sessionId(payloadSession(payload));
|
|
1138
1210
|
const session = readSession(id);
|
|
@@ -1146,7 +1218,13 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
|
|
|
1146
1218
|
const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
|
|
1147
1219
|
const next = { ...session, recent };
|
|
1148
1220
|
const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
|
|
1149
|
-
if (crossing === "stuck")
|
|
1221
|
+
if (crossing === "stuck") {
|
|
1222
|
+
notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
|
|
1223
|
+
// With music on the daemon adds the heartbeat; with it off there is no
|
|
1224
|
+
// loop to put one under, so the pulse plays once on its own. Detached: the
|
|
1225
|
+
// agent waits on this hook.
|
|
1226
|
+
if (!music && !noChime) new AudioPlayer().stop({ playChime: true, outcome: "stuck", volume, chimeVolume, detach: true });
|
|
1227
|
+
}
|
|
1150
1228
|
|
|
1151
1229
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
1152
1230
|
// to finish is the first sign of work carrying on.
|
|
@@ -1161,7 +1239,7 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
|
|
|
1161
1239
|
writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
|
|
1162
1240
|
emitEvent("resumed", id, session.project);
|
|
1163
1241
|
if (crossing) emitEvent(crossing, id, session.project);
|
|
1164
|
-
if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
1242
|
+
if (music && !daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
1165
1243
|
return true;
|
|
1166
1244
|
}
|
|
1167
1245
|
|
|
@@ -1244,6 +1322,19 @@ function setHook(hooks, event, command, id) {
|
|
|
1244
1322
|
hooks[event] = kept;
|
|
1245
1323
|
}
|
|
1246
1324
|
|
|
1325
|
+
/**
|
|
1326
|
+
* The object holding a target's events inside its config file: `hooks` for
|
|
1327
|
+
* every agent but Antigravity, whose file is a map of named hooks and keeps
|
|
1328
|
+
* ours under a name of their own (`hooksKey`).
|
|
1329
|
+
*/
|
|
1330
|
+
function hooksKey(t) {
|
|
1331
|
+
return (t && t.hooksKey) || "hooks";
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function hooksOf(settings, t) {
|
|
1335
|
+
return settings ? settings[hooksKey(t)] : undefined;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1247
1338
|
/** [event, entries] for every event in a hooks object, skipping settings keys. */
|
|
1248
1339
|
function hookEntries(hooks, t) {
|
|
1249
1340
|
const configKeys = (t && t.configKeys) || [];
|
|
@@ -1253,7 +1344,7 @@ function hookEntries(hooks, t) {
|
|
|
1253
1344
|
function readVibeEntryCount(file, id) {
|
|
1254
1345
|
try {
|
|
1255
1346
|
const { settings } = loadSettings(file, target(id));
|
|
1256
|
-
return hookEntries(settings
|
|
1347
|
+
return hookEntries(hooksOf(settings, target(id)), target(id)).reduce(
|
|
1257
1348
|
(n, [, entries]) => n + entries.filter((e) => isVibeHook(e, id)).length,
|
|
1258
1349
|
0
|
|
1259
1350
|
);
|
|
@@ -1285,14 +1376,15 @@ function loadSettings(file, t = null) {
|
|
|
1285
1376
|
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
|
|
1286
1377
|
throw new Error(`${file} does not contain a JSON object — refusing to overwrite it.`);
|
|
1287
1378
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1379
|
+
const key = hooksKey(t);
|
|
1380
|
+
if (settings[key] !== undefined) {
|
|
1381
|
+
if (settings[key] === null || typeof settings[key] !== "object" || Array.isArray(settings[key])) {
|
|
1382
|
+
throw new Error(`${file} has a "${key}" key that is not an object — refusing to overwrite it.`);
|
|
1291
1383
|
}
|
|
1292
|
-
for (const [event, entries] of hookEntries(settings
|
|
1384
|
+
for (const [event, entries] of hookEntries(settings[key], t)) {
|
|
1293
1385
|
if (!Array.isArray(entries)) {
|
|
1294
1386
|
throw new Error(
|
|
1295
|
-
`${file} has
|
|
1387
|
+
`${file} has ${key}.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
|
|
1296
1388
|
`not an array of entries — refusing to overwrite it.`
|
|
1297
1389
|
);
|
|
1298
1390
|
}
|
|
@@ -1354,33 +1446,39 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1354
1446
|
}
|
|
1355
1447
|
|
|
1356
1448
|
const ev = t.events;
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1449
|
+
// Reactive needs a pre-tool event we can listen on without deciding for the
|
|
1450
|
+
// agent; where there is none (Antigravity) it is off, not carried as a flag
|
|
1451
|
+
// that does nothing and that --status would then report.
|
|
1452
|
+
reactive = reactive && Boolean(ev.tool);
|
|
1453
|
+
const key = hooksKey(t);
|
|
1454
|
+
settings[key] = settings[key] || {};
|
|
1455
|
+
const hooks = settings[key];
|
|
1456
|
+
setHook(hooks, ev.start, hookCommand("--hook-start", genre, volume, reactive), id);
|
|
1457
|
+
setHook(hooks, ev.stop, hookCommand("--hook-stop", genre, volume), id);
|
|
1360
1458
|
|
|
1361
1459
|
// Only reactive mode needs per-tool-call signalling.
|
|
1362
1460
|
if (reactive) {
|
|
1363
|
-
setHook(
|
|
1364
|
-
} else if (
|
|
1365
|
-
const kept =
|
|
1366
|
-
if (kept.length)
|
|
1367
|
-
else delete
|
|
1461
|
+
setHook(hooks, ev.tool, hookCommand("--hook-tool", genre, volume), id);
|
|
1462
|
+
} else if (ev.tool && hooks[ev.tool]) {
|
|
1463
|
+
const kept = hooks[ev.tool].filter((entry) => !isVibeHook(entry, id));
|
|
1464
|
+
if (kept.length) hooks[ev.tool] = kept;
|
|
1465
|
+
else delete hooks[ev.tool];
|
|
1368
1466
|
}
|
|
1369
1467
|
|
|
1370
1468
|
for (const event of ev.wait || []) {
|
|
1371
|
-
setHook(
|
|
1469
|
+
setHook(hooks, event, hookCommand("--hook-wait", genre, volume), id);
|
|
1372
1470
|
}
|
|
1373
1471
|
// The agent awaits a resume before the next tool's permission check, so a
|
|
1374
1472
|
// resume can never land after the next wait.
|
|
1375
1473
|
// ponytail: one ~40ms node start per tool call; a shell-side existence
|
|
1376
1474
|
// check on the waiting file would skip it if that ever shows.
|
|
1377
1475
|
for (const event of ev.resume || []) {
|
|
1378
|
-
setHook(
|
|
1476
|
+
setHook(hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
|
|
1379
1477
|
}
|
|
1380
|
-
if (ev.failure) setHook(
|
|
1381
|
-
if (ev.end) setHook(
|
|
1478
|
+
if (ev.failure) setHook(hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
|
|
1479
|
+
if (ev.end) setHook(hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
|
|
1382
1480
|
// An interrupt ends the turn the same silent way a closed session does.
|
|
1383
|
-
if (ev.interrupt) setHook(
|
|
1481
|
+
if (ev.interrupt) setHook(hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
|
|
1384
1482
|
|
|
1385
1483
|
const after = `${JSON.stringify(settings, null, 2)}\n`;
|
|
1386
1484
|
if (!dryRun) fs.writeFileSync(file, after);
|
|
@@ -1457,18 +1555,20 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
|
|
|
1457
1555
|
}
|
|
1458
1556
|
|
|
1459
1557
|
const { settings } = loadSettings(file, t);
|
|
1460
|
-
|
|
1558
|
+
const key = hooksKey(t);
|
|
1559
|
+
const hooks = settings[key];
|
|
1560
|
+
if (!hooks) return { file, removed: 0, id };
|
|
1461
1561
|
|
|
1462
1562
|
let removed = 0;
|
|
1463
|
-
for (const [event, entries] of hookEntries(
|
|
1563
|
+
for (const [event, entries] of hookEntries(hooks, t)) {
|
|
1464
1564
|
const kept = entries.filter((entry) => !isVibeHook(entry, id));
|
|
1465
1565
|
removed += entries.length - kept.length;
|
|
1466
1566
|
|
|
1467
|
-
if (kept.length)
|
|
1468
|
-
else delete
|
|
1567
|
+
if (kept.length) hooks[event] = kept;
|
|
1568
|
+
else delete hooks[event];
|
|
1469
1569
|
}
|
|
1470
1570
|
|
|
1471
|
-
if (Object.keys(
|
|
1571
|
+
if (Object.keys(hooks).length === 0) delete settings[key];
|
|
1472
1572
|
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1473
1573
|
return { file, removed, id };
|
|
1474
1574
|
}
|
|
@@ -1492,6 +1592,7 @@ module.exports = {
|
|
|
1492
1592
|
isStuck,
|
|
1493
1593
|
STUCK_WINDOW,
|
|
1494
1594
|
agentState,
|
|
1595
|
+
formatStatusline,
|
|
1495
1596
|
followEvents,
|
|
1496
1597
|
EVENTS_FILE,
|
|
1497
1598
|
hookEnd,
|
|
@@ -1513,6 +1614,7 @@ module.exports = {
|
|
|
1513
1614
|
isVibeHook,
|
|
1514
1615
|
userHooksInstalled,
|
|
1515
1616
|
hookEntries,
|
|
1617
|
+
hooksOf,
|
|
1516
1618
|
installSlashCommand,
|
|
1517
1619
|
uninstallSlashCommand,
|
|
1518
1620
|
VIBE_HOOK_FLAG,
|
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,12 +1,12 @@
|
|
|
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
|
|
|
8
8
|
const readline = require("readline");
|
|
9
|
-
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
|
|
9
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig } = require("./player");
|
|
10
10
|
const pkg = require("../package.json");
|
|
11
11
|
|
|
12
12
|
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
@@ -148,6 +148,15 @@ function handleMessage(player, msg) {
|
|
|
148
148
|
const volume = normalizeVolume(args.volume,
|
|
149
149
|
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
|
|
150
150
|
|
|
151
|
+
// A deliberate setting, like a mute: report it as one, and start nothing.
|
|
152
|
+
if (!musicEnabled()) {
|
|
153
|
+
return {
|
|
154
|
+
jsonrpc: "2.0",
|
|
155
|
+
id,
|
|
156
|
+
result: { content: [{ type: "text", text: "The user has turned VibeAudio's music off (signals only), so nothing will play. This is deliberate and not an error — do not try again or suggest fixes; they will turn it back on when they want music." }] }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
152
161
|
// start() returns false for three unrelated reasons, and the model
|
|
153
162
|
// relays whatever we say here to the user. Reporting a deliberate mute
|
|
@@ -204,6 +213,7 @@ function handleMessage(player, msg) {
|
|
|
204
213
|
// Without this, "isPlaying: false" while muted reads as a bug
|
|
205
214
|
// worth investigating rather than a choice the user made.
|
|
206
215
|
muted: playbackDisabled(),
|
|
216
|
+
musicOff: !musicEnabled(),
|
|
207
217
|
genre: player.genre,
|
|
208
218
|
currentTier: player.currentTier,
|
|
209
219
|
uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
|
package/src/player.js
CHANGED
|
@@ -18,7 +18,7 @@ const { generateRainLoop } = require("./synth/rain");
|
|
|
18
18
|
const { generateOceanLoop } = require("./synth/ocean");
|
|
19
19
|
const { generatePianoLoop } = require("./synth/piano");
|
|
20
20
|
const { generateJazzLoop } = require("./synth/jazz");
|
|
21
|
-
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
21
|
+
const { generateSuccessChime, generateFailureChime, generateAttentionChime, generateStuckChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
23
|
const { addTension } = require("./synth/tension");
|
|
24
24
|
const pkg = require("../package.json");
|
|
@@ -430,6 +430,19 @@ function playbackDisabled() {
|
|
|
430
430
|
return muteState() !== null;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Whether music plays at all, as opposed to `playbackDisabled()`, which
|
|
435
|
+
* silences everything including the chimes. Some people want only the
|
|
436
|
+
* signals (done, failed, needs you), so this is a setting of its own rather
|
|
437
|
+
* than a genre: `genre` stays what it was for when music comes back on.
|
|
438
|
+
* Env beats this directory's saved value beats the global one; a flag is
|
|
439
|
+
* resolved by parseArgs() and handed to whoever acts on it.
|
|
440
|
+
*/
|
|
441
|
+
function musicEnabled(env = process.env, config = loadConfig()) {
|
|
442
|
+
const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
|
|
443
|
+
return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
|
|
444
|
+
}
|
|
445
|
+
|
|
433
446
|
/**
|
|
434
447
|
* `minutes` of 0 means indefinite, which the user has to ask for explicitly.
|
|
435
448
|
*/
|
|
@@ -652,7 +665,8 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0,
|
|
|
652
665
|
const CHIMES = {
|
|
653
666
|
success: generateSuccessChime,
|
|
654
667
|
failure: generateFailureChime,
|
|
655
|
-
attention: generateAttentionChime
|
|
668
|
+
attention: generateAttentionChime,
|
|
669
|
+
stuck: generateStuckChime
|
|
656
670
|
};
|
|
657
671
|
|
|
658
672
|
/**
|
|
@@ -965,6 +979,7 @@ module.exports = {
|
|
|
965
979
|
bakedGain,
|
|
966
980
|
applyGain,
|
|
967
981
|
playbackDisabled,
|
|
982
|
+
musicEnabled,
|
|
968
983
|
resolveGenre,
|
|
969
984
|
isKnownGenre,
|
|
970
985
|
normalizeVolume,
|
package/src/synth/chime.js
CHANGED
|
@@ -143,11 +143,32 @@ function generateAttentionChime(durationSec = 1.3) {
|
|
|
143
143
|
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* "Looks stuck", for someone with the music off, who has no loop for the
|
|
148
|
+
* heartbeat to sit under. It is that same heartbeat on its own - two lub-dubs
|
|
149
|
+
* on A, a resting pulse - so it means what the layer means: not an outcome and
|
|
150
|
+
* not a question, just something that has not changed for a while. Quiet, and
|
|
151
|
+
* low enough to read as a pulse rather than a tone.
|
|
152
|
+
*/
|
|
153
|
+
function generateStuckChime(durationSec = 1.8) {
|
|
154
|
+
const { thump, pulseFreq, BEAT_S, DUB_DELAY_S } = require("./tension");
|
|
155
|
+
const f = pulseFreq("A minor");
|
|
156
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
157
|
+
const out = new Float64Array(totalSamples);
|
|
158
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
159
|
+
const t = i / SAMPLE_RATE;
|
|
160
|
+
const local = t % BEAT_S;
|
|
161
|
+
out[i] = 0.3 * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
|
|
162
|
+
}
|
|
163
|
+
return createWavBuffer({ left: out, right: out, sampleRate: SAMPLE_RATE });
|
|
164
|
+
}
|
|
165
|
+
|
|
146
166
|
module.exports = {
|
|
147
167
|
DEFAULT_CHIME_KEY,
|
|
148
168
|
SUCCESS_CHIME_NOTES,
|
|
149
169
|
generateChime: generateSuccessChime,
|
|
150
170
|
generateSuccessChime,
|
|
151
171
|
generateFailureChime,
|
|
152
|
-
generateAttentionChime
|
|
172
|
+
generateAttentionChime,
|
|
173
|
+
generateStuckChime
|
|
153
174
|
};
|
package/src/synth/tension.js
CHANGED