vibeaudio 0.13.0 → 0.13.1
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 +16 -5
- package/package.json +1 -1
- package/src/cli.js +16 -3
- package/src/hooks.js +51 -8
package/README.md
CHANGED
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
|
|
21
21
|
AI coding agents take 15–45 seconds to reason, read files and write code. Staring at a blank cursor feels slow; tabbing away means checking back to see if it's done. VibeAudio fills that gap with music while the agent thinks, and a chime when your output is ready to read.
|
|
22
22
|
|
|
23
|
+
It is built as **calm technology**, in the sense of Mark Weiser and John Seely Brown's *Designing Calm Technology* (Xerox PARC, 1995): information that stays at the edge of your attention and only comes to the front when it matters. You stop noticing the music, but you notice when it changes.
|
|
24
|
+
|
|
25
|
+
| Principle | In VibeAudio |
|
|
26
|
+
| :--- | :--- |
|
|
27
|
+
| Inform without demanding attention | The music sits in the background. What you notice is it *stopping*. |
|
|
28
|
+
| Come to the front only when it matters | Chimes for done, failed and "needs you" only. The music doesn't react to every tool call unless you ask it to ([reactive mode](#reactive-mode-opt-in)). |
|
|
29
|
+
| Make the background informative | Layers build as a turn runs long, and a heartbeat joins when an agent keeps failing. That fires in under 1% of turns, by design. |
|
|
30
|
+
| Calm, not alarming | "Needs you" is a soft rising interval, not an alarm. A mute ends on its own after an hour. |
|
|
31
|
+
|
|
23
32
|
**How it behaves:**
|
|
24
33
|
|
|
25
34
|
* 🧮 **Pure synthesis, zero MP3s.** Every note, chord and pad is generated in code — no audio assets, no npm dependencies, no `node-gyp`.
|
|
@@ -251,8 +260,8 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
251
260
|
|
|
252
261
|
| | File | Music starts | Music stops + chime | Reactive (opt-in) |
|
|
253
262
|
| :--- | :--- | :--- | :--- | :--- |
|
|
254
|
-
| **Claude Code** | `~/.claude/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
255
|
-
| **Codex** | `~/.codex/hooks.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
263
|
+
| **Claude Code** | `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
264
|
+
| **Codex** | `~/.codex/hooks.json` (or `$CODEX_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
256
265
|
| **Cursor** | `~/.cursor/hooks.json` | `beforeSubmitPrompt` | `stop` | `preToolUse` |
|
|
257
266
|
| **Grok** | `~/.grok/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
258
267
|
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
@@ -265,7 +274,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
265
274
|
| | Music pauses + "your turn" chime | Music resumes | Failure chime (API error) | Session closes mid-turn (silent) |
|
|
266
275
|
| :--- | :--- | :--- | :--- | :--- |
|
|
267
276
|
| **Claude Code** | `PermissionRequest`, `Elicitation` | `PostToolUse`, `PostToolUseFailure`, `ElicitationResult` | `StopFailure` | `SessionEnd` |
|
|
268
|
-
| **Codex** | `PermissionRequest` | `PostToolUse` | — |
|
|
277
|
+
| **Codex** | `PermissionRequest` | `PostToolUse` | — | `SessionEnd`, and `Interrupt` (Esc) |
|
|
269
278
|
| **Gemini CLI** | `Notification` (tool permission) | `AfterTool` | — | `SessionEnd` |
|
|
270
279
|
| **Copilot CLI** | `Notification` (permission prompt) | `PostToolUse`, `PostToolUseFailure` | — | `SessionEnd` |
|
|
271
280
|
| **Qwen Code** | `PermissionRequest` | `PostToolUse`, `PostToolUseFailure` | `StopFailure` | `SessionEnd` |
|
|
@@ -283,6 +292,8 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
283
292
|
| **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. |
|
|
284
293
|
| **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. |
|
|
285
294
|
| **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. |
|
|
295
|
+
| **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. |
|
|
296
|
+
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `CODEX_HOME` the way the agents do, so hooks (and `/vibe`) go where your agent actually reads them. Set the variable in the shell you run `vibe --install-hooks` from. |
|
|
286
297
|
| **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`. |
|
|
287
298
|
|
|
288
299
|
</details>
|
|
@@ -309,7 +320,7 @@ Sessions with no id in their payload share a single slot, so they behave as one.
|
|
|
309
320
|
|
|
310
321
|
> **No restart needed, even mid-session — for Claude Code, Codex, Cursor and Grok.** Each re-reads its hook file every time a hook fires, so changes land on your **next prompt**. A daemon already playing keeps its old settings until that prompt replaces it — at most the tail of one turn. Whether an open Gemini CLI, Copilot CLI, Qwen Code or Windsurf session does the same hasn't been checked, so start a new session there to be sure.
|
|
311
322
|
|
|
312
|
-
> **The Claude Code desktop app is covered too**, not just the terminal — both read the same `~/.claude/settings.json
|
|
323
|
+
> **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-antigravity-via-mcp).)
|
|
313
324
|
|
|
314
325
|
> **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.
|
|
315
326
|
|
|
@@ -331,7 +342,7 @@ It needs Node 18+ on your `PATH` (the hooks run `node`), and it also adds `/vibe
|
|
|
331
342
|
|
|
332
343
|
### `/vibe` inside Claude Code
|
|
333
344
|
|
|
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:
|
|
345
|
+
Installing the Claude Code hooks also adds a `/vibe` command (`~/.claude/commands/vibe.md`, or under `$CLAUDE_CONFIG_DIR`), so you can control the music without leaving the session:
|
|
335
346
|
|
|
336
347
|
```text
|
|
337
348
|
/vibe what's installed and playing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.1",
|
|
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
|
@@ -600,6 +600,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
|
|
|
600
600
|
if (events.resume) console.log(` ${events.resume[0].padEnd(19)}→ music resumes once you've answered`);
|
|
601
601
|
if (events.failure) console.log(` ${events.failure.padEnd(19)}→ music stops + failure chime (API error)`);
|
|
602
602
|
if (events.end) console.log(` ${events.end.padEnd(19)}→ music stops if that session started it`);
|
|
603
|
+
if (events.interrupt) console.log(` ${events.interrupt.padEnd(19)}→ music stops silently when you interrupt`);
|
|
603
604
|
|
|
604
605
|
if (id === "claude") {
|
|
605
606
|
const slash = hooks.installSlashCommand({ dryRun });
|
|
@@ -770,6 +771,10 @@ function printStatus() {
|
|
|
770
771
|
if (readVibeHooks(t.file(), t).some(({ command }) => /--genre /.test(command))) {
|
|
771
772
|
console.log(` \x1b[33m↑ pinned by an older install — vibe --install-hooks makes it follow Sound above\x1b[0m`);
|
|
772
773
|
}
|
|
774
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
775
|
+
if (absent.length) {
|
|
776
|
+
console.log(` \x1b[33m+ ${absent.join(", ")} added since this install — vibe --install-hooks picks them up\x1b[0m`);
|
|
777
|
+
}
|
|
773
778
|
}
|
|
774
779
|
|
|
775
780
|
// Background player
|
|
@@ -883,8 +888,11 @@ function doctorChecks() {
|
|
|
883
888
|
const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
884
889
|
for (const p of tokens.slice(0, 2)) if (!fs.existsSync(p)) missing.add(p);
|
|
885
890
|
}
|
|
891
|
+
const absent = hooks.expectedEvents(id).filter((e) => !installed.some(({ event }) => event === e));
|
|
886
892
|
if (missing.size) {
|
|
887
893
|
add("fail", label, `points at a file that no longer exists: ${[...missing].join(", ")}`, "vibe --install-hooks");
|
|
894
|
+
} else if (absent.length) {
|
|
895
|
+
add("warn", label, `installed by an older version, missing ${absent.join(", ")}`, "vibe --install-hooks");
|
|
888
896
|
} else if (installed.some(({ command }) => /--genre /.test(command))) {
|
|
889
897
|
add("warn", label, "pinned to a genre/volume by an older install, which overrides your saved settings", "vibe --install-hooks");
|
|
890
898
|
} else {
|
|
@@ -895,15 +903,20 @@ function doctorChecks() {
|
|
|
895
903
|
// config.toml under "<file>:<event>:<group>:<index>".
|
|
896
904
|
if (id === "codex") {
|
|
897
905
|
let toml = "";
|
|
898
|
-
try { toml = fs.readFileSync(path.join(
|
|
906
|
+
try { toml = fs.readFileSync(path.join(hooks.codexHome(), "config.toml"), "utf8"); } catch (e) { /* none yet */ }
|
|
899
907
|
const pending = [];
|
|
900
908
|
try {
|
|
901
909
|
const file = t.file();
|
|
910
|
+
// Codex canonicalizes CODEX_HOME before keying (find_codex_home()), so
|
|
911
|
+
// a symlinked one - /tmp on macOS - is recorded under its real path.
|
|
912
|
+
let real = file;
|
|
913
|
+
try { real = fs.realpathSync(file); } catch (e) { /* checked below */ }
|
|
902
914
|
for (const [event, entries] of Object.entries(JSON.parse(fs.readFileSync(file, "utf8")).hooks || {})) {
|
|
903
915
|
(entries || []).forEach((entry, g) => {
|
|
904
916
|
t.commands(entry).forEach((command, i) => {
|
|
905
|
-
const
|
|
906
|
-
|
|
917
|
+
const suffix = `:${event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()}:${g}:${i}`;
|
|
918
|
+
const trusted = [file, real].some((f) => toml.includes(`"${f}${suffix}"`));
|
|
919
|
+
if (hooks.VIBE_HOOK_FLAG.test(command || "") && !trusted) pending.push(event);
|
|
907
920
|
});
|
|
908
921
|
});
|
|
909
922
|
}
|
package/src/hooks.js
CHANGED
|
@@ -153,15 +153,35 @@ const MAX_DAEMON_MS = 15 * 60 * 1000;
|
|
|
153
153
|
* - failure: a turn ending in error *instead of* the stop event
|
|
154
154
|
* - end: the session closing, which can cut a turn off before stop
|
|
155
155
|
*
|
|
156
|
+
* - interrupt: the user stopped the turn (Esc), a silent end like `end`
|
|
157
|
+
*
|
|
156
158
|
* `seed` is the root object to write when the file does not exist yet. Cursor
|
|
157
159
|
* and Copilot require a schema version; Codex rejects unknown root keys
|
|
158
160
|
* outright, so nothing may be added there beyond `hooks`.
|
|
159
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Where Claude Code and Codex keep their config. Both move with an env var,
|
|
164
|
+
* and a user who sets one has hooks read from there only: writing to the
|
|
165
|
+
* default path left VibeAudio installed in a file nothing read, and silent.
|
|
166
|
+
* Claude Code: `CLAUDE_CONFIG_DIR ?? ~/.claude`, NFC-normalized (cn() in
|
|
167
|
+
* 2.1.195's bundle; settings.json and commands/ both live under it).
|
|
168
|
+
* Codex: `CODEX_HOME`, empty treated as unset, else `~/.codex`
|
|
169
|
+
* (find_codex_home(), 0.160 source). Empty is unset for both here, so an
|
|
170
|
+
* exported-but-blank variable cannot turn the path relative.
|
|
171
|
+
*/
|
|
172
|
+
function claudeConfigDir() {
|
|
173
|
+
return (process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")).normalize("NFC");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codexHome() {
|
|
177
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
const TARGETS = {
|
|
161
181
|
claude: {
|
|
162
182
|
name: "Claude Code",
|
|
163
183
|
cmd: "claude",
|
|
164
|
-
file: () => path.join(
|
|
184
|
+
file: () => path.join(claudeConfigDir(), "settings.json"),
|
|
165
185
|
// - wait: PermissionRequest fires when the dialog is shown in the terminal,
|
|
166
186
|
// the SDK (desktop app, IDEs) and print mode alike - Notification's
|
|
167
187
|
// permission_prompt is raised by the terminal UI alone, after 6s idle.
|
|
@@ -182,17 +202,23 @@ const TARGETS = {
|
|
|
182
202
|
codex: {
|
|
183
203
|
name: "Codex",
|
|
184
204
|
cmd: "codex",
|
|
185
|
-
file: () => path.join(
|
|
205
|
+
file: () => path.join(codexHome(), "hooks.json"),
|
|
186
206
|
// PermissionRequest runs only when Codex is about to ask for approval,
|
|
187
|
-
// with tool_name, and is present in 0.125's binary.
|
|
188
|
-
//
|
|
189
|
-
//
|
|
207
|
+
// with tool_name, and is present in 0.125's binary. SessionEnd arrived in
|
|
208
|
+
// 0.145 and Interrupt in 0.150 (source of each release tag). Listing them
|
|
209
|
+
// is safe on older versions: HookEventsToml has never denied unknown
|
|
210
|
+
// fields (checked 0.125 through 0.160), so an older Codex ignores an
|
|
211
|
+
// event it does not know - only the file's root is strict. Interrupt runs
|
|
212
|
+
// on the turn-abort path, not the one that runs Stop, with session_id,
|
|
213
|
+
// cwd and transcript_path.
|
|
190
214
|
events: {
|
|
191
215
|
start: "UserPromptSubmit",
|
|
192
216
|
stop: "Stop",
|
|
193
217
|
tool: "PreToolUse",
|
|
194
218
|
wait: ["PermissionRequest"],
|
|
195
|
-
resume: ["PostToolUse"]
|
|
219
|
+
resume: ["PostToolUse"],
|
|
220
|
+
interrupt: "Interrupt",
|
|
221
|
+
end: "SessionEnd"
|
|
196
222
|
},
|
|
197
223
|
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
198
224
|
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
@@ -346,6 +372,18 @@ function detectTargets() {
|
|
|
346
372
|
});
|
|
347
373
|
}
|
|
348
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Every event an install by this version writes for a target, `tool` aside
|
|
377
|
+
* (reactive mode only). A hook file is written once and outlives upgrades,
|
|
378
|
+
* so an event added in a later version - Codex's Interrupt and SessionEnd in
|
|
379
|
+
* 0.13.1 - never reaches someone who installed before it unless they
|
|
380
|
+
* reinstall. `--doctor` and `--status` compare against this to say so.
|
|
381
|
+
*/
|
|
382
|
+
function expectedEvents(id) {
|
|
383
|
+
const ev = TARGETS[id].events;
|
|
384
|
+
return [ev.start, ev.stop, ...(ev.wait || []), ...(ev.resume || []), ev.failure, ev.end, ev.interrupt].filter(Boolean);
|
|
385
|
+
}
|
|
386
|
+
|
|
349
387
|
function settingsPath() {
|
|
350
388
|
return TARGETS.claude.file();
|
|
351
389
|
}
|
|
@@ -1047,7 +1085,7 @@ function hookEnd(raw) {
|
|
|
1047
1085
|
if (!session) return false;
|
|
1048
1086
|
|
|
1049
1087
|
fs.rmSync(sessionFile(id), { force: true });
|
|
1050
|
-
emitEvent("ended", id, session.project);
|
|
1088
|
+
emitEvent(parsePayload(raw).hook_event_name === "Interrupt" ? "interrupted" : "ended", id, session.project);
|
|
1051
1089
|
if (working(listSessions()).length === 0) {
|
|
1052
1090
|
stopDaemon({ keepSessions: true });
|
|
1053
1091
|
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
@@ -1246,6 +1284,8 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1246
1284
|
}
|
|
1247
1285
|
if (ev.failure) setHook(settings.hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
|
|
1248
1286
|
if (ev.end) setHook(settings.hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
|
|
1287
|
+
// An interrupt ends the turn the same silent way a closed session does.
|
|
1288
|
+
if (ev.interrupt) setHook(settings.hooks, ev.interrupt, hookCommand("--hook-end", genre, volume), id);
|
|
1249
1289
|
|
|
1250
1290
|
const after = `${JSON.stringify(settings, null, 2)}\n`;
|
|
1251
1291
|
if (!dryRun) fs.writeFileSync(file, after);
|
|
@@ -1261,7 +1301,7 @@ function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = fa
|
|
|
1261
1301
|
const SLASH_MARK = "<!-- vibeaudio:slash-command -->";
|
|
1262
1302
|
|
|
1263
1303
|
function slashCommandFile() {
|
|
1264
|
-
return path.join(
|
|
1304
|
+
return path.join(claudeConfigDir(), "commands", "vibe.md");
|
|
1265
1305
|
}
|
|
1266
1306
|
|
|
1267
1307
|
function slashCommandText() {
|
|
@@ -1340,6 +1380,9 @@ function uninstallHooks(file = null, { id = "claude" } = {}) {
|
|
|
1340
1380
|
|
|
1341
1381
|
module.exports = {
|
|
1342
1382
|
shellQuote,
|
|
1383
|
+
claudeConfigDir,
|
|
1384
|
+
codexHome,
|
|
1385
|
+
expectedEvents,
|
|
1343
1386
|
runDaemon,
|
|
1344
1387
|
hookStart,
|
|
1345
1388
|
hookStop,
|