vibeaudio 0.25.1 → 0.26.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 +14 -3
- package/package.json +1 -1
- package/src/cli.js +46 -9
- package/src/hooks.js +46 -1
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ vibe npm test
|
|
|
160
160
|
vibe claude -p "explain this repo"
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
**Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found. If something seems broken, `vibe --doctor` checks it and prints the fix for each problem.
|
|
163
|
+
**Lost?** `vibe --help` lists every flag, and `vibe --status` reads your live setup back to you — what's installed, what's playing, and which agents it found. Its first line says why the music is silent or playing right now (`silent: muted for 42 more min (vibe --unmute)`, `playing: lofi @ 40%, 2 sessions working (api, web)`). If something seems broken, `vibe --doctor` checks it and prints the fix for each problem.
|
|
164
164
|
|
|
165
165
|
> **Which one you want:** the wrapper plays music for as long as the wrapped process lives. That's exactly right for a command that exits when its work is done — and wrong for an interactive REPL like `claude`, where the process stays alive while you read and type, so the music never stops. Hooks know when the agent is actually thinking; the wrapper can only time the process.
|
|
166
166
|
|
|
@@ -300,7 +300,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
300
300
|
| :--- | :--- |
|
|
301
301
|
| **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. |
|
|
302
302
|
| **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. |
|
|
303
|
-
| **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
|
|
303
|
+
| **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 CLI asks the first time each fires; the Codex desktop app shows **Review hooks** in the composer, where **Allow all** approves them in one go. Both write the same records, so approving in one covers the other. |
|
|
304
304
|
| **Cursor and Antigravity say how a turn ended** | Cursor's stop event reports whether the turn completed, aborted or errored, and Antigravity's gives a termination reason, so an error plays the failure chime. Claude Code and Qwen Code send a separate `StopFailure` for an API error. The others send no verdict, so a turn there always ends on the success chime — VibeAudio won't invent a failure the agent never claimed. |
|
|
305
305
|
| <a id="antigravity-hooks"></a>**Antigravity never gets a pre-tool hook** | Its pre-tool hook has to approve or deny every tool call — one that answers nothing blocks the tool — so VibeAudio installs none, and `--reactive` does nothing there. It has no prompt event either: the start event fires before every model call, and VibeAudio starts the music on the first one of each prompt only. Its hooks live in `~/.gemini/config/hooks.json` under a name of their own, so your other named hooks there are never touched. Verified with the `agy` CLI; the desktop app reads the same file by Antigravity's own docs but hasn't been checked live. |
|
|
306
306
|
| **Upgraded, and a new feature isn't there?** | Hooks are written into your agent's config once, at install time, and an upgrade doesn't touch them. When a new version listens for more events (0.13.1 added Codex's `Interrupt` and `SessionEnd`), `vibe --doctor` and `vibe --status` name what's missing, and `vibe --install-hooks` adds it. |
|
|
@@ -334,6 +334,8 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
334
334
|
|
|
335
335
|
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`). (The separate **Claude Desktop** chat app is a different product with no hooks; that one needs [MCP](#-everything-else-claude-desktop-vs-code-via-mcp).)
|
|
336
336
|
|
|
337
|
+
> **So is the Codex desktop app** (the Codex side of the ChatGPT app): it runs the same Codex engine on the same `~/.codex/hooks.json`, so `--install-hooks` covers it with nothing extra. Approve the hooks once from its **Review hooks** prompt. Its stop button ends the music silently, as Codex's `Interrupt` does in the CLI.
|
|
338
|
+
|
|
337
339
|
> **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.
|
|
338
340
|
|
|
339
341
|
### 🔔 Desktop banners: `vibe --notify`
|
|
@@ -429,6 +431,12 @@ The optional [`/vibeaudio` mod](#vibeaudio-instant-controls-and-a-pane-claude-co
|
|
|
429
431
|
|
|
430
432
|
`/vibe` is a prompt, so Claude has to read it, run a command and answer: it costs a turn and a few tokens, and it waits for the current turn to end. The optional **VibeAudio mod** runs inside Claude Code instead, so `/vibeaudio mute` takes effect at once, even while Claude is working, and costs no tokens.
|
|
431
433
|
|
|
434
|
+
<p align="center">
|
|
435
|
+
<a href="https://kiril6.github.io/vibeaudio/#band-video"><img src="docs/video/band-poster.jpg" alt="The VibeAudio band above the Claude Code prompt" width="49%"></a>
|
|
436
|
+
<a href="https://kiril6.github.io/vibeaudio/#band-video"><img src="docs/video/pane.jpg" alt="The VibeAudio pane: now playing, sound, alerts and share" width="49%"></a>
|
|
437
|
+
<br><sub>▶ <a href="https://kiril6.github.io/vibeaudio/#band-video">Watch the 43-second demo</a> of the band and the pane (frames from the demo video, with sound on the site).</sub>
|
|
438
|
+
</p>
|
|
439
|
+
|
|
432
440
|
```text
|
|
433
441
|
claude plugin marketplace add kiril6/vibeaudio
|
|
434
442
|
claude plugin install vibeaudio-mod@vibeaudio
|
|
@@ -864,7 +872,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
864
872
|
| `--no-hud` | Disable terminal window/tab title animation | `false` |
|
|
865
873
|
| `--preview <genre>` | Play one loop of a genre and exit | — |
|
|
866
874
|
| `--render [file]` | Write this project's music to a `.wav` and exit (full scale, ignores `--volume`) | `vibeaudio-<genre>.wav` |
|
|
867
|
-
| `--status` |
|
|
875
|
+
| `--status` | Say why the music is silent or playing, then show what's installed, running and detected, and exit | — |
|
|
868
876
|
| `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
|
|
869
877
|
| `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
|
|
870
878
|
| `--speak` / `--speak-always` / `--no-speak` | Say which project finished, failed or needs you, after the chime: only with two or more sessions going (`--speak`), or every time (`--speak-always`). Saved to `config.json` | off |
|
|
@@ -872,6 +880,7 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
872
880
|
| `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
|
|
873
881
|
| `--card [--out <path>]` | With `--report`: the totals as a shareable image - PNG on macOS, SVG anywhere - saved to `~/.vibeaudio/card.png`, replaced each run | off |
|
|
874
882
|
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
|
|
883
|
+
| `--why` | Why the music is playing or silent, as one plain line (the first line of `--status`) | — |
|
|
875
884
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
876
885
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
877
886
|
| `--dashboard` | Serve a read-only live view of every agent on this machine at `http://127.0.0.1:4747`, and open it. `--port <n>` and `--no-open` adjust it | — |
|
|
@@ -935,6 +944,8 @@ The expiry is deliberate too. A call is a bounded thing; a mute you forget about
|
|
|
935
944
|
|
|
936
945
|
## 🔧 Troubleshooting
|
|
937
946
|
|
|
947
|
+
**Start with `vibe --status`.** Its first line answers "why is it silent?" or "why is it still playing?" in one sentence, with the command that changes it: a mute and how long it has left, `VIBE_DISABLE` in your shell, no audio player, music turned off (globally, for this project, or by `VIBE_MUSIC`), a session waiting on you, or simply no agent working right now.
|
|
948
|
+
|
|
938
949
|
**No sound at all**
|
|
939
950
|
Check that a player exists for your platform (see [Requirements](#-requirements)). VibeAudio prints a notice to stderr when it can't find one. On Linux: `sudo apt install pulseaudio-utils` (or `ffmpeg` / `alsa-utils`).
|
|
940
951
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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
|
@@ -41,6 +41,9 @@ const HOOK_ACTIONS = [
|
|
|
41
41
|
];
|
|
42
42
|
|
|
43
43
|
function printHelp() {
|
|
44
|
+
// Both callers process.exit() next. On macOS a piped stdout is async, so Node 18/20
|
|
45
|
+
// dropped everything past the 8 KB pipe buffer (CI on #77). Blocking writes flush first.
|
|
46
|
+
process.stdout._handle?.setBlocking?.(true);
|
|
44
47
|
console.log(`
|
|
45
48
|
\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}
|
|
46
49
|
Procedural focus music while your AI coding tools think.
|
|
@@ -80,7 +83,8 @@ Procedural focus music while your AI coding tools think.
|
|
|
80
83
|
--no-hud Disable terminal window/tab title animation
|
|
81
84
|
--preview <genre> Play one loop of a genre and exit
|
|
82
85
|
--render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
|
|
83
|
-
--status
|
|
86
|
+
--status Say why the music is silent or playing, then show what is
|
|
87
|
+
installed, running and detected, and exit
|
|
84
88
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes, fails, needs you or looks stuck (macOS and Linux; off by default)
|
|
85
89
|
--speak | --no-speak Also say which project finished, failed or needs you, after the chime - with two or more sessions going (off by default)
|
|
86
90
|
--speak-always Like --speak, but every time, even with one session
|
|
@@ -95,6 +99,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
95
99
|
--port <n> With --dashboard: the port (default ${require("./dashboard").DEFAULT_PORT}, else a free one)
|
|
96
100
|
--no-open With --dashboard: do not open the browser
|
|
97
101
|
--statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
|
|
102
|
+
--why One plain line: why the music is playing or silent (the first line of --status)
|
|
98
103
|
--events Stream agent state changes as JSON lines, until stopped
|
|
99
104
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
100
105
|
--stop Stop the background player, then exit
|
|
@@ -234,6 +239,7 @@ function parseArgs(argv) {
|
|
|
234
239
|
let statusFlag = false;
|
|
235
240
|
let stateFlag = false;
|
|
236
241
|
let statuslineFlag = false;
|
|
242
|
+
let whyFlag = false;
|
|
237
243
|
let eventsFlag = false;
|
|
238
244
|
let doctorFlag = false;
|
|
239
245
|
let notifyFlag = null;
|
|
@@ -418,6 +424,12 @@ function parseArgs(argv) {
|
|
|
418
424
|
continue;
|
|
419
425
|
}
|
|
420
426
|
|
|
427
|
+
if (arg === "--why") {
|
|
428
|
+
whyFlag = true;
|
|
429
|
+
i += 1;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
421
433
|
if (arg === "--state" || arg === "--events") {
|
|
422
434
|
if (arg === "--state") stateFlag = true;
|
|
423
435
|
else eventsFlag = true;
|
|
@@ -645,6 +657,7 @@ function parseArgs(argv) {
|
|
|
645
657
|
status: statusFlag,
|
|
646
658
|
state: stateFlag,
|
|
647
659
|
statusline: statuslineFlag,
|
|
660
|
+
why: whyFlag,
|
|
648
661
|
music,
|
|
649
662
|
events: eventsFlag,
|
|
650
663
|
doctor: doctorFlag,
|
|
@@ -869,21 +882,37 @@ function uninstallHookTargets() {
|
|
|
869
882
|
* do". Every line is read from live state rather than assumed, because the
|
|
870
883
|
* usual cause of confusion is a mismatch between config and what is running.
|
|
871
884
|
*/
|
|
885
|
+
// What hooks.explainMusic() needs, read once: the same sources the sections of
|
|
886
|
+
// --status read, so the one-line answer cannot disagree with them.
|
|
887
|
+
function musicFacts() {
|
|
888
|
+
const hooks = require("./hooks");
|
|
889
|
+
const { muteState, playbackDisabled, detectPlayer: detect } = require("./player");
|
|
890
|
+
const mute = muteState();
|
|
891
|
+
const daemon = hooks.daemonPlaying();
|
|
892
|
+
return {
|
|
893
|
+
disabledByEnv: mute === null && playbackDisabled(),
|
|
894
|
+
mute: mute === null ? null : mute.until ? Math.max(1, Math.round((mute.until - Date.now()) / 60000)) : Infinity,
|
|
895
|
+
backend: Boolean(detect()),
|
|
896
|
+
daemon,
|
|
897
|
+
genre: genreNow(),
|
|
898
|
+
volume: daemon ? (daemon.follows ? Math.round(volumeNow() * 100) : daemon.volume) : null,
|
|
899
|
+
music: musicEnabled(),
|
|
900
|
+
musicSource: process.env.VIBE_MUSIC ? "env" : projectSettings(loadConfig()).music !== undefined ? "project" : "",
|
|
901
|
+
sessions: hooks.agentState().sessions
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
872
905
|
function printStatus() {
|
|
873
906
|
const hooks = require("./hooks");
|
|
874
|
-
const {
|
|
907
|
+
const { detectPlayer: detect, CACHE_ROOT } = require("./player");
|
|
875
908
|
const on = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
876
909
|
const off = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
877
910
|
|
|
878
911
|
console.log(`\n\x1b[1m\x1b[36mVibeAudio\x1b[0m v${pkg.version}\n`);
|
|
879
912
|
|
|
880
|
-
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
console.log(`\x1b[33m🔇 Muted ${muteRemainingText(mute)}\x1b[0m \x1b[90m(since ${mute.since})\x1b[0m\n`);
|
|
884
|
-
} else if (playbackDisabled()) {
|
|
885
|
-
console.log(`\x1b[33m⏸ Muted by VIBE_DISABLE=${process.env.VIBE_DISABLE}\x1b[0m — automatic playback is off (--preview still works).\n`);
|
|
886
|
-
}
|
|
913
|
+
// The answer first (#76): everything below is the detail behind it.
|
|
914
|
+
const now = hooks.explainMusic(musicFacts());
|
|
915
|
+
console.log(`\x1b[1mNow\x1b[0m ${now.playing ? on(now.text) : `\x1b[33m${now.text}\x1b[0m`}\n`);
|
|
887
916
|
|
|
888
917
|
// What will actually play, and which of the three possible sources decided
|
|
889
918
|
// it - the usual confusion is a saved default quietly beaten by an export.
|
|
@@ -1817,6 +1846,7 @@ async function run() {
|
|
|
1817
1846
|
status: showStatus,
|
|
1818
1847
|
state: showState,
|
|
1819
1848
|
statusline: showStatusline,
|
|
1849
|
+
why: showWhy,
|
|
1820
1850
|
music,
|
|
1821
1851
|
events: followEvents,
|
|
1822
1852
|
doctor: showDoctor,
|
|
@@ -1991,6 +2021,13 @@ async function run() {
|
|
|
1991
2021
|
return;
|
|
1992
2022
|
}
|
|
1993
2023
|
|
|
2024
|
+
// --status's first line without the rest, plain: for the mod's pane and band (#60), a prompt segment, a script.
|
|
2025
|
+
if (showWhy) {
|
|
2026
|
+
const hooks = require("./hooks");
|
|
2027
|
+
console.log(hooks.explainMusic(musicFacts()).text);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
1994
2031
|
if (dashboard) {
|
|
1995
2032
|
const { startDashboard } = require("./dashboard");
|
|
1996
2033
|
let server;
|
package/src/hooks.js
CHANGED
|
@@ -266,7 +266,7 @@ const TARGETS = {
|
|
|
266
266
|
seed: () => ({}),
|
|
267
267
|
// Codex records a trusted_hash per hook in config.toml and asks before
|
|
268
268
|
// running one it has not seen, so the install is not live until approved.
|
|
269
|
-
note: "Codex asks you to trust each new hook the
|
|
269
|
+
note: "Codex asks you to trust each new hook once — in the desktop app via Review hooks in the composer (Allow all), in the CLI the first time each fires."
|
|
270
270
|
},
|
|
271
271
|
cursor: {
|
|
272
272
|
name: "Cursor",
|
|
@@ -805,6 +805,50 @@ function formatStatusline({ status, sessions }) {
|
|
|
805
805
|
return `${status}${project}${active.length > 1 ? ` ×${active.length}` : ""}`;
|
|
806
806
|
}
|
|
807
807
|
|
|
808
|
+
/**
|
|
809
|
+
* "Why is it silent?" / "why is it still playing?" as one line, the first of
|
|
810
|
+
* --status (#76). Pure over what --status gathers (`musicFacts()` in cli.js),
|
|
811
|
+
* so the order is the test: what silences everything (VIBE_DISABLE, a mute)
|
|
812
|
+
* outranks what silences only the music, and a running player outranks every
|
|
813
|
+
* reason it might not be running. Each silent line names the command that
|
|
814
|
+
* changes it. `mute` is the minutes left, Infinity for an open-ended mute,
|
|
815
|
+
* null when not muted; `musicSource` is "env", "project" or "" (saved or default).
|
|
816
|
+
*/
|
|
817
|
+
function explainMusic({ disabledByEnv, mute, backend, daemon, genre, volume, music, musicSource, sessions }) {
|
|
818
|
+
const names = (list) => {
|
|
819
|
+
const all = [...new Set(list.map((s) => (s.project ? projectName(s.project) : null)).filter(Boolean))];
|
|
820
|
+
return all.length > 3 ? `${all.slice(0, 3).join(", ")} +${all.length - 3}` : all.join(", ");
|
|
821
|
+
};
|
|
822
|
+
const counted = (list, word) => {
|
|
823
|
+
const n = names(list);
|
|
824
|
+
return `${list.length} session${list.length === 1 ? "" : "s"} ${word}${n ? ` (${n})` : ""}`;
|
|
825
|
+
};
|
|
826
|
+
const silent = (text) => ({ playing: false, text: `silent: ${text}` });
|
|
827
|
+
const working = sessions.filter((s) => s.state !== "waiting");
|
|
828
|
+
const waiting = sessions.filter((s) => s.state === "waiting");
|
|
829
|
+
|
|
830
|
+
if (disabledByEnv) return silent("VIBE_DISABLE is set in this shell (unset it to hear music)");
|
|
831
|
+
if (mute != null) return silent(`muted ${mute === Infinity ? "until you unmute" : `for ${mute} more min`} (vibe --unmute)`);
|
|
832
|
+
if (!backend) return silent("no audio player found on this machine (vibe --doctor)");
|
|
833
|
+
if (daemon) {
|
|
834
|
+
const stuck = sessions.filter((s) => s.state === "stuck");
|
|
835
|
+
const level = volume === null ? "" : ` @ ${volume}%`;
|
|
836
|
+
return {
|
|
837
|
+
playing: true,
|
|
838
|
+
text: `playing: ${daemon.genre || genre}${level}, ${working.length ? counted(working, "working") : "finishing a turn"}` +
|
|
839
|
+
`${stuck.length ? `; heartbeat on, ${names(stuck) || "a session"} keeps failing` : ""}`
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
if (waiting.length && !working.length) return silent(`${counted(waiting, "waiting on you")}; music resumes when you answer`);
|
|
843
|
+
if (!music) {
|
|
844
|
+
// The fix has to beat whatever turned it off: a saved global does not outrank an export or --here.
|
|
845
|
+
const [where, fix] = { env: [" by VIBE_MUSIC in this shell", "unset VIBE_MUSIC"], project: [" for this project", "vibe --music on --here"] }[musicSource] || ["", "vibe --music on"];
|
|
846
|
+
return silent(`music is off${where}; chimes still play (${fix})`);
|
|
847
|
+
}
|
|
848
|
+
if (working.length) return silent(`${counted(working, "working")} but the player is not running (vibe --doctor)`);
|
|
849
|
+
return silent("no agent is working; music starts with your next prompt");
|
|
850
|
+
}
|
|
851
|
+
|
|
808
852
|
const EVENTS_MAX_BYTES = 256 * 1024;
|
|
809
853
|
|
|
810
854
|
/**
|
|
@@ -1781,6 +1825,7 @@ module.exports = {
|
|
|
1781
1825
|
STUCK_WINDOW,
|
|
1782
1826
|
agentState,
|
|
1783
1827
|
formatStatusline,
|
|
1828
|
+
explainMusic,
|
|
1784
1829
|
followEvents,
|
|
1785
1830
|
EVENTS_FILE,
|
|
1786
1831
|
hookEnd,
|