vibeaudio 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -3
- package/package.json +1 -1
- package/src/cli.js +58 -11
- package/src/hooks.js +34 -6
- package/src/mcp.js +11 -1
- package/src/player.js +17 -2
- package/src/synth/chime.js +22 -1
- package/src/synth/tension.js +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ It is built as **calm technology**, in the sense of Mark Weiser and John Seely B
|
|
|
45
45
|
* 🔔 **Outcome-aware chimes.** Ascending on success, a soft descending minor chord on failure, and **silence on `Ctrl+C`** — an abort is never reported as done.
|
|
46
46
|
* 💓 **A heartbeat when an agent looks stuck.** If 4 of a session's last 8 tool calls fail — the test-edit-test loop that goes nowhere — a soft pulse on the music's tonic joins the music until things start passing again. It's rare by design: on 4,497 real turns it fired in under 1%.
|
|
47
47
|
* ✋ **A "your turn" chime.** When Claude Code stops to ask permission (or an MCP server asks for input), the music pauses and a rising two-note chime asks for you; it picks back up once you've answered.
|
|
48
|
+
* 🔕 **Signals without the music.** `vibe --music off` keeps the done, failed and "needs you" chimes, banners and the state stream, and drops the music. For people who bring their own, or share an office. ([details](#signals-only-vibe---music-off))
|
|
48
49
|
* 🔌 **Universal drop-in.** Hooks for **Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code, Windsurf and Antigravity**; MCP for **Claude Desktop**; the wrapper (`vibe <command>`) for anything else.
|
|
49
50
|
|
|
50
51
|
<details>
|
|
@@ -418,6 +419,22 @@ Removing them is one command:
|
|
|
418
419
|
vibe --uninstall-hooks
|
|
419
420
|
```
|
|
420
421
|
|
|
422
|
+
### Signals only: `vibe --music off`
|
|
423
|
+
|
|
424
|
+
VibeAudio does two jobs: music while an agent works, and signals (the done, failed and "needs you" chimes, banners, the stuck alert, `--events`). If you only want the second:
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
vibe --music off # signals only
|
|
428
|
+
vibe --music on # music back, in the genre you had
|
|
429
|
+
vibe --music off --here # this project only
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
It's a setting of its own, saved to `config.json` like genre and volume, so your genre is still there when you turn music back on. Like the others, a flag beats `VIBE_MUSIC=off`, which beats `--here`, which beats the global value, and installed hooks pick it up on the next prompt.
|
|
433
|
+
|
|
434
|
+
With music off, hooks still track every turn: the chimes, `--notify` banners, `--state`, `--events` and `--report` behave exactly as before, and no background player is started. The wrapper (`vibe npm test`) stays silent while it runs and chimes when a run that outlasted the grace window ends, so it becomes "tell me when it's done". MCP's `vibe_play` tells the model that music is off rather than claiming it started. `vibe --status` and `vibe --doctor` show the setting.
|
|
435
|
+
|
|
436
|
+
Two things differ from music on. The stuck heartbeat is a layer on the music, so with music off it plays once on its own, the same two soft beats, when a session crosses into stuck (you also get the `stuck` event and, with `--notify`, the banner). And turning music off while something is playing doesn't cut it: `vibe --stop` does, or it ends with the current turn.
|
|
437
|
+
|
|
421
438
|
### Know where your time goes: `vibe --report`
|
|
422
439
|
|
|
423
440
|
Hooks log each finished turn — which project, how long, how it ended, and how much of it the agent spent blocked on a dialog of yours — to `~/.vibeaudio/history.jsonl`. `vibe --report` (or `--report 30`) totals it:
|
|
@@ -509,9 +526,20 @@ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with
|
|
|
509
526
|
|
|
510
527
|
#### 📟 Status Bar & Desktop Integrations
|
|
511
528
|
|
|
512
|
-
|
|
529
|
+
**`vibe --statusline`** prints the same state as one plain line, with no `jq`, and **nothing at all when idle**, so a prompt segment disappears. It shows the most urgent state, with a count when several sessions are in it, and names the project when one is waiting on you:
|
|
530
|
+
|
|
531
|
+
```
|
|
532
|
+
working
|
|
533
|
+
working ×2
|
|
534
|
+
stuck
|
|
535
|
+
waiting: api
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
It is plain text on purpose: tmux and Starship do their own styling. It doesn't run `ps` and doesn't change when you're muted.
|
|
539
|
+
|
|
540
|
+
**tmux status bar:**
|
|
513
541
|
```bash
|
|
514
|
-
set -g status-right '#(vibe --
|
|
542
|
+
set -g status-right '#(vibe --statusline)'
|
|
515
543
|
```
|
|
516
544
|
|
|
517
545
|
**macOS speech alert** — announce out loud whenever an agent stops to ask for your input:
|
|
@@ -522,9 +550,13 @@ vibe --events | jq --unbuffered -r 'select(.event=="waiting") | .project' | whil
|
|
|
522
550
|
**Starship prompt** — custom indicator in `~/.config/starship.toml`:
|
|
523
551
|
```toml
|
|
524
552
|
[custom.vibe]
|
|
525
|
-
command = "vibe --
|
|
553
|
+
command = "vibe --statusline"
|
|
526
554
|
when = "command -v vibe >/dev/null"
|
|
555
|
+
format = "([🎧 $output ]($style))"
|
|
527
556
|
```
|
|
557
|
+
The parentheses matter: Starship hides a group whose variables are empty, so the 🎧 disappears with the text when idle. Put the icon in a `symbol` instead and it shows all the time. `vibe --statusline` takes about 40 ms, so running it on every prompt is fine.
|
|
558
|
+
|
|
559
|
+
*Run on tmux 3.x and Starship 1.26.0, macOS arm64, Node 24: both showed `working`, `working ×2` and `waiting: web` as sessions started and waited, and nothing when idle. Not run on Linux or Windows.*
|
|
528
560
|
|
|
529
561
|
The stream is a local file (`~/.vibeaudio/events.jsonl`, rotated at 256 KB), so it never leaves your machine. It keeps updating while you're muted, because a mute silences sound and a light isn't sound. The `v` field is the format version, and any breaking change will increment it.
|
|
530
562
|
|
|
@@ -698,6 +730,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
698
730
|
| `--grace <ms>` | Silence window before music starts | `1500` |
|
|
699
731
|
| `--here` | With a saved setting: this directory tree only, not everywhere | off |
|
|
700
732
|
| `--seed <n>` | Force a specific arrangement | derived from the project directory |
|
|
733
|
+
| `--music <on\|off>` | `off` keeps the chimes, banners and events and drops the music. With no command after it, saves it as a setting. `--here` for one project | `on` |
|
|
701
734
|
| `--whisper` | Quick preset: 15% volume (headphones / late night) | — |
|
|
702
735
|
| `--quiet` | Quick preset: 25% volume (focus / open office) | — |
|
|
703
736
|
| `--loud` | Quick preset: 75% volume (hear from across the room) | — |
|
|
@@ -709,6 +742,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
709
742
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
710
743
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
711
744
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
745
|
+
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
|
|
712
746
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
713
747
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
714
748
|
| `--stop` | Stop the background player, then exit | — |
|
|
@@ -735,6 +769,7 @@ export VIBE_CHIME_VOLUME=70 # Crisp completion chime at 70%
|
|
|
735
769
|
export VIBE_GRACE_MS=3000 # Wait 3s of thinking before any music
|
|
736
770
|
export VIBE_SEED=7 # Same arrangement everywhere, ignoring the directory
|
|
737
771
|
export VIBE_DISABLE=1 # Mute, without uninstalling anything
|
|
772
|
+
export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
|
|
738
773
|
export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
|
|
739
774
|
export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
|
|
740
775
|
export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Procedural focus music while your AI coding tools (Claude Code, Codex, Cursor, Grok, Gemini, Copilot) think — a different arrangement per project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vibeaudio": "bin/vibeaudio.js",
|
package/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
normalizeVolume,
|
|
18
18
|
loadConfig,
|
|
19
19
|
saveConfig,
|
|
20
|
+
musicEnabled,
|
|
20
21
|
projectSettings,
|
|
21
22
|
saveProjectConfig,
|
|
22
23
|
wavDurationMs,
|
|
@@ -68,6 +69,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
68
69
|
--grace <ms> Silence window before music starts, in ms (default: ${DEFAULT_GRACE_PERIOD_MS})
|
|
69
70
|
--here With a saved setting: this directory only, not everywhere
|
|
70
71
|
--seed <n> Force a specific arrangement (default: derived from the project directory)
|
|
72
|
+
--music <on|off> off = signals only: chimes, banners and events, no music (a saved setting)
|
|
71
73
|
--whisper Preset: 15% volume (late night / headphones)
|
|
72
74
|
--quiet Preset: 25% volume (focus / open office)
|
|
73
75
|
--loud Preset: 75% volume (hear from across the room)
|
|
@@ -79,6 +81,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
79
81
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
80
82
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
81
83
|
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
84
|
+
--statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
|
|
82
85
|
--events Stream agent state changes as JSON lines, until stopped
|
|
83
86
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
84
87
|
--stop Stop the background player, then exit
|
|
@@ -110,6 +113,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
110
113
|
VIBE_VOLUME=<5-100> Override the saved volume for this shell
|
|
111
114
|
VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
|
|
112
115
|
VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
|
|
116
|
+
VIBE_MUSIC=<on|off> Override the saved music setting for this shell
|
|
113
117
|
VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
|
|
114
118
|
VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
|
|
115
119
|
VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
|
|
@@ -136,6 +140,7 @@ const VALUE_FLAGS = new Set([
|
|
|
136
140
|
"-v", "--volume",
|
|
137
141
|
"-cv", "--chime-volume",
|
|
138
142
|
"--grace",
|
|
143
|
+
"--music",
|
|
139
144
|
"--seed",
|
|
140
145
|
"--tools",
|
|
141
146
|
"--event"
|
|
@@ -155,6 +160,7 @@ function parseArgs(argv) {
|
|
|
155
160
|
const here = projectSettings(saved);
|
|
156
161
|
const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
|
|
157
162
|
|
|
163
|
+
let music = musicEnabled(process.env, saved);
|
|
158
164
|
let genre = (process.env.VIBE_GENRE || setting("genre") || "lofi").toLowerCase();
|
|
159
165
|
|
|
160
166
|
let volume = normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.40));
|
|
@@ -178,6 +184,7 @@ function parseArgs(argv) {
|
|
|
178
184
|
let clearCacheFlag = false;
|
|
179
185
|
let statusFlag = false;
|
|
180
186
|
let stateFlag = false;
|
|
187
|
+
let statuslineFlag = false;
|
|
181
188
|
let eventsFlag = false;
|
|
182
189
|
let doctorFlag = false;
|
|
183
190
|
let notifyFlag = null;
|
|
@@ -295,6 +302,24 @@ function parseArgs(argv) {
|
|
|
295
302
|
continue;
|
|
296
303
|
}
|
|
297
304
|
|
|
305
|
+
if (arg === "--music") {
|
|
306
|
+
const value = args[i + 1].toLowerCase();
|
|
307
|
+
if (value !== "on" && value !== "off") {
|
|
308
|
+
console.error(`\x1b[31m[vibeaudio] --music takes on or off, not '${args[i + 1]}'.\x1b[0m`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
music = value === "on";
|
|
312
|
+
typed.music = music;
|
|
313
|
+
i += 2;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (arg === "--statusline") {
|
|
318
|
+
statuslineFlag = true;
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
298
323
|
if (arg === "--state" || arg === "--events") {
|
|
299
324
|
if (arg === "--state") stateFlag = true;
|
|
300
325
|
else eventsFlag = true;
|
|
@@ -492,6 +517,8 @@ function parseArgs(argv) {
|
|
|
492
517
|
clearCache: clearCacheFlag,
|
|
493
518
|
status: statusFlag,
|
|
494
519
|
state: stateFlag,
|
|
520
|
+
statusline: statuslineFlag,
|
|
521
|
+
music,
|
|
495
522
|
events: eventsFlag,
|
|
496
523
|
doctor: doctorFlag,
|
|
497
524
|
notify: notifyFlag,
|
|
@@ -734,6 +761,7 @@ function printStatus() {
|
|
|
734
761
|
: off(" default");
|
|
735
762
|
|
|
736
763
|
console.log(`\x1b[1mSound\x1b[0m`);
|
|
764
|
+
console.log(` music ${musicEnabled() ? on("on") : "\x1b[33moff\x1b[0m"}${source("VIBE_MUSIC", "music")}${musicEnabled() ? "" : off(" signals only — vibe --music on")}`);
|
|
737
765
|
console.log(` genre ${on(genreNow())}${source("VIBE_GENRE", "genre")}`);
|
|
738
766
|
console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
|
|
739
767
|
console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
|
|
@@ -943,6 +971,7 @@ function doctorChecks() {
|
|
|
943
971
|
if (mute !== null) add("warn", "Mute", `muted ${muteRemainingText(mute)}`, "vibe --unmute");
|
|
944
972
|
if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
|
|
945
973
|
if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
|
|
974
|
+
if (!musicEnabled()) add("ok", "Music", "off — chimes and banners only, by your setting (vibe --music on)");
|
|
946
975
|
|
|
947
976
|
const pid = readDaemonPid(hooks.PID_FILE);
|
|
948
977
|
if (pid === null) add("ok", "Background player", "not running");
|
|
@@ -1178,6 +1207,7 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1178
1207
|
if (typed.volume !== undefined) patch.volume = Math.round(typed.volume * 100);
|
|
1179
1208
|
if (typed.chimeVolume !== undefined) patch.chimeVolume = Math.round(typed.chimeVolume * 100);
|
|
1180
1209
|
if (typed.grace !== undefined) patch.grace = typed.grace;
|
|
1210
|
+
if (typed.music !== undefined) patch.music = typed.music;
|
|
1181
1211
|
|
|
1182
1212
|
const { CONFIG_FILE } = require("./player");
|
|
1183
1213
|
if (hereOnly) saveProjectConfig(patch);
|
|
@@ -1187,7 +1217,8 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1187
1217
|
genre: (v) => `genre ${v}`,
|
|
1188
1218
|
volume: (v) => `volume ${v}%`,
|
|
1189
1219
|
chimeVolume: (v) => `chime volume ${v}%`,
|
|
1190
|
-
grace: (v) => `grace ${v}ms
|
|
1220
|
+
grace: (v) => `grace ${v}ms`,
|
|
1221
|
+
music: (v) => `music ${v ? "on" : "off"}`
|
|
1191
1222
|
};
|
|
1192
1223
|
const changed = Object.keys(patch).map((k) => label[k](patch[k])).join(", ");
|
|
1193
1224
|
const where = hereOnly ? ` for ${process.cwd()}` : "";
|
|
@@ -1201,6 +1232,12 @@ function saveDefaults(typed, hereOnly = false) {
|
|
|
1201
1232
|
if (playing && patch.genre && playing.genre && playing.genre !== patch.genre) {
|
|
1202
1233
|
console.log(` \x1b[90m${playing.genre} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1203
1234
|
}
|
|
1235
|
+
if (playing && patch.music === false) {
|
|
1236
|
+
console.log(` \x1b[90m${playing.genre || "The music"} is still playing — vibe --stop cuts it short.\x1b[0m`);
|
|
1237
|
+
}
|
|
1238
|
+
if (patch.music === false) {
|
|
1239
|
+
console.log(` \x1b[90mSignals only: chimes, banners, --events and --report work as before.\x1b[0m`);
|
|
1240
|
+
}
|
|
1204
1241
|
if (playing && playing.follows && patch.volume !== undefined) {
|
|
1205
1242
|
console.log(` \x1b[90mThe music playing now follows the new volume within a second.\x1b[0m`);
|
|
1206
1243
|
}
|
|
@@ -1335,7 +1372,7 @@ function renderToFile(target, genre) {
|
|
|
1335
1372
|
console.log(` \x1b[90mAnother project's sound: run it there, or vibe --seed <n> --render.\x1b[0m\n`);
|
|
1336
1373
|
}
|
|
1337
1374
|
|
|
1338
|
-
function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
|
|
1375
|
+
function runHookAction(action, { music = true, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent = null, tools, dryRun, typed = {} }) {
|
|
1339
1376
|
const hooks = require("./hooks");
|
|
1340
1377
|
|
|
1341
1378
|
if (plugin && action.startsWith("hook-")) {
|
|
@@ -1361,7 +1398,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1361
1398
|
// The payload names the session (so SessionEnd can tell this session's
|
|
1362
1399
|
// music from another's) and the transcript (so an interrupt, which
|
|
1363
1400
|
// fires no hook, can still stop it).
|
|
1364
|
-
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1401
|
+
hooks.readPayload((raw) => hooks.hookStart(genre, volume, { music, reactive, follow: typed.volume === undefined, turn: hooks.newTurn(raw) }));
|
|
1365
1402
|
return;
|
|
1366
1403
|
|
|
1367
1404
|
case "hook-stop":
|
|
@@ -1388,7 +1425,7 @@ function runHookAction(action, { genre, volume, chimeVolume, noChime, reactive,
|
|
|
1388
1425
|
return;
|
|
1389
1426
|
|
|
1390
1427
|
case "hook-resume":
|
|
1391
|
-
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { reactive, follow: typed.volume === undefined }));
|
|
1428
|
+
hooks.readPayload((raw) => hooks.hookResume(raw, genre, volume, { music, reactive, follow: typed.volume === undefined, chimeVolume, noChime }));
|
|
1392
1429
|
return;
|
|
1393
1430
|
|
|
1394
1431
|
case "hook-end":
|
|
@@ -1455,10 +1492,10 @@ function windowsCommandNeedsShell(command) {
|
|
|
1455
1492
|
return !/[\\/]/.test(command);
|
|
1456
1493
|
}
|
|
1457
1494
|
|
|
1458
|
-
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false) {
|
|
1495
|
+
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false, music = true) {
|
|
1459
1496
|
const player = new AudioPlayer();
|
|
1460
1497
|
const hookDriven = hooksAlreadyCover(cmdArgs);
|
|
1461
|
-
const hud = !noHud && !hookDriven ? new TerminalHud(genre) : null;
|
|
1498
|
+
const hud = !noHud && !hookDriven && music ? new TerminalHud(genre) : null;
|
|
1462
1499
|
const startTime = Date.now();
|
|
1463
1500
|
let musicStarted = false;
|
|
1464
1501
|
let finished = false;
|
|
@@ -1486,8 +1523,8 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
|
|
|
1486
1523
|
|
|
1487
1524
|
// Grace window before triggering audio (silences fast commands)
|
|
1488
1525
|
const graceTimer = hookDriven ? null : setTimeout(() => {
|
|
1489
|
-
musicStarted = true;
|
|
1490
|
-
player.start(genre, volume);
|
|
1526
|
+
musicStarted = true; // Set with music off too: the chime marks the end of a run that outlasted the grace window.
|
|
1527
|
+
if (music) player.start(genre, volume);
|
|
1491
1528
|
if (hud) hud.start();
|
|
1492
1529
|
}, grace);
|
|
1493
1530
|
|
|
@@ -1566,6 +1603,8 @@ async function run() {
|
|
|
1566
1603
|
clearCache: shouldClear,
|
|
1567
1604
|
status: showStatus,
|
|
1568
1605
|
state: showState,
|
|
1606
|
+
statusline: showStatusline,
|
|
1607
|
+
music,
|
|
1569
1608
|
events: followEvents,
|
|
1570
1609
|
doctor: showDoctor,
|
|
1571
1610
|
notify: notifyChange,
|
|
@@ -1596,7 +1635,7 @@ async function run() {
|
|
|
1596
1635
|
|
|
1597
1636
|
if (hookAction) {
|
|
1598
1637
|
try {
|
|
1599
|
-
return runHookAction(hookAction, { genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
|
|
1638
|
+
return runHookAction(hookAction, { music, genre, volume, chimeVolume, noChime, reactive, followVolume, plugin, hookEvent, tools, dryRun, typed });
|
|
1600
1639
|
} catch (e) {
|
|
1601
1640
|
// Settings problems are the user's to fix — report them, don't stack-trace.
|
|
1602
1641
|
console.error(`\x1b[31m[vibeaudio] ${e.message}\x1b[0m`);
|
|
@@ -1667,6 +1706,14 @@ async function run() {
|
|
|
1667
1706
|
return;
|
|
1668
1707
|
}
|
|
1669
1708
|
|
|
1709
|
+
// Idle prints nothing, not even a newline, so a prompt segment disappears.
|
|
1710
|
+
if (showStatusline) {
|
|
1711
|
+
const hooks = require("./hooks");
|
|
1712
|
+
const line = hooks.formatStatusline(hooks.agentState());
|
|
1713
|
+
if (line) console.log(line);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1670
1717
|
if (followEvents) {
|
|
1671
1718
|
process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
|
|
1672
1719
|
require("./hooks").followEvents();
|
|
@@ -1736,10 +1783,10 @@ async function run() {
|
|
|
1736
1783
|
}
|
|
1737
1784
|
}
|
|
1738
1785
|
|
|
1739
|
-
return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud);
|
|
1786
|
+
return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud, music);
|
|
1740
1787
|
}
|
|
1741
1788
|
|
|
1742
|
-
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud);
|
|
1789
|
+
executeCommand(cmdArgs, genre, volume, chimeVolume, grace, noChime, noHud, music);
|
|
1743
1790
|
}
|
|
1744
1791
|
|
|
1745
1792
|
module.exports = {
|
package/src/hooks.js
CHANGED
|
@@ -755,6 +755,20 @@ function agentState(sessions = listSessions()) {
|
|
|
755
755
|
return { v: 1, status, sessions: list };
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
/**
|
|
759
|
+
* `vibe --statusline`: the state as one plain line for a tmux segment or a
|
|
760
|
+
* shell prompt, and nothing at all when idle so the segment disappears. The
|
|
761
|
+
* most urgent status, with a count when more than one session is in it; a
|
|
762
|
+
* waiting session names its project, since that is the one you have to go to.
|
|
763
|
+
* Plain text because tmux and Starship style it themselves.
|
|
764
|
+
*/
|
|
765
|
+
function formatStatusline({ status, sessions }) {
|
|
766
|
+
if (status === "idle") return "";
|
|
767
|
+
const active = sessions.filter((s) => s.state === status);
|
|
768
|
+
const project = status === "waiting" && active[0].project ? `: ${projectName(active[0].project)}` : "";
|
|
769
|
+
return `${status}${project}${active.length > 1 ? ` ×${active.length}` : ""}`;
|
|
770
|
+
}
|
|
771
|
+
|
|
758
772
|
const EVENTS_MAX_BYTES = 256 * 1024;
|
|
759
773
|
|
|
760
774
|
/**
|
|
@@ -973,7 +987,7 @@ function spawnDaemon(genre, volume, reactive, follow = false) {
|
|
|
973
987
|
* always did, so a genre change lands on the next prompt. With one working the
|
|
974
988
|
* music carries on: restarting it would cut every other session's stream.
|
|
975
989
|
*/
|
|
976
|
-
function hookStart(genre, volume, { reactive = false, turn = null, follow = false } = {}) {
|
|
990
|
+
function hookStart(genre, volume, { reactive = false, turn = null, follow = false, music = true } = {}) {
|
|
977
991
|
if (turn && turn.blocked) return null; // Rejected before it began: nothing to play for.
|
|
978
992
|
if (turn && turn.continues) return null; // A later model call in a turn already playing.
|
|
979
993
|
const id = sessionId(turn && turn.session);
|
|
@@ -985,6 +999,10 @@ function hookStart(genre, volume, { reactive = false, turn = null, follow = fals
|
|
|
985
999
|
|
|
986
1000
|
stopDaemon({ keepSessions: true });
|
|
987
1001
|
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
1002
|
+
// Signals only: the session is tracked (chimes, events and the report read
|
|
1003
|
+
// it) but there is no music to start. Stopping first means a switch to off
|
|
1004
|
+
// lands on the next prompt rather than playing until the turn ends.
|
|
1005
|
+
if (!music) return null;
|
|
988
1006
|
return spawnDaemon(genre, volume, reactive, follow);
|
|
989
1007
|
}
|
|
990
1008
|
|
|
@@ -1050,9 +1068,12 @@ function notifyEnabled(env = process.env, config = loadConfig()) {
|
|
|
1050
1068
|
}
|
|
1051
1069
|
|
|
1052
1070
|
/** The project a hook fired in: the payload's (see payloadProject), else ours. */
|
|
1071
|
+
function projectName(dir) {
|
|
1072
|
+
return path.basename(String(dir).replace(/[\\/]+$/, "")) || "a session";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1053
1075
|
function sessionLabel(payload, cwd = process.cwd()) {
|
|
1054
|
-
|
|
1055
|
-
return path.basename(dir.replace(/[\\/]+$/, "")) || "a session";
|
|
1076
|
+
return projectName(payloadProject(payload) || cwd);
|
|
1056
1077
|
}
|
|
1057
1078
|
|
|
1058
1079
|
/**
|
|
@@ -1183,7 +1204,7 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
|
|
|
1183
1204
|
* one needing approval, can resume early - after the chime already did its
|
|
1184
1205
|
* job. Match on tool_input as well if that ever shows up in practice.
|
|
1185
1206
|
*/
|
|
1186
|
-
function hookResume(raw, genre, volume, { reactive = false, follow = false } = {}) {
|
|
1207
|
+
function hookResume(raw, genre, volume, { reactive = false, follow = false, music = true, chimeVolume = null, noChime = false } = {}) {
|
|
1187
1208
|
const payload = parsePayload(raw);
|
|
1188
1209
|
const id = sessionId(payloadSession(payload));
|
|
1189
1210
|
const session = readSession(id);
|
|
@@ -1197,7 +1218,13 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
|
|
|
1197
1218
|
const recent = [...(session.recent || []), failed ? 1 : 0].slice(-STUCK_WINDOW);
|
|
1198
1219
|
const next = { ...session, recent };
|
|
1199
1220
|
const crossing = isStuck(next) === isStuck(session) ? null : isStuck(next) ? "stuck" : "recovered";
|
|
1200
|
-
if (crossing === "stuck")
|
|
1221
|
+
if (crossing === "stuck") {
|
|
1222
|
+
notify(raw, `looks stuck - ${STUCK_FAILURES} of its last ${STUCK_WINDOW} tool calls failed`);
|
|
1223
|
+
// With music on the daemon adds the heartbeat; with it off there is no
|
|
1224
|
+
// loop to put one under, so the pulse plays once on its own. Detached: the
|
|
1225
|
+
// agent waits on this hook.
|
|
1226
|
+
if (!music && !noChime) new AudioPlayer().stop({ playChime: true, outcome: "stuck", volume, chimeVolume, detach: true });
|
|
1227
|
+
}
|
|
1201
1228
|
|
|
1202
1229
|
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
1203
1230
|
// to finish is the first sign of work carrying on.
|
|
@@ -1212,7 +1239,7 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false } = {
|
|
|
1212
1239
|
writeSession(id, { ...next, waiting: null, waitStart: null, blockedMs });
|
|
1213
1240
|
emitEvent("resumed", id, session.project);
|
|
1214
1241
|
if (crossing) emitEvent(crossing, id, session.project);
|
|
1215
|
-
if (!daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
1242
|
+
if (music && !daemonRunning()) spawnDaemon(genre, volume, reactive, follow);
|
|
1216
1243
|
return true;
|
|
1217
1244
|
}
|
|
1218
1245
|
|
|
@@ -1565,6 +1592,7 @@ module.exports = {
|
|
|
1565
1592
|
isStuck,
|
|
1566
1593
|
STUCK_WINDOW,
|
|
1567
1594
|
agentState,
|
|
1595
|
+
formatStatusline,
|
|
1568
1596
|
followEvents,
|
|
1569
1597
|
EVENTS_FILE,
|
|
1570
1598
|
hookEnd,
|
package/src/mcp.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const readline = require("readline");
|
|
9
|
-
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, isKnownGenre, loadConfig } = require("./player");
|
|
9
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig } = require("./player");
|
|
10
10
|
const pkg = require("../package.json");
|
|
11
11
|
|
|
12
12
|
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
@@ -148,6 +148,15 @@ function handleMessage(player, msg) {
|
|
|
148
148
|
const volume = normalizeVolume(args.volume,
|
|
149
149
|
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(saved.volume, 0.4)));
|
|
150
150
|
|
|
151
|
+
// A deliberate setting, like a mute: report it as one, and start nothing.
|
|
152
|
+
if (!musicEnabled()) {
|
|
153
|
+
return {
|
|
154
|
+
jsonrpc: "2.0",
|
|
155
|
+
id,
|
|
156
|
+
result: { content: [{ type: "text", text: "The user has turned VibeAudio's music off (signals only), so nothing will play. This is deliberate and not an error — do not try again or suggest fixes; they will turn it back on when they want music." }] }
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
152
161
|
// start() returns false for three unrelated reasons, and the model
|
|
153
162
|
// relays whatever we say here to the user. Reporting a deliberate mute
|
|
@@ -204,6 +213,7 @@ function handleMessage(player, msg) {
|
|
|
204
213
|
// Without this, "isPlaying: false" while muted reads as a bug
|
|
205
214
|
// worth investigating rather than a choice the user made.
|
|
206
215
|
muted: playbackDisabled(),
|
|
216
|
+
musicOff: !musicEnabled(),
|
|
207
217
|
genre: player.genre,
|
|
208
218
|
currentTier: player.currentTier,
|
|
209
219
|
uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
|
package/src/player.js
CHANGED
|
@@ -18,7 +18,7 @@ const { generateRainLoop } = require("./synth/rain");
|
|
|
18
18
|
const { generateOceanLoop } = require("./synth/ocean");
|
|
19
19
|
const { generatePianoLoop } = require("./synth/piano");
|
|
20
20
|
const { generateJazzLoop } = require("./synth/jazz");
|
|
21
|
-
const { generateSuccessChime, generateFailureChime, generateAttentionChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
21
|
+
const { generateSuccessChime, generateFailureChime, generateAttentionChime, generateStuckChime, DEFAULT_CHIME_KEY, SUCCESS_CHIME_NOTES } = require("./synth/chime");
|
|
22
22
|
const { hashString } = require("./synth/generator");
|
|
23
23
|
const { addTension } = require("./synth/tension");
|
|
24
24
|
const pkg = require("../package.json");
|
|
@@ -430,6 +430,19 @@ function playbackDisabled() {
|
|
|
430
430
|
return muteState() !== null;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Whether music plays at all, as opposed to `playbackDisabled()`, which
|
|
435
|
+
* silences everything including the chimes. Some people want only the
|
|
436
|
+
* signals (done, failed, needs you), so this is a setting of its own rather
|
|
437
|
+
* than a genre: `genre` stays what it was for when music comes back on.
|
|
438
|
+
* Env beats this directory's saved value beats the global one; a flag is
|
|
439
|
+
* resolved by parseArgs() and handed to whoever acts on it.
|
|
440
|
+
*/
|
|
441
|
+
function musicEnabled(env = process.env, config = loadConfig()) {
|
|
442
|
+
const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
|
|
443
|
+
return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
|
|
444
|
+
}
|
|
445
|
+
|
|
433
446
|
/**
|
|
434
447
|
* `minutes` of 0 means indefinite, which the user has to ask for explicitly.
|
|
435
448
|
*/
|
|
@@ -652,7 +665,8 @@ function getAudioPath(genre, tier = 2, seed = projectSeed(), gain = 1, bar = 0,
|
|
|
652
665
|
const CHIMES = {
|
|
653
666
|
success: generateSuccessChime,
|
|
654
667
|
failure: generateFailureChime,
|
|
655
|
-
attention: generateAttentionChime
|
|
668
|
+
attention: generateAttentionChime,
|
|
669
|
+
stuck: generateStuckChime
|
|
656
670
|
};
|
|
657
671
|
|
|
658
672
|
/**
|
|
@@ -965,6 +979,7 @@ module.exports = {
|
|
|
965
979
|
bakedGain,
|
|
966
980
|
applyGain,
|
|
967
981
|
playbackDisabled,
|
|
982
|
+
musicEnabled,
|
|
968
983
|
resolveGenre,
|
|
969
984
|
isKnownGenre,
|
|
970
985
|
normalizeVolume,
|
package/src/synth/chime.js
CHANGED
|
@@ -143,11 +143,32 @@ function generateAttentionChime(durationSec = 1.3) {
|
|
|
143
143
|
return createWavBuffer({ left, right, sampleRate: SAMPLE_RATE });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* "Looks stuck", for someone with the music off, who has no loop for the
|
|
148
|
+
* heartbeat to sit under. It is that same heartbeat on its own - two lub-dubs
|
|
149
|
+
* on A, a resting pulse - so it means what the layer means: not an outcome and
|
|
150
|
+
* not a question, just something that has not changed for a while. Quiet, and
|
|
151
|
+
* low enough to read as a pulse rather than a tone.
|
|
152
|
+
*/
|
|
153
|
+
function generateStuckChime(durationSec = 1.8) {
|
|
154
|
+
const { thump, pulseFreq, BEAT_S, DUB_DELAY_S } = require("./tension");
|
|
155
|
+
const f = pulseFreq("A minor");
|
|
156
|
+
const totalSamples = Math.floor(SAMPLE_RATE * durationSec);
|
|
157
|
+
const out = new Float64Array(totalSamples);
|
|
158
|
+
for (let i = 0; i < totalSamples; i++) {
|
|
159
|
+
const t = i / SAMPLE_RATE;
|
|
160
|
+
const local = t % BEAT_S;
|
|
161
|
+
out[i] = 0.3 * (thump(local, f) + 0.7 * thump(local - DUB_DELAY_S, f));
|
|
162
|
+
}
|
|
163
|
+
return createWavBuffer({ left: out, right: out, sampleRate: SAMPLE_RATE });
|
|
164
|
+
}
|
|
165
|
+
|
|
146
166
|
module.exports = {
|
|
147
167
|
DEFAULT_CHIME_KEY,
|
|
148
168
|
SUCCESS_CHIME_NOTES,
|
|
149
169
|
generateChime: generateSuccessChime,
|
|
150
170
|
generateSuccessChime,
|
|
151
171
|
generateFailureChime,
|
|
152
|
-
generateAttentionChime
|
|
172
|
+
generateAttentionChime,
|
|
173
|
+
generateStuckChime
|
|
153
174
|
};
|
package/src/synth/tension.js
CHANGED