vibeaudio 0.18.0 → 0.20.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. The menu's last entry, **Alerts & extras**, flips the saved toggles (banners, spoken announcements, `vibe --announce`, music, a one-hour mute) without a flag to remember. 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
 
@@ -314,7 +314,8 @@ Every session of an agent is tracked separately, by the session id in its hook p
314
314
  * **The music plays while any session is working.** A second prompt doesn't restart it, and it stops only when the last working session finishes.
315
315
  * **Every session gets its own chime.** A quick question that finishes while another agent is still busy chimes "done" and the music carries on underneath.
316
316
 
317
- * **Which one?** With three terminals going, a chime says something finished and leaves you alt-tabbing to find out what. `vibe --notify` adds a desktop banner — `api: finished`, `web: needs you (Bash)` — named after the project the session runs in. Off by default, since a banner is more intrusive than a sound; macOS (`osascript`) and Linux (`notify-send`, needs a notification daemon), nothing on Windows yet. A mute silences the banner too.
317
+ * **Which one?** With three terminals going, a chime says something finished and leaves you alt-tabbing to find out what. `vibe --notify` adds a desktop banner (`api: finished`, `web: needs you (Bash)`) and [`vibe --speak`](#-spoken-announcements-vibe---speak) adds a spoken line. Both are off by default. [Banner details below.](#-desktop-banners-vibe---notify)
318
+ * **Say it out loud.** `vibe --speak` adds a spoken line after the chime (*"api: done."*) when two or more sessions are going. [Details below.](#-spoken-announcements-vibe---speak)
318
319
 
319
320
  <details>
320
321
  <summary><b>The rest of how sessions share one stream</b></summary>
@@ -333,6 +334,44 @@ Sessions with no id in their payload share a single slot, so they behave as one.
333
334
 
334
335
  > **One player is shared.** Prompt two agents at once and the last prompt owns the music. One person, one set of speakers — deliberate, not a limitation being worked around.
335
336
 
337
+ ### 🔔 Desktop banners: `vibe --notify`
338
+
339
+ ```bash
340
+ vibe --notify # on
341
+ vibe --no-notify # off again (the default)
342
+ ```
343
+
344
+ A banner names the project when a turn **finishes**, **fails**, **needs you** (a permission dialog or a question) or **looks stuck**: `api: finished`, `web: needs you (Bash)`. It is saved, so it applies from your next prompt with nothing to reinstall. `vibe --status` shows it on the `notify` line, the **Alerts & extras** screen in the `vibe` menu toggles it, and `VIBE_NOTIFY=1` turns it on for one shell.
345
+
346
+ - **Platforms.** macOS through `osascript`, Linux through `notify-send` (it needs a notification daemon, which most desktops have). **Windows has no banner yet**; `vibe --doctor` says so when `--notify` is on and nothing can show one. The macOS command runs without error, but how the banner looks on screen has not been checked, and the Linux path has not been run.
347
+ - **Off by default**, because a banner is more intrusive than a sound.
348
+ - **Safe by construction.** The project name reaches the notifier as an argument, never as part of a script, because a directory name is whatever its owner called it.
349
+ - **Mute.** `--mute` and `VIBE_DISABLE` silence banners with everything else.
350
+ - **Scripts.** With it on, `vibe --announce "deploy finished"` shows its text as a banner too.
351
+
352
+ ### 🔊 Spoken announcements: `vibe --speak`
353
+
354
+ A chime says that something finished, not which project. With `--speak`, the system voice adds one short sentence after the chime:
355
+
356
+ > "api: done." · "api: failed." · "web needs you: Bash." · "payments looks stuck."
357
+
358
+ ```bash
359
+ vibe --speak # on: only when two or more sessions are going
360
+ vibe --speak-always # on: every time, even with one session
361
+ vibe --no-speak # off again (the default)
362
+ ```
363
+
364
+ It is saved, so it applies from your next prompt with nothing to reinstall, and `vibe --status` shows the current setting on its `speak` line. The `vibe` menu has it too: **Alerts & extras**, then Enter on "Spoken announcements" steps through off, auto and always. `VIBE_SPEAK=off` turns it off for one shell.
365
+
366
+ **To hear it once:** `vibe --speak-always`, then run any agent turn (and `vibe --unmute` first if you muted).
367
+
368
+ - **Why only with two sessions by default.** The sentence answers "which one?". With a single session the chime already told you, so `--speak` stays quiet and `--speak-always` is the opt-in for every time.
369
+ - **What it says.** A project name and one of four fixed states. It never reads the agent's output, and it names a tool only if it is one of the built-in ones (`Bash`, `Edit`, ...), not an MCP tool whose name someone else chose. Names like `my-app_v2` are spoken as "my app v2".
370
+ - **Voices.** macOS `say`, Linux `spd-say` (or `espeak-ng` / `espeak`), Windows `System.Speech` through PowerShell. Nothing is installed by VibeAudio; with no voice present it stays silent, and `vibe --doctor` says so. Verified against the real macOS `say` and stub voices; **not run on Linux or Windows**.
371
+ - **Safety.** The text reaches the voice as an argument (an environment variable on Windows), never as part of a shell command or script, and embedded speech commands such as `[[volm 0]]` are stripped.
372
+ - **Several at once.** Sessions that finish together speak one after another, not over each other, and the same sentence twice within five seconds is said once. Not done: merging them into "api and web: done", and lowering the music while it speaks.
373
+ - **Muting.** `--mute` and `VIBE_DISABLE` silence it with everything else. `vibe --announce "deploy finished"` text is spoken as well when speech is on.
374
+
336
375
  ### As a plugin (Claude Code and Codex)
337
376
 
338
377
  If Claude Code or Codex is the agent you use, the plugin installs the same hooks with no npm step. Inside a Claude Code session, type:
@@ -522,7 +561,7 @@ Each session is `working`, `stuck` (4 of its last 8 tool calls failed) or `waiti
522
561
  {"v":1,"at":1791026130000,"event":"waiting","session":"…","project":"/work/api","tool":"Bash","status":"waiting"}
523
562
  ```
524
563
 
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.
564
+ 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
565
 
527
566
  #### 📟 Status Bar & Desktop Integrations
528
567
 
@@ -563,11 +602,18 @@ The parentheses matter: Starship hides a group whose variables are empty, so the
563
602
  vibe --events | jq --unbuffered -c 'select(.event=="waiting" or .event=="stuck") | {event, project}' \
564
603
  | while read -r line; do curl -s -X POST -H 'Content-Type: application/json' -d "$line" https://example.com/hook; done
565
604
  ```
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.
605
+ 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.
606
+
607
+ **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:
608
+ ```bash
609
+ vibe --events | jq --unbuffered -c 'select(.event != "state") | {status, event, project}' \
610
+ | while read -r line; do curl -s -X POST -H 'Content-Type: application/json' -d "$line" http://homeassistant.local:8123/api/webhook/vibeaudio; done
611
+ ```
612
+ `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.
567
613
 
568
614
  *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
615
 
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.*
616
+ *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.*
571
617
 
572
618
  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.
573
619
 
@@ -594,10 +640,12 @@ vibe --announce "deploy" --outcome attention # the "needs you" chime
594
640
  gh run watch; vibe --announce "CI finished" --exit-code $?
595
641
  ```
596
642
 
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.
643
+ It plays the chime for the outcome, shows the `--notify` banner and says the text aloud if you have those 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
644
 
599
645
  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
646
 
647
+ To switch it off for good, for a script you can't edit: `vibe --announcements off` (or `VIBE_ANNOUNCE=off` for one shell, or the **Alerts & extras** screen in the `vibe` menu). It then does nothing and still exits 0, without even writing the event. A mute is different: it silences the sound but keeps the event.
648
+
601
649
  ## 🖥️ Everything Else (Claude Desktop, VS Code… via MCP)
602
650
 
603
651
  **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.
@@ -781,6 +829,8 @@ The full order, highest first: **a flag** → **an environment variable** → **
781
829
  | `--status` | Show what's installed, running and detected, then exit | — |
782
830
  | `--doctor` | Check the setup; every problem comes with the command that fixes it. Exits 1 on a failure, so it scripts | — |
783
831
  | `--notify` / `--no-notify` | Also show a desktop banner naming the project when a turn finishes, fails or needs you. Saved to `config.json` | off |
832
+ | `--speak` / `--speak-always` / `--no-speak` | Say which project finished, failed or needs you, after the chime: only with two or more sessions going (`--speak`), or every time (`--speak-always`). Saved to `config.json` | off |
833
+ | `--announcements <on\|off>` | Turn `vibe --announce` on or off for good. Saved to `config.json` | on |
784
834
  | `--report [days]` | How long you waited on agents, and on which projects, from the local turn log | `7` days |
785
835
  | `--statusline` | The same state as one plain line for tmux or a shell prompt (`working ×2`, `waiting: api`); prints nothing when idle | — |
786
836
  | `--state` | What every agent on the machine is doing, as one line of JSON: `idle`, `working`, `stuck` or `waiting` | — |
@@ -813,6 +863,8 @@ export VIBE_SEED=7 # Same arrangement everywhere, ignoring the dire
813
863
  export VIBE_DISABLE=1 # Mute, without uninstalling anything
814
864
  export VIBE_MUSIC=off # Signals only: chimes and banners, no music, for this shell
815
865
  export VIBE_NOTIFY=1 # Desktop banner naming the project, for this shell
866
+ export VIBE_SPEAK=auto # Spoken announcements (auto, always or off), for this shell
867
+ export VIBE_ANNOUNCE=off # Make vibe --announce do nothing, for this shell
816
868
  export VIBE_NO_HISTORY=1 # Do not log finished turns (what --report reads)
817
869
  export VIBE_NO_UPDATE_CHECK=1 # Never ask npm whether a newer version is out
818
870
  export VIBE_NO_FADE=1 # macOS: play with plain afplay, no fade in/out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeaudio",
3
- "version": "0.18.0",
3
+ "version": "0.20.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
@@ -61,7 +61,9 @@ Procedural focus music while your AI coding tools think.
61
61
  vibe --preview jazz
62
62
  gh run watch; vibe --announce "CI finished" --exit-code $? \x1b[90m# the chime for a CI run or a script\x1b[0m
63
63
  vibe --render \x1b[90m# save this repo's sound as a .wav to share\x1b[0m
64
- vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre)\x1b[0m
64
+ vibe --notify \x1b[90m# a desktop banner naming the project when a turn finishes or needs you (macOS, Linux)\x1b[0m
65
+ vibe --speak \x1b[90m# a voice names the project after the chime (--no-speak turns it off)\x1b[0m
66
+ vibe \x1b[90m# menu: pick a tool and a sound (p auditions a genre); "Alerts & extras" flips banners, speech, announce, music, mute\x1b[0m
65
67
 
66
68
  \x1b[1mOPTIONS:\x1b[0m
67
69
  -g, --genre <name> Select genre: lofi (default), synthwave, 8bit, electronic, jazz, zen, piano, drone, rain, ocean, random
@@ -79,10 +81,13 @@ Procedural focus music while your AI coding tools think.
79
81
  --preview <genre> Play one loop of a genre and exit
80
82
  --render [file] Write this project's music to a .wav and exit (full scale, ignores --volume)
81
83
  --status Show what is installed, running and detected, then exit
82
- --notify | --no-notify Also show a desktop banner naming the project when a turn finishes or needs you (off by default)
84
+ --notify | --no-notify Also show a desktop banner naming the project when a turn finishes, fails, needs you or looks stuck (macOS and Linux; off by default)
85
+ --speak | --no-speak Also say which project finished, failed or needs you, after the chime - with two or more sessions going (off by default)
86
+ --speak-always Like --speak, but every time, even with one session
87
+ --announcements <on|off> Turn vibe --announce on or off for good (a saved setting; on by default)
83
88
  --report [days] How long you waited on agents, and where (default: 7 days)
84
89
  --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
90
+ --announce <text> Play the signals for something that is not an agent (CI, a deploy, a script), then exit (spoken too when --speak is on)
86
91
  --outcome <o> With --announce: success (default), failure or attention
87
92
  --exit-code <n> With --announce: 0 is success, anything else failure (use $?)
88
93
  --dashboard Serve a read-only live view of every agent on this machine, and open it
@@ -102,7 +107,7 @@ Procedural focus music while your AI coding tools think.
102
107
  --dry-run With --install-hooks: show what would change, write nothing
103
108
  --uninstall-hooks Remove the hooks again, from every agent
104
109
  -h, --help Show this help message
105
- --version Show version
110
+ --version Show version (update with: npm i -g vibeaudio)
106
111
 
107
112
  \x1b[1mSETTINGS:\x1b[0m
108
113
  A genre or volume with no command after it is saved as your default:
@@ -115,15 +120,45 @@ Procedural focus music while your AI coding tools think.
115
120
  \x1b[1m--here\x1b[0m setting beats the global one, so a one-off
116
121
  \x1b[1mvibe --genre 8bit npm test\x1b[0m stays a one-off.
117
122
 
123
+ \x1b[1mINTEGRATIONS:\x1b[0m
124
+ Every agent's state, as one stream you can build on (lights, banners, webhooks):
125
+
126
+ vibe --events \x1b[90m# the current state, then one JSON line per change\x1b[0m
127
+ vibe --state \x1b[90m# one snapshot: idle, working, stuck or waiting\x1b[0m
128
+ vibe --statusline \x1b[90m# one plain line for tmux or a prompt\x1b[0m
129
+
130
+ Events: started, waiting, resumed, stuck, recovered, finished, interrupted, ended, announced.
131
+ Recipes (tmux, Starship, voice, webhook, Home Assistant): https://kiril6.github.io/vibeaudio/
132
+
133
+ \x1b[1mUNINSTALL:\x1b[0m
134
+ In this order, so no agent is left running a hook that points at nothing:
135
+
136
+ vibe --uninstall-hooks \x1b[90m# 1. unwire every agent, and stop any player still running\x1b[0m
137
+ npm rm -g vibeaudio \x1b[90m# 2. remove the CLI\x1b[0m
138
+ rm -rf ~/.vibeaudio \x1b[90m# 3. optional: cached audio, saved settings, state\x1b[0m
139
+
118
140
  \x1b[1mENVIRONMENT:\x1b[0m
119
141
  VIBE_GENRE=<name> Override the saved genre for this shell
120
142
  VIBE_VOLUME=<5-100> Override the saved volume for this shell
121
143
  VIBE_CHIME_VOLUME=<5-100> Override the saved chime volume for this shell
122
144
  VIBE_GRACE_MS=<ms> Override the saved grace window, in ms
123
145
  VIBE_MUSIC=<on|off> Override the saved music setting for this shell
146
+ VIBE_NOTIFY=<on|off> Override the saved desktop-banner setting for this shell
147
+ VIBE_SPEAK=<auto|always|off> Override the saved spoken-announcement setting for this shell
148
+ VIBE_ANNOUNCE=<on|off> Turn vibe --announce off for this shell
124
149
  VIBE_SEED=<n> Pin the arrangement instead of deriving it from the directory
125
150
  VIBE_DISABLE=1 Mute automatic playback without uninstalling anything
126
151
  VIBE_NO_UPDATE_CHECK=1 Never check npm for a newer version
152
+
153
+ \x1b[1mNO SOUND?\x1b[0m
154
+ vibe --doctor \x1b[90m# checks the setup; each problem comes with its fix\x1b[0m
155
+ vibe --status \x1b[90m# shows whether you are muted, and what is installed\x1b[0m
156
+ vibe --unmute \x1b[90m# clears a mute\x1b[0m
157
+
158
+ \x1b[1mMORE:\x1b[0m
159
+ Settings and state: ~/.vibeaudio
160
+ Docs: https://kiril6.github.io/vibeaudio/
161
+ Problems and ideas: https://github.com/kiril6/vibeaudio/issues
127
162
  `);
128
163
  printUpdateNotice();
129
164
  }
@@ -148,6 +183,7 @@ const VALUE_FLAGS = new Set([
148
183
  "-cv", "--chime-volume",
149
184
  "--grace",
150
185
  "--music",
186
+ "--announcements",
151
187
  "--seed",
152
188
  "--tools",
153
189
  "--event",
@@ -199,6 +235,8 @@ function parseArgs(argv) {
199
235
  let eventsFlag = false;
200
236
  let doctorFlag = false;
201
237
  let notifyFlag = null;
238
+ let speakFlag = null;
239
+ let announcementsFlag = null;
202
240
  let reportDays = null;
203
241
  let stopFlag = false;
204
242
  let muteFlag = null;
@@ -400,6 +438,23 @@ function parseArgs(argv) {
400
438
  continue;
401
439
  }
402
440
 
441
+ if (arg === "--speak" || arg === "--speak-always" || arg === "--no-speak") {
442
+ speakFlag = arg === "--speak" ? "auto" : arg === "--speak-always" ? "always" : "off";
443
+ i += 1;
444
+ continue;
445
+ }
446
+
447
+ if (arg === "--announcements") {
448
+ const value = args[i + 1].toLowerCase();
449
+ if (value !== "on" && value !== "off") {
450
+ console.error(`\x1b[31m[vibeaudio] --announcements takes on or off, not '${args[i + 1]}'.\x1b[0m`);
451
+ process.exit(1);
452
+ }
453
+ announcementsFlag = value === "on";
454
+ i += 2;
455
+ continue;
456
+ }
457
+
403
458
  if (arg === "--doctor") {
404
459
  doctorFlag = true;
405
460
  i += 1;
@@ -578,6 +633,8 @@ function parseArgs(argv) {
578
633
  events: eventsFlag,
579
634
  doctor: doctorFlag,
580
635
  notify: notifyFlag,
636
+ speak: speakFlag,
637
+ announcements: announcementsFlag,
581
638
  report: reportDays,
582
639
  stop: stopFlag,
583
640
  mute: muteFlag,
@@ -717,6 +774,7 @@ function installHookTargets(ids, genre, volume, reactive, dryRun = false, typed
717
774
  const live = ids.filter((id) => hooks.TARGETS[id].liveReload !== false).map((id) => hooks.TARGETS[id].name);
718
775
  if (live.length) console.log(` ${live.join(", ")}: takes effect on your next prompt - no restart needed.`);
719
776
  console.log(` \x1b[90mChange the sound any time with: vibe --genre <name> --volume <n> — no reinstall.\x1b[0m`);
777
+ console.log(` \x1b[90mCheck it with vibe --status · quiet for a call: vibe --mute · everything else: vibe --help\x1b[0m`);
720
778
  if (ids.length > 1) {
721
779
  console.log(` \x1b[90mOne player is shared: whichever agent you prompt last owns the music.\x1b[0m`);
722
780
  }
@@ -824,6 +882,10 @@ function printStatus() {
824
882
  console.log(` volume ${on(`${Math.round(volumeNow() * 100)}%`)}${source("VIBE_VOLUME", "volume")}`);
825
883
  console.log(` ${off("change either with: vibe --genre <name> --volume <n>")}`);
826
884
  console.log(` notify ${hooks.notifyEnabled() ? on("on") : off("off")}${off(" desktop banner naming the project — vibe --notify / --no-notify")}`);
885
+ const speech = require("./speech");
886
+ const speakNow = speech.speakMode(process.env, loadConfig());
887
+ console.log(` speak ${speakNow === "off" ? off("off") : on(speakNow)}${off(speakNow === "off" ? " a voice names the project after the chime — vibe --speak / --speak-always" : speakNow === "auto" ? " only with two or more sessions — vibe --speak-always / --no-speak" : " every time — vibe --no-speak")}${speakNow !== "off" && !speech.voiceAvailable() ? " \x1b[33mno voice found on this machine\x1b[0m" : ""}`);
888
+ console.log(` announce ${hooks.announceEnabled() ? on("on") : "\x1b[33moff\x1b[0m"}${off(hooks.announceEnabled() ? " vibe --announce <text> for scripts and CI — vibe --announcements off to disable" : " vibe --announce does nothing — vibe --announcements on")}`);
827
889
 
828
890
  // Audio backend
829
891
  const backend = detect();
@@ -1030,6 +1092,22 @@ function doctorChecks() {
1030
1092
  if (playbackDisabled()) add("warn", "VIBE_DISABLE", `set to "${process.env.VIBE_DISABLE}" — automatic playback is off`, "unset VIBE_DISABLE");
1031
1093
  if (mute === null && !playbackDisabled()) add("ok", "Mute", "not muted");
1032
1094
  if (!musicEnabled()) add("ok", "Music", "off — chimes and banners only, by your setting (vibe --music on)");
1095
+ if (hooks.notifyEnabled()) {
1096
+ if (process.platform === "darwin") add("ok", "Desktop banners", "on, through osascript");
1097
+ else if (process.platform === "linux") {
1098
+ if (require("./interactive").isInstalled("notify-send")) add("ok", "Desktop banners", "on, through notify-send");
1099
+ else add("warn", "Desktop banners", "on, but notify-send was not found, so no banner appears", "install libnotify (notify-send) and a notification daemon");
1100
+ } else add("warn", "Desktop banners", "on, but this platform has no banner backend yet, so none appears", "vibe --no-notify");
1101
+ }
1102
+ {
1103
+ const speech = require("./speech");
1104
+ const mode = speech.speakMode(process.env, loadConfig());
1105
+ if (mode !== "off") {
1106
+ if (speech.voiceAvailable()) add("ok", "Spoken announcements", `${mode} — a voice is available`);
1107
+ else add("warn", "Spoken announcements", `${mode}, but no voice was found, so nothing is said`,
1108
+ process.platform === "linux" ? "install spd-say, espeak-ng or espeak" : "this system has no speech command on PATH");
1109
+ }
1110
+ }
1033
1111
 
1034
1112
  const pid = readDaemonPid(hooks.PID_FILE);
1035
1113
  if (pid === null) add("ok", "Background player", "not running");
@@ -1659,6 +1737,44 @@ function executeCommand(cmdArgs, genre, volume, chimeVolume, grace = DEFAULT_GRA
1659
1737
  process.on("SIGTERM", () => relaySignal("SIGTERM"));
1660
1738
  }
1661
1739
 
1740
+ /**
1741
+ * Acts on what the menu returned, and says whether to launch the tool.
1742
+ *
1743
+ * Choosing hooks is setup: it installs them and stops. Nothing is started, so
1744
+ * someone running several agents can set one up now and open it when they
1745
+ * like - the music plays from their first prompt either way. Only "just launch
1746
+ * it" and "this session only" go on to start the tool. A failed install is an
1747
+ * error with a non-zero exit, not a launch without the hooks that were asked for.
1748
+ */
1749
+ function applyMenuSelection(selection, volume) {
1750
+ if (!selection.installHooks) return true;
1751
+ const hooks = require("./hooks");
1752
+ try {
1753
+ installHookTargets([selection.hookTarget], selection.genre, volume, selection.reactive, false,
1754
+ { genre: selection.genre, volume });
1755
+ } catch (err) {
1756
+ console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
1757
+ process.exit(1);
1758
+ }
1759
+ const saved = loadConfig();
1760
+ // Everything VibeAudio is hooked into on this machine, not just this run's
1761
+ // pick: with several agents, "which ones are set up?" is the question, and
1762
+ // which one to open first is the user's call.
1763
+ const hooked = Object.entries(hooks.TARGETS).filter(([id]) => hooks.userHooksInstalled(null, id));
1764
+ const names = hooked.map(([, t]) => t.name).join(", ");
1765
+ const cmds = hooked.map(([, t]) => t.cmd).join(", ");
1766
+ console.log(
1767
+ `\n\x1b[32m✔ VibeAudio v${pkg.version} is hooked into: ${names} (${saved.genre} @ ${saved.volume}%).\x1b[0m ` +
1768
+ `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` +
1769
+ ` \x1b[90mMore agents? Run vibe again for each, or vibe --install-hooks to set up every one it finds.\x1b[0m\n` +
1770
+ ` \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` +
1771
+ ` \x1b[90mOptional: vibe --speak adds a spoken line naming the project after the chime (vibe --no-speak turns it off).\x1b[0m\n` +
1772
+ ` \x1b[90mAll commands and options: vibe --help\x1b[0m\n`
1773
+ );
1774
+ printUpdateNotice();
1775
+ return false;
1776
+ }
1777
+
1662
1778
  async function run() {
1663
1779
  const {
1664
1780
  genre,
@@ -1677,6 +1793,8 @@ async function run() {
1677
1793
  events: followEvents,
1678
1794
  doctor: showDoctor,
1679
1795
  notify: notifyChange,
1796
+ speak: speakChange,
1797
+ announcements: announcementsChange,
1680
1798
  report: reportDays,
1681
1799
  stop: shouldStop,
1682
1800
  mute: muteChange,
@@ -1714,6 +1832,23 @@ async function run() {
1714
1832
  }
1715
1833
  }
1716
1834
 
1835
+ // These act on their own and then exit. With a command after them the command
1836
+ // used to be dropped without a word and the exit code was 0, so
1837
+ // `vibe --notify npm test && deploy` deployed without running the tests.
1838
+ {
1839
+ const own = muteChange !== null ? (muteChange ? "--mute" : "--unmute")
1840
+ : notifyChange !== null ? (notifyChange ? "--notify" : "--no-notify")
1841
+ : speakChange !== null ? (speakChange === "off" ? "--no-speak" : speakChange === "always" ? "--speak-always" : "--speak")
1842
+ : announcementsChange !== null ? "--announcements"
1843
+ : announceReq.text !== null ? "--announce"
1844
+ : null;
1845
+ if (own && cmdArgs.length > 0) {
1846
+ console.error(`\x1b[31m[vibeaudio] ${own} does not wrap a command, so '${cmdArgs.join(" ")}' was not run.\x1b[0m ` +
1847
+ (own === "--announce" ? `Put it after the command instead: ${cmdArgs.join(" ")}; vibe --announce "..." --exit-code $?` : `Run ${own} on its own, then the command.`));
1848
+ process.exit(1);
1849
+ }
1850
+ }
1851
+
1717
1852
  if (announceReq.text === null && (announceReq.outcome !== null || announceReq.exitCode !== null)) {
1718
1853
  console.error("\x1b[31m[vibeaudio] --outcome and --exit-code go with --announce <text>.\x1b[0m");
1719
1854
  process.exit(1);
@@ -1771,6 +1906,25 @@ async function run() {
1771
1906
  return;
1772
1907
  }
1773
1908
 
1909
+ if (speakChange !== null) {
1910
+ saveConfig({ speak: speakChange });
1911
+ console.log(speakChange === "off"
1912
+ ? "\x1b[90mSpoken announcements off.\x1b[0m"
1913
+ : `\x1b[32m✔ Spoken announcements on${speakChange === "always" ? " (every time)" : " (when two or more sessions are going)"}.\x1b[0m After the chime, a voice says which project finished, failed or needs you.`);
1914
+ if (speakChange !== "off" && !require("./speech").voiceAvailable()) {
1915
+ console.log(" \x1b[33mNo voice found on this machine\x1b[0m, so nothing will be said. Linux needs spd-say, espeak-ng or espeak.");
1916
+ }
1917
+ return;
1918
+ }
1919
+
1920
+ if (announcementsChange !== null) {
1921
+ saveConfig({ announce: announcementsChange });
1922
+ console.log(announcementsChange
1923
+ ? "\x1b[32m✔ vibe --announce is on.\x1b[0m"
1924
+ : "\x1b[90mvibe --announce is off: it now does nothing, and exits 0.\x1b[0m");
1925
+ return;
1926
+ }
1927
+
1774
1928
  if (shouldStop) {
1775
1929
  const hooks = require("./hooks");
1776
1930
  const stopped = hooks.stopDaemon();
@@ -1874,19 +2028,10 @@ async function run() {
1874
2028
  process.exit(0);
1875
2029
  }
1876
2030
 
1877
- const chosenVol = selection.volume !== undefined ? selection.volume : volume;
2031
+ if (selection.settings) return; // the toggles were saved on the way out of that screen
1878
2032
 
1879
- if (selection.installHooks) {
1880
- // The install can refuse (npx checkout) or abort (malformed settings).
1881
- // Either way, say so and still launch the tool the user asked for -
1882
- // they came here to start an agent, not to configure one.
1883
- try {
1884
- installHookTargets([selection.hookTarget], selection.genre, chosenVol, selection.reactive, false,
1885
- { genre: selection.genre, volume: chosenVol });
1886
- } catch (err) {
1887
- console.error(`\x1b[31m[vibeaudio] ${err.message}\x1b[0m`);
1888
- }
1889
- }
2033
+ const chosenVol = selection.volume !== undefined ? selection.volume : volume;
2034
+ if (!applyMenuSelection(selection, chosenVol)) return;
1890
2035
 
1891
2036
  return executeCommand(selection.cmd, selection.genre, chosenVol, chimeVolume, grace, noChime, noHud, music);
1892
2037
  }
@@ -1898,6 +2043,7 @@ module.exports = {
1898
2043
  run,
1899
2044
  parseArgs,
1900
2045
  hooksAlreadyCover,
2046
+ applyMenuSelection,
1901
2047
  previewAudioFile,
1902
2048
  windowsCommandNeedsShell
1903
2049
  };
package/src/hooks.js CHANGED
@@ -17,6 +17,7 @@ const path = require("path");
17
17
  const { spawn, execFileSync } = require("child_process");
18
18
  const { AudioPlayer, loadConfig, playbackDisabled } = require("./player");
19
19
  const { recordTurn } = require("./history");
20
+ const speech = require("./speech");
20
21
 
21
22
  const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
22
23
  const PID_FILE = path.join(STATE_DIR, "daemon.pid");
@@ -1159,6 +1160,37 @@ function notify(raw, message) {
1159
1160
  return banner(`${sessionLabel(parsePayload(raw))}: ${message}`);
1160
1161
  }
1161
1162
 
1163
+ /**
1164
+ * `--announce` can be switched off for good (menu, `--announcements off`,
1165
+ * `VIBE_ANNOUNCE=off`) for a script that cannot be edited. On unless said
1166
+ * otherwise; env beats the saved setting.
1167
+ */
1168
+ function announceEnabled(env = process.env, config = loadConfig()) {
1169
+ const raw = String(env.VIBE_ANNOUNCE ?? config.announce ?? "").trim().toLowerCase();
1170
+ return !["0", "false", "off", "no"].includes(raw);
1171
+ }
1172
+
1173
+ /**
1174
+ * The spoken line for a hook event (#33), after its chime. `sessionCount` is
1175
+ * how many sessions are in flight, this one included: in `auto` mode it takes
1176
+ * two to be worth saying, since the point is "which one?". `chimed` says a
1177
+ * chime was started detached, so the sentence waits for it; one that already
1178
+ * played to the end (the Stop hook plays it inline) needs no wait. Only a
1179
+ * built-in tool is named: an MCP tool's name is third-party text.
1180
+ */
1181
+ function speakEvent(raw, kind, sessionCount, { chimed = false, volume = 0.4, tool = "" } = {}) {
1182
+ try {
1183
+ if (!speech.shouldSpeak(speech.speakMode(process.env, loadConfig()), sessionCount)) return false;
1184
+ const named = tool && Object.hasOwn(TOOL_TIERS, tool) ? tool : "";
1185
+ return speech.speak(speech.phrase(kind, sessionLabel(parsePayload(raw)), named), {
1186
+ delayMs: speech.chimeDelayMs(kind, chimed),
1187
+ volume
1188
+ });
1189
+ } catch (e) {
1190
+ return false; // speech must never fail the hook
1191
+ }
1192
+ }
1193
+
1162
1194
  const ANNOUNCE_FILE = path.join(STATE_DIR, "announce.json");
1163
1195
  const ANNOUNCE_MAX_CHARS = 200;
1164
1196
  const ANNOUNCE_REPEAT_MS = 5000;
@@ -1180,6 +1212,7 @@ const ANNOUNCE_OUTCOMES = ["success", "failure", "attention"];
1180
1212
  */
1181
1213
  function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre } = {}) {
1182
1214
  try {
1215
+ if (!announceEnabled()) return false; // switched off: inert, event included
1183
1216
  const clean = String(text).replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, ANNOUNCE_MAX_CHARS);
1184
1217
  const now = Date.now();
1185
1218
  try {
@@ -1193,6 +1226,11 @@ function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null,
1193
1226
  banner(`${clean}${outcome === "failure" ? " (failed)" : outcome === "attention" ? " (needs you)" : ""}`);
1194
1227
  // Detached: a pipeline should not wait out the chime.
1195
1228
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre, detach: true });
1229
+ // The caller's own text, read after the chime. No session count to wait on:
1230
+ // a script has no "which one", so `auto` and `always` both speak it.
1231
+ if (speech.speakMode(process.env, loadConfig()) !== "off") {
1232
+ speech.speak(clean, { delayMs: speech.chimeDelayMs(outcome, !noChime), volume: chimeVolume !== null ? chimeVolume : volume });
1233
+ }
1196
1234
  return true;
1197
1235
  } catch (e) {
1198
1236
  return true; // a notifier must never fail the script that called it
@@ -1201,6 +1239,7 @@ function announce(text, { outcome = "success", volume = 0.4, chimeVolume = null,
1201
1239
 
1202
1240
  function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false, genre, raw = "" } = {}) {
1203
1241
  const id = sessionId(payloadSession(parsePayload(raw)));
1242
+ const active = listSessions().length; // this session included, until its file goes
1204
1243
  // A turn that ends while paused for the user - a denied tool that nothing
1205
1244
  // resumed after - still finished, so its session counts either way.
1206
1245
  const session = readSession(id);
@@ -1223,10 +1262,16 @@ function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChi
1223
1262
  if (!others) fs.rmSync(INTENSITY_FILE, { force: true });
1224
1263
  if (!(tracked || wasPlaying)) return false;
1225
1264
  notify(raw, outcome === "failure" ? "failed" : "finished");
1226
- if (noChime) return false;
1265
+ const speakVolume = chimeVolume !== null ? chimeVolume : volume;
1266
+ if (noChime) {
1267
+ speakEvent(raw, outcome === "failure" ? "failure" : "success", active, { volume: speakVolume });
1268
+ return false;
1269
+ }
1227
1270
 
1228
- // Chime plays in this short-lived hook process.
1271
+ // Chime plays in this short-lived hook process, to the end; the sentence
1272
+ // starts after it, so it needs no delay of its own.
1229
1273
  new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume, genre });
1274
+ speakEvent(raw, outcome === "failure" ? "failure" : "success", active, { volume: speakVolume });
1230
1275
  return true;
1231
1276
  }
1232
1277
 
@@ -1278,6 +1323,11 @@ function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {
1278
1323
  const tool = payloadToolName(raw);
1279
1324
  notify(raw, tool ? `needs you (${tool})` : "needs you");
1280
1325
  if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
1326
+ speakEvent(raw, "attention", listSessions().length, {
1327
+ chimed: !noChime && !playbackDisabled(),
1328
+ volume: chimeVolume !== null ? chimeVolume : volume,
1329
+ tool
1330
+ });
1281
1331
  return true;
1282
1332
  }
1283
1333
 
@@ -1314,6 +1364,10 @@ function hookResume(raw, genre, volume, { reactive = false, follow = false, musi
1314
1364
  // loop to put one under, so the pulse plays once on its own. Detached: the
1315
1365
  // agent waits on this hook.
1316
1366
  if (!music && !noChime) new AudioPlayer().stop({ playChime: true, outcome: "stuck", volume, chimeVolume, detach: true });
1367
+ speakEvent(raw, "stuck", listSessions().length, {
1368
+ chimed: !music && !noChime && !playbackDisabled(),
1369
+ volume: chimeVolume !== null ? chimeVolume : volume
1370
+ });
1317
1371
  }
1318
1372
 
1319
1373
  // An empty key is a wait that named nothing (a Notification): the next tool
@@ -1671,6 +1725,7 @@ module.exports = {
1671
1725
  qwenHome,
1672
1726
  grokHome,
1673
1727
  announce,
1728
+ announceEnabled,
1674
1729
  recentEvents,
1675
1730
  emitEvent,
1676
1731
  ANNOUNCE_OUTCOMES,
@@ -3,15 +3,36 @@
3
3
  * Zero dependencies - Pure Node.js ANSI and Readline
4
4
  */
5
5
 
6
- const { spawnSync } = require("child_process");
6
+ const fs = require("fs");
7
+ const path = require("path");
7
8
  const readline = require("readline");
8
9
 
9
- // Windows has no `which`; without this every tool shows as missing there.
10
- const LOOKUP_CMD = process.platform === "win32" ? "where" : "which";
11
-
12
- function isInstalled(cmd) {
13
- // An argument vector, not a command string: nothing in `cmd` reaches a shell.
14
- return spawnSync(LOOKUP_CMD, [cmd], { stdio: "ignore", timeout: 3000, killSignal: "SIGKILL" }).status === 0;
10
+ /**
11
+ * Is `cmd` an executable on PATH? Read off the filesystem, not by asking
12
+ * `which`/`where`: a lookup is a process spawn, and on CI runners that spawn
13
+ * has hung and missed its timeout (#58), where a directory scan cannot. It
14
+ * also needs no `which` to exist, and nothing in `cmd` reaches a shell.
15
+ * A name with a separator is a path, as `which` treats it.
16
+ */
17
+ function isInstalled(cmd, env = process.env) {
18
+ if (typeof cmd !== "string" || !cmd) return false;
19
+ const win = process.platform === "win32";
20
+ const exts = win ? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
21
+ const dirs = /[\\/]/.test(cmd) ? [""] : String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
22
+ const hasExt = win && exts.some((e) => cmd.toLowerCase().endsWith(e.toLowerCase()));
23
+ for (const dir of dirs) {
24
+ for (const ext of hasExt ? ["", ...exts] : exts) {
25
+ const file = path.join(dir, cmd + ext);
26
+ try {
27
+ if (!fs.statSync(file).isFile()) continue;
28
+ if (!win) fs.accessSync(file, fs.constants.X_OK);
29
+ return true;
30
+ } catch (e) {
31
+ // Not here, or not executable: keep looking.
32
+ }
33
+ }
34
+ }
35
+ return false;
15
36
  }
16
37
 
17
38
  // Convenience only - the wrapper runs any command, and "Custom command..."
@@ -35,7 +56,8 @@ const AI_TOOLS = [
35
56
  { name: "Antigravity CLI", cmd: ["agy"], check: "agy", hookTarget: "antigravity" },
36
57
  { name: "Aider", cmd: ["aider"], check: "aider" },
37
58
  { name: "Ollama (Llama 3)", cmd: ["ollama", "run", "llama3"], check: "ollama" },
38
- { name: "Custom command...", cmd: null }
59
+ { name: "Custom command...", cmd: null },
60
+ { name: "⚙️ Alerts & extras...", cmd: null, settings: true }
39
61
  ];
40
62
 
41
63
  const GENRES = [
@@ -263,7 +285,7 @@ const DELIVERY_FRESH = [
263
285
  {
264
286
  id: "hooks",
265
287
  name: "Agent hooks",
266
- desc: "Music follows the agent's thinking. Set once, no wrapper needed"
288
+ desc: "Music follows the agent's thinking. Installs, then returns to your shell: nothing starts"
267
289
  },
268
290
  {
269
291
  id: "wrapper",
@@ -285,7 +307,7 @@ const DELIVERY_INSTALLED = [
285
307
  {
286
308
  id: "hooks",
287
309
  name: "Reconfigure the hooks",
288
- desc: "Pick a new genre, volume or reactive setting"
310
+ desc: "Pick a new genre, volume or reactive setting. Nothing starts"
289
311
  }
290
312
  ];
291
313
 
@@ -294,6 +316,64 @@ const REACTIVE = [
294
316
  { id: true, name: "Reactive", desc: "Intensity follows the tool in use - noticeable, by design" }
295
317
  ];
296
318
 
319
+ const NEXT_SPEAK = { off: "auto", auto: "always", always: "off" };
320
+
321
+ /**
322
+ * The saved toggles that have no flag a person would think to look for:
323
+ * banners, the spoken line, `vibe --announce`, music and the mute. One screen,
324
+ * not a question per setting in the setup flow, which runs every time a tool
325
+ * is picked. Enter flips the highlighted one and the list comes back with its
326
+ * new value; every change is saved at once and lands on the next prompt, like
327
+ * `vibe --genre`. A variable set in this shell outranks the saved value, so the
328
+ * row says so rather than letting the toggle look dead.
329
+ */
330
+ async function promptSettings() {
331
+ const { loadConfig, saveConfig, muteState, setMuted, musicEnabled, DEFAULT_MUTE_MINUTES } = require("./player");
332
+ const hooks = require("./hooks");
333
+ const speech = require("./speech");
334
+
335
+ for (;;) {
336
+ const config = loadConfig();
337
+ const pinned = (name) => (process.env[name] !== undefined ? ` (${name} is set in this shell and wins)` : "");
338
+ const speak = speech.speakMode(process.env, config);
339
+ const items = [
340
+ { id: "notify", name: "Desktop banners", value: hooks.notifyEnabled(process.env, config) ? "on" : "off",
341
+ desc: `name the project when a turn finishes or needs you${pinned("VIBE_NOTIFY")}` },
342
+ { id: "speak", name: "Spoken announcements", value: speak,
343
+ desc: `a voice names the project after the chime: off, auto (two or more sessions) or always${pinned("VIBE_SPEAK")}${speak !== "off" && !speech.voiceAvailable() ? " - no voice found on this machine" : ""}` },
344
+ { id: "announce", name: "vibe --announce", value: hooks.announceEnabled(process.env, config) ? "on" : "off",
345
+ desc: `the chime for CI runs and scripts${pinned("VIBE_ANNOUNCE")}` },
346
+ { id: "music", name: "Music", value: musicEnabled() ? "on" : "off",
347
+ desc: `off keeps the chimes, banners and events, and drops the music${pinned("VIBE_MUSIC")}` },
348
+ { id: "mute", name: "Mute", value: muteState() !== null ? "on" : "off",
349
+ desc: `silence everything for ${DEFAULT_MUTE_MINUTES} minutes (vibe --unmute ends it)` },
350
+ { id: "done", name: "Done", value: "", desc: "" }
351
+ ];
352
+
353
+ const chosen = await selectMenu(
354
+ "Alerts & extras \x1b[0m\x1b[90m(Enter flips the highlighted one)\x1b[0m",
355
+ items,
356
+ (item, num) => item.id === "done"
357
+ ? `${num}. ${item.name}`
358
+ : `${num}. ${item.name}: ${item.value === "off" ? "\x1b[33moff\x1b[0m" : `\x1b[32m${item.value}\x1b[0m`} \x1b[90m— ${item.desc}\x1b[0m`
359
+ );
360
+
361
+ if (chosen.id === "done") break;
362
+ if (chosen.id === "notify") saveConfig({ notify: chosen.value !== "on" });
363
+ else if (chosen.id === "speak") saveConfig({ speak: NEXT_SPEAK[chosen.value] });
364
+ else if (chosen.id === "announce") saveConfig({ announce: chosen.value !== "on" });
365
+ else if (chosen.id === "music") saveConfig({ music: chosen.value !== "on" });
366
+ else if (chosen.id === "mute") {
367
+ if (chosen.value === "on") setMuted(false);
368
+ else {
369
+ setMuted(true, DEFAULT_MUTE_MINUTES);
370
+ hooks.stopDaemon({ keepSessions: true }); // silence what is playing now, not just the next prompt
371
+ }
372
+ }
373
+ }
374
+ console.log("\n\x1b[32m✔ Saved.\x1b[0m Changes apply from your next prompt. Nothing was started.\n");
375
+ }
376
+
297
377
  /**
298
378
  * `hooksInstalledFor` is asked about the tool the user picked, not about
299
379
  * Claude: the caller cannot know which tool that is until this menu runs, and
@@ -323,6 +403,11 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
323
403
  }
324
404
  );
325
405
 
406
+ if (selectedTool.settings) {
407
+ await promptSettings();
408
+ return { settings: true };
409
+ }
410
+
326
411
  let finalCmd = selectedTool.cmd;
327
412
  if (!finalCmd) {
328
413
  finalCmd = await promptCustomCommand();
@@ -387,4 +472,4 @@ async function promptInteractive({ hooksInstalledFor = () => false } = {}) {
387
472
  };
388
473
  }
389
474
 
390
- module.exports = { promptInteractive, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
475
+ module.exports = { promptInteractive, promptSettings, tokenizeCommand, isInstalled, hookTargetOf, playPreview, stopPreview, AI_TOOLS };
package/src/speech.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Spoken announcements (#33): the sentence after the chime that says *which*
3
+ * project finished, failed or needs you. A chime says that something happened
4
+ * and leaves you alt-tabbing to find out what.
5
+ *
6
+ * Opt-in (`--speak`, `speak` in config.json, `VIBE_SPEAK`). Three modes:
7
+ * `auto` speaks only when two or more sessions are going - the one case where
8
+ * the chime cannot answer "which one?"; with a single session the sentence
9
+ * would repeat what the chime already said. `always` speaks every time.
10
+ *
11
+ * The voice is the operating system's own (`say`, `spd-say` / `espeak`,
12
+ * `System.Speech`), so nothing is installed and nothing leaves the machine.
13
+ * The words are fixed: a project name and a state, never the agent's output.
14
+ * The project name is user-controlled text, so it travels as argv (or an
15
+ * environment variable on Windows), never spliced into a shell or a script.
16
+ *
17
+ * Speech runs in a detached process of its own (`node speech.js --run ...`):
18
+ * the agent waits on the hook, and the sentence has to start after the chime,
19
+ * not on top of it. That process takes a lock for the length of the sentence,
20
+ * so sessions that finish together speak one after another, not over each
21
+ * other. Merging them into one sentence is not done.
22
+ */
23
+ const fs = require("fs");
24
+ const os = require("os");
25
+ const path = require("path");
26
+ const { spawn } = require("child_process");
27
+
28
+ const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
29
+ const LOCK_FILE = path.join(STATE_DIR, "speech.lock");
30
+ const LAST_FILE = path.join(STATE_DIR, "speech.json");
31
+
32
+ const SPEECH_MAX_CHARS = 200;
33
+ const REPEAT_MS = 5000; // the same sentence inside this window is dropped
34
+ const LOCK_STALE_MS = 30000; // a holder this old died without releasing
35
+ const LOCK_WAIT_MS = 20000; // a queue this long is not worth joining
36
+ const SPEAK_TIMEOUT_MS = 15000; // a voice that hangs is killed
37
+
38
+ // How long each chime plays, plus a breath: the sentence starts after it.
39
+ const CHIME_MS = { success: 1600, failure: 1800, attention: 1300, stuck: 1800 };
40
+ const CHIME_GAP_MS = 150;
41
+
42
+ const MODES = ["off", "auto", "always"];
43
+
44
+ function parseMode(value) {
45
+ const raw = String(value ?? "").trim().toLowerCase();
46
+ if (raw === "always") return "always";
47
+ if (["1", "true", "on", "yes", "auto"].includes(raw)) return "auto";
48
+ return "off";
49
+ }
50
+
51
+ /** Env beats the saved setting, like every other one. */
52
+ function speakMode(env = process.env, config = {}) {
53
+ return parseMode(env.VIBE_SPEAK ?? config.speak);
54
+ }
55
+
56
+ /** Whether to speak now: `auto` needs two or more sessions to be worth it. */
57
+ function shouldSpeak(mode, sessionCount) {
58
+ return mode === "always" || (mode === "auto" && sessionCount >= 2);
59
+ }
60
+
61
+ /**
62
+ * Text that is safe to hand a voice: no control characters, and no embedded
63
+ * speech commands. macOS `say` reads `[[volm 0]]` and its kin out of the text,
64
+ * so a project directory called that would otherwise steer the voice.
65
+ */
66
+ function cleanText(text, max = SPEECH_MAX_CHARS) {
67
+ let out = String(text).replace(/[\u0000-\u001f\u007f]+/g, " ");
68
+ while (/\[\[[^\]]*\]\]/.test(out)) out = out.replace(/\[\[[^\]]*\]\]/g, " ");
69
+ out = out.replace(/\[\[|\]\]/g, " "); // what nesting left behind
70
+ return out.replace(/\s+/g, " ").trim().slice(0, max);
71
+ }
72
+
73
+ /** "my-app_v2" reads better as "my app v2" than as "my dash app underscore v2". */
74
+ function speakableLabel(label) {
75
+ return cleanText(String(label).replace(/[-_.]+/g, " "), 60) || "a session";
76
+ }
77
+
78
+ /**
79
+ * The fixed sentences. `tool` is only passed for a built-in tool name: an MCP
80
+ * tool's name is third-party text, and the line never speaks it.
81
+ */
82
+ function phrase(kind, label, tool = "") {
83
+ const who = speakableLabel(label);
84
+ const what = cleanText(String(tool).replace(/_/g, " "), 40);
85
+ switch (kind) {
86
+ case "failure": return `${who}: failed.`;
87
+ case "attention": return what ? `${who} needs you: ${what}.` : `${who} needs you.`;
88
+ case "stuck": return `${who} looks stuck.`;
89
+ default: return `${who}: done.`;
90
+ }
91
+ }
92
+
93
+ const POWERSHELL_SCRIPT =
94
+ "Add-Type -AssemblyName System.Speech; " +
95
+ "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; " +
96
+ "$s.Volume = [int]$env:VIBE_SPEECH_VOLUME; " +
97
+ "$s.Speak($env:VIBE_SPEECH_TEXT)";
98
+
99
+ /**
100
+ * The command that speaks `text`, or null where this machine has no voice.
101
+ * `volume` is VibeAudio's 0-1 setting where the voice has a way to take it:
102
+ * macOS `say` follows the system volume unless told otherwise, and `spd-say`
103
+ * has no usable scale, so it speaks at its own level.
104
+ */
105
+ function voiceCommand(platform, text, volume = 0.4, has = () => false) {
106
+ const v = Math.max(0.05, Math.min(1, Number(volume) || 0.4));
107
+ if (platform === "darwin") {
108
+ return has("say") ? { cmd: "say", args: ["--", `[[volm ${v.toFixed(2)}]] ${text}`] } : null;
109
+ }
110
+ if (platform === "win32") {
111
+ return {
112
+ cmd: "powershell.exe",
113
+ args: ["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT],
114
+ env: { VIBE_SPEECH_TEXT: text, VIBE_SPEECH_VOLUME: String(Math.round(v * 100)) }
115
+ };
116
+ }
117
+ if (has("spd-say")) return { cmd: "spd-say", args: ["-w", "--", text] };
118
+ for (const cmd of ["espeak-ng", "espeak"]) {
119
+ if (has(cmd)) return { cmd, args: ["-a", String(Math.round(v * 200)), "--", text] };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function currentVoice(text, volume) {
125
+ const { isInstalled } = require("./interactive");
126
+ return voiceCommand(process.platform, text, volume, isInstalled);
127
+ }
128
+
129
+ /** `--doctor`: is there anything to speak with here? */
130
+ function voiceAvailable() {
131
+ return currentVoice("test", 0.4) !== null;
132
+ }
133
+
134
+ /**
135
+ * Hand a sentence to a detached process that waits `delayMs` (for the chime),
136
+ * takes its turn on the lock, and speaks. Returns false when nothing was
137
+ * started. Never throws and never waits: the caller is a hook.
138
+ */
139
+ function speak(text, { delayMs = 0, volume = 0.4 } = {}) {
140
+ try {
141
+ const { playbackDisabled } = require("./player");
142
+ if (playbackDisabled()) return false; // a mute means quiet, speech included
143
+ const clean = cleanText(text);
144
+ if (!clean || !currentVoice(clean, volume)) return false;
145
+ const child = spawn(process.execPath, [__filename, "--run", String(Math.max(0, Math.round(delayMs))), String(volume), clean], {
146
+ detached: true,
147
+ stdio: "ignore"
148
+ });
149
+ child.on("error", () => {});
150
+ child.unref();
151
+ return true;
152
+ } catch (e) {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /** How long to wait so a sentence starts after the chime that was just played. */
158
+ function chimeDelayMs(outcome, played = true) {
159
+ return played ? (CHIME_MS[outcome] || CHIME_MS.success) + CHIME_GAP_MS : 0;
160
+ }
161
+
162
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
163
+
164
+ function pidAlive(pid) {
165
+ try {
166
+ process.kill(pid, 0);
167
+ return true;
168
+ } catch (e) {
169
+ return e.code === "EPERM";
170
+ }
171
+ }
172
+
173
+ /** One speaker at a time across processes: exclusive create, with a stale check. */
174
+ async function acquireLock(waitMs = LOCK_WAIT_MS) {
175
+ fs.mkdirSync(STATE_DIR, { recursive: true });
176
+ const deadline = Date.now() + waitMs;
177
+ for (;;) {
178
+ try {
179
+ fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: "wx" });
180
+ return () => fs.rmSync(LOCK_FILE, { force: true });
181
+ } catch (e) {
182
+ if (e.code !== "EEXIST") return null;
183
+ }
184
+ try {
185
+ const held = JSON.parse(fs.readFileSync(LOCK_FILE, "utf8"));
186
+ if (Date.now() - held.at > LOCK_STALE_MS || !pidAlive(held.pid)) fs.rmSync(LOCK_FILE, { force: true });
187
+ } catch (e) {
188
+ // Unreadable (being written, or just removed): look again.
189
+ }
190
+ if (Date.now() > deadline) return null;
191
+ await sleep(100);
192
+ }
193
+ }
194
+
195
+ function speakNow(text, volume) {
196
+ return new Promise((resolve) => {
197
+ const voice = currentVoice(text, volume);
198
+ if (!voice) return resolve(false);
199
+ let done = false;
200
+ const finish = (ok) => {
201
+ if (done) return;
202
+ done = true;
203
+ clearTimeout(timer);
204
+ resolve(ok);
205
+ };
206
+ let child;
207
+ try {
208
+ child = spawn(voice.cmd, voice.args, { stdio: "ignore", env: { ...process.env, ...(voice.env || {}) } });
209
+ } catch (e) {
210
+ return resolve(false);
211
+ }
212
+ const timer = setTimeout(() => {
213
+ try { child.kill("SIGKILL"); } catch (e) { /* gone already */ }
214
+ finish(false);
215
+ }, SPEAK_TIMEOUT_MS);
216
+ child.on("error", () => finish(false));
217
+ child.on("close", (code) => finish(code === 0));
218
+ });
219
+ }
220
+
221
+ /** The body of the detached process. */
222
+ async function run(delayMs, volume, text) {
223
+ const { playbackDisabled } = require("./player");
224
+ await sleep(delayMs);
225
+ const release = await acquireLock();
226
+ if (!release) return; // could not get a turn: drop it rather than talk over someone
227
+ try {
228
+ if (playbackDisabled()) return; // muted while it waited
229
+ try {
230
+ const last = JSON.parse(fs.readFileSync(LAST_FILE, "utf8"));
231
+ if (last.text === text && Date.now() - last.at < REPEAT_MS) return;
232
+ } catch (e) { /* first one, or unreadable: nothing to compare */ }
233
+ fs.writeFileSync(LAST_FILE, JSON.stringify({ text, at: Date.now() }));
234
+ await speakNow(text, volume);
235
+ } finally {
236
+ release();
237
+ }
238
+ }
239
+
240
+ if (require.main === module && process.argv[2] === "--run") {
241
+ run(Number(process.argv[3]) || 0, Number(process.argv[4]) || 0.4, String(process.argv[5] || ""))
242
+ .catch(() => {})
243
+ .finally(() => process.exit(0));
244
+ }
245
+
246
+ module.exports = {
247
+ MODES,
248
+ speakMode,
249
+ shouldSpeak,
250
+ cleanText,
251
+ phrase,
252
+ voiceCommand,
253
+ voiceAvailable,
254
+ speak,
255
+ chimeDelayMs,
256
+ acquireLock,
257
+ LOCK_FILE,
258
+ LAST_FILE,
259
+ SPEECH_MAX_CHARS
260
+ };