vibeaudio 0.11.1 → 0.13.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 +66 -5
- package/package.json +2 -1
- package/src/cli.js +81 -8
- package/src/history.js +36 -5
- package/src/hooks.js +158 -16
- package/src/mac-player.jxa +6 -0
- package/src/player.js +29 -5
- package/src/synth/tension.js +67 -0
package/README.md
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
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
|
|
|
11
|
-
> **🔊 [Listen to every genre →](https://kiril6.github.io/vibeaudio/)** — hear all
|
|
11
|
+
> **🔊 [Listen to every genre →](https://kiril6.github.io/vibeaudio/)** — hear all 10 genres, the tier escalation, and the three chimes, rendered from the real synth.
|
|
12
12
|
|
|
13
13
|
> **In a hurry?** `npm i -g vibeaudio`, then `vibe --install-hooks`. Your next prompt has music.
|
|
14
|
+
>
|
|
15
|
+
> **Claude Code only?** Skip npm: `claude plugin marketplace add kiril6/vibeaudio`, then `claude plugin install vibeaudio@vibeaudio` ([details](#as-a-claude-code-plugin)).
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -25,7 +27,9 @@ AI coding agents take 15–45 seconds to reason, read files and write code. Star
|
|
|
25
27
|
* 📈 **Escalating layers.** Tier 1 (0–15s) gentle intro → Tier 2 (15–45s) main groove → Tier 3 (45s+) deep focus. You can hear how deep into the task the agent is.
|
|
26
28
|
* 🔁 **A phrase, not a loop.** Each piece is three bars that rotate through your project's own progressions, so a long turn moves through a ~21-second phrase instead of replaying one 7-second bar for half an hour.
|
|
27
29
|
* 🎛️ **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.
|
|
30
|
+
* 🌅 **It fades, it doesn't cut.** On macOS the music eases in and out instead of starting and stopping mid-note, and `vibe --volume` reaches music that is already playing within a second.
|
|
28
31
|
* 🔔 **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.
|
|
32
|
+
* 💓 **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%.
|
|
29
33
|
* ✋ **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
34
|
* 🔌 **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
35
|
|
|
@@ -276,7 +280,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
276
280
|
| | |
|
|
277
281
|
| :--- | :--- |
|
|
278
282
|
| **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. |
|
|
279
|
-
| **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. 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. |
|
|
283
|
+
| **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. |
|
|
280
284
|
| **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. |
|
|
281
285
|
| **Only Cursor can play the failure chime** | Its stop event reports whether the turn completed, aborted or errored. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
|
|
282
286
|
| **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`. |
|
|
@@ -309,6 +313,22 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
309
313
|
|
|
310
314
|
> **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.
|
|
311
315
|
|
|
316
|
+
### As a Claude Code plugin
|
|
317
|
+
|
|
318
|
+
If Claude Code is the only agent you use, the plugin installs the same hooks with no npm step:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
claude plugin marketplace add kiril6/vibeaudio
|
|
322
|
+
claude plugin install vibeaudio@vibeaudio
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
It needs Node 18+ on your `PATH` (the hooks run `node`), and it also adds `/vibeaudio:vibe`, the plugin's spelling of [`/vibe`](#vibe-inside-claude-code). Update with `claude plugin update vibeaudio@vibeaudio`; remove with `claude plugin uninstall vibeaudio@vibeaudio` (music already playing stops on its own within 15 minutes, or run `/vibeaudio:vibe stop` first).
|
|
326
|
+
|
|
327
|
+
- **Settings are the same file.** `vibe --genre jazz` and the rest work as always, but the `vibe` command comes from `npm i -g vibeaudio`; the plugin alone puts nothing on your `PATH`. Use `/vibeaudio:vibe genre jazz` instead.
|
|
328
|
+
- **Both at once is safe.** If `--install-hooks` has also been run, the plugin's hooks step aside and the installed ones play, so there's no double chime. Remove the installed hooks first if you want the plugin to own it.
|
|
329
|
+
- **Claude Code only.** Codex, Cursor, Gemini and the others still use `vibe --install-hooks`.
|
|
330
|
+
- **Not reactive.** `--reactive` is an `--install-hooks` option; the plugin doesn't carry it.
|
|
331
|
+
|
|
312
332
|
### `/vibe` inside Claude Code
|
|
313
333
|
|
|
314
334
|
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`), so you can control the music without leaving the session:
|
|
@@ -350,7 +370,7 @@ vibe --reactive --install-hooks # on
|
|
|
350
370
|
vibe --install-hooks # off again
|
|
351
371
|
```
|
|
352
372
|
|
|
353
|
-
Music already playing keeps the old genre until the next prompt swaps the daemon — `vibe --stop` cuts it short.
|
|
373
|
+
Music already playing keeps the old genre until the next prompt swaps the daemon — `vibe --stop` cuts it short. A new **volume** is the exception: the music playing now eases to it within a second.
|
|
354
374
|
|
|
355
375
|
Removing them is one command:
|
|
356
376
|
|
|
@@ -367,14 +387,24 @@ Hooks log each finished turn — which project, how long, how it ended, and how
|
|
|
367
387
|
You waited 3h 55m on agents, 7m 51s per turn
|
|
368
388
|
Agents waited 5m 35s on you — permission dialogs and questions
|
|
369
389
|
Typical turn 2m 57s median
|
|
390
|
+
Back to it 41s median from done to your next prompt, 27 times
|
|
391
|
+
32s with a chime 1m 50s without (muted or --no-chime)
|
|
370
392
|
```
|
|
371
393
|
|
|
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.
|
|
394
|
+
plus where the time went by project and, for windows up to two weeks, by day. "You waited" is the agent's own working time; the time it spent blocked on you is shown separately, because they are different problems. "Back to it" is the other direction: how long a finished turn sat before your next prompt in the same session — gaps over 30 minutes count as breaks and are left out. Once both sides have five turns, it is split by whether a chime announced the turn, so you can see what the chime is worth to you. The log stays on your machine, never leaves it, is capped at about 1 MB, and `VIBE_NO_HISTORY=1` turns it off. Only hook-driven turns are logged — a command wrapped as `vibe <command>` is not, since its lifetime isn't the same thing as an agent's working time.
|
|
373
395
|
|
|
374
396
|
### The chime is in the music's key
|
|
375
397
|
|
|
376
398
|
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
399
|
|
|
400
|
+
### When an agent looks stuck
|
|
401
|
+
|
|
402
|
+
The worst stretch of agent work is twenty minutes of the same thing failing while the music says all is well. So when **4 of a session's last 8 tool calls fail**, a soft heartbeat — two low beats about once a second, on the tonic of your genre — joins the music at the next loop boundary, and leaves once enough calls succeed. With `--notify` on, you also get one banner when the session crosses the line ("api: looks stuck"), not one per failure.
|
|
403
|
+
|
|
404
|
+
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.
|
|
405
|
+
|
|
406
|
+
It needs the agent to report failed tool calls, which **Claude Code, Copilot CLI and Qwen Code** do (`PostToolUseFailure`). Codex, Cursor, Gemini, Grok and Windsurf don't, so for them the music never adds the heartbeat.
|
|
407
|
+
|
|
378
408
|
### Reactive mode (opt-in)
|
|
379
409
|
|
|
380
410
|
```bash
|
|
@@ -408,6 +438,35 @@ Changes land at the next loop boundary, so it shifts musically rather than cutti
|
|
|
408
438
|
|
|
409
439
|
---
|
|
410
440
|
|
|
441
|
+
### Build on it: `vibe --state` and `vibe --events`
|
|
442
|
+
|
|
443
|
+
The hard part of VibeAudio isn't the music. It's turning eight agents' different hook events into one set of states that mean the same thing everywhere. The music is one consumer of those states, and anything else can be another: a smart light, a menu bar icon, a tmux status line, a Stream Deck key.
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
vibe --state
|
|
447
|
+
# {"v":1,"status":"waiting","sessions":[{"session":"…","project":"/work/api","state":"waiting","since":1791026124771,"tool":"Bash"}]}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiting` (on you: a permission dialog or a question). `status` is the machine's overall state: the most urgent of its sessions, in the order `waiting` > `stuck` > `working` > `idle`. One session waiting on you matters more than three working.
|
|
451
|
+
|
|
452
|
+
`vibe --events` prints that snapshot once, then a JSON line for every change, as it happens:
|
|
453
|
+
|
|
454
|
+
```json
|
|
455
|
+
{"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended`. Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field. Some examples:
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
# tmux: show the state in the status bar
|
|
462
|
+
set -g status-right '#(vibe --state | jq -r .status)'
|
|
463
|
+
|
|
464
|
+
# macOS: say it out loud when any agent needs you
|
|
465
|
+
vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | while read p; do say "$(basename "$p") needs you"; done
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
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.
|
|
469
|
+
|
|
411
470
|
## 🖥️ Everything Else (Claude Desktop, Antigravity… via MCP)
|
|
412
471
|
|
|
413
472
|
**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.
|
|
@@ -586,6 +645,8 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
586
645
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
587
646
|
| `--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
647
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
648
|
+
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
649
|
+
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
589
650
|
| `--stop` | Stop the background player, then exit | — |
|
|
590
651
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
591
652
|
| `--unmute` | Resume normal playback, then exit | — |
|
|
@@ -681,7 +742,7 @@ This needs a PulseAudio-compatible sound server on **your local machine**: a Lin
|
|
|
681
742
|
If the second connection fails with the socket "already in use", an earlier session left it behind: `rm /tmp/vibe-pulse.sock` on the server, or set `StreamLocalBindUnlink yes` in the server's `sshd_config`. If `paplay` says access denied, your local PulseAudio requires its cookie — copy `~/.config/pulse/cookie` to the same path on the server.
|
|
682
743
|
|
|
683
744
|
**Volume flag does nothing**
|
|
684
|
-
Shouldn't happen any more — where the player can't attenuate (`aplay`, PowerShell), the gain is baked into the audio instead. A `--volume` change
|
|
745
|
+
Shouldn't happen any more — where the player can't attenuate (`aplay`, PowerShell), the gain is baked into the audio instead. A `--volume` change reaches music already playing within a second on macOS hooks (it eases there), at the next loop boundary elsewhere, and in the wrapper or MCP on their next run; `vibe --status` shows the volume in effect and what set it.
|
|
685
746
|
|
|
686
747
|
---
|
|
687
748
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node test/test-synth.js",
|
|
12
12
|
"start": "node bin/vibeaudio.js",
|
|
13
|
+
"version": "node -e \"const f='.claude-plugin/plugin.json',j=require('./'+f);j.version=require('./package.json').version;require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
|
|
13
14
|
"postinstall": "node scripts/postinstall.js"
|
|
14
15
|
},
|
|
15
16
|
"publishConfig": {
|
package/src/cli.js
CHANGED
|
@@ -78,6 +78,8 @@ Procedural focus music while your AI coding tools think.
|
|
|
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
80
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
81
|
+
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
82
|
+
--events Stream agent state changes as JSON lines, until stopped
|
|
81
83
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
82
84
|
--stop Stop the background player, then exit
|
|
83
85
|
--mute [minutes] Silence everything for a call (default: 60 min, 0 = until unmuted)
|
|
@@ -174,6 +176,8 @@ function parseArgs(argv) {
|
|
|
174
176
|
let render = null;
|
|
175
177
|
let clearCacheFlag = false;
|
|
176
178
|
let statusFlag = false;
|
|
179
|
+
let stateFlag = false;
|
|
180
|
+
let eventsFlag = false;
|
|
177
181
|
let doctorFlag = false;
|
|
178
182
|
let notifyFlag = null;
|
|
179
183
|
let reportDays = null;
|
|
@@ -183,6 +187,8 @@ function parseArgs(argv) {
|
|
|
183
187
|
let hookAction = null;
|
|
184
188
|
let mcp = false;
|
|
185
189
|
let reactive = false;
|
|
190
|
+
let followVolume = false;
|
|
191
|
+
let plugin = false;
|
|
186
192
|
let here_flag = false;
|
|
187
193
|
let dryRun = false;
|
|
188
194
|
let tools = null;
|
|
@@ -287,6 +293,13 @@ function parseArgs(argv) {
|
|
|
287
293
|
continue;
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
if (arg === "--state" || arg === "--events") {
|
|
297
|
+
if (arg === "--state") stateFlag = true;
|
|
298
|
+
else eventsFlag = true;
|
|
299
|
+
i += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
290
303
|
if (arg === "--report") {
|
|
291
304
|
// Optional window in days, like --mute's minutes: `vibe --report 30`.
|
|
292
305
|
i += 1;
|
|
@@ -352,6 +365,22 @@ function parseArgs(argv) {
|
|
|
352
365
|
continue;
|
|
353
366
|
}
|
|
354
367
|
|
|
368
|
+
// Internal, set by the hooks: a daemon with no volume of its own follows
|
|
369
|
+
// the saved one while it plays.
|
|
370
|
+
if (arg === "--follow-volume") {
|
|
371
|
+
followVolume = true;
|
|
372
|
+
i += 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Internal, set by the plugin's hooks.json: yield to hooks that
|
|
377
|
+
// --install-hooks already wrote, so both being present is not a double chime.
|
|
378
|
+
if (arg === "--plugin") {
|
|
379
|
+
plugin = true;
|
|
380
|
+
i += 1;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
355
384
|
if (arg === "--dry-run") {
|
|
356
385
|
dryRun = true;
|
|
357
386
|
i += 1;
|
|
@@ -452,6 +481,8 @@ function parseArgs(argv) {
|
|
|
452
481
|
render,
|
|
453
482
|
clearCache: clearCacheFlag,
|
|
454
483
|
status: statusFlag,
|
|
484
|
+
state: stateFlag,
|
|
485
|
+
events: eventsFlag,
|
|
455
486
|
doctor: doctorFlag,
|
|
456
487
|
notify: notifyFlag,
|
|
457
488
|
report: reportDays,
|
|
@@ -461,6 +492,8 @@ function parseArgs(argv) {
|
|
|
461
492
|
hookAction,
|
|
462
493
|
mcp,
|
|
463
494
|
reactive,
|
|
495
|
+
followVolume,
|
|
496
|
+
plugin,
|
|
464
497
|
dryRun,
|
|
465
498
|
tools,
|
|
466
499
|
typed,
|
|
@@ -751,8 +784,9 @@ function printStatus() {
|
|
|
751
784
|
// What it was started with, which is not necessarily what is saved: a
|
|
752
785
|
// daemon keeps its settings until the next prompt replaces it, and
|
|
753
786
|
// "I changed the genre and nothing happened" is that gap.
|
|
787
|
+
const level = playing.follows ? Math.round(volumeNow() * 100) : playing.volume; // A following daemon plays what is saved.
|
|
754
788
|
const now = playing.genre
|
|
755
|
-
? ` ${on(playing.genre)}${
|
|
789
|
+
? ` ${on(playing.genre)}${level === null ? "" : on(` @ ${level}%`)}${playing.reactive ? off(", reactive") : ""}`
|
|
756
790
|
: "";
|
|
757
791
|
console.log(` ${on("playing")}${now}${off(` pid ${pid}`)}`);
|
|
758
792
|
if (playing.genre && playing.genre !== genreNow()) {
|
|
@@ -915,10 +949,12 @@ function formatDuration(ms) {
|
|
|
915
949
|
* The summary of ~/.vibeaudio/history.jsonl. "You waited" is the agent's own
|
|
916
950
|
* working time - a turn's length minus the stretches it spent blocked on a
|
|
917
951
|
* dialog of yours - because the two are different complaints: one is the
|
|
918
|
-
* agent being slow, the other is you being away.
|
|
952
|
+
* agent being slow, the other is you being away. "Back to it" is the other
|
|
953
|
+
* direction: how long a finished turn waited for your next prompt, with gaps
|
|
954
|
+
* over RETURN_WINDOW_MS read as breaks and left out.
|
|
919
955
|
*/
|
|
920
956
|
function printReport(days, now = Date.now()) {
|
|
921
|
-
const { readTurns } = require("./history");
|
|
957
|
+
const { readTurns, returnTimes } = require("./history");
|
|
922
958
|
const turns = readTurns(days, now);
|
|
923
959
|
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
924
960
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -957,6 +993,19 @@ function printReport(days, now = Date.now()) {
|
|
|
957
993
|
row("Typical turn", formatDuration(median), "median");
|
|
958
994
|
row("Longest turn", formatDuration(longest.ms), label(longest));
|
|
959
995
|
|
|
996
|
+
// How fast you came back once a turn was done - the human half of the loop.
|
|
997
|
+
const middle = (xs) => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)];
|
|
998
|
+
const gaps = returnTimes(turns);
|
|
999
|
+
if (gaps.length) {
|
|
1000
|
+
row("Back to it", formatDuration(middle(gaps.map((g) => g.ms))), `median from done to your next prompt, ${gaps.length} ${gaps.length === 1 ? "time" : "times"}`);
|
|
1001
|
+
// Only once both sides have enough to mean something; three turns is an anecdote.
|
|
1002
|
+
const withChime = gaps.filter((g) => g.chimed === true).map((g) => g.ms);
|
|
1003
|
+
const without = gaps.filter((g) => g.chimed === false).map((g) => g.ms);
|
|
1004
|
+
if (withChime.length >= 5 && without.length >= 5) {
|
|
1005
|
+
row("", `${formatDuration(middle(withChime))} with a chime`, `${formatDuration(middle(without))} without (muted or --no-chime)`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
960
1009
|
const byProject = new Map();
|
|
961
1010
|
for (const t of turns) {
|
|
962
1011
|
const p = byProject.get(label(t)) || { ms: 0, n: 0 };
|
|
@@ -1128,6 +1177,9 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1128
1177
|
if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
|
|
1129
1178
|
console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1130
1179
|
}
|
|
1180
|
+
if (playing && playing.follows && patch.volume !== undefined) {
|
|
1181
|
+
console.log(` \x1b[90mThe music playing now follows the new volume within a second.\x1b[0m`);
|
|
1182
|
+
}
|
|
1131
1183
|
|
|
1132
1184
|
// An install from before the settings moved out of the hook command line
|
|
1133
1185
|
// still carries its own genre, and a flag beats this file. Saying nothing
|
|
@@ -1259,18 +1311,23 @@ function renderToFile(target, genre) {
|
|
|
1259
1311
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1260
1312
|
}
|
|
1261
1313
|
|
|
1262
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed = {} }) {
|
|
1314
|
+
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed = {} }) {
|
|
1263
1315
|
const hooks = require("./hooks");
|
|
1264
1316
|
|
|
1317
|
+
// The plugin's hooks and --install-hooks' hooks are the same events: with both
|
|
1318
|
+
// present every prompt would restart the music and every turn chime twice.
|
|
1319
|
+
// The installed ones win because they carry the user's --reactive choice.
|
|
1320
|
+
if (plugin && action.startsWith("hook-") && hooks.userHooksInstalled()) return;
|
|
1321
|
+
|
|
1265
1322
|
switch (action) {
|
|
1266
1323
|
case "daemon":
|
|
1267
|
-
return hooks.runDaemon(genre, volume, { reactive });
|
|
1324
|
+
return hooks.runDaemon(genre, volume, { reactive, volumeSource: followVolume ? volumeNow : null });
|
|
1268
1325
|
|
|
1269
1326
|
case "hook-start":
|
|
1270
1327
|
// The payload names the session (so SessionEnd can tell this session's
|
|
1271
1328
|
// music from another's) and the transcript (so an interrupt, which
|
|
1272
1329
|
// fires no hook, can still stop it).
|
|
1273
|
-
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, turn: hooks.newTurn(raw) }));
|
|
1330
|
+
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1274
1331
|
return;
|
|
1275
1332
|
|
|
1276
1333
|
case "hook-stop":
|
|
@@ -1297,7 +1354,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1297
1354
|
return;
|
|
1298
1355
|
|
|
1299
1356
|
case "hook-resume":
|
|
1300
|
-
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive }));
|
|
1357
|
+
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive, follow: typed.volume === undefined }));
|
|
1301
1358
|
return;
|
|
1302
1359
|
|
|
1303
1360
|
case "hook-end":
|
|
@@ -1474,6 +1531,8 @@ async function run() {
|
|
|
1474
1531
|
render,
|
|
1475
1532
|
clearCache: shouldClear,
|
|
1476
1533
|
status: showStatus,
|
|
1534
|
+
state: showState,
|
|
1535
|
+
events: followEvents,
|
|
1477
1536
|
doctor: showDoctor,
|
|
1478
1537
|
notify: notifyChange,
|
|
1479
1538
|
report: reportDays,
|
|
@@ -1483,6 +1542,8 @@ async function run() {
|
|
|
1483
1542
|
hookAction,
|
|
1484
1543
|
mcp,
|
|
1485
1544
|
reactive,
|
|
1545
|
+
followVolume,
|
|
1546
|
+
plugin,
|
|
1486
1547
|
dryRun,
|
|
1487
1548
|
tools,
|
|
1488
1549
|
typed,
|
|
@@ -1500,7 +1561,7 @@ async function run() {
|
|
|
1500
1561
|
|
|
1501
1562
|
if (hookAction) {
|
|
1502
1563
|
try {
|
|
1503
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, tools, dryRun, typed });
|
|
1564
|
+
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, tools, dryRun, typed });
|
|
1504
1565
|
} catch (e) {
|
|
1505
1566
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1506
1567
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -1564,6 +1625,18 @@ async function run() {
|
|
|
1564
1625
|
return printDoctor();
|
|
1565
1626
|
}
|
|
1566
1627
|
|
|
1628
|
+
// Machine-readable, for lights, menu bars and status lines: see "Build on it" in the README.
|
|
1629
|
+
if (showState) {
|
|
1630
|
+
console.log(JSON.stringify(require("./hooks").agentState()));
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (followEvents) {
|
|
1635
|
+
process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
|
|
1636
|
+
require("./hooks").followEvents();
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1567
1640
|
if (reportDays !== null) {
|
|
1568
1641
|
return printReport(reportDays);
|
|
1569
1642
|
}
|
package/src/history.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A local log of finished turns - what `vibe --report` reads. One JSON object
|
|
3
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,
|
|
5
|
-
*
|
|
4
|
+
* how long it took, how much of that the agent spent blocked on you, how it
|
|
5
|
+
* ended, which session it was and whether a chime announced it. It never
|
|
6
|
+
* leaves the machine; VIBE_NO_HISTORY=1 turns it off.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
const fs = require("fs");
|
|
@@ -14,12 +15,12 @@ const MAX_BYTES = 1024 * 1024;
|
|
|
14
15
|
const KEEP_LINES = 4000;
|
|
15
16
|
|
|
16
17
|
/** 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
|
+
function recordTurn({ project, ms, blockedMs = 0, outcome = "success", session, chimed, at = Date.now() }, file = HISTORY_FILE) {
|
|
18
19
|
if (process.env.VIBE_NO_HISTORY) return false;
|
|
19
20
|
if (!Number.isFinite(ms) || ms < 0) return false;
|
|
20
21
|
try {
|
|
21
22
|
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
|
+
fs.appendFileSync(file, `${JSON.stringify({ at, project, ms, blockedMs: Math.min(Math.max(blockedMs, 0), ms), outcome, session, chimed })}\n`);
|
|
23
24
|
// Bounded, so a year of use is still a small file. Rewritten rarely, from
|
|
24
25
|
// the tail, so the newest turns are always the ones kept.
|
|
25
26
|
if (fs.statSync(file).size > MAX_BYTES) {
|
|
@@ -54,4 +55,34 @@ function readTurns(days, now = Date.now(), file = HISTORY_FILE) {
|
|
|
54
55
|
return turns;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
// A gap longer than this is a break - lunch, a meeting, the end of the day -
|
|
59
|
+
// not someone coming back to a finished turn, and would swamp the median.
|
|
60
|
+
const RETURN_WINDOW_MS = 30 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How long each finished turn sat before the next prompt in the same session:
|
|
64
|
+
* the time it took you to come back. A turn's start is `at - ms`. Sessions,
|
|
65
|
+
* not projects, are what pair up - two agents in one repo interleave - so a
|
|
66
|
+
* line from before sessions were logged (or an `anon` one) falls back to its
|
|
67
|
+
* project. `chimed` is the finished turn's, since that is the chime you were
|
|
68
|
+
* answering.
|
|
69
|
+
*/
|
|
70
|
+
function returnTimes(turns) {
|
|
71
|
+
const groups = new Map();
|
|
72
|
+
for (const t of turns) {
|
|
73
|
+
const key = t.session && t.session !== "anon" ? `s:${t.session}` : `p:${t.project}`;
|
|
74
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
75
|
+
groups.get(key).push(t);
|
|
76
|
+
}
|
|
77
|
+
const gaps = [];
|
|
78
|
+
for (const list of groups.values()) {
|
|
79
|
+
list.sort((a, b) => a.at - b.at);
|
|
80
|
+
for (let i = 1; i < list.length; i++) {
|
|
81
|
+
const ms = list[i].at - list[i].ms - list[i - 1].at;
|
|
82
|
+
if (ms >= 0 && ms <= RETURN_WINDOW_MS) gaps.push({ ms, chimed: list[i - 1].chimed });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return gaps;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { HISTORY_FILE, RETURN_WINDOW_MS, recordTurn, readTurns, returnTimes };
|
package/src/hooks.js
CHANGED
|
@@ -25,6 +25,7 @@ const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
|
|
|
25
25
|
// them is working; each holds where its transcript began and, while paused for
|
|
26
26
|
// the user, what will resume it.
|
|
27
27
|
const SESSIONS_DIR = path.join(STATE_DIR, "sessions");
|
|
28
|
+
const EVENTS_FILE = path.join(STATE_DIR, "events.jsonl");
|
|
28
29
|
// Payloads that name no session share this one slot.
|
|
29
30
|
const ANON_SESSION = "anon";
|
|
30
31
|
const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
|
|
@@ -410,7 +411,8 @@ function daemonPlaying() {
|
|
|
410
411
|
pid,
|
|
411
412
|
genre: genre ? genre[1] : null,
|
|
412
413
|
volume: volume ? Number(volume[1]) : null,
|
|
413
|
-
reactive: /--reactive/.test(argv)
|
|
414
|
+
reactive: /--reactive/.test(argv),
|
|
415
|
+
follows: /--follow-volume/.test(argv)
|
|
414
416
|
};
|
|
415
417
|
}
|
|
416
418
|
|
|
@@ -451,7 +453,7 @@ function newTurn(raw) {
|
|
|
451
453
|
const payload = parsePayload(raw);
|
|
452
454
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
453
455
|
const offset = transcript ? fileSize(transcript) : 0;
|
|
454
|
-
return { session: String(payloadSession(payload) || ""), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
456
|
+
return { session: String(payloadSession(payload) || ""), project: payload.cwd || process.cwd(), transcript, offset, blocked: blockedJustBefore(transcript, offset) };
|
|
455
457
|
}
|
|
456
458
|
|
|
457
459
|
const BLOCK_LOOKBACK_MS = 3000;
|
|
@@ -544,6 +546,102 @@ function listSessions() {
|
|
|
544
546
|
// Working, as opposed to paused for the user.
|
|
545
547
|
const working = (sessions) => sessions.filter((s) => s.waiting == null);
|
|
546
548
|
|
|
549
|
+
// "Stuck": STUCK_FAILURES of a session's last STUCK_WINDOW tool calls failed.
|
|
550
|
+
// Measured on 4,497 real Claude Code turns (46,032 tool calls, 3.8% failing):
|
|
551
|
+
// this fires in 0.9% of turns, at minute 3 by the median, and those turns ran
|
|
552
|
+
// a median 3.3 minutes more - time someone could have stepped in. "3 of 8"
|
|
553
|
+
// fired in 2.4%, and "3 in a row" misses the usual loop, where an edit that
|
|
554
|
+
// succeeds sits between every failing test run. Rare is the point: a signal
|
|
555
|
+
// that fires often is one people learn to tune out.
|
|
556
|
+
const STUCK_WINDOW = 8;
|
|
557
|
+
const STUCK_FAILURES = 4;
|
|
558
|
+
const isStuck = (session) => (session.recent || []).filter(Boolean).length >= STUCK_FAILURES;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The public face of the session files: what every agent on the machine is
|
|
562
|
+
* doing, in five words that mean the same thing whichever agent it is. The
|
|
563
|
+
* hard part of this project is mapping eight agents' hook events onto those
|
|
564
|
+
* states; the music is one consumer of them, and `vibe --state` / `--events`
|
|
565
|
+
* let anything else be another - a light, a menu bar, a tmux status line.
|
|
566
|
+
* `status` is the machine's: the most urgent of its sessions, since one
|
|
567
|
+
* session waiting on you matters more than three working.
|
|
568
|
+
*/
|
|
569
|
+
const STATE_RANK = ["idle", "working", "stuck", "waiting"];
|
|
570
|
+
const sessionState = (s) => (s.waiting != null ? "waiting" : isStuck(s) ? "stuck" : "working");
|
|
571
|
+
|
|
572
|
+
function agentState(sessions = listSessions()) {
|
|
573
|
+
const list = sessions.map((s) => ({
|
|
574
|
+
session: s.id,
|
|
575
|
+
project: s.project || null,
|
|
576
|
+
state: sessionState(s),
|
|
577
|
+
since: s.started || null,
|
|
578
|
+
...(s.waiting ? { tool: s.waiting } : {})
|
|
579
|
+
}));
|
|
580
|
+
const status = list.reduce((a, s) => (STATE_RANK.indexOf(s.state) > STATE_RANK.indexOf(a) ? s.state : a), "idle");
|
|
581
|
+
return { v: 1, status, sessions: list };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const EVENTS_MAX_BYTES = 256 * 1024;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Appends one transition to ~/.vibeaudio/events.jsonl, after the session file
|
|
588
|
+
* already reflects it, so `status` is the state the event left behind. Not
|
|
589
|
+
* gated by a mute: a mute silences sound, and a light watching this is not
|
|
590
|
+
* sound. Rotated to `.1` rather than trimmed in place, so a follower sees a
|
|
591
|
+
* fresh file instead of re-reading the lines a trim kept. Never throws.
|
|
592
|
+
*/
|
|
593
|
+
function emitEvent(event, session, project, extra = {}) {
|
|
594
|
+
try {
|
|
595
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
596
|
+
try {
|
|
597
|
+
if (fs.statSync(EVENTS_FILE).size > EVENTS_MAX_BYTES) fs.renameSync(EVENTS_FILE, `${EVENTS_FILE}.1`);
|
|
598
|
+
} catch (e) { /* no file yet */ }
|
|
599
|
+
const line = { v: 1, at: Date.now(), event, session, project: project || null, ...extra, status: agentState().status };
|
|
600
|
+
fs.appendFileSync(EVENTS_FILE, `${JSON.stringify(line)}\n`); // One write under PIPE_BUF: appends do not interleave.
|
|
601
|
+
return true;
|
|
602
|
+
} catch (e) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* `vibe --events`: the current state as one line, then every event as it is
|
|
609
|
+
* written, until killed. Polled rather than fs.watch'd, which is unreliable on
|
|
610
|
+
* the platforms this runs on; a quarter second is well inside a turn.
|
|
611
|
+
*/
|
|
612
|
+
function followEvents(write = (s) => process.stdout.write(s), pollMs = 250) {
|
|
613
|
+
write(`${JSON.stringify({ event: "state", at: Date.now(), ...agentState() })}\n`);
|
|
614
|
+
const stat = () => {
|
|
615
|
+
try {
|
|
616
|
+
const st = fs.statSync(EVENTS_FILE);
|
|
617
|
+
return { size: st.size, ino: st.ino };
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return { size: 0, ino: null };
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
let { size: pos, ino } = stat();
|
|
623
|
+
let partial = "";
|
|
624
|
+
return setInterval(() => {
|
|
625
|
+
const now = stat();
|
|
626
|
+
if (now.ino !== ino || now.size < pos) {
|
|
627
|
+
ino = now.ino; // Rotated: the new file holds only what came after.
|
|
628
|
+
pos = 0;
|
|
629
|
+
partial = "";
|
|
630
|
+
}
|
|
631
|
+
if (now.size <= pos) return;
|
|
632
|
+
try {
|
|
633
|
+
const fd = fs.openSync(EVENTS_FILE, "r");
|
|
634
|
+
const buf = Buffer.alloc(now.size - pos);
|
|
635
|
+
fs.readSync(fd, buf, 0, buf.length, pos);
|
|
636
|
+
fs.closeSync(fd);
|
|
637
|
+
pos = now.size;
|
|
638
|
+
const lines = (partial + buf.toString("utf8")).split("\n");
|
|
639
|
+
partial = lines.pop();
|
|
640
|
+
for (const l of lines) if (l) write(`${l}\n`);
|
|
641
|
+
} catch (e) { /* removed between stat and open: next poll */ }
|
|
642
|
+
}, pollMs);
|
|
643
|
+
}
|
|
644
|
+
|
|
547
645
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
548
646
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
549
647
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
@@ -610,7 +708,7 @@ const INTERRUPT_POLL_MS = 500;
|
|
|
610
708
|
* Internal mode: plays while any session is working. The player's own loop
|
|
611
709
|
* timer keeps the event loop alive.
|
|
612
710
|
*/
|
|
613
|
-
function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
711
|
+
function runDaemon(genre, volume, { reactive = false, volumeSource = null } = {}) {
|
|
614
712
|
const watchers = new Map(); // session id -> its transcript poll
|
|
615
713
|
|
|
616
714
|
// Drops sessions whose transcript shows an interrupt (Esc fires no hook, so
|
|
@@ -626,6 +724,7 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
|
626
724
|
if (watchers.get(s.id)()) {
|
|
627
725
|
fs.rmSync(sessionFile(s.id), { force: true });
|
|
628
726
|
watchers.delete(s.id);
|
|
727
|
+
emitEvent("interrupted", s.id, s.project);
|
|
629
728
|
}
|
|
630
729
|
}
|
|
631
730
|
return working(listSessions()).length > 0;
|
|
@@ -647,7 +746,9 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
|
647
746
|
// The same piece with more of it, as time escalation already does: two
|
|
648
747
|
// sessions working is tier 2 at least, three or more is tier 3. Read at
|
|
649
748
|
// each loop boundary, so it lands cleanly and eases off as they finish.
|
|
650
|
-
minTier: () => Math.min(3, working(listSessions()).length)
|
|
749
|
+
minTier: () => Math.min(3, working(listSessions()).length),
|
|
750
|
+
// A heartbeat under the music while any working session looks stuck.
|
|
751
|
+
tension: () => working(listSessions()).some(isStuck)
|
|
651
752
|
});
|
|
652
753
|
if (!started) process.exit(0);
|
|
653
754
|
|
|
@@ -661,6 +762,8 @@ function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
|
661
762
|
setTimeout(shutdown, MAX_DAEMON_MS);
|
|
662
763
|
|
|
663
764
|
setInterval(() => {
|
|
765
|
+
// A volume saved while this plays reaches it now, not at the next prompt.
|
|
766
|
+
if (volumeSource) player.setVolume(volumeSource());
|
|
664
767
|
if (sweep()) return;
|
|
665
768
|
player.stop({ playChime: false });
|
|
666
769
|
endIdle();
|
|
@@ -672,9 +775,10 @@ function daemonRunning() {
|
|
|
672
775
|
return pid !== null && isOurDaemon(pid);
|
|
673
776
|
}
|
|
674
777
|
|
|
675
|
-
function spawnDaemon(genre, volume, reactive) {
|
|
778
|
+
function spawnDaemon(genre, volume, reactive, follow = false) {
|
|
676
779
|
const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
|
|
677
780
|
if (reactive) args.push("--reactive");
|
|
781
|
+
if (follow) args.push("--follow-volume");
|
|
678
782
|
|
|
679
783
|
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
|
|
680
784
|
child.unref();
|
|
@@ -689,16 +793,18 @@ function spawnDaemon(genre, volume, reactive) {
|
|
|
689
793
|
* always did, so a genre change lands on the next prompt. With one working the
|
|
690
794
|
* music carries on: restarting it would cut every other session's stream.
|
|
691
795
|
*/
|
|
692
|
-
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
796
|
+
function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
|
|
693
797
|
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
694
798
|
const id = sessionId(turn && turn.session);
|
|
695
799
|
// Before the spawn: the daemon reads it on startup.
|
|
696
|
-
|
|
800
|
+
const project = (turn && turn.project) || process.cwd();
|
|
801
|
+
writeSession(id, { project, transcript: (turn && turn.transcript) || "", offset: (turn && turn.offset) || 0, waiting: null, started: Date.now(), blockedMs: 0 });
|
|
802
|
+
emitEvent("started", id, project);
|
|
697
803
|
if (working(listSessions()).some((s) => s.id !== id) && daemonRunning()) return readPid();
|
|
698
804
|
|
|
699
805
|
stopDaemon({ keepSessions: true });
|
|
700
806
|
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
701
|
-
return spawnDaemon(genre, volume, reactive);
|
|
807
|
+
return spawnDaemon(genre, volume, reactive, follow);
|
|
702
808
|
}
|
|
703
809
|
|
|
704
810
|
/**
|
|
@@ -805,11 +911,14 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
|
|
|
805
911
|
const session = readSession(id);
|
|
806
912
|
const tracked = session !== null;
|
|
807
913
|
fs.rmSync(sessionFile(id), { force: true });
|
|
914
|
+
if (tracked) emitEvent("finished", id, parsePayload(raw).cwd || session.project, { outcome });
|
|
808
915
|
if (session && Number.isFinite(session.started)) {
|
|
809
916
|
const now = Date.now();
|
|
810
917
|
// A turn that ends while still paused for you has been blocked since then.
|
|
811
918
|
const blockedMs = (session.blockedMs || 0) + (session.waiting != null && session.waitStart ? now - session.waitStart : 0);
|
|
812
|
-
|
|
919
|
+
// ponytail: assumes a playback backend exists; a machine with none hears no music either.
|
|
920
|
+
const chimed = !noChime && !playbackDisabled();
|
|
921
|
+
recordTurn({ project: parsePayload(raw).cwd || process.cwd(), ms: now - session.started, blockedMs, outcome, session: id, chimed, at: now });
|
|
813
922
|
}
|
|
814
923
|
|
|
815
924
|
// The music is every working session's, so it ends with the last of them.
|
|
@@ -869,6 +978,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
869
978
|
if (!session || session.waiting != null) return false;
|
|
870
979
|
|
|
871
980
|
writeSession(id, { ...session, waiting: waitKey(raw), waitStart: Date.now() });
|
|
981
|
+
emitEvent("waiting", id, session.project, waitKey(raw) ? { tool: waitKey(raw) } : {});
|
|
872
982
|
if (working(listSessions()).length === 0) stopDaemon({ keepSessions: true });
|
|
873
983
|
const tool = payloadToolName(raw);
|
|
874
984
|
notify(raw, tool ? `needs you (${tool})` : "needs you");
|
|
@@ -889,17 +999,36 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
889
999
|
* one needing approval, can resume early - after the chime already did its
|
|
890
1000
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
891
1001
|
*/
|
|
892
|
-
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
893
|
-
const
|
|
1002
|
+
function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
|
|
1003
|
+
const payload = parsePayload(raw);
|
|
1004
|
+
const id = sessionId(payloadSession(payload));
|
|
894
1005
|
const session = readSession(id);
|
|
895
|
-
if (!session
|
|
1006
|
+
if (!session) return false;
|
|
1007
|
+
|
|
1008
|
+
// Every tool call lands here, so this is where the stuck signal is kept.
|
|
1009
|
+
// An interrupt is the user stopping a tool, not the tool failing.
|
|
1010
|
+
// ponytail: two parallel tool calls in one session can each read, then write,
|
|
1011
|
+
// and one result is lost. A heuristic over eight calls survives that.
|
|
1012
|
+
const failed = payload.hook_event_name === "PostToolUseFailure" && !payload.is_interrupt;
|
|
1013
|
+
const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
|
|
1014
|
+
const next = { ...session, recent };
|
|
1015
|
+
const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
|
|
1016
|
+
if (crossing === "stuck") notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
|
|
1017
|
+
|
|
896
1018
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
897
1019
|
// to finish is the first sign of work carrying on.
|
|
898
|
-
|
|
1020
|
+
const resumes = session.waiting != null && (session.waiting === "" || session.waiting === waitKey(raw));
|
|
1021
|
+
if (!resumes) {
|
|
1022
|
+
writeSession(id, next);
|
|
1023
|
+
if (crossing) emitEvent(crossing, id, session.project);
|
|
1024
|
+
return false; // Not waiting - the common case, on every tool call.
|
|
1025
|
+
}
|
|
899
1026
|
|
|
900
1027
|
const blockedMs = (session.blockedMs || 0) + (session.waitStart ? Date.now() - session.waitStart : 0);
|
|
901
|
-
writeSession(id, { ...
|
|
902
|
-
|
|
1028
|
+
writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
|
|
1029
|
+
emitEvent("resumed", id, session.project);
|
|
1030
|
+
if (crossing) emitEvent(crossing, id, session.project);
|
|
1031
|
+
if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
903
1032
|
return true;
|
|
904
1033
|
}
|
|
905
1034
|
|
|
@@ -914,9 +1043,11 @@ function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
|
914
1043
|
*/
|
|
915
1044
|
function hookEnd(raw) {
|
|
916
1045
|
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
917
|
-
|
|
1046
|
+
const session = readSession(id);
|
|
1047
|
+
if (!session) return false;
|
|
918
1048
|
|
|
919
1049
|
fs.rmSync(sessionFile(id), { force: true });
|
|
1050
|
+
emitEvent("ended", id, session.project);
|
|
920
1051
|
if (working(listSessions()).length === 0) {
|
|
921
1052
|
stopDaemon({ keepSessions: true });
|
|
922
1053
|
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
@@ -998,6 +1129,11 @@ function readVibeEntryCount(file, id) {
|
|
|
998
1129
|
}
|
|
999
1130
|
}
|
|
1000
1131
|
|
|
1132
|
+
/** True when --install-hooks has already written our entries for Claude Code. */
|
|
1133
|
+
function userHooksInstalled(file = null) {
|
|
1134
|
+
return readVibeEntryCount(file || TARGETS.claude.file(), "claude") > 0;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1001
1137
|
function loadSettings(file, t = null) {
|
|
1002
1138
|
if (!fs.existsSync(file)) return { settings: t ? t.seed() : {}, raw: null };
|
|
1003
1139
|
const raw = fs.readFileSync(file, "utf8");
|
|
@@ -1210,6 +1346,11 @@ module.exports = {
|
|
|
1210
1346
|
hookTool,
|
|
1211
1347
|
hookWait,
|
|
1212
1348
|
hookResume,
|
|
1349
|
+
isStuck,
|
|
1350
|
+
STUCK_WINDOW,
|
|
1351
|
+
agentState,
|
|
1352
|
+
followEvents,
|
|
1353
|
+
EVENTS_FILE,
|
|
1213
1354
|
hookEnd,
|
|
1214
1355
|
newTurn,
|
|
1215
1356
|
outcomeFromPayload,
|
|
@@ -1227,6 +1368,7 @@ module.exports = {
|
|
|
1227
1368
|
uninstallHooks,
|
|
1228
1369
|
settingsPath,
|
|
1229
1370
|
isVibeHook,
|
|
1371
|
+
userHooksInstalled,
|
|
1230
1372
|
hookEntries,
|
|
1231
1373
|
installSlashCommand,
|
|
1232
1374
|
uninstallSlashCommand,
|
package/src/mac-player.jxa
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// VibeAudio macOS player: one long-lived process driving AVAudioPlayer, run by
|
|
2
2
|
// `osascript -l JavaScript`. Lines on stdin:
|
|
3
3
|
// play <volume 0-1> <fade-in seconds> <path to wav>
|
|
4
|
+
// volume <volume 0-1> <ramp seconds> (everything sounding now)
|
|
4
5
|
// End of input - a closed pipe, or the daemon dying - fades out and exits, so
|
|
5
6
|
// the audio can never outlive whoever started it.
|
|
6
7
|
ObjC.import("Foundation");
|
|
@@ -26,6 +27,11 @@ function play(volume, fadeIn, path) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function handle(line) {
|
|
30
|
+
const v = /^volume (\S+) (\S+)$/.exec(line);
|
|
31
|
+
if (v) {
|
|
32
|
+
for (const p of players) if (p.playing) p.setVolumeFadeDuration(parseFloat(v[1]), parseFloat(v[2]));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
29
35
|
const m = /^play (\S+) (\S+) (.+)$/.exec(line);
|
|
30
36
|
if (m) play(parseFloat(m[1]), parseFloat(m[2]), m[3]);
|
|
31
37
|
}
|
package/src/player.js
CHANGED
|
@@ -20,6 +20,7 @@ const { generatePianoLoop } = require("./synth/piano");
|
|
|
20
20
|
const { generateJazzLoop } = require("./synth/jazz");
|
|
21
21
|
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
|
+
const { addTension } = require("./synth/tension");
|
|
23
24
|
const pkg = require("../package.json");
|
|
24
25
|
|
|
25
26
|
const CACHE_ROOT = path.join(os.homedir(), ".vibeaudio", "cache");
|
|
@@ -116,6 +117,7 @@ let warnedNoPlayer = false;
|
|
|
116
117
|
// run, and it is used for the rest of the process once one has failed.
|
|
117
118
|
const MAC_HELPER = path.join(__dirname, "mac-player.jxa");
|
|
118
119
|
const FADE_IN_S = 0.5;
|
|
120
|
+
const VOLUME_RAMP_S = 0.3;
|
|
119
121
|
let helperFailed = false;
|
|
120
122
|
|
|
121
123
|
function helperUsable(backend) {
|
|
@@ -616,7 +618,7 @@ function writeCacheFileAtomic(filePath, buffer) {
|
|
|
616
618
|
}
|
|
617
619
|
}
|
|
618
620
|
|
|
619
|
-
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0) {
|
|
621
|
+
function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0, tension = false) {
|
|
620
622
|
ensureCacheDir();
|
|
621
623
|
const normalizedGenre = resolveGenre(genre);
|
|
622
624
|
const safeTier = Math.max(1, Math.min(3, tier));
|
|
@@ -628,11 +630,14 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0)
|
|
|
628
630
|
// into this opens on exactly the bar it has always opened on.
|
|
629
631
|
const barSuffix = safeBar === 0 ? "" : `_b${safeBar}`;
|
|
630
632
|
const dir = seedDir(seed);
|
|
631
|
-
|
|
633
|
+
// The stuck heartbeat is its own file, rendered only once an agent is stuck.
|
|
634
|
+
const tensionSuffix = tension ? "_x" : "";
|
|
635
|
+
const filePath = path.join(dir, `loop_${normalizedGenre}_t${safeTier}${barSuffix}${tensionSuffix}${gainSuffix(gain)}.wav`);
|
|
632
636
|
|
|
633
637
|
if (!fs.existsSync(filePath)) {
|
|
634
638
|
fs.mkdirSync(dir, { recursive: true });
|
|
635
|
-
|
|
639
|
+
const loop = generateLoop(normalizedGenre, safeTier, seed >>> 0, safeBar);
|
|
640
|
+
writeCacheFileAtomic(filePath, applyGain(tension ? addTension(loop, GENRE_KEYS[normalizedGenre]) : loop, gain));
|
|
636
641
|
pruneSeedDirs();
|
|
637
642
|
}
|
|
638
643
|
|
|
@@ -727,6 +732,7 @@ class AudioPlayer {
|
|
|
727
732
|
this.seed = projectSeed();
|
|
728
733
|
this.intensity = null;
|
|
729
734
|
this.minTier = null;
|
|
735
|
+
this.tension = null;
|
|
730
736
|
this.currentTier = 1;
|
|
731
737
|
this.bar = 0;
|
|
732
738
|
this.nextTimer = null;
|
|
@@ -737,7 +743,7 @@ class AudioPlayer {
|
|
|
737
743
|
* Returns true if playback actually started. Restarts when called with
|
|
738
744
|
* different settings while playing, so a genre switch is not silently dropped.
|
|
739
745
|
*/
|
|
740
|
-
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null } = {}) {
|
|
746
|
+
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
|
|
741
747
|
const resolved = resolveGenre(genre);
|
|
742
748
|
const targetVolume = Math.max(0.05, Math.min(1.0, volume));
|
|
743
749
|
|
|
@@ -761,6 +767,7 @@ class AudioPlayer {
|
|
|
761
767
|
this.bar = 0; // Every run opens on the project's own bar.
|
|
762
768
|
this.intensity = intensity;
|
|
763
769
|
this.minTier = minTier;
|
|
770
|
+
this.tension = tension;
|
|
764
771
|
|
|
765
772
|
installExitHook();
|
|
766
773
|
activePlayers.add(this);
|
|
@@ -799,7 +806,8 @@ class AudioPlayer {
|
|
|
799
806
|
// never both, or the volume would be applied twice.
|
|
800
807
|
const backend = detectPlayer();
|
|
801
808
|
const gain = bakedGain(backend, this.volume);
|
|
802
|
-
|
|
809
|
+
// An agent that looks stuck adds a heartbeat, landing at this loop boundary.
|
|
810
|
+
const audioFile = getAudioPath(this.genre, this.currentTier, this.seed, gain, this.bar, Boolean(this.tension && this.tension()));
|
|
803
811
|
// Advanced after the choice, so the bar that plays first is bar 0.
|
|
804
812
|
this.bar = (this.bar + 1) % LOOP_BARS;
|
|
805
813
|
if (!(helperUsable(backend) && this.playViaHelper(audioFile, backend))) this.spawnLoop(audioFile, backend);
|
|
@@ -808,6 +816,22 @@ class AudioPlayer {
|
|
|
808
816
|
this.nextTimer = setTimeout(() => this.playLoop(), Math.max(250, durationMs - LOOP_OVERLAP_MS));
|
|
809
817
|
}
|
|
810
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Changes the volume of music already playing. The helper eases the loops
|
|
821
|
+
* that are sounding to it; any other backend takes it at the next loop, which
|
|
822
|
+
* is as live as a player that reads its volume once at spawn can be.
|
|
823
|
+
*/
|
|
824
|
+
setVolume(volume) {
|
|
825
|
+
const target = Math.max(0.05, Math.min(1.0, Number(volume))); // Already a fraction, unlike normalizeVolume's percent.
|
|
826
|
+
if (!this.isPlaying || !Number.isFinite(target) || target === this.volume) return;
|
|
827
|
+
this.volume = target;
|
|
828
|
+
try {
|
|
829
|
+
if (this.helper) this.helper.stdin.write(`volume ${this.volume} ${VOLUME_RAMP_S}\n`);
|
|
830
|
+
} catch (e) {
|
|
831
|
+
// The helper's own exit handler deals with a dead pipe.
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
811
835
|
spawnLoop(audioFile, backend) {
|
|
812
836
|
const proc = spawn(backend.cmd, backend.args(audioFile, this.volume), { stdio: "ignore" });
|
|
813
837
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "stuck" layer: a soft heartbeat under the loop, played while an agent's
|
|
3
|
+
* recent tool calls keep failing. More of the same music would say "working
|
|
4
|
+
* hard", which is not the news; a pulse that was not there before changes the
|
|
5
|
+
* music's character, and a heartbeat is the most widely read tension cue there
|
|
6
|
+
* is. Pitched on the genre's tonic so it sits in the key rather than against it.
|
|
7
|
+
*
|
|
8
|
+
* Pure like every other synth module: a WAV buffer in, a new one out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { noteToFreq, SAMPLE_RATE } = require("./generator");
|
|
12
|
+
|
|
13
|
+
const BEAT_S = 1.1; // About 55 bpm: a resting heart, not a racing one.
|
|
14
|
+
const DUB_DELAY_S = 0.24;
|
|
15
|
+
const EDGE_S = 0.15; // Clear of the loop's boundary fades, so seams stay clean.
|
|
16
|
+
const LEVEL = 0.22;
|
|
17
|
+
|
|
18
|
+
// Laptop speakers roll off below ~120 Hz, and a pulse nobody hears is no
|
|
19
|
+
// signal, so the tonic sits between 110 and 220 Hz with a second harmonic.
|
|
20
|
+
function pulseFreq(key) {
|
|
21
|
+
const tonic = String(key || "A minor").split(" ")[0];
|
|
22
|
+
let f = noteToFreq(`${tonic}3`);
|
|
23
|
+
while (f > 220) f /= 2;
|
|
24
|
+
while (f < 110) f *= 2;
|
|
25
|
+
return f;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// One thump: a short pitch drop, like a soft kick, decaying in ~0.2s.
|
|
29
|
+
function thump(t, f) {
|
|
30
|
+
if (t < 0 || t > 0.35) return 0;
|
|
31
|
+
const phase = f * t + (f * 0.5 * 0.04) * (1 - Math.exp(-t / 0.04)); // f*1.5 falling to f
|
|
32
|
+
const env = Math.min(1, t / 0.004) * Math.exp(-t / 0.075);
|
|
33
|
+
return env * (Math.sin(2 * Math.PI * phase) + 0.45 * Math.sin(4 * Math.PI * phase));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mixes the heartbeat into a canonical 16-bit WAV (mono or stereo) and returns a new buffer. */
|
|
37
|
+
function addTension(wav, key) {
|
|
38
|
+
if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF") {
|
|
39
|
+
throw new Error("addTension expects a canonical 44-byte-header WAV");
|
|
40
|
+
}
|
|
41
|
+
const channels = wav.readUInt16LE(22);
|
|
42
|
+
const rate = wav.readUInt32LE(24) || SAMPLE_RATE;
|
|
43
|
+
const frames = Math.floor((wav.length - 44) / (2 * channels));
|
|
44
|
+
const duration = frames / rate;
|
|
45
|
+
const f = pulseFreq(key);
|
|
46
|
+
|
|
47
|
+
// A whole number of beats per loop, so the pulse keeps time across loops.
|
|
48
|
+
const beats = Math.max(1, Math.round((duration - 2 * EDGE_S) / BEAT_S));
|
|
49
|
+
const period = (duration - 2 * EDGE_S) / beats;
|
|
50
|
+
const out = Buffer.from(wav);
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < frames; i++) {
|
|
53
|
+
const t = i / rate - EDGE_S;
|
|
54
|
+
if (t < 0 || t > duration - 2 * EDGE_S) continue;
|
|
55
|
+
const local = t % period;
|
|
56
|
+
const s = LEVEL * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
|
|
57
|
+
if (s === 0) continue;
|
|
58
|
+
for (let c = 0; c < channels; c++) {
|
|
59
|
+
const offset = 44 + (i * channels + c) * 2;
|
|
60
|
+
const mixed = Math.round(out.readInt16LE(offset) + s * 32767);
|
|
61
|
+
out.writeInt16LE(Math.max(-32768, Math.min(32767, mixed)), offset);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { addTension, pulseFreq };
|