vibeaudio 0.16.2 → 0.18.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 +50 -8
- package/package.json +1 -1
- package/src/cli.js +106 -1
- package/src/dashboard.js +187 -0
- package/src/hooks.js +102 -9
- package/src/interactive.js +1 -1
- package/src/mcp.js +115 -12
- package/src/player.js +13 -6
package/README.md
CHANGED
|
@@ -269,10 +269,10 @@ Each tool spells its events its own way, and VibeAudio writes whichever dialect
|
|
|
269
269
|
| **Claude Code** | `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
270
270
|
| **Codex** | `~/.codex/hooks.json` (or `$CODEX_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
271
271
|
| **Cursor** | `~/.cursor/hooks.json` | `beforeSubmitPrompt` | `stop` | `preToolUse` |
|
|
272
|
-
| **Grok** | `~/.grok/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
273
|
-
| **Gemini CLI** | `~/.gemini/settings.json` | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
274
|
-
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
275
|
-
| **Qwen Code** | `~/.qwen/settings.json` | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
272
|
+
| **Grok** | `~/.grok/hooks/vibeaudio.json` (or `$GROK_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
273
|
+
| **Gemini CLI** | `~/.gemini/settings.json` (or `$GEMINI_CLI_HOME/.gemini`) | `BeforeAgent` | `AfterAgent` | `BeforeTool` |
|
|
274
|
+
| **Copilot CLI** | `~/.copilot/hooks/vibeaudio.json` (or `$COPILOT_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
275
|
+
| **Qwen Code** | `~/.qwen/settings.json` (or `$QWEN_HOME`) | `UserPromptSubmit` | `Stop` | `PreToolUse` |
|
|
276
276
|
| **Windsurf** | `~/.codeium/windsurf/hooks.json` | `pre_user_prompt` | `post_cascade_response` | `pre_run_command` |
|
|
277
277
|
| **Antigravity** | `~/.gemini/config/hooks.json`, under its own `vibeaudio` name | `PreInvocation` (the first of each prompt) | `Stop` | — never ([why](#antigravity-hooks)) |
|
|
278
278
|
|
|
@@ -302,7 +302,7 @@ Where an agent reports more than start and stop, VibeAudio listens for that too
|
|
|
302
302
|
| **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. |
|
|
303
303
|
| <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. |
|
|
304
304
|
| **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. |
|
|
305
|
-
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR` and `
|
|
305
|
+
| **Moved your config folder?** | VibeAudio follows `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `COPILOT_HOME`, `GEMINI_CLI_HOME`, `QWEN_HOME` and `GROK_HOME` the way each agent does, so hooks (and `/vibe`) go where your agent actually reads them. Cursor and Windsurf have no such variable that we could confirm. Set the variable in the shell you run `vibe --install-hooks` from. |
|
|
306
306
|
| **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`. |
|
|
307
307
|
|
|
308
308
|
</details>
|
|
@@ -540,7 +540,9 @@ It is plain text on purpose: tmux and Starship do their own styling. It doesn't
|
|
|
540
540
|
**tmux status bar:**
|
|
541
541
|
```bash
|
|
542
542
|
set -g status-right '#(vibe --statusline)'
|
|
543
|
+
set -g status-interval 5
|
|
543
544
|
```
|
|
545
|
+
tmux re-runs `#(...)` every `status-interval` seconds, 15 by default, so without the second line a dialog can wait up to 15 seconds before the bar says so.
|
|
544
546
|
|
|
545
547
|
**macOS speech alert** — announce out loud whenever an agent stops to ask for your input:
|
|
546
548
|
```bash
|
|
@@ -556,10 +558,46 @@ format = "([🎧 $output ]($style))"
|
|
|
556
558
|
```
|
|
557
559
|
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
560
|
|
|
559
|
-
|
|
561
|
+
**Webhook** — post `waiting` and `stuck` to anything that takes an HTTP request (a smart plug bridge, a Stream Deck plugin, Slack's incoming webhooks):
|
|
562
|
+
```bash
|
|
563
|
+
vibe --events | jq --unbuffered -c 'select(.event=="waiting" or .event=="stuck") | {event, project}' \
|
|
564
|
+
| while read -r line; do curl -s -X POST -H 'Content-Type: application/json' -d "$line" https://example.com/hook; done
|
|
565
|
+
```
|
|
566
|
+
The `select` also drops the snapshot `--events` prints first (it has no `event` field), so only changes after you start it are sent: a session already waiting at that moment is not announced. For the current state, call `vibe --state` once.
|
|
567
|
+
|
|
568
|
+
*Not covered yet: a macOS menu bar plugin (SwiftBar/xbar) and Linux desktop alerts. Neither has been run on a real setup, so neither is documented. `--notify` already raises a banner when a turn finishes or needs you.*
|
|
569
|
+
|
|
570
|
+
*Verified on macOS arm64 with Node 24, tmux 3.7 and Starship 1.26: the tmux and Starship segments showed `working`, `working ×2` and `waiting: web` and disappeared when idle; the `say` and webhook pipelines fired once per `waiting` event while the stream stayed open. Not run on Linux or Windows.*
|
|
560
571
|
|
|
561
572
|
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.
|
|
562
573
|
|
|
574
|
+
#### 🖥️ A live view: `vibe --dashboard`
|
|
575
|
+
|
|
576
|
+
```bash
|
|
577
|
+
vibe --dashboard # serves http://127.0.0.1:4747 and opens it
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+

|
|
581
|
+
|
|
582
|
+
One row per session (waiting first, then stuck, then working), with the project, the tool it's waiting on and how long it has been in that state, and a feed of recent events with failures and stuck sessions in red. It's built on `--events`, so it shows every agent VibeAudio hooks into, and it updates as things happen.
|
|
583
|
+
|
|
584
|
+
It's **read-only and local**: it binds to `127.0.0.1` only, refuses any request whose `Host` isn't its own address (so a web page can't reach it through DNS rebinding), sends no CORS headers, and only answers `GET`, so there's nothing a page in another tab could trigger. It uses no external assets, and nothing leaves your machine. It runs in the foreground until Ctrl+C. `--port <n>` picks the port (4747 by default; if that's taken, a free one is used), and `--no-open` skips opening the browser.
|
|
585
|
+
|
|
586
|
+
#### 🔔 Beyond agents: `vibe --announce`
|
|
587
|
+
|
|
588
|
+
A CI run, a deploy, a long script that wasn't started through `vibe <command>`: the same chimes can say those finished too.
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
vibe --announce "CI green" # success chime
|
|
592
|
+
vibe --announce "deploy" --outcome attention # the "needs you" chime
|
|
593
|
+
./deploy.sh; vibe --announce "deploy" --exit-code $? # 0 is success, anything else failure
|
|
594
|
+
gh run watch; vibe --announce "CI finished" --exit-code $?
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
It plays the chime for the outcome, shows the `--notify` banner if you have it on, and appends an `announced` event (`source: "cli"`, the text and the outcome) to `~/.vibeaudio/events.jsonl`, so a light or dashboard built on `--events` reacts too. It never starts or stops music and never touches an agent session. A mute silences the sound and banner but not the event, like every event.
|
|
598
|
+
|
|
599
|
+
It always exits 0, so it can't turn a pipeline red, and the text is only ever passed as an argument, never through a shell. Text is capped at 200 characters, and the same text announced again within five seconds is dropped, so a loop can't machine-gun your speakers. `--no-chime` keeps it to the banner and event.
|
|
600
|
+
|
|
563
601
|
## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
|
|
564
602
|
|
|
565
603
|
**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) [Windsurf](https://docs.devin.ai/desktop/cascade/hooks) and [Antigravity](https://antigravity.google/docs/hooks) have hook systems, and `--install-hooks` writes to all nine** — 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.
|
|
@@ -630,9 +668,11 @@ VIBE_VOLUME = "25"
|
|
|
630
668
|
Restart the app afterwards — this one is read from the environment the server was launched with, so unlike the saved default it can't change under a running client.
|
|
631
669
|
|
|
632
670
|
#### Exposed MCP Tools:
|
|
633
|
-
* `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`).
|
|
671
|
+
* `vibe_play`: Start procedural focus music (`genre`: `lofi`, `synthwave`, `8bit`, `electronic`, `jazz`, `zen`, `piano`, `drone`, `rain`, `ocean`, `random`; `volume`: `5-100`; `project_dir`: the workspace's absolute path, optional).
|
|
634
672
|
* `vibe_stop`: Stop music and play the completion chime (`outcome`: `success` or `failure`).
|
|
635
|
-
* `vibe_status`: Return current playback state and active tier.
|
|
673
|
+
* `vibe_status`: Return current playback state and active tier, and which project the arrangement comes from.
|
|
674
|
+
|
|
675
|
+
**Which project it sounds like.** Hooks and the wrapper run inside your project, so each repo has its own arrangement. An MCP server is started once by the app, so it asks: a client that supports MCP *roots* (Claude Code does) reports its open folder, and any client's model can pass `project_dir`. That project's own settings apply too: a genre saved with `vibe --genre jazz --here` plays for that project over MCP as well. With neither, it uses the folder the app launched the server in, as before; `VIBE_SEED` still overrides all of it. `vibe_status` shows the `project` and its `projectSource` (`project_dir`, `roots`, `cwd` or `VIBE_SEED`).
|
|
636
676
|
|
|
637
677
|
Playback stops automatically if the desktop client disconnects, and caps out after 15 minutes — which also covers the likelier case of a model that started the music and never called `vibe_stop`.
|
|
638
678
|
|
|
@@ -745,6 +785,8 @@ The full order, highest first: **a flag** → **an environment variable** → **
|
|
|
745
785
|
| `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
|
|
746
786
|
| `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
|
|
747
787
|
| `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
|
|
788
|
+
| `--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 | — |
|
|
789
|
+
| `--announce <text>` | Play the chime for something that isn't an agent (CI, a deploy, a script), then exit. Add `--outcome success\|failure\|attention` or `--exit-code $?` | `success` |
|
|
748
790
|
| `--stop` | Stop the background player, then exit | — |
|
|
749
791
|
| `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
|
|
750
792
|
| `--unmute` | Resume normal playback, then exit | — |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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
|
@@ -59,6 +59,7 @@ Procedural focus music while your AI coding tools think.
|
|
|
59
59
|
vibe --genre 8bit sleep 5
|
|
60
60
|
vibe --volume 30 npm test
|
|
61
61
|
vibe --preview jazz
|
|
62
|
+
gh run watch; vibe --announce "CI finished" --exit-code $? \x1b[90m# the chime for a CI run or a script\x1b[0m
|
|
62
63
|
vibe --render \x1b[90m# save this repo's sound as a .wav to share\x1b[0m
|
|
63
64
|
vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
|
|
64
65
|
|
|
@@ -81,6 +82,12 @@ Procedural focus music while your AI coding tools think.
|
|
|
81
82
|
--notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
|
|
82
83
|
--report [days] How long you waited on agents, and where (default: 7 days)
|
|
83
84
|
--state Print what every agent is doing as JSON: idle, working, stuck or waiting
|
|
85
|
+
--announce <text> Play the signals for something that is not an agent (CI, a deploy, a script), then exit
|
|
86
|
+
--outcome <o> With --announce: success (default), failure or attention
|
|
87
|
+
--exit-code <n> With --announce: 0 is success, anything else failure (use $?)
|
|
88
|
+
--dashboard Serve a read-only live view of every agent on this machine, and open it
|
|
89
|
+
--port <n> With --dashboard: the port (default ${require("./dashboard").DEFAULT_PORT}, else a free one)
|
|
90
|
+
--no-open With --dashboard: do not open the browser
|
|
84
91
|
--statusline Print that as one plain line for tmux or a prompt; prints nothing when idle
|
|
85
92
|
--events Stream agent state changes as JSON lines, until stopped
|
|
86
93
|
--doctor Check the setup; each problem comes with its fix (exit 1 if any)
|
|
@@ -143,7 +150,11 @@ const VALUE_FLAGS = new Set([
|
|
|
143
150
|
"--music",
|
|
144
151
|
"--seed",
|
|
145
152
|
"--tools",
|
|
146
|
-
"--event"
|
|
153
|
+
"--event",
|
|
154
|
+
"--announce",
|
|
155
|
+
"--outcome",
|
|
156
|
+
"--exit-code",
|
|
157
|
+
"--port"
|
|
147
158
|
]);
|
|
148
159
|
|
|
149
160
|
function parseArgs(argv) {
|
|
@@ -198,6 +209,12 @@ function parseArgs(argv) {
|
|
|
198
209
|
let followVolume = false;
|
|
199
210
|
let plugin = false;
|
|
200
211
|
let hookEvent = null;
|
|
212
|
+
let dashboardFlag = false;
|
|
213
|
+
let dashboardPort = null;
|
|
214
|
+
let noOpen = false;
|
|
215
|
+
let announceText = null;
|
|
216
|
+
let announceOutcome = null;
|
|
217
|
+
let announceExit = null;
|
|
201
218
|
let here_flag = false;
|
|
202
219
|
let dryRun = false;
|
|
203
220
|
let tools = null;
|
|
@@ -314,6 +331,45 @@ function parseArgs(argv) {
|
|
|
314
331
|
continue;
|
|
315
332
|
}
|
|
316
333
|
|
|
334
|
+
if (arg === "--announce" || arg === "--outcome" || arg === "--exit-code") {
|
|
335
|
+
const value = args[i + 1];
|
|
336
|
+
if (arg === "--announce") announceText = value;
|
|
337
|
+
else if (arg === "--outcome") {
|
|
338
|
+
announceOutcome = value.toLowerCase();
|
|
339
|
+
const known = require("./hooks").ANNOUNCE_OUTCOMES;
|
|
340
|
+
if (!known.includes(announceOutcome)) {
|
|
341
|
+
console.error(`\x1b[31m[vibeaudio] --outcome takes ${known.join(", ")}, not '${value}'.\x1b[0m`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
if (!/^-?\d+$/.test(value)) {
|
|
346
|
+
console.error(`\x1b[31m[vibeaudio] --exit-code takes a number, not '${value}'.\x1b[0m`);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
announceExit = parseInt(value, 10);
|
|
350
|
+
}
|
|
351
|
+
i += 2;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (arg === "--dashboard" || arg === "--no-open") {
|
|
356
|
+
if (arg === "--dashboard") dashboardFlag = true;
|
|
357
|
+
else noOpen = true;
|
|
358
|
+
i += 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (arg === "--port") {
|
|
363
|
+
const n = Number(args[i + 1]);
|
|
364
|
+
if (!/^\d+$/.test(args[i + 1]) || n > 65535) {
|
|
365
|
+
console.error(`\x1b[31m[vibeaudio] --port takes a number from 0 to 65535, not '${args[i + 1]}'.\x1b[0m`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
dashboardPort = n;
|
|
369
|
+
i += 2;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
317
373
|
if (arg === "--statusline") {
|
|
318
374
|
statuslineFlag = true;
|
|
319
375
|
i += 1;
|
|
@@ -532,6 +588,8 @@ function parseArgs(argv) {
|
|
|
532
588
|
followVolume,
|
|
533
589
|
plugin,
|
|
534
590
|
hookEvent,
|
|
591
|
+
dashboard: dashboardFlag ? { port: dashboardPort, open: !noOpen } : null,
|
|
592
|
+
announce: { text: announceText, outcome: announceOutcome, exitCode: announceExit },
|
|
535
593
|
dryRun,
|
|
536
594
|
tools,
|
|
537
595
|
typed,
|
|
@@ -1492,6 +1550,17 @@ function windowsCommandNeedsShell(command) {
|
|
|
1492
1550
|
return !/[\\/]/.test(command);
|
|
1493
1551
|
}
|
|
1494
1552
|
|
|
1553
|
+
function openBrowser(url) {
|
|
1554
|
+
const { spawn } = require("child_process");
|
|
1555
|
+
const [cmd, args] =
|
|
1556
|
+
process.platform === "darwin" ? ["open", [url]]
|
|
1557
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
1558
|
+
: ["xdg-open", [url]];
|
|
1559
|
+
try {
|
|
1560
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref(); // no browser to open is not an error
|
|
1561
|
+
} catch (e) { /* same */ }
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1495
1564
|
function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false, music = true) {
|
|
1496
1565
|
const player = new AudioPlayer();
|
|
1497
1566
|
const hookDriven = hooksAlreadyCover(cmdArgs);
|
|
@@ -1618,6 +1687,8 @@ async function run() {
|
|
|
1618
1687
|
followVolume,
|
|
1619
1688
|
plugin,
|
|
1620
1689
|
hookEvent,
|
|
1690
|
+
dashboard,
|
|
1691
|
+
announce: announceReq,
|
|
1621
1692
|
dryRun,
|
|
1622
1693
|
tools,
|
|
1623
1694
|
typed,
|
|
@@ -1643,6 +1714,25 @@ async function run() {
|
|
|
1643
1714
|
}
|
|
1644
1715
|
}
|
|
1645
1716
|
|
|
1717
|
+
if (announceReq.text === null && (announceReq.outcome !== null || announceReq.exitCode !== null)) {
|
|
1718
|
+
console.error("\x1b[31m[vibeaudio] --outcome and --exit-code go with --announce <text>.\x1b[0m");
|
|
1719
|
+
process.exit(1);
|
|
1720
|
+
}
|
|
1721
|
+
if (announceReq.text !== null) {
|
|
1722
|
+
if (!announceReq.text.trim()) {
|
|
1723
|
+
console.error("\x1b[31m[vibeaudio] --announce needs some text.\x1b[0m Run vibe --help for the options.");
|
|
1724
|
+
process.exit(1);
|
|
1725
|
+
}
|
|
1726
|
+
if (announceReq.outcome !== null && announceReq.exitCode !== null) {
|
|
1727
|
+
console.error("\x1b[31m[vibeaudio] Give --outcome or --exit-code, not both.\x1b[0m");
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
const outcome = announceReq.outcome || (announceReq.exitCode === null || announceReq.exitCode === 0 ? "success" : "failure");
|
|
1731
|
+
// Exit 0 whatever happens: `cmd; vibe --announce ...` must not turn a pipeline red.
|
|
1732
|
+
require("./hooks").announce(announceReq.text, { outcome, volume, chimeVolume, noChime, genre });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1646
1736
|
if (reactive) {
|
|
1647
1737
|
console.error(
|
|
1648
1738
|
"\x1b[33m[vibeaudio] --reactive only applies to --install-hooks; ignoring it here.\x1b[0m"
|
|
@@ -1714,6 +1804,21 @@ async function run() {
|
|
|
1714
1804
|
return;
|
|
1715
1805
|
}
|
|
1716
1806
|
|
|
1807
|
+
if (dashboard) {
|
|
1808
|
+
const { startDashboard } = require("./dashboard");
|
|
1809
|
+
let server;
|
|
1810
|
+
try {
|
|
1811
|
+
server = await startDashboard({ port: dashboard.port });
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
console.error(`\x1b[31m[vibeaudio] Could not start the dashboard: ${e.code === "EADDRINUSE" ? `port ${dashboard.port} is in use` : e.message}.\x1b[0m`);
|
|
1814
|
+
process.exit(1);
|
|
1815
|
+
}
|
|
1816
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
1817
|
+
console.log(`\x1b[1mVibeAudio dashboard\x1b[0m ${url} \x1b[90m(read-only, this machine only; Ctrl+C to stop)\x1b[0m`);
|
|
1818
|
+
if (dashboard.open) openBrowser(url);
|
|
1819
|
+
return; // the listening server keeps the process alive
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1717
1822
|
if (followEvents) {
|
|
1718
1823
|
process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
|
|
1719
1824
|
require("./hooks").followEvents();
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `vibe --dashboard`: a read-only local page showing what every agent is doing.
|
|
5
|
+
* Built on what already exists: the page is fed by followEvents() (the same
|
|
6
|
+
* follower behind `vibe --events`) over Server-Sent Events, so there is one
|
|
7
|
+
* source of truth and nothing here reads session files itself.
|
|
8
|
+
*
|
|
9
|
+
* Local by construction: bound to 127.0.0.1, a Host check against the bound
|
|
10
|
+
* address (so a page on the open web cannot reach it through DNS rebinding),
|
|
11
|
+
* no CORS headers, and a CSP that allows exactly this page's one script. It
|
|
12
|
+
* only ever answers GET, so there is nothing a cross-site request could do.
|
|
13
|
+
* Controls (mute, stop) are a separate decision, because they would need CSRF
|
|
14
|
+
* protection first.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const http = require("http");
|
|
18
|
+
const crypto = require("crypto");
|
|
19
|
+
const hooks = require("./hooks");
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PORT = 4747;
|
|
22
|
+
const MAX_CLIENTS = 20;
|
|
23
|
+
const HEARTBEAT_MS = 15000;
|
|
24
|
+
const BACKLOG_LINES = 25;
|
|
25
|
+
|
|
26
|
+
const STYLE = `
|
|
27
|
+
:root{color-scheme:light dark;--bg:#f6f7f9;--card:#fff;--ink:#14171c;--dim:#667085;--line:#e3e6ea;--work:#1a7f4b;--wait:#b45309;--stuck:#c0262d;--idle:#98a2b3}
|
|
28
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0e1116;--card:#171b22;--ink:#e8ebf0;--dim:#8b95a5;--line:#262c36;--work:#4cc38a;--wait:#f5a524;--stuck:#ff6b72;--idle:#667085}}
|
|
29
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
30
|
+
main{max-width:860px;margin:0 auto;padding:24px 16px 48px}
|
|
31
|
+
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:20px}
|
|
32
|
+
h1{font-size:20px;margin:0}h2{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);margin:28px 0 8px}
|
|
33
|
+
#status{font-weight:600}#conn{margin-left:auto;font-size:13px;color:var(--dim)}
|
|
34
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
|
35
|
+
.row{display:grid;grid-template-columns:132px 1fr auto;gap:12px;align-items:center;padding:12px 14px;border-top:1px solid var(--line)}
|
|
36
|
+
.row:first-child{border-top:0}.state{font-weight:600}.proj{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
37
|
+
.path{color:var(--dim);font-size:12px;display:block;overflow:hidden;text-overflow:ellipsis}.time{color:var(--dim);font-variant-numeric:tabular-nums;font-size:13px}
|
|
38
|
+
.working .state{color:var(--work)}.waiting .state{color:var(--wait)}.stuck .state{color:var(--stuck)}
|
|
39
|
+
.waiting{background:color-mix(in srgb,var(--wait) 9%,transparent)}.stuck{background:color-mix(in srgb,var(--stuck) 9%,transparent)}
|
|
40
|
+
.empty{padding:20px 14px;color:var(--dim)}
|
|
41
|
+
li{list-style:none;display:grid;grid-template-columns:72px 96px 1fr;gap:10px;padding:7px 14px;border-top:1px solid var(--line);font-size:13px}
|
|
42
|
+
ul{margin:0;padding:0}li:first-child{border-top:0}li .t{color:var(--dim);font-variant-numeric:tabular-nums}li.bad{color:var(--stuck)}li.attn{color:var(--wait)}
|
|
43
|
+
@media(max-width:520px){.row{grid-template-columns:80px 1fr}.row .time{grid-column:2}li{grid-template-columns:60px 1fr}li .e{grid-column:2}li .d{grid-column:2}}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const SCRIPT = `
|
|
47
|
+
const $ = (id) => document.getElementById(id);
|
|
48
|
+
const RANK = { waiting: 0, stuck: 1, working: 2 };
|
|
49
|
+
const seen = new Map(); // session -> { state, at }: when this page first saw it in that state
|
|
50
|
+
let rows = [];
|
|
51
|
+
const base = (p) => String(p || "").replace(/[\\\\/]+$/, "").split(/[\\\\/]/).pop() || "a session";
|
|
52
|
+
const dur = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? s + "s" : s < 3600 ? Math.floor(s / 60) + "m " + (s % 60) + "s" : Math.floor(s / 3600) + "h " + Math.floor(s % 3600 / 60) + "m"; };
|
|
53
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
54
|
+
|
|
55
|
+
function renderState(st) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
$("status").textContent = st.status;
|
|
58
|
+
document.title = st.status === "idle" ? "VibeAudio" : st.status + " - VibeAudio";
|
|
59
|
+
rows = st.sessions.slice().sort((a, b) => RANK[a.state] - RANK[b.state]).map((s) => {
|
|
60
|
+
const prev = seen.get(s.session);
|
|
61
|
+
const at = prev && prev.state === s.state ? prev.at : (prev ? now : ((s.state === "waiting" && s.waitingSince) || s.since || now));
|
|
62
|
+
seen.set(s.session, { state: s.state, at });
|
|
63
|
+
return { ...s, at };
|
|
64
|
+
});
|
|
65
|
+
for (const id of seen.keys()) if (!st.sessions.some((s) => s.session === id)) seen.delete(id);
|
|
66
|
+
paint();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function paint() {
|
|
70
|
+
const box = $("sessions");
|
|
71
|
+
box.replaceChildren();
|
|
72
|
+
if (!rows.length) { box.append(el("div", "empty", "Nothing is running. Start a turn in any agent with VibeAudio hooks and it appears here.")); return; }
|
|
73
|
+
for (const s of rows) {
|
|
74
|
+
const row = el("div", "row " + s.state);
|
|
75
|
+
row.append(el("span", "state", s.state + (s.tool ? " (" + s.tool + ")" : "")));
|
|
76
|
+
const name = el("span", "proj", base(s.project));
|
|
77
|
+
if (s.project) name.append(el("span", "path", s.project));
|
|
78
|
+
row.append(name, el("span", "time", dur(Date.now() - s.at)));
|
|
79
|
+
box.append(row);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addEvent(e) {
|
|
84
|
+
const li = el("li", e.outcome === "failure" || e.event === "stuck" ? "bad" : e.event === "waiting" || (e.event === "announced" && e.outcome === "attention") ? "attn" : "");
|
|
85
|
+
li.append(el("span", "t", new Date(e.at).toLocaleTimeString([], { hour12: false })), el("span", "e", e.event), el("span", "d", [e.text || base(e.project), e.tool, e.outcome].filter(Boolean).join(" · ")));
|
|
86
|
+
const list = $("feed");
|
|
87
|
+
list.prepend(li);
|
|
88
|
+
while (list.children.length > 60) list.lastChild.remove();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const es = new EventSource("/events");
|
|
92
|
+
es.addEventListener("state", (m) => renderState(JSON.parse(m.data)));
|
|
93
|
+
es.addEventListener("event", (m) => addEvent(JSON.parse(m.data)));
|
|
94
|
+
es.addEventListener("backlog", (m) => JSON.parse(m.data).forEach(addEvent));
|
|
95
|
+
es.onopen = () => { $("conn").textContent = "live"; };
|
|
96
|
+
es.onerror = () => { $("conn").textContent = "reconnecting…"; };
|
|
97
|
+
setInterval(paint, 1000);
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
const BODY = `<main>
|
|
101
|
+
<header><h1>VibeAudio</h1><span id="status" aria-live="polite">idle</span><span id="conn">connecting…</span></header>
|
|
102
|
+
<h2>Sessions</h2><div class="card" id="sessions"></div>
|
|
103
|
+
<h2>Recent events</h2><div class="card"><ul id="feed"></ul></div>
|
|
104
|
+
</main>`;
|
|
105
|
+
|
|
106
|
+
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VibeAudio</title><style>${STYLE}</style></head><body>${BODY}<script>${SCRIPT}</script></body></html>`;
|
|
107
|
+
|
|
108
|
+
// The page's one script is allowed by hash, so no other script, inline or not, can run in it.
|
|
109
|
+
const SCRIPT_HASH = crypto.createHash("sha256").update(SCRIPT).digest("base64");
|
|
110
|
+
const CSP = `default-src 'none'; script-src 'sha256-${SCRIPT_HASH}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
|
|
111
|
+
const HEADERS = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": CSP, "Referrer-Policy": "no-referrer" };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates the server (not yet listening). `close()` also ends the event
|
|
115
|
+
* streams, which would otherwise keep it open forever.
|
|
116
|
+
*/
|
|
117
|
+
function createDashboard() {
|
|
118
|
+
const clients = new Set();
|
|
119
|
+
const server = http.createServer((req, res) => {
|
|
120
|
+
// DNS rebinding: a hostile page can point its own name at 127.0.0.1, but the
|
|
121
|
+
// browser then sends that name as Host. Only our own address is accepted.
|
|
122
|
+
const port = server.address() && server.address().port;
|
|
123
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
124
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
125
|
+
res.writeHead(403, { ...HEADERS, "Content-Type": "text/plain" }).end("Forbidden\n");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
129
|
+
res.writeHead(405, { ...HEADERS, Allow: "GET, HEAD", "Content-Type": "text/plain" }).end("Read-only\n");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const url = (req.url || "").split("?")[0];
|
|
133
|
+
if (url === "/") {
|
|
134
|
+
res.writeHead(200, { ...HEADERS, "Content-Type": "text/html; charset=utf-8" });
|
|
135
|
+
res.end(req.method === "HEAD" ? undefined : PAGE);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (url === "/events" && req.method === "GET") {
|
|
139
|
+
if (clients.size >= MAX_CLIENTS) {
|
|
140
|
+
res.writeHead(503, { ...HEADERS, "Content-Type": "text/plain" }).end("Too many viewers\n");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
res.writeHead(200, { ...HEADERS, "Content-Type": "text/event-stream", Connection: "keep-alive" });
|
|
144
|
+
const send = (name, data) => res.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
145
|
+
send("backlog", hooks.recentEvents(BACKLOG_LINES));
|
|
146
|
+
const follower = hooks.followEvents((chunk) => {
|
|
147
|
+
let line;
|
|
148
|
+
try { line = JSON.parse(chunk); } catch (e) { return; }
|
|
149
|
+
if (line.event === "state") return send("state", line);
|
|
150
|
+
send("event", line);
|
|
151
|
+
send("state", hooks.agentState()); // an event says what changed; the page wants the list
|
|
152
|
+
});
|
|
153
|
+
const beat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
154
|
+
const client = { res, stop: () => { clearInterval(follower); clearInterval(beat); clients.delete(client); } };
|
|
155
|
+
clients.add(client);
|
|
156
|
+
req.on("close", client.stop);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
res.writeHead(404, { ...HEADERS, "Content-Type": "text/plain" }).end("Not found\n");
|
|
160
|
+
});
|
|
161
|
+
const close = server.close.bind(server);
|
|
162
|
+
server.close = (cb) => {
|
|
163
|
+
for (const c of [...clients]) { c.stop(); c.res.end(); }
|
|
164
|
+
return close(cb);
|
|
165
|
+
};
|
|
166
|
+
return server;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Listens on 127.0.0.1 only. An explicit port is taken or it is an error; the
|
|
171
|
+
* default port falls back to a free one, so a second dashboard still starts.
|
|
172
|
+
*/
|
|
173
|
+
function startDashboard({ port = null } = {}) {
|
|
174
|
+
const server = createDashboard();
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const listen = (p) => {
|
|
177
|
+
server.once("error", (e) => {
|
|
178
|
+
if (e.code === "EADDRINUSE" && port === null && p !== 0) return listen(0);
|
|
179
|
+
reject(e);
|
|
180
|
+
});
|
|
181
|
+
server.listen(p, "127.0.0.1", () => resolve(server));
|
|
182
|
+
};
|
|
183
|
+
listen(port === null ? DEFAULT_PORT : port);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { createDashboard, startDashboard, DEFAULT_PORT, CSP };
|
package/src/hooks.js
CHANGED
|
@@ -189,6 +189,32 @@ function codexHome() {
|
|
|
189
189
|
return process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The three further agents that move with an env var, each read from the
|
|
194
|
+
* agent's own code; empty is unset for all of them.
|
|
195
|
+
* Gemini CLI: `GEMINI_CLI_HOME` replaces the *home*, so settings live in
|
|
196
|
+
* `<it>/.gemini` (homedir() in core, 0.62.0 bundle); used as written, no `~`.
|
|
197
|
+
* Qwen Code: `QWEN_HOME` *is* the `.qwen` directory, with `~` expanded and the
|
|
198
|
+
* rest path.resolve()d (Storage.getGlobalQwenDir(), 0.25.0 bundle).
|
|
199
|
+
* Grok: `GROK_HOME` *is* the `.grok` directory (grok_home(); the npm launcher
|
|
200
|
+
* and postinstall.js of @xai-official/grok 1.0.46 write config.toml there).
|
|
201
|
+
* Cursor and Windsurf: no override found, and neither is open to read.
|
|
202
|
+
*/
|
|
203
|
+
function geminiDir() {
|
|
204
|
+
return path.join(process.env.GEMINI_CLI_HOME || os.homedir(), ".gemini");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function qwenHome() {
|
|
208
|
+
const dir = process.env.QWEN_HOME;
|
|
209
|
+
if (!dir) return path.join(os.homedir(), ".qwen");
|
|
210
|
+
const rest = /^~(?:[\\/]|$)/.test(dir) ? dir.slice(2).split(/[\\/]+/).filter(Boolean) : null;
|
|
211
|
+
return path.resolve(rest ? path.join(os.homedir(), ...rest) : dir);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function grokHome() {
|
|
215
|
+
return process.env.GROK_HOME || path.join(os.homedir(), ".grok");
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
const TARGETS = {
|
|
193
219
|
claude: {
|
|
194
220
|
name: "Claude Code",
|
|
@@ -256,10 +282,10 @@ const TARGETS = {
|
|
|
256
282
|
// Grok reads every *.json in this directory, so we get a file of our own
|
|
257
283
|
// rather than merging into someone else's - which also makes uninstall a
|
|
258
284
|
// delete instead of an edit. `dedicated` says so.
|
|
259
|
-
file: () => path.join(
|
|
285
|
+
file: () => path.join(grokHome(), "hooks", "vibeaudio.json"),
|
|
260
286
|
dedicated: true,
|
|
261
287
|
// Directory, not file: detection can't use dirname() like the others.
|
|
262
|
-
configDir: () =>
|
|
288
|
+
configDir: () => grokHome(),
|
|
263
289
|
events: { start: "UserPromptSubmit", stop: "Stop", tool: "PreToolUse" },
|
|
264
290
|
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
265
291
|
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
@@ -268,7 +294,7 @@ const TARGETS = {
|
|
|
268
294
|
gemini: {
|
|
269
295
|
name: "Gemini CLI",
|
|
270
296
|
cmd: "gemini",
|
|
271
|
-
file: () => path.join(
|
|
297
|
+
file: () => path.join(geminiDir(), "settings.json"),
|
|
272
298
|
// Verified against gemini-cli v0.59.0's source (hooks/types.ts,
|
|
273
299
|
// settingsSchema.ts) - the Gemini CLI available to test against predated
|
|
274
300
|
// hooks, so there was no binary to run. Hooks are on by default and
|
|
@@ -321,7 +347,7 @@ const TARGETS = {
|
|
|
321
347
|
qwen: {
|
|
322
348
|
name: "Qwen Code",
|
|
323
349
|
cmd: "qwen",
|
|
324
|
-
file: () => path.join(
|
|
350
|
+
file: () => path.join(qwenHome(), "settings.json"),
|
|
325
351
|
// Verified against qwen-code v0.23.3's source (hooks/types.ts): Claude
|
|
326
352
|
// Code's event set, including a PermissionRequest raised when the dialog
|
|
327
353
|
// is displayed, with tool_name. Timeouts are milliseconds.
|
|
@@ -535,10 +561,13 @@ function daemonArgv(pid) {
|
|
|
535
561
|
try {
|
|
536
562
|
return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
537
563
|
encoding: "utf8",
|
|
538
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
564
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
565
|
+
// Run on every prompt: a ps that never answers must not hold the hook.
|
|
566
|
+
timeout: 3000,
|
|
567
|
+
killSignal: "SIGKILL"
|
|
539
568
|
});
|
|
540
569
|
} catch (e) {
|
|
541
|
-
return null; // No such process, or
|
|
570
|
+
return null; // No such process, ps unavailable, or it did not answer in time.
|
|
542
571
|
}
|
|
543
572
|
}
|
|
544
573
|
|
|
@@ -754,7 +783,8 @@ function agentState(sessions = listSessions()) {
|
|
|
754
783
|
project: s.project || null,
|
|
755
784
|
state: sessionState(s),
|
|
756
785
|
since: s.started || null,
|
|
757
|
-
...(s.waiting ? { tool: s.waiting } : {})
|
|
786
|
+
...(s.waiting ? { tool: s.waiting } : {}),
|
|
787
|
+
...(s.waiting != null && s.waitStart ? { waitingSince: s.waitStart } : {})
|
|
758
788
|
}));
|
|
759
789
|
const status = list.reduce((a, s) => (STATE_RANK.indexOf(s.state) > STATE_RANK.indexOf(a) ? s.state : a), "idle");
|
|
760
790
|
return { v: 1, status, sessions: list };
|
|
@@ -835,6 +865,17 @@ function followEvents(write = (s) => process.stdout.write(s), pollMs = 250) {
|
|
|
835
865
|
}, pollMs);
|
|
836
866
|
}
|
|
837
867
|
|
|
868
|
+
/** The last `n` events in the log, oldest first; [] when there is none yet. */
|
|
869
|
+
function recentEvents(n = 25) {
|
|
870
|
+
try {
|
|
871
|
+
return fs.readFileSync(EVENTS_FILE, "utf8").split("\n").filter(Boolean).slice(-n).flatMap((l) => {
|
|
872
|
+
try { return [JSON.parse(l)]; } catch (e) { return []; } // a torn line is not worth losing the rest
|
|
873
|
+
});
|
|
874
|
+
} catch (e) {
|
|
875
|
+
return [];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
838
879
|
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
839
880
|
// "[Request interrupted by user]" or "... for tool use]".
|
|
840
881
|
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
@@ -1100,9 +1141,9 @@ function notifyCommand(platform, title, body) {
|
|
|
1100
1141
|
// Detached and unref'd: the agent waits on this hook, and a missing
|
|
1101
1142
|
// osascript/notify-send (a Linux box with no notification daemon) must be
|
|
1102
1143
|
// invisible rather than an error in someone's agent.
|
|
1103
|
-
function
|
|
1144
|
+
function banner(body) {
|
|
1104
1145
|
if (!notifyEnabled() || playbackDisabled()) return false; // a mute means quiet, banners included
|
|
1105
|
-
const command = notifyCommand(process.platform, "VibeAudio",
|
|
1146
|
+
const command = notifyCommand(process.platform, "VibeAudio", body);
|
|
1106
1147
|
if (!command) return false;
|
|
1107
1148
|
try {
|
|
1108
1149
|
const child = spawn(command.cmd, command.args, { detached: true, stdio: "ignore" });
|
|
@@ -1114,6 +1155,50 @@ function notify(raw, message) {
|
|
|
1114
1155
|
}
|
|
1115
1156
|
}
|
|
1116
1157
|
|
|
1158
|
+
function notify(raw, message) {
|
|
1159
|
+
return banner(`${sessionLabel(parsePayload(raw))}: ${message}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const ANNOUNCE_FILE = path.join(STATE_DIR, "announce.json");
|
|
1163
|
+
const ANNOUNCE_MAX_CHARS = 200;
|
|
1164
|
+
const ANNOUNCE_REPEAT_MS = 5000;
|
|
1165
|
+
const ANNOUNCE_OUTCOMES = ["success", "failure", "attention"];
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `vibe --announce <text>`: VibeAudio's signals for something that is not an
|
|
1169
|
+
* agent turn (a CI run, a deploy, a script). A chime for the outcome, the
|
|
1170
|
+
* `--notify` banner, and an `announced` event, so a light watching `--events`
|
|
1171
|
+
* reacts too. Never starts or stops music and never touches a session.
|
|
1172
|
+
*
|
|
1173
|
+
* The text is shown in a banner, so control characters go and the length is
|
|
1174
|
+
* capped; it only ever travels as argv. A repeat of the same text within a few
|
|
1175
|
+
* seconds is dropped whole, so a script in a loop cannot machine-gun the
|
|
1176
|
+
* speakers. Mute silences the chime and banner; the event is still written,
|
|
1177
|
+
* like every event.
|
|
1178
|
+
*
|
|
1179
|
+
* Returns false when it was a repeat, true otherwise; never throws.
|
|
1180
|
+
*/
|
|
1181
|
+
function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre } = {}) {
|
|
1182
|
+
try {
|
|
1183
|
+
const clean = String(text).replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, ANNOUNCE_MAX_CHARS);
|
|
1184
|
+
const now = Date.now();
|
|
1185
|
+
try {
|
|
1186
|
+
const last = JSON.parse(fs.readFileSync(ANNOUNCE_FILE, "utf8"));
|
|
1187
|
+
if (last.text === clean && now - last.at < ANNOUNCE_REPEAT_MS) return false;
|
|
1188
|
+
} catch (e) { /* first one, or an unreadable file: nothing to compare */ }
|
|
1189
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
1190
|
+
fs.writeFileSync(ANNOUNCE_FILE, JSON.stringify({ text: clean, at: now }));
|
|
1191
|
+
|
|
1192
|
+
emitEvent("announced", null, null, { source: "cli", text: clean, outcome });
|
|
1193
|
+
banner(`${clean}${outcome === "failure" ? " (failed)" : outcome === "attention" ? " (needs you)" : ""}`);
|
|
1194
|
+
// Detached: a pipeline should not wait out the chime.
|
|
1195
|
+
if (!noChime) new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre, detach: true });
|
|
1196
|
+
return true;
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
return true; // a notifier must never fail the script that called it
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1117
1202
|
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
|
|
1118
1203
|
const id = sessionId(payloadSession(parsePayload(raw)));
|
|
1119
1204
|
// A turn that ends while paused for the user - a denied tool that nothing
|
|
@@ -1582,6 +1667,14 @@ module.exports = {
|
|
|
1582
1667
|
shellQuote,
|
|
1583
1668
|
claudeConfigDir,
|
|
1584
1669
|
codexHome,
|
|
1670
|
+
geminiDir,
|
|
1671
|
+
qwenHome,
|
|
1672
|
+
grokHome,
|
|
1673
|
+
announce,
|
|
1674
|
+
recentEvents,
|
|
1675
|
+
emitEvent,
|
|
1676
|
+
ANNOUNCE_OUTCOMES,
|
|
1677
|
+
ANNOUNCE_MAX_CHARS,
|
|
1585
1678
|
expectedEvents,
|
|
1586
1679
|
PLUGIN_FILES,
|
|
1587
1680
|
PLUGIN_AGENTS,
|
package/src/interactive.js
CHANGED
|
@@ -11,7 +11,7 @@ const LOOKUP_CMD = process.platform === "win32" ? "where" : "which";
|
|
|
11
11
|
|
|
12
12
|
function isInstalled(cmd) {
|
|
13
13
|
// An argument vector, not a command string: nothing in `cmd` reaches a shell.
|
|
14
|
-
return spawnSync(LOOKUP_CMD, [cmd], { stdio: "ignore" }).status === 0;
|
|
14
|
+
return spawnSync(LOOKUP_CMD, [cmd], { stdio: "ignore", timeout: 3000, killSignal: "SIGKILL" }).status === 0;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Convenience only - the wrapper runs any command, and "Custom command..."
|
package/src/mcp.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const readline = require("readline");
|
|
9
|
-
const
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { fileURLToPath } = require("url");
|
|
11
|
+
const { AudioPlayer, AVAILABLE_GENRES, normalizeVolume, playbackDisabled, musicEnabled, isKnownGenre, loadConfig, projectSettings } = require("./player");
|
|
10
12
|
const pkg = require("../package.json");
|
|
11
13
|
|
|
12
14
|
// A desktop client that crashes never sends vibe_stop, so playback needs its
|
|
@@ -26,6 +28,64 @@ const annotate = (title, { readOnly, idempotent }) => ({
|
|
|
26
28
|
|
|
27
29
|
const OUTCOMES = ["success", "failure"];
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Which project this conversation is about. Hooks and the wrapper know it (they
|
|
33
|
+
* run in it); an MCP server is launched once by the client, in a directory the
|
|
34
|
+
* client chose, so it has to be told. Two sources, in order:
|
|
35
|
+
* 1. `project_dir`, an argument the model passes when it has a workspace.
|
|
36
|
+
* 2. The client's roots (`roots/list`), the protocol's own answer.
|
|
37
|
+
* With neither, the server's cwd, which is what it always used.
|
|
38
|
+
* The value is only hashed and matched against keys in our own config file; it
|
|
39
|
+
* is never opened, so it is not a file-access question - but it comes from the
|
|
40
|
+
* client, so it must be a plain absolute path of sane length.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_PROJECT_DIR = 1024;
|
|
43
|
+
function cleanProjectDir(value) {
|
|
44
|
+
if (typeof value !== "string") return null;
|
|
45
|
+
const v = value.trim();
|
|
46
|
+
if (!v || v.length > MAX_PROJECT_DIR || v.includes("\0") || !path.isAbsolute(v)) return null;
|
|
47
|
+
return path.resolve(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A root is a file: URI. Anything else (a remote workspace) is not a path here.
|
|
51
|
+
function rootDirs(roots) {
|
|
52
|
+
const dirs = [];
|
|
53
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
54
|
+
try {
|
|
55
|
+
const uri = root && typeof root.uri === "string" ? root.uri : "";
|
|
56
|
+
if (!uri.startsWith("file:")) continue;
|
|
57
|
+
const dir = cleanProjectDir(fileURLToPath(uri));
|
|
58
|
+
if (dir) dirs.push(dir);
|
|
59
|
+
} catch (e) { /* a malformed URI is skipped, not fatal */ }
|
|
60
|
+
}
|
|
61
|
+
return dirs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** { dir, source }: what to seed from, and why - vibe_status reports the second. */
|
|
65
|
+
function resolveProject(args, session) {
|
|
66
|
+
const explicit = cleanProjectDir(args && args.project_dir);
|
|
67
|
+
let dir = null;
|
|
68
|
+
let source = "cwd";
|
|
69
|
+
if (explicit) { dir = explicit; source = "project_dir"; }
|
|
70
|
+
else if (session && session.roots.length) { dir = session.roots[0]; source = "roots"; }
|
|
71
|
+
// VIBE_SEED outranks every directory (projectSeed reads it); say so.
|
|
72
|
+
if (process.env.VIBE_SEED && !isNaN(parseInt(process.env.VIBE_SEED, 10))) source = "VIBE_SEED";
|
|
73
|
+
return { dir, source };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What the client has told us about itself. `send` writes a request to it.
|
|
77
|
+
function newSession(send = null) {
|
|
78
|
+
return { supportsRoots: false, roots: [], rootsRequest: null, send, nextId: 1 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Ask for the roots. Our own id space ("vibeaudio-roots-N") cannot collide with
|
|
82
|
+
// the client's, and a reply is matched by it.
|
|
83
|
+
function requestRoots(session) {
|
|
84
|
+
if (!session || !session.supportsRoots || !session.send) return;
|
|
85
|
+
session.rootsRequest = `vibeaudio-roots-${session.nextId++}`;
|
|
86
|
+
session.send({ jsonrpc: "2.0", id: session.rootsRequest, method: "roots/list" });
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
const TOOLS = [
|
|
30
90
|
{
|
|
31
91
|
name: "vibe_play",
|
|
@@ -49,6 +109,13 @@ const TOOLS = [
|
|
|
49
109
|
description: "Playback volume from 5 to 100 (default: 40)",
|
|
50
110
|
minimum: 5,
|
|
51
111
|
maximum: 100
|
|
112
|
+
},
|
|
113
|
+
project_dir: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description:
|
|
116
|
+
"Absolute path of the active workspace, if there is one. Omit it when you " +
|
|
117
|
+
"have no workspace - do not guess a path. It makes this project sound like " +
|
|
118
|
+
"itself and applies the genre and volume the user saved for it."
|
|
52
119
|
}
|
|
53
120
|
}
|
|
54
121
|
}
|
|
@@ -90,10 +157,22 @@ const TOOLS = [
|
|
|
90
157
|
}
|
|
91
158
|
];
|
|
92
159
|
|
|
93
|
-
function handleMessage(player, msg) {
|
|
160
|
+
function handleMessage(player, msg, session = null) {
|
|
94
161
|
const { id, method, params } = msg;
|
|
95
162
|
|
|
163
|
+
// The client's answer to our roots/list: it has a result, not a method.
|
|
164
|
+
if (method === undefined && session && id !== undefined && id === session.rootsRequest) {
|
|
165
|
+
session.rootsRequest = null;
|
|
166
|
+
session.roots = rootDirs(msg.result && msg.result.roots);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
// Any other response is an answer to something we asked, not a request: it
|
|
170
|
+
// gets no reply (a "method not found" for it would be sent to the client).
|
|
171
|
+
if (method === undefined && (msg.result !== undefined || msg.error !== undefined)) return null;
|
|
172
|
+
|
|
96
173
|
if (method === "initialize") {
|
|
174
|
+
// roots is a client capability: only a client that declares it can be asked.
|
|
175
|
+
if (session) session.supportsRoots = Boolean(params && params.capabilities && params.capabilities.roots);
|
|
97
176
|
return {
|
|
98
177
|
jsonrpc: "2.0",
|
|
99
178
|
id,
|
|
@@ -117,13 +196,22 @@ function handleMessage(player, msg) {
|
|
|
117
196
|
"and you are ready to hand back a result.\n" +
|
|
118
197
|
"Do not use it for quick answers - starting and stopping music around a " +
|
|
119
198
|
"one-second reply is worse than silence. Leave the genre and volume alone " +
|
|
120
|
-
"unless the user asks; they are the user's preference, not yours
|
|
199
|
+
"unless the user asks; they are the user's preference, not yours.\n" +
|
|
200
|
+
"If you have a workspace, pass its absolute path as vibe_play's project_dir so " +
|
|
201
|
+
"this project sounds like itself; omit it when you have none."
|
|
121
202
|
}
|
|
122
203
|
};
|
|
123
204
|
}
|
|
124
205
|
|
|
125
206
|
if (method === "notifications/initialized") {
|
|
126
|
-
// Client acknowledgment - no response needed
|
|
207
|
+
// Client acknowledgment - no response needed, but this is the first moment
|
|
208
|
+
// a request of ours is allowed, so ask which project it has open.
|
|
209
|
+
requestRoots(session);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (method === "notifications/roots/list_changed") {
|
|
214
|
+
requestRoots(session);
|
|
127
215
|
return null;
|
|
128
216
|
}
|
|
129
217
|
|
|
@@ -153,10 +241,16 @@ function handleMessage(player, msg) {
|
|
|
153
241
|
// saved file here is what makes `vibe --genre jazz` mean jazz in a
|
|
154
242
|
// desktop client too, rather than only in the terminal.
|
|
155
243
|
const saved = loadConfig();
|
|
244
|
+
const project = resolveProject(args, session);
|
|
245
|
+
const dir = project.dir || process.cwd();
|
|
246
|
+
// What `vibe --here` saved for this project sits between the environment
|
|
247
|
+
// and the machine-wide default, as it does for the CLI and the hooks.
|
|
248
|
+
const here = projectSettings(saved, dir);
|
|
249
|
+
const setting = (key) => (here[key] !== undefined ? here[key] : saved[key]);
|
|
156
250
|
// The argument comes from the client: only a string counts. A number or
|
|
157
251
|
// object here used to reach `.toLowerCase()` and fail the whole call.
|
|
158
252
|
const asked = typeof args.genre === "string" ? args.genre.trim() : "";
|
|
159
|
-
const requested = String(asked || process.env.VIBE_GENRE ||
|
|
253
|
+
const requested = String(asked || process.env.VIBE_GENRE || setting("genre") || "lofi");
|
|
160
254
|
// An unknown genre already fell back to lofi inside the generator, but
|
|
161
255
|
// player.genre kept the name nobody implements - so the model told the
|
|
162
256
|
// user it was playing something that does not exist.
|
|
@@ -165,10 +259,10 @@ function handleMessage(player, msg) {
|
|
|
165
259
|
// as the CLI, so a mistyped VIBE_VOLUME falls back instead of reaching
|
|
166
260
|
// the player as NaN.
|
|
167
261
|
const volume = normalizeVolume(args.volume,
|
|
168
|
-
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(
|
|
262
|
+
normalizeVolume(process.env.VIBE_VOLUME, normalizeVolume(setting("volume"), 0.4)));
|
|
169
263
|
|
|
170
264
|
// A deliberate setting, like a mute: report it as one, and start nothing.
|
|
171
|
-
if (!musicEnabled()) {
|
|
265
|
+
if (!musicEnabled(process.env, saved, dir)) {
|
|
172
266
|
return {
|
|
173
267
|
jsonrpc: "2.0",
|
|
174
268
|
id,
|
|
@@ -176,7 +270,7 @@ function handleMessage(player, msg) {
|
|
|
176
270
|
};
|
|
177
271
|
}
|
|
178
272
|
|
|
179
|
-
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS });
|
|
273
|
+
const started = player.start(genre, volume, { maxDurationMs: MAX_PLAYBACK_MS, project: project.dir });
|
|
180
274
|
// start() returns false for three unrelated reasons, and the model
|
|
181
275
|
// relays whatever we say here to the user. Reporting a deliberate mute
|
|
182
276
|
// as a missing audio player sends them debugging their sound stack.
|
|
@@ -232,6 +326,7 @@ function handleMessage(player, msg) {
|
|
|
232
326
|
}
|
|
233
327
|
|
|
234
328
|
if (name === "vibe_status") {
|
|
329
|
+
const statusProject = resolveProject({}, session);
|
|
235
330
|
return {
|
|
236
331
|
jsonrpc: "2.0",
|
|
237
332
|
id,
|
|
@@ -244,7 +339,13 @@ function handleMessage(player, msg) {
|
|
|
244
339
|
// Without this, "isPlaying: false" while muted reads as a bug
|
|
245
340
|
// worth investigating rather than a choice the user made.
|
|
246
341
|
muted: playbackDisabled(),
|
|
247
|
-
musicOff: !musicEnabled(),
|
|
342
|
+
musicOff: !musicEnabled(process.env, loadConfig(), statusProject.dir || process.cwd()),
|
|
343
|
+
// Which project the next vibe_play seeds from and how we learned
|
|
344
|
+
// it (project_dir | roots | cwd | VIBE_SEED), and, while music
|
|
345
|
+
// plays, the one it is playing for.
|
|
346
|
+
project: statusProject.dir || process.cwd(),
|
|
347
|
+
projectSource: statusProject.source,
|
|
348
|
+
playingProject: player.isPlaying ? (player.projectDir || process.cwd()) : null,
|
|
248
349
|
genre: player.genre,
|
|
249
350
|
currentTier: player.currentTier,
|
|
250
351
|
uptimeMs: player.isPlaying ? Date.now() - player.startTime : 0
|
|
@@ -281,6 +382,8 @@ function handleMessage(player, msg) {
|
|
|
281
382
|
|
|
282
383
|
function startMcpServer() {
|
|
283
384
|
const player = new AudioPlayer();
|
|
385
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
|
|
386
|
+
const session = newSession(send);
|
|
284
387
|
|
|
285
388
|
// In stdio MCP mode, stderr is used for logging, stdout is strictly reserved for JSON-RPC
|
|
286
389
|
process.stderr.write(`[vibeaudio] MCP Server running on stdio (v${pkg.version})\n`);
|
|
@@ -306,7 +409,7 @@ function startMcpServer() {
|
|
|
306
409
|
|
|
307
410
|
let response;
|
|
308
411
|
try {
|
|
309
|
-
response = handleMessage(player, msg);
|
|
412
|
+
response = handleMessage(player, msg, session);
|
|
310
413
|
} catch (err) {
|
|
311
414
|
process.stderr.write(`[vibeaudio] Handler error: ${err.message}\n`);
|
|
312
415
|
// A request (one with an id) must always get a reply. Swallowing the
|
|
@@ -316,7 +419,7 @@ function startMcpServer() {
|
|
|
316
419
|
}
|
|
317
420
|
}
|
|
318
421
|
|
|
319
|
-
if (response)
|
|
422
|
+
if (response) send(response);
|
|
320
423
|
});
|
|
321
424
|
|
|
322
425
|
// Client disconnected - never leave audio looping behind.
|
|
@@ -336,4 +439,4 @@ function startMcpServer() {
|
|
|
336
439
|
});
|
|
337
440
|
}
|
|
338
441
|
|
|
339
|
-
module.exports = { startMcpServer, handleMessage, TOOLS };
|
|
442
|
+
module.exports = { startMcpServer, handleMessage, TOOLS, newSession, resolveProject, cleanProjectDir, rootDirs };
|
package/src/player.js
CHANGED
|
@@ -8,6 +8,9 @@ const path = require("path");
|
|
|
8
8
|
const os = require("os");
|
|
9
9
|
const { spawn, spawnSync } = require("child_process");
|
|
10
10
|
|
|
11
|
+
// The most a `which`/`ps` lookup may take; they answer in milliseconds, and a hook waits on them.
|
|
12
|
+
const LOOKUP_TIMEOUT_MS = 3000;
|
|
13
|
+
|
|
11
14
|
const { generateLofiLoop } = require("./synth/lofi");
|
|
12
15
|
const { generateSynthwaveLoop } = require("./synth/synthwave");
|
|
13
16
|
const { generateChiptuneLoop } = require("./synth/chiptune");
|
|
@@ -146,7 +149,8 @@ function detectPlayer() {
|
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
cachedPlayer = PLAYER_CANDIDATES.find(
|
|
149
|
-
|
|
152
|
+
// A lookup that never answers is "not found", not a hook that never returns.
|
|
153
|
+
(c) => spawnSync("which", [c.cmd], { stdio: "ignore", timeout: LOOKUP_TIMEOUT_MS, killSignal: "SIGKILL" }).status === 0
|
|
150
154
|
) || null;
|
|
151
155
|
|
|
152
156
|
return cachedPlayer;
|
|
@@ -438,8 +442,8 @@ function playbackDisabled() {
|
|
|
438
442
|
* Env beats this directory's saved value beats the global one; a flag is
|
|
439
443
|
* resolved by parseArgs() and handed to whoever acts on it.
|
|
440
444
|
*/
|
|
441
|
-
function musicEnabled(env = process.env, config = loadConfig()) {
|
|
442
|
-
const raw = env.VIBE_MUSIC ?? projectSettings(config).music ?? config.music ?? "on";
|
|
445
|
+
function musicEnabled(env = process.env, config = loadConfig(), cwd = process.cwd()) {
|
|
446
|
+
const raw = env.VIBE_MUSIC ?? projectSettings(config, cwd).music ?? config.music ?? "on";
|
|
443
447
|
return !["off", "0", "false", "no"].includes(String(raw).trim().toLowerCase());
|
|
444
448
|
}
|
|
445
449
|
|
|
@@ -751,6 +755,7 @@ class AudioPlayer {
|
|
|
751
755
|
this.genre = "lofi";
|
|
752
756
|
this.volume = 0.42;
|
|
753
757
|
this.seed = projectSeed();
|
|
758
|
+
this.projectDir = null; // Set when a client names the project (MCP); null means the cwd.
|
|
754
759
|
this.intensity = null;
|
|
755
760
|
this.minTier = null;
|
|
756
761
|
this.tension = null;
|
|
@@ -764,12 +769,13 @@ class AudioPlayer {
|
|
|
764
769
|
* Returns true if playback actually started. Restarts when called with
|
|
765
770
|
* different settings while playing, so a genre switch is not silently dropped.
|
|
766
771
|
*/
|
|
767
|
-
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null } = {}) {
|
|
772
|
+
start(genre = "lofi", volume = 0.42, { maxDurationMs = null, intensity = null, minTier = null, tension = null, project = null } = {}) {
|
|
768
773
|
const resolved = resolveGenre(genre);
|
|
769
774
|
const targetVolume = Math.max(0.05, Math.min(1.0, volume));
|
|
770
775
|
|
|
771
776
|
if (this.isPlaying) {
|
|
772
|
-
|
|
777
|
+
// A different project is a different arrangement, so it restarts too.
|
|
778
|
+
if (resolved === this.genre && targetVolume === this.volume && (project || null) === this.projectDir) return false;
|
|
773
779
|
this.stop({ playChime: false });
|
|
774
780
|
}
|
|
775
781
|
|
|
@@ -784,7 +790,8 @@ class AudioPlayer {
|
|
|
784
790
|
this.startTime = Date.now();
|
|
785
791
|
this.genre = resolved;
|
|
786
792
|
this.volume = targetVolume;
|
|
787
|
-
this.
|
|
793
|
+
this.projectDir = project || null;
|
|
794
|
+
this.seed = projectSeed(project || process.cwd());
|
|
788
795
|
this.bar = 0; // Every run opens on the project's own bar.
|
|
789
796
|
this.intensity = intensity;
|
|
790
797
|
this.minTier = minTier;
|