vibeaudio 0.9.0 → 0.11.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 +36 -12
- package/package.json +1 -1
- package/src/cli.js +127 -1
- package/src/history.js +57 -0
- package/src/hooks.js +52 -13
- package/src/interactive.js +1 -0
- package/src/mac-player.jxa +46 -0
- package/src/output.js +57 -0
- package/src/player.js +105 -8
- package/src/synth/chime.js +21 -5
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> **Procedural focus music while your AI coding tools think.**
|
|
6
6
|
> Every project gets its own arrangement. Zero dependencies, zero audio files.
|
|
7
|
-
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Aider — and any terminal command.
|
|
7
|
+
> Works with Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf, Aider — and any terminal command.
|
|
8
8
|
|
|
9
9
|
**[Install](#-install)** · **[Agent hooks](#-agent-hooks-no-wrapper-needed)** · **[Genres](#-music-genres)** · **[Flags](#-options--flags)** · **[Troubleshooting](#-troubleshooting)** · **[Uninstall](#-uninstall)**
|
|
10
10
|
|
|
@@ -27,7 +27,7 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
27
27
|
* 🎛️ **Music that follows the work** ([reactive mode](#reactive-mode-opt-in), opt-in). Calm while the agent reads, fuller while it edits, busiest when it runs commands or hands work to sub-agents — you can hear *what* it's doing, not just for how long.
|
|
28
28
|
* 🔔 **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.
|
|
29
29
|
* ✋ **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.
|
|
30
|
-
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI
|
|
30
|
+
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code and Windsurf**; MCP for **Claude Desktop and Antigravity**; the wrapper (`vibe <command>`) for anything else.
|
|
31
31
|
|
|
32
32
|
<details>
|
|
33
33
|
<summary><b>And the quieter four</b> — silence on fast commands, several sessions at once, exit codes, the HUD</summary>
|
|
@@ -223,7 +223,7 @@ Adding an entry to the launcher is one line in [`src/interactive.js`](src/intera
|
|
|
223
223
|
|
|
224
224
|
## 🪝 Agent Hooks (no wrapper needed)
|
|
225
225
|
|
|
226
|
-
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI
|
|
226
|
+
> **`--install-hooks` supports Claude Code, Codex, Cursor, Grok, Gemini CLI, GitHub Copilot CLI, Qwen Code and Windsurf.** Everything else uses the [wrapper or MCP](#-everything-else-claude-desktop-antigravity-via-mcp) instead.
|
|
227
227
|
|
|
228
228
|
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.
|
|
229
229
|
|
|
@@ -236,12 +236,12 @@ vibe --genre jazz --volume 25 --install-hooks # install, and save these as yo
|
|
|
236
236
|
vibe --install-hooks --dry-run # show what would change, write nothing
|
|
237
237
|
```
|
|
238
238
|
|
|
239
|
-
**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` overrides that. Add `--dry-run` to see, per event, what would be added or changed in each file before anything is written.
|
|
239
|
+
**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.
|
|
240
240
|
|
|
241
241
|
That's it — run your agent normally, with no `vibe` prefix.
|
|
242
242
|
|
|
243
243
|
<details>
|
|
244
|
-
<summary><b>Which file and which events, per agent</b> —
|
|
244
|
+
<summary><b>Which file and which events, per agent</b> — eight dialects, all written for you</summary>
|
|
245
245
|
|
|
246
246
|
Each tool spells its events its own way, and VibeAudio writes whichever dialect the file expects:
|
|
247
247
|
|
|
@@ -254,6 +254,7 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
254
254
|
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
255
255
|
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
256
256
|
| **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
257
|
+
| **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
|
|
257
258
|
|
|
258
259
|
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:
|
|
259
260
|
|
|
@@ -264,7 +265,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
264
265
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
265
266
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
266
267
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
267
|
-
| **Cursor, Grok** | — | — | — | — |
|
|
268
|
+
| **Cursor, Grok, Windsurf** | — | — | — | — |
|
|
268
269
|
|
|
269
270
|
</details>
|
|
270
271
|
|
|
@@ -302,7 +303,7 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
302
303
|
|
|
303
304
|
</details>
|
|
304
305
|
|
|
305
|
-
> **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
|
|
306
|
+
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or Windsurf session does the same hasn't been checked, so start a new session there to be sure.
|
|
306
307
|
|
|
307
308
|
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json`. (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-antigravity-via-mcp).)
|
|
308
309
|
|
|
@@ -357,6 +358,23 @@ Removing them is one command:
|
|
|
357
358
|
vibe --uninstall-hooks
|
|
358
359
|
```
|
|
359
360
|
|
|
361
|
+
### Know where your time goes: `vibe --report`
|
|
362
|
+
|
|
363
|
+
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:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
Turns 30 3 failed
|
|
367
|
+
You waited 3h 55m on agents, 7m 51s per turn
|
|
368
|
+
Agents waited 5m 35s on you — permission dialogs and questions
|
|
369
|
+
Typical turn 2m 57s median
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged — a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
|
|
373
|
+
|
|
374
|
+
### The chime is in the music's key
|
|
375
|
+
|
|
376
|
+
The success chime is the tonic chord of whatever key your genre sits in — C major for lofi, 8bit, jazz and piano, D minor for synthwave and electronic, D major for zen, A minor for drone — so it lands as the resolution of the piece rather than a bell over it. `rain`, `ocean` and `random` have no single key to match and keep the original C major chime.
|
|
377
|
+
|
|
360
378
|
### Reactive mode (opt-in)
|
|
361
379
|
|
|
362
380
|
```bash
|
|
@@ -392,9 +410,9 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
392
410
|
|
|
393
411
|
## 🖥️ Everything Else (Claude Desktop, Antigravity… via MCP)
|
|
394
412
|
|
|
395
|
-
**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)
|
|
413
|
+
**Claude Code, [Codex](https://github.com/openai/codex), [Cursor](https://cursor.com/docs/hooks), [Grok](https://docs.x.ai/build/features/hooks), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Copilot CLI](https://docs.github.com/en/copilot/reference/hooks-configuration), [Qwen Code](https://github.com/QwenLM/qwen-code) and [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) have hook systems, and `--install-hooks` writes to all eight** — use [hooks](#-agent-hooks-no-wrapper-needed) there, they're strictly better. This section is for everything else. MCP is the way in: it's plain stdio JSON-RPC, so the setup is identical everywhere and only the config file differs.
|
|
396
414
|
|
|
397
|
-
> **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
|
|
415
|
+
> **This is weaker than hooks, by nature.** Hooks fire on an event; MCP tools are *model-invoked*, so the assistant has to decide to call `vibe_play` and remember `vibe_stop`. Expect the occasional silent turn — say "play some focus music while you work on this" if you want it reliably. **On any of the eight agents above, use [hooks](#-agent-hooks-no-wrapper-needed) instead.**
|
|
398
416
|
|
|
399
417
|
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:
|
|
400
418
|
|
|
@@ -567,13 +585,14 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
567
585
|
| `--status` | Show what's installed, running and detected, then exit | — |
|
|
568
586
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
569
587
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
588
|
+
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
570
589
|
| `--stop` | Stop the background player, then exit | — |
|
|
571
590
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
572
591
|
| `--unmute` | Resume normal playback, then exit | — |
|
|
573
592
|
| `--clear-cache` | Delete all cached audio, then exit | — |
|
|
574
593
|
| `--mcp` | Run as an MCP stdio server for desktop apps | — |
|
|
575
594
|
| `--install-hooks` | Wire music into your agent's hooks (no wrapper needed) | — |
|
|
576
|
-
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen` | auto-detect |
|
|
595
|
+
| `--tools <list>` | With `--install-hooks`: `claude,codex,cursor,grok,gemini,copilot,qwen,windsurf` | auto-detect |
|
|
577
596
|
| `--reactive` | With `--install-hooks`: intensity follows the tool in use | off |
|
|
578
597
|
| `--dry-run` | With `--install-hooks`: show what would change in each file, write nothing | off |
|
|
579
598
|
| `--uninstall-hooks` | Remove the hooks again, from every agent | — |
|
|
@@ -592,7 +611,9 @@ export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
|
|
|
592
611
|
export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
|
|
593
612
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
594
613
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
614
|
+
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
595
615
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
|
616
|
+
export VIBE_NO_FADE=1 # macOS: play with plain afplay, no fade in/out
|
|
596
617
|
```
|
|
597
618
|
|
|
598
619
|
**`VIBE_DISABLE=1` is for a shell you always want quiet** — a CI job, a shared machine, a terminal profile you keep silent. It's read at playback time and covers hooks, wrapper and MCP alike.
|
|
@@ -624,6 +645,9 @@ The expiry is deliberate too. A call is a bounded thing; a mute you forget about
|
|
|
624
645
|
**No sound at all**
|
|
625
646
|
Check that a player exists for your platform (see [Requirements](#-requirements)). VibeAudio prints a notice to stderr when it can't find one. On Linux: `sudo apt install pulseaudio-utils` (or `ffmpeg` / `alsa-utils`).
|
|
626
647
|
|
|
648
|
+
**Silent after connecting a monitor or headphones (macOS)**
|
|
649
|
+
`afplay` plays on macOS's *default output device*, which moves when a display or headphones connect. A monitor often has no system volume control at all and speakers much quieter than the laptop's, so a quiet setting — a sparse genre like `piano` at 25% — can fall below what you can hear. `vibe --doctor` shows where sound is going and warns if that output is muted or has no volume control. Then try `vibe --genre lofi --volume 60` for one prompt, and check the monitor's own speaker volume.
|
|
650
|
+
|
|
627
651
|
**Music starts immediately instead of after the grace window**
|
|
628
652
|
It usually is waiting — your AI tool's own startup (auth, session load) just takes longer than 1.5s, so music and the tool's first output appear together. Raise the window: `vibe --grace 3000 claude`.
|
|
629
653
|
|
|
@@ -716,7 +740,7 @@ rm -rf ~/.vibeaudio # 3. optional: cached audio, saved settings, dae
|
|
|
716
740
|
|
|
717
741
|
Step 1 also **stops a background player that's still going**. That matters: once the hooks are gone nothing will ever send the `Stop` event, and after step 2 there's no `vibe` left to stop it with — music would simply play on until its 15-minute cap. If you ever need to do it by hand: `pkill -f "vibeaudio.js --daemon"`.
|
|
718
742
|
|
|
719
|
-
> **Order matters.** `npm rm -g` deletes the binary but not your hook config. Removing the package first strands hook entries that point at a path that no longer exists, and your agent will run a failing hook on every prompt. If you already did it in the wrong order, reinstall, run `vibe --uninstall-hooks`, then remove again — or delete the `vibeaudio` entries from `~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.cursor/hooks.json`, `~/.gemini/settings.json` and `~/.
|
|
743
|
+
> **Order matters.** `npm rm -g` deletes the binary but not your hook config. Removing the package first strands hook entries that point at a path that no longer exists, and your agent will run a failing hook on every prompt. If you already did it in the wrong order, reinstall, run `vibe --uninstall-hooks`, then remove again — or delete the `vibeaudio` entries from `~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.cursor/hooks.json`, `~/.gemini/settings.json`, `~/.qwen/settings.json` and `~/.codeium/windsurf/hooks.json` by hand, and delete `~/.grok/hooks/vibeaudio.json`, `~/.copilot/hooks/vibeaudio.json` and `~/.claude/commands/vibe.md`.
|
|
720
744
|
|
|
721
745
|
Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects — and drops your saved genre and volume. Both come back on their own, so skip it if you're reinstalling.
|
|
722
746
|
|
|
@@ -724,7 +748,7 @@ Step 3 reclaims disk — the audio cache, pruned to the 3 most recent projects
|
|
|
724
748
|
|
|
725
749
|
| Leftover | Why, and how to remove it |
|
|
726
750
|
| :--- | :--- |
|
|
727
|
-
| `*.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
|
|
751
|
+
| `*.vibeaudio.bak` next to each shared hook config | Your config as it was before the first install — one each for Claude Code, Codex, Cursor, Gemini CLI, Qwen Code and Windsurf. A safety net we won't delete for you; `rm` them once you're happy the real files are correct, and `vibe --uninstall-hooks` prints the path of every one it finds. (Grok and Copilot CLI leave nothing: their files are ours alone, so uninstall deletes them outright.) |
|
|
728
752
|
| `vibeaudio` entries in other apps' MCP configs | VibeAudio never edits those files, so it can't clean them either. Drop the entry from [whichever config you added it to](#-everything-else-claude-desktop-antigravity-via-mcp). |
|
|
729
753
|
|
|
730
754
|
Apart from those two, the three commands above remove everything VibeAudio writes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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
|
@@ -77,6 +77,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
77
77
|
--render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
|
|
78
78
|
--status Show what is installed, running and detected, then exit
|
|
79
79
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
80
|
+
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
80
81
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
81
82
|
--stop Stop the background player, then exit
|
|
82
83
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -84,7 +85,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
84
85
|
--clear-cache Delete cached audio, then exit
|
|
85
86
|
--mcp Run as Model Context Protocol (MCP) server for Desktop apps
|
|
86
87
|
--install-hooks Wire music into your agent's hooks (no wrapper needed)
|
|
87
|
-
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen (auto-detect)
|
|
88
|
+
--tools <list> With --install-hooks: claude,codex,cursor,grok,gemini,copilot,qwen,windsurf (auto-detect)
|
|
88
89
|
--reactive With --install-hooks: intensity follows the tool in use
|
|
89
90
|
--dry-run With --install-hooks: show what would change, write nothing
|
|
90
91
|
--uninstall-hooks Remove the hooks again, from every agent
|
|
@@ -175,6 +176,7 @@ function parseArgs(argv) {
|
|
|
175
176
|
let statusFlag = false;
|
|
176
177
|
let doctorFlag = false;
|
|
177
178
|
let notifyFlag = null;
|
|
179
|
+
let reportDays = null;
|
|
178
180
|
let stopFlag = false;
|
|
179
181
|
let muteFlag = null;
|
|
180
182
|
let muteMinutes = null;
|
|
@@ -285,6 +287,17 @@ function parseArgs(argv) {
|
|
|
285
287
|
continue;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
if (arg === "--report") {
|
|
291
|
+
// Optional window in days, like --mute's minutes: `vibe --report 30`.
|
|
292
|
+
i += 1;
|
|
293
|
+
reportDays = 7;
|
|
294
|
+
if (args[i] !== undefined && /^\d+$/.test(args[i])) {
|
|
295
|
+
reportDays = Math.min(Math.max(parseInt(args[i], 10), 1), 365);
|
|
296
|
+
i += 1;
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
288
301
|
if (arg === "--notify" || arg === "--no-notify") {
|
|
289
302
|
notifyFlag = arg === "--notify";
|
|
290
303
|
i += 1;
|
|
@@ -441,6 +454,7 @@ function parseArgs(argv) {
|
|
|
441
454
|
status: statusFlag,
|
|
442
455
|
doctor: doctorFlag,
|
|
443
456
|
notify: notifyFlag,
|
|
457
|
+
report: reportDays,
|
|
444
458
|
stop: stopFlag,
|
|
445
459
|
mute: muteFlag,
|
|
446
460
|
muteMinutes,
|
|
@@ -800,6 +814,24 @@ function doctorChecks() {
|
|
|
800
814
|
add("ok", "Saved settings", "config.json parses (or none saved)");
|
|
801
815
|
}
|
|
802
816
|
|
|
817
|
+
// afplay plays on the default output device, which moves when a monitor or
|
|
818
|
+
// headphones connect - and an external display often has no system volume at
|
|
819
|
+
// all, or speakers far quieter than the laptop's. Say where sound is going.
|
|
820
|
+
const out = require("./output").macOutput();
|
|
821
|
+
if (out) {
|
|
822
|
+
const where = out.device ? `${out.device.name}${out.device.transport ? ` (${out.device.transport})` : ""}` : "the default output";
|
|
823
|
+
if (out.muted) {
|
|
824
|
+
add("warn", "Sound output", `${where} is muted in macOS — nothing will be heard`, "unmute it in the menu bar, or: osascript -e 'set volume output muted false'");
|
|
825
|
+
} else if (out.volume === null) {
|
|
826
|
+
add("warn", "Sound output", `${where} has no system volume control, so VibeAudio's own volume is the only one — and its speakers may be quiet`,
|
|
827
|
+
"raise it with: vibe --volume 60 (and check the monitor's own speaker volume)");
|
|
828
|
+
} else if (out.volume < 10) {
|
|
829
|
+
add("warn", "Sound output", `${where}, system volume ${out.volume}%`, "raise the macOS volume");
|
|
830
|
+
} else {
|
|
831
|
+
add("ok", "Sound output", `${where}, system volume ${out.volume}%`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
803
835
|
const detected = hooks.detectTargets();
|
|
804
836
|
for (const id of Object.keys(hooks.TARGETS)) {
|
|
805
837
|
const t = hooks.TARGETS[id];
|
|
@@ -870,6 +902,93 @@ function doctorChecks() {
|
|
|
870
902
|
return checks;
|
|
871
903
|
}
|
|
872
904
|
|
|
905
|
+
/** 95 -> "1m 35s", 4_000_000 -> "1h 06m". */
|
|
906
|
+
function formatDuration(ms) {
|
|
907
|
+
const s = Math.round(ms / 1000);
|
|
908
|
+
if (s < 60) return `${s}s`;
|
|
909
|
+
const m = Math.floor(s / 60);
|
|
910
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
911
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
916
|
+
* working time - a turn's length minus the stretches it spent blocked on a
|
|
917
|
+
* dialog of yours - because the two are different complaints: one is the
|
|
918
|
+
* agent being slow, the other is you being away.
|
|
919
|
+
*/
|
|
920
|
+
function printReport(days, now = Date.now()) {
|
|
921
|
+
const { readTurns } = require("./history");
|
|
922
|
+
const turns = readTurns(days, now);
|
|
923
|
+
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
924
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
925
|
+
console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m report — last ${days === 1 ? "day" : `${days} days`}\n`);
|
|
926
|
+
|
|
927
|
+
if (!turns.length) {
|
|
928
|
+
console.log(` No turns recorded yet.`);
|
|
929
|
+
console.log(dim(` Turns are logged when your agent's hooks fire (vibe --install-hooks). A command wrapped as \`vibe <command>\` is not.`));
|
|
930
|
+
console.log(dim(` The log stays on this machine; VIBE_NO_HISTORY=1 turns it off.`));
|
|
931
|
+
console.log();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const working = (t) => t.ms - (t.blockedMs || 0);
|
|
936
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
937
|
+
const sorted = turns.map((t) => t.ms).sort((a, b) => a - b);
|
|
938
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
939
|
+
const longest = turns.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
940
|
+
const failed = turns.filter((t) => t.outcome === "failure").length;
|
|
941
|
+
const waitedOnAgents = sum(turns.map(working));
|
|
942
|
+
const blocked = sum(turns.map((t) => t.blockedMs || 0));
|
|
943
|
+
|
|
944
|
+
// Two projects called `api` under different parents stay apart.
|
|
945
|
+
const names = new Map();
|
|
946
|
+
for (const t of turns) {
|
|
947
|
+
const parts = String(t.project || "?").split(/[\\/]/).filter(Boolean);
|
|
948
|
+
const clash = [...names.entries()].some(([p, n]) => p !== t.project && n === parts.slice(-1)[0]);
|
|
949
|
+
names.set(t.project, clash ? parts.slice(-2).join("/") : parts.slice(-1)[0] || "?");
|
|
950
|
+
}
|
|
951
|
+
const label = (t) => names.get(t.project);
|
|
952
|
+
|
|
953
|
+
const row = (k, v, extra = "") => console.log(` ${k.padEnd(18)}${bold(v)}${extra ? dim(` ${extra}`) : ""}`);
|
|
954
|
+
row("Turns", String(turns.length), failed ? `${failed} failed` : "");
|
|
955
|
+
row("You waited", formatDuration(waitedOnAgents), `on agents, ${formatDuration(waitedOnAgents / turns.length)} per turn`);
|
|
956
|
+
if (blocked >= 1000) row("Agents waited", formatDuration(blocked), "on you — permission dialogs and questions");
|
|
957
|
+
row("Typical turn", formatDuration(median), "median");
|
|
958
|
+
row("Longest turn", formatDuration(longest.ms), label(longest));
|
|
959
|
+
|
|
960
|
+
const byProject = new Map();
|
|
961
|
+
for (const t of turns) {
|
|
962
|
+
const p = byProject.get(label(t)) || { ms: 0, n: 0 };
|
|
963
|
+
p.ms += working(t);
|
|
964
|
+
p.n += 1;
|
|
965
|
+
byProject.set(label(t), p);
|
|
966
|
+
}
|
|
967
|
+
const top = [...byProject.entries()].sort((a, b) => b[1].ms - a[1].ms).slice(0, 5);
|
|
968
|
+
const widest = top[0][1].ms || 1;
|
|
969
|
+
console.log(`\n${bold("Where the time went")}`);
|
|
970
|
+
for (const [name, p] of top) {
|
|
971
|
+
console.log(` ${name.slice(0, 18).padEnd(19)}${formatDuration(p.ms).padStart(8)} ${"█".repeat(Math.max(1, Math.round((p.ms / widest) * 14))).padEnd(14)} ${dim(`${p.n} ${p.n === 1 ? "turn" : "turns"}`)}`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (days <= 14) {
|
|
975
|
+
console.log(`\n${bold("By day")}`);
|
|
976
|
+
const byDay = new Map();
|
|
977
|
+
for (const t of turns) {
|
|
978
|
+
const key = new Date(t.at).toLocaleDateString("en-CA");
|
|
979
|
+
byDay.set(key, (byDay.get(key) || 0) + working(t));
|
|
980
|
+
}
|
|
981
|
+
const maxDay = Math.max(...byDay.values()) || 1;
|
|
982
|
+
for (let d = days - 1; d >= 0; d--) {
|
|
983
|
+
const date = new Date(now - d * 86400000);
|
|
984
|
+
const ms = byDay.get(date.toLocaleDateString("en-CA")) || 0;
|
|
985
|
+
const name = `${date.toLocaleDateString("en-US", { weekday: "short" })} ${date.getDate()}`;
|
|
986
|
+
console.log(` ${name.padEnd(8)}${(ms ? formatDuration(ms) : "—").padStart(8)} ${"█".repeat(ms ? Math.max(1, Math.round((ms / maxDay) * 14)) : 0)}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
console.log();
|
|
990
|
+
}
|
|
991
|
+
|
|
873
992
|
function printDoctor() {
|
|
874
993
|
const mark = { ok: "\x1b[32m✔\x1b[0m", warn: "\x1b[33m!\x1b[0m", fail: "\x1b[31m✘\x1b[0m" };
|
|
875
994
|
const checks = doctorChecks();
|
|
@@ -966,6 +1085,7 @@ function detectAiTools(hooked = new Set()) {
|
|
|
966
1085
|
{ cmd: "gemini", name: "Gemini CLI", integration: viaHooks("gemini") },
|
|
967
1086
|
{ cmd: "copilot", name: "GitHub Copilot CLI", integration: viaHooks("copilot") },
|
|
968
1087
|
{ cmd: "qwen", name: "Qwen Code", integration: viaHooks("qwen") },
|
|
1088
|
+
{ cmd: "windsurf", name: "Windsurf", integration: viaHooks("windsurf") },
|
|
969
1089
|
{ cmd: "aider", name: "Aider", integration: "wrapper — vibe aider" },
|
|
970
1090
|
{ cmd: "ollama", name: "Ollama", integration: "wrapper — vibe ollama run <model>" }
|
|
971
1091
|
];
|
|
@@ -1163,6 +1283,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1163
1283
|
volume,
|
|
1164
1284
|
chimeVolume,
|
|
1165
1285
|
noChime,
|
|
1286
|
+
genre,
|
|
1166
1287
|
raw
|
|
1167
1288
|
});
|
|
1168
1289
|
});
|
|
@@ -1355,6 +1476,7 @@ async function run() {
|
|
|
1355
1476
|
status: showStatus,
|
|
1356
1477
|
doctor: showDoctor,
|
|
1357
1478
|
notify: notifyChange,
|
|
1479
|
+
report: reportDays,
|
|
1358
1480
|
stop: shouldStop,
|
|
1359
1481
|
mute: muteChange,
|
|
1360
1482
|
muteMinutes,
|
|
@@ -1442,6 +1564,10 @@ async function run() {
|
|
|
1442
1564
|
return printDoctor();
|
|
1443
1565
|
}
|
|
1444
1566
|
|
|
1567
|
+
if (reportDays !== null) {
|
|
1568
|
+
return printReport(reportDays);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1445
1571
|
if (shouldClear) {
|
|
1446
1572
|
console.log(`\x1b[32m[vibeaudio] Cleared cache at ${clearCache()}\x1b[0m`);
|
|
1447
1573
|
return;
|
package/src/history.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local log of finished turns - what `vibe --report` reads. One JSON object
|
|
3
|
+
* per line in ~/.vibeaudio/history.jsonl: when the turn ended, which project,
|
|
4
|
+
* how long it took, how much of that the agent spent blocked on you, and how
|
|
5
|
+
* it ended. It never leaves the machine; VIBE_NO_HISTORY=1 turns it off.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const HISTORY_FILE = path.join(os.homedir(), ".vibeaudio", "history.jsonl");
|
|
13
|
+
const MAX_BYTES = 1024 * 1024;
|
|
14
|
+
const KEEP_LINES = 4000;
|
|
15
|
+
|
|
16
|
+
/** Appends one turn. Never throws: a log is not worth failing a hook over. */
|
|
17
|
+
function recordTurn({ project, ms, blockedMs = 0, outcome = "success", at = Date.now() }, file = HISTORY_FILE) {
|
|
18
|
+
if (process.env.VIBE_NO_HISTORY) return false;
|
|
19
|
+
if (!Number.isFinite(ms) || ms < 0) return false;
|
|
20
|
+
try {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome })}\n`);
|
|
23
|
+
// Bounded, so a year of use is still a small file. Rewritten rarely, from
|
|
24
|
+
// the tail, so the newest turns are always the ones kept.
|
|
25
|
+
if (fs.statSync(file).size > MAX_BYTES) {
|
|
26
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
27
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmp, `${lines.slice(-KEEP_LINES).join("\n")}\n`);
|
|
29
|
+
fs.renameSync(tmp, file);
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Turns that ended within the last `days` days. A line that does not parse is skipped, not fatal. */
|
|
38
|
+
function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = fs.readFileSync(file, "utf8");
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const since = now - days * 86400000;
|
|
46
|
+
const turns = [];
|
|
47
|
+
for (const line of raw.split("\n")) {
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
try {
|
|
50
|
+
const t = JSON.parse(line);
|
|
51
|
+
if (t && Number.isFinite(t.at) && Number.isFinite(t.ms) && t.at >= since) turns.push(t);
|
|
52
|
+
} catch (e) { /* a torn or hand-edited line */ }
|
|
53
|
+
}
|
|
54
|
+
return turns;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { HISTORY_FILE, recordTurn, readTurns };
|
package/src/hooks.js
CHANGED
|
@@ -16,6 +16,7 @@ const os = require("os");
|
|
|
16
16
|
const path = require("path");
|
|
17
17
|
const { spawn, execFileSync } = require("child_process");
|
|
18
18
|
const { AudioPlayer, loadConfig, playbackDisabled } = require("./player");
|
|
19
|
+
const { recordTurn } = require("./history");
|
|
19
20
|
|
|
20
21
|
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
21
22
|
const PID_FILE = path.join(STATE_DIR, "daemon.pid");
|
|
@@ -99,7 +100,14 @@ function payloadToolName(raw) {
|
|
|
99
100
|
const payload = parsePayload(raw);
|
|
100
101
|
// Claude Code, Codex and Cursor send tool_name; Grok sends toolName.
|
|
101
102
|
// Reading only one of them would silently pin that agent to tier 2.
|
|
102
|
-
|
|
103
|
+
// Windsurf names no tool: the event is the signal, and only the shell one is wired.
|
|
104
|
+
const byEvent = payload.agent_action_name === "pre_run_command" ? "Shell" : "";
|
|
105
|
+
return String(payload.tool_name || payload.toolName || byEvent);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Windsurf calls the conversation `trajectory_id`.
|
|
109
|
+
function payloadSession(payload) {
|
|
110
|
+
return payload.session_id || payload.trajectory_id;
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
/**
|
|
@@ -291,6 +299,24 @@ const TARGETS = {
|
|
|
291
299
|
// Unlike the four above, not verified to re-read hooks mid-session.
|
|
292
300
|
liveReload: false,
|
|
293
301
|
note: "Not checked whether an open Qwen Code session reloads hooks — start a new one to be sure."
|
|
302
|
+
},
|
|
303
|
+
windsurf: {
|
|
304
|
+
name: "Windsurf",
|
|
305
|
+
cmd: "windsurf",
|
|
306
|
+
// Checked against Windsurf's hooks documentation only: it was not installed
|
|
307
|
+
// here, so nothing was run. User-level file, a flat {command} entry with no
|
|
308
|
+
// timeout field, and the conversation named `trajectory_id` rather than
|
|
309
|
+
// session_id. post_cascade_response is the end-of-reply event; Windsurf has
|
|
310
|
+
// no permission-dialog, failure or session-end event, so none is wired, and
|
|
311
|
+
// its pre-tool events are per kind - pre_run_command is the one reactive
|
|
312
|
+
// mode listens to.
|
|
313
|
+
file: () => path.join(os.homedir(), ".codeium", "windsurf", "hooks.json"),
|
|
314
|
+
events: { start: "pre_user_prompt", stop: "post_cascade_response", tool: "pre_run_command" },
|
|
315
|
+
entry: (command) => ({ command }),
|
|
316
|
+
commands: (entry) => (entry.command ? [entry.command] : []),
|
|
317
|
+
seed: () => ({}),
|
|
318
|
+
liveReload: false,
|
|
319
|
+
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."
|
|
294
320
|
}
|
|
295
321
|
};
|
|
296
322
|
|
|
@@ -424,7 +450,7 @@ function fileSize(file) {
|
|
|
424
450
|
function newTurn(raw) {
|
|
425
451
|
const payload = parsePayload(raw);
|
|
426
452
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
427
|
-
return { session: String(payload
|
|
453
|
+
return { session: String(payloadSession(payload) || ""), transcript, offset: transcript ? fileSize(transcript) : 0 };
|
|
428
454
|
}
|
|
429
455
|
|
|
430
456
|
// The id ends up in a file name.
|
|
@@ -484,11 +510,16 @@ const working = (sessions) => sessions.filter((s) => s.waiting == null);
|
|
|
484
510
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
485
511
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
486
512
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
513
|
+
const BLOCKED_MARK = "UserPromptSubmit operation blocked by hook";
|
|
487
514
|
|
|
488
515
|
function isInterruptEntry(line) {
|
|
489
|
-
if (!line.includes(INTERRUPT_MARK)) return false; // Cheap filter before parsing.
|
|
516
|
+
if (!line.includes(INTERRUPT_MARK) && !line.includes(BLOCKED_MARK)) return false; // Cheap filter before parsing.
|
|
490
517
|
try {
|
|
491
518
|
const entry = JSON.parse(line);
|
|
519
|
+
// A prompt another hook blocked: hooks run side by side, so ours had already
|
|
520
|
+
// started the music, and no Stop will ever follow. The turn is over before
|
|
521
|
+
// it began, which is the same silent end as an interrupt.
|
|
522
|
+
if (entry.type === "system" && typeof entry.content === "string") return entry.content.startsWith(BLOCKED_MARK);
|
|
492
523
|
if (entry.type !== "user" || !entry.message) return false;
|
|
493
524
|
// Structural, not substring: a transcript that merely quotes the phrase -
|
|
494
525
|
// in a tool result, a file, a prompt about this very feature - is not one.
|
|
@@ -624,7 +655,7 @@ function spawnDaemon(genre, volume, reactive) {
|
|
|
624
655
|
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
625
656
|
const id = sessionId(turn && turn.session);
|
|
626
657
|
// Before the spawn: the daemon reads it on startup.
|
|
627
|
-
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null });
|
|
658
|
+
writeSession(id, { transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
628
659
|
if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
|
|
629
660
|
|
|
630
661
|
stopDaemon({ keepSessions: true });
|
|
@@ -729,12 +760,19 @@ function notify(raw, message) {
|
|
|
729
760
|
}
|
|
730
761
|
}
|
|
731
762
|
|
|
732
|
-
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, raw = "" } = {}) {
|
|
733
|
-
const id = sessionId(parsePayload(raw)
|
|
763
|
+
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
|
|
764
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
734
765
|
// A turn that ends while paused for the user - a denied tool that nothing
|
|
735
766
|
// resumed after - still finished, so its session counts either way.
|
|
736
|
-
const
|
|
767
|
+
const session = readSession(id);
|
|
768
|
+
const tracked = session !== null;
|
|
737
769
|
fs.rmSync(sessionFile(id), { force: true });
|
|
770
|
+
if (session && Number.isFinite(session.started)) {
|
|
771
|
+
const now = Date.now();
|
|
772
|
+
// A turn that ends while still paused for you has been blocked since then.
|
|
773
|
+
const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
|
|
774
|
+
recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, at: now });
|
|
775
|
+
}
|
|
738
776
|
|
|
739
777
|
// The music is every working session's, so it ends with the last of them.
|
|
740
778
|
// Everyone else's carries on, and this session still gets its own chime.
|
|
@@ -746,7 +784,7 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
746
784
|
if (noChime) return false;
|
|
747
785
|
|
|
748
786
|
// Chime plays in this short-lived hook process.
|
|
749
|
-
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
|
|
787
|
+
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre });
|
|
750
788
|
return true;
|
|
751
789
|
}
|
|
752
790
|
|
|
@@ -788,11 +826,11 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
788
826
|
const type = payload.notification_type ?? payload.notificationType;
|
|
789
827
|
if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
|
|
790
828
|
|
|
791
|
-
const id = sessionId(payload
|
|
829
|
+
const id = sessionId(payloadSession(payload));
|
|
792
830
|
const session = readSession(id);
|
|
793
831
|
if (!session || session.waiting != null) return false;
|
|
794
832
|
|
|
795
|
-
writeSession(id, { ...session, waiting: waitKey(raw) });
|
|
833
|
+
writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
|
|
796
834
|
if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
|
|
797
835
|
const tool = payloadToolName(raw);
|
|
798
836
|
notify(raw, tool ? `needs you (${tool})` : "needs you");
|
|
@@ -814,14 +852,15 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
814
852
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
815
853
|
*/
|
|
816
854
|
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
817
|
-
const id = sessionId(parsePayload(raw)
|
|
855
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
818
856
|
const session = readSession(id);
|
|
819
857
|
if (!session || session.waiting == null) return false; // Not waiting - the common case, on every tool call.
|
|
820
858
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
821
859
|
// to finish is the first sign of work carrying on.
|
|
822
860
|
if (session.waiting !== "" && session.waiting !== waitKey(raw)) return false;
|
|
823
861
|
|
|
824
|
-
|
|
862
|
+
const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
|
|
863
|
+
writeSession(id, { ...session, waiting: null, waitStart: null, blockedMs });
|
|
825
864
|
if (!daemonRunning()) spawnDaemon(genre, volume, reactive);
|
|
826
865
|
return true;
|
|
827
866
|
}
|
|
@@ -836,7 +875,7 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
836
875
|
* applies.
|
|
837
876
|
*/
|
|
838
877
|
function hookEnd(raw) {
|
|
839
|
-
const id = sessionId(parsePayload(raw)
|
|
878
|
+
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
840
879
|
if (!readSession(id)) return false;
|
|
841
880
|
|
|
842
881
|
fs.rmSync(sessionFile(id), { force: true });
|
package/src/interactive.js
CHANGED
|
@@ -35,6 +35,7 @@ const AI_TOOLS = [
|
|
|
35
35
|
{ name: "Cursor CLI", cmd: ["cursor-agent"], check: "cursor-agent", hookTarget: "cursor" },
|
|
36
36
|
{ name: "GitHub Copilot CLI", cmd: ["copilot"], check: "copilot", hookTarget: "copilot" },
|
|
37
37
|
{ name: "Qwen Code", cmd: ["qwen"], check: "qwen", hookTarget: "qwen" },
|
|
38
|
+
{ name: "Windsurf", cmd: ["windsurf"], check: "windsurf", hookTarget: "windsurf" },
|
|
38
39
|
{ name: "Aider", cmd: ["aider"], check: "aider" },
|
|
39
40
|
{ name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
|
|
40
41
|
{ name: "Custom command...", cmd: null }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// VibeAudio macOS player: one long-lived process driving AVAudioPlayer, run by
|
|
2
|
+
// `osascript -l JavaScript`. Lines on stdin:
|
|
3
|
+
// play <volume 0-1> <fade-in seconds> <path to wav>
|
|
4
|
+
// End of input - a closed pipe, or the daemon dying - fades out and exits, so
|
|
5
|
+
// the audio can never outlive whoever started it.
|
|
6
|
+
ObjC.import("Foundation");
|
|
7
|
+
ObjC.import("AVFoundation");
|
|
8
|
+
|
|
9
|
+
const FADE_OUT_S = 0.3;
|
|
10
|
+
const players = [];
|
|
11
|
+
|
|
12
|
+
function sleep(seconds) {
|
|
13
|
+
$.NSThread.sleepForTimeInterval(seconds);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function play(volume, fadeIn, path) {
|
|
17
|
+
const url = $.NSURL.fileURLWithPath(path);
|
|
18
|
+
const p = $.AVAudioPlayer.alloc.initWithContentsOfURLError(url, null);
|
|
19
|
+
if (!p || !p.prepareToPlay) return;
|
|
20
|
+
p.volume = fadeIn > 0 ? 0 : volume;
|
|
21
|
+
p.play;
|
|
22
|
+
if (fadeIn > 0) p.setVolumeFadeDuration(volume, fadeIn);
|
|
23
|
+
players.push(p);
|
|
24
|
+
// Finished loops are done with: keep only what is still sounding.
|
|
25
|
+
for (let i = players.length - 2; i >= 0; i--) if (!players[i].playing) players.splice(i, 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handle(line) {
|
|
29
|
+
const m = /^play (\S+) (\S+) (.+)$/.exec(line);
|
|
30
|
+
if (m) play(parseFloat(m[1]), parseFloat(m[2]), m[3]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stdin = $.NSFileHandle.fileHandleWithStandardInput;
|
|
34
|
+
let pending = "";
|
|
35
|
+
for (;;) {
|
|
36
|
+
const data = stdin.availableData; // Blocks; playback runs on its own thread.
|
|
37
|
+
if (Number(data.length) === 0) break; // JXA hands NSData.length back as a string.
|
|
38
|
+
pending += $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;
|
|
39
|
+
const lines = pending.split("\n");
|
|
40
|
+
pending = lines.pop();
|
|
41
|
+
lines.forEach(handle);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const p of players) if (p.playing) p.setVolumeFadeDuration(0, FADE_OUT_S);
|
|
45
|
+
sleep(FADE_OUT_S + 0.05);
|
|
46
|
+
for (const p of players) p.stop;
|
package/src/output.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What macOS is actually sending sound to. `afplay` plays on the default
|
|
3
|
+
* output device, which is not always the one you are listening on: connect a
|
|
4
|
+
* monitor and the default can move to it, where the system volume may be
|
|
5
|
+
* missing or the speakers quiet. Read only by `--doctor`; nothing else asks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
/** "output volume:39, input volume:88, alert volume:63, output muted:false" */
|
|
11
|
+
function parseVolumeSettings(text) {
|
|
12
|
+
const volume = /output volume:\s*(\d+)/.exec(text);
|
|
13
|
+
const muted = /output muted:\s*(true|false)/.exec(text);
|
|
14
|
+
return {
|
|
15
|
+
// "missing value" is what macOS reports for a device with no software
|
|
16
|
+
// volume control - common for monitors - so absent means no knob, not zero.
|
|
17
|
+
volume: volume ? Number(volume[1]) : null,
|
|
18
|
+
muted: muted ? muted[1] === "true" : null
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TRANSPORTS = {
|
|
23
|
+
coreaudio_device_type_usb: "USB",
|
|
24
|
+
coreaudio_device_type_hdmi: "HDMI",
|
|
25
|
+
coreaudio_device_type_displayport: "DisplayPort",
|
|
26
|
+
coreaudio_device_type_builtin: "built-in",
|
|
27
|
+
coreaudio_device_type_bluetooth: "Bluetooth",
|
|
28
|
+
coreaudio_device_type_airplay: "AirPlay",
|
|
29
|
+
coreaudio_device_type_virtual: "virtual"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The device `afplay` will use, from `system_profiler SPAudioDataType -json`. */
|
|
33
|
+
function parseDefaultOutput(json) {
|
|
34
|
+
try {
|
|
35
|
+
const items = (JSON.parse(json).SPAudioDataType || []).flatMap((group) => group._items || []);
|
|
36
|
+
const item = items.find((i) => i.coreaudio_default_audio_output_device === "spaudio_yes");
|
|
37
|
+
if (!item) return null;
|
|
38
|
+
return { name: item._name, transport: TRANSPORTS[item.coreaudio_device_transport] || null };
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** null off macOS, or when either lookup fails: absence of an answer is not a finding. */
|
|
45
|
+
function macOutput() {
|
|
46
|
+
if (process.platform !== "darwin") return null;
|
|
47
|
+
const run = (cmd, args) => {
|
|
48
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 });
|
|
49
|
+
return r.status === 0 ? r.stdout : null;
|
|
50
|
+
};
|
|
51
|
+
const settings = run("osascript", ["-e", "get volume settings"]);
|
|
52
|
+
if (settings === null) return null;
|
|
53
|
+
const profile = run("system_profiler", ["SPAudioDataType", "-json"]);
|
|
54
|
+
return { ...parseVolumeSettings(settings), device: profile ? parseDefaultOutput(profile) : null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { parseVolumeSettings, parseDefaultOutput, macOutput };
|
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 } = require("./synth/chime");
|
|
21
|
+
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
23
|
const pkg = require("../package.json");
|
|
24
24
|
|
|
@@ -110,6 +110,18 @@ const PLAYER_CANDIDATES = [
|
|
|
110
110
|
let cachedPlayer;
|
|
111
111
|
let warnedNoPlayer = false;
|
|
112
112
|
|
|
113
|
+
// macOS: one long-lived AVAudioPlayer process instead of an afplay per loop,
|
|
114
|
+
// which is what lets music fade in and out rather than start and end mid-note.
|
|
115
|
+
// afplay stays the fallback: a helper that cannot start is not worth a silent
|
|
116
|
+
// run, and it is used for the rest of the process once one has failed.
|
|
117
|
+
const MAC_HELPER = path.join(__dirname, "mac-player.jxa");
|
|
118
|
+
const FADE_IN_S = 0.5;
|
|
119
|
+
let helperFailed = false;
|
|
120
|
+
|
|
121
|
+
function helperUsable(backend) {
|
|
122
|
+
return process.platform === "darwin" && backend.cmd === "afplay" && !helperFailed && !process.env.VIBE_NO_FADE;
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
function detectPlayer() {
|
|
114
126
|
if (cachedPlayer !== undefined) return cachedPlayer;
|
|
115
127
|
|
|
@@ -633,13 +645,45 @@ const CHIMES = {
|
|
|
633
645
|
attention: generateAttentionChime
|
|
634
646
|
};
|
|
635
647
|
|
|
636
|
-
|
|
648
|
+
/**
|
|
649
|
+
* The key each genre sits in, so the success chime can resolve onto it. Read
|
|
650
|
+
* off the generators: lofi, 8bit, jazz and piano are C major (or its relative
|
|
651
|
+
* A minor, which shares the chord); synthwave and electronic are D minor; zen
|
|
652
|
+
* is D major; drone's roots (D, A, E) all take an A minor chord. Rain and
|
|
653
|
+
* ocean are noise with no pitch, and `random` is resolved by whoever started
|
|
654
|
+
* the music, so those keep the original chime.
|
|
655
|
+
*/
|
|
656
|
+
const GENRE_KEYS = {
|
|
657
|
+
lofi: "C major",
|
|
658
|
+
"8bit": "C major",
|
|
659
|
+
jazz: "C major",
|
|
660
|
+
piano: "C major",
|
|
661
|
+
synthwave: "D minor",
|
|
662
|
+
electronic: "D minor",
|
|
663
|
+
zen: "D major",
|
|
664
|
+
drone: "A minor"
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
function chimeKeyFor(genre) {
|
|
668
|
+
// resolveGenre would roll the dice here, and a chime in a key picked at
|
|
669
|
+
// random is no more matched than the default.
|
|
670
|
+
if (String(genre || "").trim().toLowerCase() === "random") return DEFAULT_CHIME_KEY;
|
|
671
|
+
const key = GENRE_KEYS[resolveGenre(genre || "")];
|
|
672
|
+
return key && SUCCESS_CHIME_NOTES[key] ? key : DEFAULT_CHIME_KEY;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function getChimePath(outcome = "success", gain = 1, key = DEFAULT_CHIME_KEY) {
|
|
637
676
|
ensureCacheDir();
|
|
638
677
|
const kind = outcome === "error" ? "failure" : CHIMES[outcome] ? outcome : "success";
|
|
639
|
-
|
|
678
|
+
// Only the success chime has keys, and the default keeps its original file
|
|
679
|
+
// name so an upgrade does not leave a cached copy behind.
|
|
680
|
+
const keyed = kind === "success" && key !== DEFAULT_CHIME_KEY && SUCCESS_CHIME_NOTES[key];
|
|
681
|
+
const suffix = keyed ? `_${key.replace(" ", "")}` : "";
|
|
682
|
+
const filePath = path.join(CACHE_DIR, `chime_${kind}${suffix}${gainSuffix(gain)}.wav`);
|
|
640
683
|
|
|
641
684
|
if (!fs.existsSync(filePath)) {
|
|
642
|
-
|
|
685
|
+
const wav = keyed ? generateSuccessChime(1.6, key) : CHIMES[kind]();
|
|
686
|
+
writeCacheFileAtomic(filePath, applyGain(wav, gain));
|
|
643
687
|
}
|
|
644
688
|
return filePath;
|
|
645
689
|
}
|
|
@@ -676,6 +720,7 @@ class AudioPlayer {
|
|
|
676
720
|
constructor() {
|
|
677
721
|
this.isPlaying = false;
|
|
678
722
|
this.procs = new Set();
|
|
723
|
+
this.helper = null;
|
|
679
724
|
this.startTime = 0;
|
|
680
725
|
this.genre = "lofi";
|
|
681
726
|
this.volume = 0.42;
|
|
@@ -757,6 +802,13 @@ class AudioPlayer {
|
|
|
757
802
|
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar);
|
|
758
803
|
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
759
804
|
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
805
|
+
if (!(helperUsable(backend) && this.playViaHelper(audioFile, backend))) this.spawnLoop(audioFile, backend);
|
|
806
|
+
|
|
807
|
+
const durationMs = wavDurationMs(audioFile) || 6500;
|
|
808
|
+
this.nextTimer = setTimeout(() => this.playLoop(), Math.max(250, durationMs - LOOP_OVERLAP_MS));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
spawnLoop(audioFile, backend) {
|
|
760
812
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
761
813
|
|
|
762
814
|
this.procs.add(proc);
|
|
@@ -766,12 +818,55 @@ class AudioPlayer {
|
|
|
766
818
|
this.isPlaying = false;
|
|
767
819
|
warnNoPlayer();
|
|
768
820
|
});
|
|
821
|
+
}
|
|
769
822
|
|
|
770
|
-
|
|
771
|
-
|
|
823
|
+
/**
|
|
824
|
+
* Hands one loop to the macOS helper, starting it on first use. Returns false
|
|
825
|
+
* when the helper could not be started, so the caller plays it with afplay.
|
|
826
|
+
* A helper that dies while we are playing is dropped and the loop that was
|
|
827
|
+
* meant for it replayed, so a failure costs one restart, not the music.
|
|
828
|
+
*/
|
|
829
|
+
playViaHelper(audioFile, backend) {
|
|
830
|
+
try {
|
|
831
|
+
if (!this.helper) {
|
|
832
|
+
const helper = spawn("osascript", ["-l", "JavaScript", MAC_HELPER], { stdio: ["pipe", "ignore", "ignore"] });
|
|
833
|
+
helper.stdin.on("error", () => {}); // EPIPE once it is gone.
|
|
834
|
+
const dropped = () => {
|
|
835
|
+
if (this.helper !== helper) return;
|
|
836
|
+
this.helper = null;
|
|
837
|
+
helperFailed = true;
|
|
838
|
+
if (this.isPlaying) this.spawnLoop(audioFile, backend);
|
|
839
|
+
};
|
|
840
|
+
helper.on("error", dropped);
|
|
841
|
+
helper.on("exit", dropped);
|
|
842
|
+
this.helper = helper;
|
|
843
|
+
this.helperFirst = true;
|
|
844
|
+
}
|
|
845
|
+
const fade = this.helperFirst ? FADE_IN_S : 0; // Later loops carry their own boundary fades.
|
|
846
|
+
this.helperFirst = false;
|
|
847
|
+
this.helper.stdin.write(`play ${this.volume} ${fade} ${audioFile}\n`);
|
|
848
|
+
return true;
|
|
849
|
+
} catch (e) {
|
|
850
|
+
helperFailed = true;
|
|
851
|
+
this.helper = null;
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
772
854
|
}
|
|
773
855
|
|
|
774
856
|
killProcs() {
|
|
857
|
+
// Closing the helper's input is the stop: it fades out and exits on its own,
|
|
858
|
+
// so the music ends on a decay, and a daemon that dies without cleanup closes
|
|
859
|
+
// the pipe just the same.
|
|
860
|
+
if (this.helper) {
|
|
861
|
+
const helper = this.helper;
|
|
862
|
+
this.helper = null;
|
|
863
|
+
try {
|
|
864
|
+
helper.stdin.end();
|
|
865
|
+
helper.unref();
|
|
866
|
+
} catch (e) {
|
|
867
|
+
// Already gone.
|
|
868
|
+
}
|
|
869
|
+
}
|
|
775
870
|
for (const proc of this.procs) {
|
|
776
871
|
try {
|
|
777
872
|
proc.kill("SIGTERM");
|
|
@@ -787,7 +882,7 @@ class AudioPlayer {
|
|
|
787
882
|
* that must hand control back at once - an agent waits on its hooks, and a
|
|
788
883
|
* blocking chime held the permission dialog back for its whole length.
|
|
789
884
|
*/
|
|
790
|
-
stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false } = {}) {
|
|
885
|
+
stop({ playChime = true, outcome = "success", volume = 0.35, chimeVolume = null, detach = false, genre = this.genre } = {}) {
|
|
791
886
|
const wasPlaying = this.isPlaying;
|
|
792
887
|
this.isPlaying = false;
|
|
793
888
|
|
|
@@ -809,7 +904,7 @@ class AudioPlayer {
|
|
|
809
904
|
|
|
810
905
|
const targetVol = chimeVolume !== null ? chimeVolume : Math.min(0.65, Math.max(0.35, volume * 1.1));
|
|
811
906
|
const clamped = Math.max(0.05, Math.min(1.0, targetVol));
|
|
812
|
-
const chimeFile = getChimePath(outcome, bakedGain(backend, clamped));
|
|
907
|
+
const chimeFile = getChimePath(outcome, bakedGain(backend, clamped), chimeKeyFor(genre));
|
|
813
908
|
|
|
814
909
|
try {
|
|
815
910
|
if (detach) {
|
|
@@ -834,6 +929,8 @@ module.exports = {
|
|
|
834
929
|
pruneSeedDirs,
|
|
835
930
|
writeCacheFileAtomic,
|
|
836
931
|
getChimePath,
|
|
932
|
+
chimeKeyFor,
|
|
933
|
+
GENRE_KEYS,
|
|
837
934
|
clearCache,
|
|
838
935
|
detectPlayer,
|
|
839
936
|
bakedGain,
|
package/src/synth/chime.js
CHANGED
|
@@ -10,16 +10,30 @@ const {
|
|
|
10
10
|
createWavBuffer
|
|
11
11
|
} = require("./generator");
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// The success chime is the tonic chord of whatever key the music is in - root,
|
|
14
|
+
// fifth, octave, third - so it lands as the resolution of the piece instead of
|
|
15
|
+
// a stranger's bell over it. The default is the original C major chime.
|
|
16
|
+
const DEFAULT_CHIME_KEY = "C major";
|
|
17
|
+
const SUCCESS_CHIME_NOTES = {
|
|
18
|
+
"C major": ["C5", "G5", "C6", "E6"],
|
|
19
|
+
"D major": ["D5", "A5", "D6", "F#6"],
|
|
20
|
+
"D minor": ["D5", "A5", "D6", "F6"],
|
|
21
|
+
// Sits low on purpose: A, E and C are consonant over each of the drone's
|
|
22
|
+
// three roots (D, A, E), so one chime serves all of them.
|
|
23
|
+
"A minor": ["A4", "E5", "A5", "C6"]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function generateSuccessChime(durationSec = 1.6, key = DEFAULT_CHIME_KEY) {
|
|
27
|
+
const names = SUCCESS_CHIME_NOTES[key] || SUCCESS_CHIME_NOTES[DEFAULT_CHIME_KEY];
|
|
14
28
|
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
15
29
|
const left = new Float64Array(totalSamples);
|
|
16
30
|
const right = new Float64Array(totalSamples);
|
|
17
31
|
|
|
18
32
|
const chimeNotes = [
|
|
19
|
-
{ note:
|
|
20
|
-
{ note:
|
|
21
|
-
{ note:
|
|
22
|
-
{ note:
|
|
33
|
+
{ note: names[0], delay: 0.00, pan: 0.4 },
|
|
34
|
+
{ note: names[1], delay: 0.09, pan: 0.6 },
|
|
35
|
+
{ note: names[2], delay: 0.18, pan: 0.45 },
|
|
36
|
+
{ note: names[3], delay: 0.27, pan: 0.55 }
|
|
23
37
|
];
|
|
24
38
|
|
|
25
39
|
for (const c of chimeNotes) {
|
|
@@ -130,6 +144,8 @@ function generateAttentionChime(durationSec = 1.3) {
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
module.exports = {
|
|
147
|
+
DEFAULT_CHIME_KEY,
|
|
148
|
+
SUCCESS_CHIME_NOTES,
|
|
133
149
|
generateChime: generateSuccessChime,
|
|
134
150
|
generateSuccessChime,
|
|
135
151
|
generateFailureChime,
|