vibeaudio 0.17.0 → 0.19.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 CHANGED
@@ -147,7 +147,7 @@ Want the music to follow what the agent is doing — calm while it reads, busies
147
147
  vibe --reactive --install-hooks
148
148
  ```
149
149
 
150
- Prefer a guided setup? Run `vibe` with nothing after it: a menu asks for your agent, genre and volume (press `p` to hear a genre first) and installs the hooks for you.
150
+ Prefer a guided setup? Run `vibe` with nothing after it: a menu asks for your agent, genre and volume (press `p` to hear a genre first) and installs the hooks for you. Installing hooks doesn't start the agent: open it whenever you like, and the music plays from your first prompt. ("Just launch it" and "This session only" do start it.)
151
151
 
152
152
  Now run your agent normally, with no prefix. Music starts when you submit a prompt and stops with a chime when the agent finishes. [Details below.](#-agent-hooks-no-wrapper-needed)
153
153
 
@@ -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 `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. |
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>
@@ -522,7 +522,7 @@ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiti
522
522
  {"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
523
523
  ```
524
524
 
525
- Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended` (with `reason` when `vibe --stop` or `--uninstall-hooks` ended it). Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field.
525
+ Events: `started`, `waiting`, `resumed`, `stuck`, `recovered`, `finished` (with `outcome`: `success` or `failure`), `interrupted`, `ended` (with `reason` when `vibe --stop` or `--uninstall-hooks` ended it) and `announced` (from [`vibe --announce`](#-beyond-agents-vibe---announce), with `text` and `outcome`, and no session). Every line also carries `status`, the machine's state after the event, so a consumer that only cares about the overall state can read that one field.
526
526
 
527
527
  #### 📟 Status Bar & Desktop Integrations
528
528
 
@@ -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,53 @@ 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
- *Run on tmux 3.x and Starship 1.26.0, macOS arm64, Node 24: both showed `working`, `working ×2` and `waiting: web` as sessions started and waited, and nothing when idle. Not run on Linux or Windows.*
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 (its `event` is `state`), 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
+ **Home Assistant (or any smart light)** — send the machine's overall state on every change, and let the receiving side choose the colour. Home Assistant takes this as a [webhook trigger](https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger); replace the URL and the id:
569
+ ```bash
570
+ vibe --events | jq --unbuffered -c 'select(.event != "state") | {status, event, project}' \
571
+ | while read -r line; do curl -s -X POST -H 'Content-Type: application/json' -d "$line" http://homeassistant.local:8123/api/webhook/vibeaudio; done
572
+ ```
573
+ `status` is `idle`, `working`, `stuck` or `waiting`, already the most urgent across all your sessions, so the automation is a plain switch on one field (for example amber for `waiting`, red for `stuck`, off for `idle`) and never has to count sessions itself. A web request goes out on every change, so point it at a local bridge rather than a metered service.
574
+
575
+ *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.*
576
+
577
+ *Verified on macOS arm64 with Node 24, tmux 3.7 and Starship 1.26 (the Home Assistant recipe: the request it sends, against a local stand-in server, was `{"status":"waiting","event":"waiting","project":"/work/api"}` on a permission dialog; not run against Home Assistant itself): 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
578
 
561
579
  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
580
 
581
+ #### 🖥️ A live view: `vibe --dashboard`
582
+
583
+ ```bash
584
+ vibe --dashboard # serves http://127.0.0.1:4747 and opens it
585
+ ```
586
+
587
+ ![The VibeAudio dashboard: one waiting, one stuck and one working session, with the recent events below](docs/dashboard.png)
588
+
589
+ 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.
590
+
591
+ 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.
592
+
593
+ #### 🔔 Beyond agents: `vibe --announce`
594
+
595
+ A CI run, a deploy, a long script that wasn't started through `vibe <command>`: the same chimes can say those finished too.
596
+
597
+ ```bash
598
+ vibe --announce "CI green" # success chime
599
+ vibe --announce "deploy" --outcome attention # the "needs you" chime
600
+ ./deploy.sh; vibe --announce "deploy" --exit-code $? # 0 is success, anything else failure
601
+ gh run watch; vibe --announce "CI finished" --exit-code $?
602
+ ```
603
+
604
+ 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.
605
+
606
+ 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.
607
+
563
608
  ## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
564
609
 
565
610
  **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.
@@ -747,6 +792,8 @@ The full order, highest first: **a flag** → **an environment variable** → **
747
792
  | `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
748
793
  | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
749
794
  | `--events` | Stream agent state changes as JSON lines, starting with the current state, until stopped | — |
795
+ | `--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 | — |
796
+ | `--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` |
750
797
  | `--stop` | Stop the background player, then exit | — |
751
798
  | `--mute [minutes]` | Silence everything for a call, then exit | `60` min (`0` = until unmuted) |
752
799
  | `--unmute` | Resume normal playback, then exit | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.17.0",
3
+ "version": "0.19.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)
@@ -95,7 +102,7 @@ Procedural focus music while your AI coding tools think.
95
102
  --dry-run With --install-hooks: show what would change, write nothing
96
103
  --uninstall-hooks Remove the hooks again, from every agent
97
104
  -h, --help Show this help message
98
- --version Show version
105
+ --version Show version (update with: npm i -g vibeaudio)
99
106
 
100
107
  \x1b[1mSETTINGS:\x1b[0m
101
108
  A genre or volume with no command after it is saved as your default:
@@ -108,15 +115,43 @@ Procedural focus music while your AI coding tools think.
108
115
  \x1b[1m--here\x1b[0m setting beats the global one, so a one-off
109
116
  \x1b[1mvibe --genre 8bit npm test\x1b[0m stays a one-off.
110
117
 
118
+ \x1b[1mINTEGRATIONS:\x1b[0m
119
+ Every agent's state, as one stream you can build on (lights, banners, webhooks):
120
+
121
+ vibe --events \x1b[90m# the current state, then one JSON line per change\x1b[0m
122
+ vibe --state \x1b[90m# one snapshot: idle, working, stuck or waiting\x1b[0m
123
+ vibe --statusline \x1b[90m# one plain line for tmux or a prompt\x1b[0m
124
+
125
+ Events: started, waiting, resumed, stuck, recovered, finished, interrupted, ended, announced.
126
+ Recipes (tmux, Starship, voice, webhook, Home Assistant): https://kiril6.github.io/vibeaudio/
127
+
128
+ \x1b[1mUNINSTALL:\x1b[0m
129
+ In this order, so no agent is left running a hook that points at nothing:
130
+
131
+ vibe --uninstall-hooks \x1b[90m# 1. unwire every agent, and stop any player still running\x1b[0m
132
+ npm rm -g vibeaudio \x1b[90m# 2. remove the CLI\x1b[0m
133
+ rm -rf ~/.vibeaudio \x1b[90m# 3. optional: cached audio, saved settings, state\x1b[0m
134
+
111
135
  \x1b[1mENVIRONMENT:\x1b[0m
112
136
  VIBE_GENRE=<name> Override the saved genre for this shell
113
137
  VIBE_VOLUME=<5-100> Override the saved volume for this shell
114
138
  VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
115
139
  VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
116
140
  VIBE_MUSIC=<on|off> Override the saved music setting for this shell
141
+ VIBE_NOTIFY=<on|off> Override the saved desktop-banner setting for this shell
117
142
  VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
118
143
  VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
119
144
  VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
145
+
146
+ \x1b[1mNO SOUND?\x1b[0m
147
+ vibe --doctor \x1b[90m# checks the setup; each problem comes with its fix\x1b[0m
148
+ vibe --status \x1b[90m# shows whether you are muted, and what is installed\x1b[0m
149
+ vibe --unmute \x1b[90m# clears a mute\x1b[0m
150
+
151
+ \x1b[1mMORE:\x1b[0m
152
+ Settings and state: ~/.vibeaudio
153
+ Docs: https://kiril6.github.io/vibeaudio/
154
+ Problems and ideas: https://github.com/kiril6/vibeaudio/issues
120
155
  `);
121
156
  printUpdateNotice();
122
157
  }
@@ -143,7 +178,11 @@ const VALUE_FLAGS = new Set([
143
178
  "--music",
144
179
  "--seed",
145
180
  "--tools",
146
- "--event"
181
+ "--event",
182
+ "--announce",
183
+ "--outcome",
184
+ "--exit-code",
185
+ "--port"
147
186
  ]);
148
187
 
149
188
  function parseArgs(argv) {
@@ -198,6 +237,12 @@ function parseArgs(argv) {
198
237
  let followVolume = false;
199
238
  let plugin = false;
200
239
  let hookEvent = null;
240
+ let dashboardFlag = false;
241
+ let dashboardPort = null;
242
+ let noOpen = false;
243
+ let announceText = null;
244
+ let announceOutcome = null;
245
+ let announceExit = null;
201
246
  let here_flag = false;
202
247
  let dryRun = false;
203
248
  let tools = null;
@@ -314,6 +359,45 @@ function parseArgs(argv) {
314
359
  continue;
315
360
  }
316
361
 
362
+ if (arg === "--announce" || arg === "--outcome" || arg === "--exit-code") {
363
+ const value = args[i + 1];
364
+ if (arg === "--announce") announceText = value;
365
+ else if (arg === "--outcome") {
366
+ announceOutcome = value.toLowerCase();
367
+ const known = require("./hooks").ANNOUNCE_OUTCOMES;
368
+ if (!known.includes(announceOutcome)) {
369
+ console.error(`\x1b[31m[vibeaudio] --outcome takes ${known.join(", ")}, not '${value}'.\x1b[0m`);
370
+ process.exit(1);
371
+ }
372
+ } else {
373
+ if (!/^-?\d+$/.test(value)) {
374
+ console.error(`\x1b[31m[vibeaudio] --exit-code takes a number, not '${value}'.\x1b[0m`);
375
+ process.exit(1);
376
+ }
377
+ announceExit = parseInt(value, 10);
378
+ }
379
+ i += 2;
380
+ continue;
381
+ }
382
+
383
+ if (arg === "--dashboard" || arg === "--no-open") {
384
+ if (arg === "--dashboard") dashboardFlag = true;
385
+ else noOpen = true;
386
+ i += 1;
387
+ continue;
388
+ }
389
+
390
+ if (arg === "--port") {
391
+ const n = Number(args[i + 1]);
392
+ if (!/^\d+$/.test(args[i + 1]) || n > 65535) {
393
+ console.error(`\x1b[31m[vibeaudio] --port takes a number from 0 to 65535, not '${args[i + 1]}'.\x1b[0m`);
394
+ process.exit(1);
395
+ }
396
+ dashboardPort = n;
397
+ i += 2;
398
+ continue;
399
+ }
400
+
317
401
  if (arg === "--statusline") {
318
402
  statuslineFlag = true;
319
403
  i += 1;
@@ -532,6 +616,8 @@ function parseArgs(argv) {
532
616
  followVolume,
533
617
  plugin,
534
618
  hookEvent,
619
+ dashboard: dashboardFlag ? { port: dashboardPort, open: !noOpen } : null,
620
+ announce: { text: announceText, outcome: announceOutcome, exitCode: announceExit },
535
621
  dryRun,
536
622
  tools,
537
623
  typed,
@@ -659,6 +745,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
659
745
  const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
660
746
  if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
661
747
  console.log(` \x1b[90mChange the sound any time with: vibe --genre <name> --volume <n> — no reinstall.\x1b[0m`);
748
+ console.log(` \x1b[90mCheck it with vibe --status · quiet for a call: vibe --mute · everything else: vibe --help\x1b[0m`);
662
749
  if (ids.length > 1) {
663
750
  console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
664
751
  }
@@ -1492,6 +1579,17 @@ function windowsCommandNeedsShell(command) {
1492
1579
  return !/[\\/]/.test(command);
1493
1580
  }
1494
1581
 
1582
+ function openBrowser(url) {
1583
+ const { spawn } = require("child_process");
1584
+ const [cmd, args] =
1585
+ process.platform === "darwin" ? ["open", [url]]
1586
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
1587
+ : ["xdg-open", [url]];
1588
+ try {
1589
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref(); // no browser to open is not an error
1590
+ } catch (e) { /* same */ }
1591
+ }
1592
+
1495
1593
  function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRACE_PERIOD_MS, noChime, noHud = false, music = true) {
1496
1594
  const player = new AudioPlayer();
1497
1595
  const hookDriven = hooksAlreadyCover(cmdArgs);
@@ -1590,6 +1688,42 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
1590
1688
  process.on("SIGTERM", () => relaySignal("SIGTERM"));
1591
1689
  }
1592
1690
 
1691
+ /**
1692
+ * Acts on what the menu returned, and says whether to launch the tool.
1693
+ *
1694
+ * Choosing hooks is setup: it installs them and stops. Nothing is started, so
1695
+ * someone running several agents can set one up now and open it when they
1696
+ * like - the music plays from their first prompt either way. Only "just launch
1697
+ * it" and "this session only" go on to start the tool. A failed install is an
1698
+ * error with a non-zero exit, not a launch without the hooks that were asked for.
1699
+ */
1700
+ function applyMenuSelection(selection, volume) {
1701
+ if (!selection.installHooks) return true;
1702
+ const hooks = require("./hooks");
1703
+ try {
1704
+ installHookTargets([selection.hookTarget], selection.genre, volume, selection.reactive, false,
1705
+ { genre: selection.genre, volume });
1706
+ } catch (err) {
1707
+ console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
1708
+ process.exit(1);
1709
+ }
1710
+ const saved = loadConfig();
1711
+ // Everything VibeAudio is hooked into on this machine, not just this run's
1712
+ // pick: with several agents, "which ones are set up?" is the question, and
1713
+ // which one to open first is the user's call.
1714
+ const hooked = Object.entries(hooks.TARGETS).filter(([id]) => hooks.userHooksInstalled(null, id));
1715
+ const names = hooked.map(([, t]) => t.name).join(", ");
1716
+ const cmds = hooked.map(([, t]) => t.cmd).join(", ");
1717
+ console.log(
1718
+ `\n\x1b[32m✔ VibeAudio v${pkg.version} is hooked into: ${names} (${saved.genre} @ ${saved.volume}%).\x1b[0m ` +
1719
+ `Nothing was started: open ${hooked.length > 1 ? "any of them" : "it"} whenever you like (\x1b[1m${cmds}\x1b[0m), and the music plays from your first prompt.\n` +
1720
+ ` \x1b[90mMore agents? Run vibe again for each, or vibe --install-hooks to set up every one it finds.\x1b[0m\n` +
1721
+ ` \x1b[90mStay current: run npm i -g ${pkg.name} now and then (hooks keep working); vibe --status tells you when a newer version is out.\x1b[0m\n`
1722
+ );
1723
+ printUpdateNotice();
1724
+ return false;
1725
+ }
1726
+
1593
1727
  async function run() {
1594
1728
  const {
1595
1729
  genre,
@@ -1618,6 +1752,8 @@ async function run() {
1618
1752
  followVolume,
1619
1753
  plugin,
1620
1754
  hookEvent,
1755
+ dashboard,
1756
+ announce: announceReq,
1621
1757
  dryRun,
1622
1758
  tools,
1623
1759
  typed,
@@ -1643,6 +1779,25 @@ async function run() {
1643
1779
  }
1644
1780
  }
1645
1781
 
1782
+ if (announceReq.text === null && (announceReq.outcome !== null || announceReq.exitCode !== null)) {
1783
+ console.error("\x1b[31m[vibeaudio] --outcome and --exit-code go with --announce <text>.\x1b[0m");
1784
+ process.exit(1);
1785
+ }
1786
+ if (announceReq.text !== null) {
1787
+ if (!announceReq.text.trim()) {
1788
+ console.error("\x1b[31m[vibeaudio] --announce needs some text.\x1b[0m Run vibe --help for the options.");
1789
+ process.exit(1);
1790
+ }
1791
+ if (announceReq.outcome !== null && announceReq.exitCode !== null) {
1792
+ console.error("\x1b[31m[vibeaudio] Give --outcome or --exit-code, not both.\x1b[0m");
1793
+ process.exit(1);
1794
+ }
1795
+ const outcome = announceReq.outcome || (announceReq.exitCode === null || announceReq.exitCode === 0 ? "success" : "failure");
1796
+ // Exit 0 whatever happens: `cmd; vibe --announce ...` must not turn a pipeline red.
1797
+ require("./hooks").announce(announceReq.text, { outcome, volume, chimeVolume, noChime, genre });
1798
+ return;
1799
+ }
1800
+
1646
1801
  if (reactive) {
1647
1802
  console.error(
1648
1803
  "\x1b[33m[vibeaudio] --reactive only applies to --install-hooks; ignoring it here.\x1b[0m"
@@ -1714,6 +1869,21 @@ async function run() {
1714
1869
  return;
1715
1870
  }
1716
1871
 
1872
+ if (dashboard) {
1873
+ const { startDashboard } = require("./dashboard");
1874
+ let server;
1875
+ try {
1876
+ server = await startDashboard({ port: dashboard.port });
1877
+ } catch (e) {
1878
+ console.error(`\x1b[31m[vibeaudio] Could not start the dashboard: ${e.code === "EADDRINUSE" ? `port ${dashboard.port} is in use` : e.message}.\x1b[0m`);
1879
+ process.exit(1);
1880
+ }
1881
+ const url = `http://127.0.0.1:${server.address().port}/`;
1882
+ console.log(`\x1b[1mVibeAudio dashboard\x1b[0m ${url} \x1b[90m(read-only, this machine only; Ctrl+C to stop)\x1b[0m`);
1883
+ if (dashboard.open) openBrowser(url);
1884
+ return; // the listening server keeps the process alive
1885
+ }
1886
+
1717
1887
  if (followEvents) {
1718
1888
  process.stdout.on("error", () => process.exit(0)); // `vibe --events | head` closing the pipe is not an error.
1719
1889
  require("./hooks").followEvents();
@@ -1770,18 +1940,7 @@ async function run() {
1770
1940
  }
1771
1941
 
1772
1942
  const chosenVol = selection.volume !== undefined ? selection.volume : volume;
1773
-
1774
- if (selection.installHooks) {
1775
- // The install can refuse (npx checkout) or abort (malformed settings).
1776
- // Either way, say so and still launch the tool the user asked for -
1777
- // they came here to start an agent, not to configure one.
1778
- try {
1779
- installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive, false,
1780
- { genre: selection.genre, volume: chosenVol });
1781
- } catch (err) {
1782
- console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
1783
- }
1784
- }
1943
+ if (!applyMenuSelection(selection, chosenVol)) return;
1785
1944
 
1786
1945
  return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud, music);
1787
1946
  }
@@ -1793,6 +1952,7 @@ module.exports = {
1793
1952
  run,
1794
1953
  parseArgs,
1795
1954
  hooksAlreadyCover,
1955
+ applyMenuSelection,
1796
1956
  previewAudioFile,
1797
1957
  windowsCommandNeedsShell
1798
1958
  };
@@ -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(os.homedir(), ".grok", "hooks", "vibeaudio.json"),
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: () => path.join(os.homedir(), ".grok"),
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(os.homedir(), ".gemini", "settings.json"),
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(os.homedir(), ".qwen", "settings.json"),
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 ps unavailable.
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 notify(raw, message) {
1144
+ function banner(body) {
1104
1145
  if (!notifyEnabled() || playbackDisabled()) return false; // a mute means quiet, banners included
1105
- const command = notifyCommand(process.platform, "VibeAudio", `${sessionLabel(parsePayload(raw))}: ${message}`);
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,
@@ -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..."
@@ -263,7 +263,7 @@ const DELIVERY_FRESH = [
263
263
  {
264
264
  id: "hooks",
265
265
  name: "Agent hooks",
266
- desc: "Music follows the agent's thinking. Set once, no wrapper needed"
266
+ desc: "Music follows the agent's thinking. Installs, then returns to your shell: nothing starts"
267
267
  },
268
268
  {
269
269
  id: "wrapper",
@@ -285,7 +285,7 @@ const DELIVERY_INSTALLED = [
285
285
  {
286
286
  id: "hooks",
287
287
  name: "Reconfigure the hooks",
288
- desc: "Pick a new genre, volume or reactive setting"
288
+ desc: "Pick a new genre, volume or reactive setting. Nothing starts"
289
289
  }
290
290
  ];
291
291
 
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
- (c) => spawnSync("which", [c.cmd], { stdio: "ignore" }).status === 0
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;