agentvibes 5.12.0 → 5.13.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.
Files changed (72) hide show
  1. package/.claude/commands/agent-vibes/commands.json +0 -20
  2. package/.claude/commands/agent-vibes/unmute.md +6 -2
  3. package/.claude/config/audio-effects.cfg +6 -6
  4. package/.claude/github-star-reminder.txt +1 -1
  5. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  6. package/.claude/hooks/bmad-party-speak.sh +20 -4
  7. package/.claude/hooks/bmad-speak.sh +60 -2
  8. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  9. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  10. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  11. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  12. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  13. package/.claude/hooks/kokoro-installer.sh +20 -10
  14. package/.claude/hooks/language-manager.sh +10 -3
  15. package/.claude/hooks/party-set-room.sh +71 -0
  16. package/.claude/hooks/party-stage-roster.py +328 -0
  17. package/.claude/hooks/personality-manager.sh +19 -2
  18. package/.claude/hooks/piper-voice-manager.sh +3 -2
  19. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  20. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  21. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  22. package/.claude/hooks/play-tts-soprano.sh +3 -2
  23. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  24. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  25. package/.claude/hooks/play-tts.sh +66 -61
  26. package/.claude/hooks/provider-catalog.json +352 -0
  27. package/.claude/hooks/provider-catalog.sh +161 -0
  28. package/.claude/hooks/provider-commands.sh +2 -1
  29. package/.claude/hooks/provider-manager.sh +47 -9
  30. package/.claude/hooks/python-resolver.sh +117 -0
  31. package/.claude/hooks/session-id.sh +56 -0
  32. package/.claude/hooks/session-start-tts.sh +39 -0
  33. package/.claude/hooks/speed-manager.sh +1 -1
  34. package/.claude/hooks/translate-manager.sh +3 -2
  35. package/.claude/hooks/translator.py +1 -1
  36. package/.claude/hooks/voice-manager.sh +242 -10
  37. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  38. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  39. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  40. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  41. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  42. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  43. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  44. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  45. package/.mcp.json +0 -7
  46. package/README.md +12 -3
  47. package/RELEASE_NOTES.md +43 -0
  48. package/mcp-server/server.py +146 -49
  49. package/mcp-server/test_mcp_correctness.py +20 -2
  50. package/mcp-server/test_windows_script_parity.py +0 -2
  51. package/package.json +1 -1
  52. package/src/cli/list-voices.js +218 -114
  53. package/src/console/bling.js +71 -0
  54. package/src/console/music-preview.js +79 -0
  55. package/src/console/tabs/music-tab.js +16 -39
  56. package/src/console/tabs/settings-tab.js +195 -13
  57. package/src/console/tabs/setup-tab.js +9 -34
  58. package/src/console/tabs/voices-tab.js +83 -14
  59. package/src/console/widgets/track-picker.js +82 -0
  60. package/src/installer.js +124 -10
  61. package/src/services/provider-catalog.js +412 -0
  62. package/src/services/provider-voice-catalog.js +52 -73
  63. package/src/services/tts-engine-service.js +29 -0
  64. package/src/utils/provider-validator.js +62 -12
  65. package/.claude/commands/agent-vibes/language.md +0 -23
  66. package/.claude/commands/agent-vibes/learn.md +0 -67
  67. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  68. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  69. package/.claude/commands/agent-vibes/target.md +0 -30
  70. package/.claude/hooks/learn-manager.sh +0 -492
  71. package/.claude/hooks/replay-target-audio.sh +0 -95
  72. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -1,114 +1,218 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Voice List Display - Beautiful multi-column voice listing
4
- * Called by voice-manager.sh to display voices with boxen formatting
5
- */
6
-
7
- import { formatVoicesList } from '../utils/list-formatter.js';
8
- import fs from 'fs';
9
- import path from 'path';
10
- import { execFileSync } from 'child_process';
11
- import os from 'os';
12
-
13
- /**
14
- * Get Piper voices from voice directory
15
- */
16
- function getPiperVoices(voiceDir, currentVoice) {
17
- const voices = [];
18
-
19
- if (!fs.existsSync(voiceDir)) {
20
- return voices;
21
- }
22
-
23
- const files = fs.readdirSync(voiceDir);
24
- for (const file of files) {
25
- if (file.endsWith('.onnx')) {
26
- const voiceName = path.basename(file, '.onnx');
27
- voices.push({
28
- name: voiceName,
29
- lang: extractLanguage(voiceName),
30
- current: voiceName === currentVoice
31
- });
32
- }
33
- }
34
-
35
- return voices.sort((a, b) => a.name.localeCompare(b.name));
36
- }
37
-
38
- /**
39
- * Get macOS voices using say -v ?
40
- */
41
- function getMacOSVoices(currentVoice) {
42
- const voices = [];
43
-
44
- if (os.platform() !== 'darwin') {
45
- return voices;
46
- }
47
-
48
- try {
49
- const output = execFileSync('say', ['-v', '?'], { encoding: 'utf8' }); // NOSONAR - Safe: checking macOS say voices from system PATH
50
- const lines = output.split('\n');
51
-
52
- for (const line of lines) {
53
- if (!line.trim()) continue;
54
-
55
- const parts = line.trim().split(/\s+/);
56
- if (parts.length >= 2) {
57
- const voiceName = parts[0];
58
- const lang = parts[1];
59
-
60
- voices.push({
61
- name: voiceName,
62
- lang,
63
- current: voiceName === currentVoice
64
- });
65
- }
66
- }
67
- } catch (error) {
68
- // say command failed
69
- }
70
-
71
- return voices;
72
- }
73
-
74
- /**
75
- * Extract language code from voice name
76
- */
77
- function extractLanguage(voiceName) {
78
- const match = voiceName.match(/^([a-z]{2}_[A-Z]{2})/);
79
- return match ? match[1] : '';
80
- }
81
-
82
- /**
83
- * Main function
84
- */
85
- function main() {
86
- const args = process.argv.slice(2);
87
-
88
- // Parse arguments
89
- const provider = args[0] || 'piper';
90
- const currentVoice = args[1] || '';
91
- const voiceDir = args[2] || '';
92
-
93
- let voices = [];
94
- let providerName = 'Piper TTS';
95
-
96
- if (provider === 'piper') {
97
- voices = getPiperVoices(voiceDir, currentVoice);
98
- providerName = 'Piper TTS';
99
- } else if (provider === 'macos') {
100
- voices = getMacOSVoices(currentVoice);
101
- providerName = 'macOS TTS';
102
- }
103
-
104
- // Display with boxen
105
- const output = formatVoicesList(voices, {
106
- provider: providerName,
107
- columns: 2,
108
- showUsage: true
109
- });
110
-
111
- console.log(output);
112
- }
113
-
114
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Voice List Display - Beautiful multi-column voice listing
4
+ * Called by voice-manager.sh to display voices with boxen formatting
5
+ */
6
+
7
+ import { formatVoicesList } from '../utils/list-formatter.js';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { execFileSync } from 'child_process';
12
+ import os from 'os';
13
+ import {
14
+ KOKORO_VOICE_IDS,
15
+ kokoroGender,
16
+ ELEVENLABS_VOICES,
17
+ } from '../services/provider-voice-catalog.js';
18
+ import {
19
+ listVoices as catalogListVoices,
20
+ getProvider,
21
+ } from '../services/provider-catalog.js';
22
+
23
+ /**
24
+ * Get Piper voices from voice directory
25
+ */
26
+ function getPiperVoices(voiceDir, currentVoice) {
27
+ const voices = [];
28
+
29
+ if (!fs.existsSync(voiceDir)) {
30
+ return voices;
31
+ }
32
+
33
+ const files = fs.readdirSync(voiceDir);
34
+ for (const file of files) {
35
+ if (file.endsWith('.onnx')) {
36
+ const voiceName = path.basename(file, '.onnx');
37
+ voices.push({
38
+ name: voiceName,
39
+ lang: extractLanguage(voiceName),
40
+ current: voiceName === currentVoice
41
+ });
42
+ }
43
+ }
44
+
45
+ return voices.sort((a, b) => a.name.localeCompare(b.name));
46
+ }
47
+
48
+ /**
49
+ * Get macOS voices using say -v ?
50
+ */
51
+ function getMacOSVoices(currentVoice) {
52
+ const voices = [];
53
+
54
+ if (os.platform() !== 'darwin') {
55
+ return voices;
56
+ }
57
+
58
+ try {
59
+ const output = execFileSync('say', ['-v', '?'], { encoding: 'utf8' }); // NOSONAR - Safe: checking macOS say voices from system PATH
60
+ const lines = output.split('\n');
61
+
62
+ for (const line of lines) {
63
+ if (!line.trim()) continue;
64
+
65
+ const parts = line.trim().split(/\s+/);
66
+ if (parts.length >= 2) {
67
+ const voiceName = parts[0];
68
+ const lang = parts[1];
69
+
70
+ voices.push({
71
+ name: voiceName,
72
+ lang,
73
+ current: voiceName === currentVoice
74
+ });
75
+ }
76
+ }
77
+ } catch (error) {
78
+ // say command failed
79
+ }
80
+
81
+ return voices;
82
+ }
83
+
84
+ /**
85
+ * Kokoro voice ids follow `<lang><sex>_name`. The first char is the language.
86
+ * Map it to a human-readable language label for the listing.
87
+ */
88
+ const KOKORO_LANG_LABELS = {
89
+ a: 'en-US', b: 'en-GB', j: 'ja', z: 'zh', e: 'es',
90
+ f: 'fr', h: 'hi', i: 'it', p: 'pt-BR', k: 'ko',
91
+ };
92
+
93
+ /**
94
+ * Get Kokoro voices from the canonical catalog (KOKORO_VOICE_IDS).
95
+ * Kokoro's voice set is a fixed catalog, not discovered on disk.
96
+ */
97
+ function getKokoroVoices(currentVoice) {
98
+ return KOKORO_VOICE_IDS.map((id) => ({
99
+ name: id,
100
+ lang: KOKORO_LANG_LABELS[id[0]] || '',
101
+ gender: kokoroGender(id),
102
+ current: id === currentVoice,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * Get ElevenLabs voices from the canonical catalog (ELEVENLABS_VOICES).
108
+ * The listing shows the friendly name; matching against currentVoice accepts
109
+ * either the friendly name or the raw voice_id.
110
+ */
111
+ function getElevenLabsVoices(currentVoice) {
112
+ return ELEVENLABS_VOICES.map((v) => ({
113
+ name: v.name,
114
+ lang: v.lang || '',
115
+ gender: v.gender || '',
116
+ current: v.name === currentVoice || v.id === currentVoice,
117
+ }));
118
+ }
119
+
120
+ /**
121
+ * Get Soprano voices from the canonical catalog. Soprano is voiceModel `single`
122
+ * (design §3.1): exactly one canonical voice, soprano-default — no picker.
123
+ */
124
+ function getSopranoVoices(currentVoice) {
125
+ return catalogListVoices('soprano').map((v) => ({
126
+ name: v.id,
127
+ lang: '',
128
+ gender: v.gender || '',
129
+ current: v.id === currentVoice || currentVoice === '' || currentVoice === 'soprano',
130
+ }));
131
+ }
132
+
133
+ /**
134
+ * Extract language code from voice name
135
+ */
136
+ function extractLanguage(voiceName) {
137
+ const match = voiceName.match(/^([a-z]{2}_[A-Z]{2})/);
138
+ return match ? match[1] : '';
139
+ }
140
+
141
+ /**
142
+ * Discovered providers list-voices enumerates HERE, each via its own platform
143
+ * discovery path (piper: `*.onnx` disk glob; macos: `say -v ?`). Other discovered
144
+ * providers (windows-piper / windows-sapi) are enumerated by the Windows lister,
145
+ * not this Unix/darwin CLI, so they fall to the honest "no voice list" label —
146
+ * preserving pre-AVI-S9.5 output. Adding a record with a static/name-to-id/single
147
+ * voiceModel adds a list arm for free (no edit here needed).
148
+ */
149
+ const LISTABLE_DISCOVERED = new Set(['piper', 'macos']);
150
+
151
+ /**
152
+ * Resolve a provider token to its voice list + display label by iterating the
153
+ * Provider Catalog (src/services/provider-catalog.js) and branching ONLY on the
154
+ * record's `voiceModel` — replacing the former four hardcoded per-provider
155
+ * equality branches (AVI-S9.5 / design row 19). Unknown tokens keep the honest
156
+ * "no voice list available" label from AVI-S8.1.
157
+ *
158
+ * @param {string} provider
159
+ * @param {string} currentVoice
160
+ * @param {string} voiceDir
161
+ * @returns {{ voices: object[], providerName: string }}
162
+ */
163
+ function selectVoices(provider, currentVoice, voiceDir) {
164
+ const record = getProvider(provider);
165
+ if (!record) {
166
+ return { voices: [], providerName: `${provider} (no voice list available)` };
167
+ }
168
+
169
+ // Label preserves the pre-existing strings exactly (macOS uses "TTS", not the
170
+ // catalog display name "macOS Say"); all others equal record.displayName.
171
+ const providerName = record.id === 'macos' ? 'macOS TTS' : record.displayName;
172
+
173
+ switch (record.voiceModel) {
174
+ case 'static': // kokoro
175
+ return { voices: getKokoroVoices(currentVoice), providerName };
176
+ case 'name-to-id': // elevenlabs
177
+ return { voices: getElevenLabsVoices(currentVoice), providerName };
178
+ case 'single': // soprano
179
+ return { voices: getSopranoVoices(currentVoice), providerName };
180
+ case 'discovered':
181
+ default:
182
+ if (record.id === 'piper') return { voices: getPiperVoices(voiceDir, currentVoice), providerName };
183
+ if (record.id === 'macos') return { voices: getMacOSVoices(currentVoice), providerName };
184
+ // A discovered provider without a discovery path on this platform: label
185
+ // it honestly instead of rendering an empty list under a wrong provider.
186
+ return { voices: [], providerName: `${provider} (no voice list available)` };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Main function
192
+ */
193
+ function main() {
194
+ const args = process.argv.slice(2);
195
+
196
+ // Parse arguments
197
+ const provider = args[0] || 'piper';
198
+ const currentVoice = args[1] || '';
199
+ const voiceDir = args[2] || '';
200
+
201
+ const { voices, providerName } = selectVoices(provider, currentVoice, voiceDir);
202
+
203
+ // Display with boxen
204
+ const output = formatVoicesList(voices, {
205
+ provider: providerName,
206
+ columns: 2,
207
+ showUsage: true
208
+ });
209
+
210
+ console.log(output);
211
+ }
212
+
213
+ // Only run when invoked as a CLI (keeps selectVoices importable by tests).
214
+ const _invokedDirectly = process.argv[1] &&
215
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
216
+ if (_invokedDirectly) main();
217
+
218
+ export { selectVoices, LISTABLE_DISCOVERED };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * AgentVibes — Shared "bling" readiness cue.
3
+ *
4
+ * A short, fire-and-forget chime played the instant a preview is committed, so
5
+ * silence during synthesis / an SSH round-trip never reads as a hang. Used by
6
+ * BOTH the voice picker (setup-tab.js) and the music-preview surfaces
7
+ * (music-tab.js, track-picker.js) so they can't drift.
8
+ *
9
+ * `buildBlingCommand` was originally defined in setup-tab.js; it was moved here
10
+ * and is re-exported from setup-tab.js for backward compatibility (existing
11
+ * imports and tests keep working).
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+
18
+ /**
19
+ * Build the fire-and-forget "preview ready" cue command for a platform. Pure
20
+ * (no spawning here, so it is unit-testable). Plays the bundled CC0 wav when
21
+ * present, else falls back to a system sound (Windows) / freedesktop cue or
22
+ * terminal bell (POSIX). stdio is ignored by the caller, so the POSIX bell is
23
+ * redirected to /dev/tty rather than the discarded stdout.
24
+ * @param {string} platform - process.platform value
25
+ * @param {string} wavPath - absolute path to the bling wav
26
+ * @param {boolean} haveWav - whether wavPath exists on disk
27
+ * @returns {{command: string, args: string[]}}
28
+ */
29
+ export function buildBlingCommand(platform, wavPath, haveWav) {
30
+ if (platform === 'win32') {
31
+ const ps = haveWav
32
+ ? `Add-Type -AssemblyName System.Windows.Forms; (New-Object System.Media.SoundPlayer('${wavPath.replace(/'/g, "''")}')).PlaySync()`
33
+ : '[System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 700';
34
+ return { command: 'powershell', args: ['-NoProfile', '-Command', ps] };
35
+ }
36
+ if (haveWav) {
37
+ // Pass wavPath as a positional arg ($1) so the path is never interpolated
38
+ // into the shell string (prevents injection / breakage on special chars).
39
+ const sh = 'paplay "$1" 2>/dev/null || aplay -q "$1" 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
40
+ return { command: 'bash', args: ['-c', sh, '--', wavPath] };
41
+ }
42
+ const sh = 'paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null || printf "\\a" > /dev/tty 2>/dev/null';
43
+ return { command: 'bash', args: ['-c', sh] };
44
+ }
45
+
46
+ /**
47
+ * Resolve the bundled CC0 bling wav for a package root.
48
+ * Sound: "Ui sounds - Shimmering success" by Philip_Berger, CC0 (freesound
49
+ * #648212). See .claude/audio/ui/CREDITS.txt.
50
+ * @param {string} packageRoot - AgentVibes package/repo root
51
+ * @returns {string}
52
+ */
53
+ export function resolveBlingWav(packageRoot) {
54
+ return path.join(packageRoot, '.claude', 'audio', 'ui', 'bling-success.wav');
55
+ }
56
+
57
+ /**
58
+ * Play the readiness cue, fire-and-forget. Detached + unref'd + errors
59
+ * swallowed, so it never blocks the UI or fails a preview. A missing wav falls
60
+ * back to a platform system sound via buildBlingCommand.
61
+ * @param {string} packageRoot - AgentVibes package/repo root
62
+ */
63
+ export function playBlingCue(packageRoot) {
64
+ try {
65
+ const wav = resolveBlingWav(packageRoot);
66
+ const { command, args } = buildBlingCommand(process.platform, wav, fs.existsSync(wav));
67
+ const cue = spawn(command, args, { stdio: 'ignore', detached: true }); // NOSONAR
68
+ cue.on('error', () => { /* best-effort cue; a spawn failure must not surface */ });
69
+ cue.unref();
70
+ } catch { /* the readiness cue is purely cosmetic — never break the preview */ }
71
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * AgentVibes — Shared music-preview transport helpers.
3
+ *
4
+ * Both music-preview surfaces (the Music tab and the track-picker widget) must
5
+ * make the same remote-vs-local decision: a transport provider routes audio to
6
+ * a remote receiver, so a local MP3 preview would be silent on a headless box
7
+ * and must be forwarded instead. This module holds the provider-resolve and the
8
+ * remote-forward spawn so the two surfaces can't drift.
9
+ *
10
+ * The caller owns UI feedback (status text, error messages) and attaches its own
11
+ * exit/error/stderr handlers to the returned child process.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+ import { spawn } from 'node:child_process';
18
+
19
+ // Transport providers route audio to a remote receiver; local MP3 playback is
20
+ // silent on a headless/remote box, so track previews must be forwarded instead.
21
+ export const REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
22
+
23
+ /**
24
+ * Resolve the active provider and the project dir it was read from, using the
25
+ * same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
26
+ * home). Returns { remote, projectDir } so a remote preview can forward the
27
+ * track to the receiver and tell the sender which .claude dir to resolve.
28
+ * @param {string} packageRoot - AgentVibes package/repo root (package fallback)
29
+ * @returns {{ remote: boolean, projectDir: string }}
30
+ */
31
+ export function resolveMusicProvider(packageRoot) {
32
+ const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), packageRoot, os.homedir()].filter(Boolean);
33
+ for (const d of dirs) {
34
+ const p = path.join(d, '.claude', 'tts-provider.txt');
35
+ try {
36
+ if (fs.existsSync(p)) {
37
+ const provider = fs.readFileSync(p, 'utf8').trim();
38
+ return { remote: REMOTE_PROVIDERS.includes(provider), projectDir: d };
39
+ }
40
+ } catch { /* next */ }
41
+ }
42
+ return { remote: false, projectDir: '' };
43
+ }
44
+
45
+ /**
46
+ * Resolve the SSH sender script path, preferring the package-bundled copy and
47
+ * falling back to the project's .claude/hooks copy.
48
+ * @param {string} packageRoot
49
+ * @param {string} projectDir
50
+ * @returns {string}
51
+ */
52
+ export function resolveRemoteSender(packageRoot, projectDir) {
53
+ const pkgSender = path.resolve(packageRoot, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
54
+ return fs.existsSync(pkgSender)
55
+ ? pkgSender
56
+ : path.join(projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
57
+ }
58
+
59
+ /**
60
+ * Spawn the SSH sender to forward a music track to the receiver, or stop the
61
+ * current one. Fire-and-forget (the sender exits after handing the payload to
62
+ * SSH; the receiver plays/stops asynchronously). Returns the child process so
63
+ * the caller can wire its own exit/error/stderr handlers for UI feedback.
64
+ *
65
+ * @param {object} opts
66
+ * @param {string} opts.packageRoot
67
+ * @param {string} opts.projectDir
68
+ * @param {object} opts.env - base spawn env (e.g. buildAudioEnv())
69
+ * @param {string} [opts.track] - track filename to play (ignored when stop)
70
+ * @param {boolean} [opts.stop] - send an explicit music-stop signal instead
71
+ * @returns {import('node:child_process').ChildProcess}
72
+ */
73
+ export function spawnMusicRemote({ packageRoot, projectDir, env, track = null, stop = false }) {
74
+ const senderPath = resolveRemoteSender(packageRoot, projectDir);
75
+ const spawnEnv = { ...env, CLAUDE_PROJECT_DIR: projectDir };
76
+ if (stop) spawnEnv.AGENTVIBES_MUSIC_STOP = '1';
77
+ else spawnEnv.AGENTVIBES_MUSIC_ONLY = track;
78
+ return spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: spawnEnv }); // NOSONAR
79
+ }
@@ -11,43 +11,20 @@
11
11
 
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
- import os from 'node:os';
15
- import { spawn } from 'node:child_process';
16
14
  import { fileURLToPath } from 'node:url';
17
15
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
16
+ import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
17
+ import { playBlingCue } from '../bling.js';
18
18
  import { t } from '../../i18n/strings.js';
19
19
 
20
20
  const _MUSIC_TAB_DIR = path.dirname(fileURLToPath(import.meta.url));
21
21
 
22
- // Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
23
- const _PKG_TRACKS_DIR = path.resolve(
24
- _MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'audio', 'tracks'
25
- );
26
-
27
- // Transport providers route audio to a remote receiver; local MP3 playback is
28
- // silent on a headless/remote box, so track previews must be forwarded instead.
29
- const _REMOTE_PROVIDERS = ['ssh-remote', 'agentvibes-receiver'];
22
+ // AgentVibes package/repo root — used to resolve bundled assets (tracks dir,
23
+ // bling cue) and as the package fallback when resolving the active provider.
24
+ const _PKG_ROOT = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
30
25
 
31
- /**
32
- * Resolve the active provider and the project dir it was read from, using the
33
- * same search order as the voice pickers (CLAUDE_PROJECT_DIR → cwd → package →
34
- * home). Returns { remote, projectDir } so a remote preview can forward the
35
- * track to the receiver and tell the sender which .claude dir to resolve.
36
- */
37
- function _resolveMusicProvider() {
38
- const projectRoot = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..');
39
- const dirs = [process.env.CLAUDE_PROJECT_DIR, process.cwd(), projectRoot, os.homedir()].filter(Boolean);
40
- for (const d of dirs) {
41
- const p = path.join(d, '.claude', 'tts-provider.txt');
42
- try {
43
- if (fs.existsSync(p)) {
44
- const provider = fs.readFileSync(p, 'utf8').trim();
45
- return { remote: _REMOTE_PROVIDERS.includes(provider), projectDir: d };
46
- }
47
- } catch { /* next */ }
48
- }
49
- return { remote: false, projectDir: '' };
50
- }
26
+ // Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
27
+ const _PKG_TRACKS_DIR = path.join(_PKG_ROOT, '.claude', 'audio', 'tracks');
51
28
 
52
29
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
53
30
 
@@ -591,16 +568,9 @@ export function createMusicTab(screen, services) {
591
568
  * @param {{track?:string, stop?:boolean}} opts
592
569
  */
593
570
  function _sendMusicRemote(mp, { track = null, stop = false } = {}) {
594
- const _pkgSender = path.resolve(_MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'hooks', 'play-tts-ssh-remote.sh');
595
- const senderPath = fs.existsSync(_pkgSender)
596
- ? _pkgSender
597
- : path.join(mp.projectDir, '.claude', 'hooks', 'play-tts-ssh-remote.sh');
598
- const env = { ..._spawnEnv, CLAUDE_PROJECT_DIR: mp.projectDir };
599
- if (stop) env.AGENTVIBES_MUSIC_STOP = '1';
600
- else env.AGENTVIBES_MUSIC_ONLY = track;
601
571
  let rproc;
602
572
  try {
603
- rproc = spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env }); // NOSONAR
573
+ rproc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
604
574
  } catch {
605
575
  previewLine.setContent('{red-fg}Remote music preview failed{/red-fg}');
606
576
  screen.render();
@@ -646,7 +616,7 @@ export function createMusicTab(screen, services) {
646
616
  // is silent on a headless box). The receiver auto-stops any prior track when
647
617
  // a new one arrives; pressing Space on the currently-playing track sends an
648
618
  // explicit stop (toggle off).
649
- const _mp = _resolveMusicProvider();
619
+ const _mp = resolveMusicProvider(_PKG_ROOT);
650
620
  if (_mp.remote) {
651
621
  if (_remotePlayingTrackId === trackId) {
652
622
  _sendMusicRemote(_mp, { stop: true });
@@ -655,6 +625,9 @@ export function createMusicTab(screen, services) {
655
625
  screen.render();
656
626
  return;
657
627
  }
628
+ // Bling first (fire-and-forget, plays locally) — same readiness cue as the
629
+ // voice preview — then forward the track to the receiver.
630
+ playBlingCue(_PKG_ROOT);
658
631
  const rlabel = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
659
632
  if (_sendMusicRemote(_mp, { track: trackId })) {
660
633
  _remotePlayingTrackId = trackId;
@@ -678,6 +651,10 @@ export function createMusicTab(screen, services) {
678
651
  _killPlayingProcess();
679
652
  _playingTrackId = null;
680
653
 
654
+ // Bling first (fire-and-forget) — same readiness cue as the voice preview —
655
+ // then start local playback.
656
+ playBlingCue(_PKG_ROOT);
657
+
681
658
  const proc = spawnMp3Player(trackPath, _spawnEnv);
682
659
  if (!proc) {
683
660
  const installHint = process.platform === 'win32'