agentvibes 5.11.2 → 5.12.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 (54) hide show
  1. package/.claude/config/audio-effects.cfg +1 -1
  2. package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
  3. package/.claude/github-star-reminder.txt +1 -1
  4. package/.claude/hooks/audio-processor.sh +1 -1
  5. package/.claude/hooks/background-music-manager.sh +2 -1
  6. package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
  7. package/.claude/hooks/bmad-speak.sh +26 -8
  8. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
  9. package/.claude/hooks/play-tts-kokoro.sh +9 -0
  10. package/.claude/hooks/play-tts-piper.sh +13 -1
  11. package/.claude/hooks/play-tts-ssh-remote.sh +117 -7
  12. package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
  13. package/.claude/hooks/play-tts.sh +106 -13
  14. package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
  15. package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
  16. package/.claude/hooks-windows/play-tts.ps1 +163 -30
  17. package/.claude/hooks-windows/tts-watcher.ps1 +55 -0
  18. package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
  19. package/README.md +11 -6
  20. package/RELEASE_NOTES.md +67 -0
  21. package/bin/mcp-server.sh +6 -19
  22. package/bin/resolve-utterance.js +137 -0
  23. package/mcp-server/server.py +280 -99
  24. package/mcp-server/test_mcp_correctness.py +486 -0
  25. package/mcp-server/test_windows_script_parity.py +341 -336
  26. package/package.json +3 -2
  27. package/setup-windows.ps1 +867 -815
  28. package/src/console/app.js +14 -11
  29. package/src/console/footer-config.js +0 -4
  30. package/src/console/navigation.js +0 -1
  31. package/src/console/tabs/agents-tab.js +96 -11
  32. package/src/console/tabs/music-tab.js +108 -3
  33. package/src/console/tabs/placeholder-tab.js +0 -2
  34. package/src/console/tabs/settings-tab.js +41 -2
  35. package/src/console/tabs/setup-tab.js +34 -6
  36. package/src/console/tabs/voices-tab.js +19 -9
  37. package/src/console/widgets/track-picker.js +3 -3
  38. package/src/i18n/de.js +0 -1
  39. package/src/i18n/en.js +0 -1
  40. package/src/i18n/es.js +0 -1
  41. package/src/i18n/fr.js +0 -1
  42. package/src/i18n/hi.js +0 -1
  43. package/src/i18n/ja.js +0 -1
  44. package/src/i18n/ko.js +0 -1
  45. package/src/i18n/pt.js +0 -1
  46. package/src/i18n/zh-CN.js +0 -1
  47. package/src/installer.js +205 -15
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/llm-provider-service.js +7 -7
  50. package/src/services/navigation-service.js +1 -1
  51. package/src/services/utterance-loader.js +280 -0
  52. package/src/services/utterance-resolver.js +526 -0
  53. package/templates/agentvibes-receiver.sh +74 -12
  54. package/voice-assignments.json +8245 -0
@@ -22,7 +22,6 @@ import { FOOTER_CONFIG, DEFAULT_FOOTER_COLOR } from './footer-config.js';
22
22
  import { createModalOverlay } from './modals/modal-overlay.js';
23
23
  import { BRAND_PINK } from './brand-colors.js';
24
24
  import { createSettingsTab } from './tabs/settings-tab.js';
25
- import { createVoicesTab } from './tabs/voices-tab.js';
26
25
  import { createMusicTab } from './tabs/music-tab.js';
27
26
  import { createSetupTab } from './tabs/setup-tab.js';
28
27
  import { createHelpTab } from './tabs/help-tab.js';
@@ -728,13 +727,6 @@ export class AgentVibesConsole {
728
727
  };
729
728
  this.tabs['settings'] = createSettingsTab(this.screen, services);
730
729
 
731
- // Destroy voices placeholder and mount real voices tab
732
- const voicesPlaceholder = this.tabs['voices'];
733
- if (voicesPlaceholder && typeof voicesPlaceholder.destroy === 'function') {
734
- voicesPlaceholder.destroy();
735
- }
736
- this.tabs['voices'] = createVoicesTab(this.screen, services);
737
-
738
730
  // Destroy music placeholder and mount real music tab
739
731
  const musicPlaceholder = this.tabs['music'];
740
732
  if (musicPlaceholder && typeof musicPlaceholder.destroy === 'function') {
@@ -899,8 +891,19 @@ export class AgentVibesConsole {
899
891
  // Private: Global keyboard handlers
900
892
 
901
893
  _registerHandlers() {
902
- // Q or Ctrl+C → clean exit (no zombie processes)
903
- this.screen.key(['q', 'Q', 'C-c'], () => {
894
+ // Q or Ctrl+C → clean exit (no zombie processes).
895
+ // Guarded by isModalOpen(): blessed fires screen-level key handlers
896
+ // before focused-element handlers, so without this guard 'q' here would
897
+ // always win over a picker/modal's own Esc/q close binding — e.g. typing
898
+ // 'q' to jump to a voice named "Quinn" would kill the whole TUI instead
899
+ // of just being handled by the picker. Ctrl+C always force-quits (it's
900
+ // the universal "get me out" signal and has no in-modal meaning).
901
+ this.screen.key(['q', 'Q', 'C-c'], (ch, key) => {
902
+ const isCtrlC = key?.ctrl && key?.name === 'c';
903
+ if (!isCtrlC && this.navigationService?.isModalOpen()) {
904
+ // Let the focused modal/picker's own key handler deal with it.
905
+ return;
906
+ }
904
907
  this.screen.destroy();
905
908
  process.exit(0);
906
909
  });
@@ -912,7 +915,7 @@ export class AgentVibesConsole {
912
915
  *
913
916
  * @param {object} opts
914
917
  * @param {string} [opts.startTab='settings'] - Which tab to show on launch.
915
- * Used by story 6.5 (command routing). Values: 'settings' | 'install' | 'voices' | 'music'
918
+ * Used by story 6.5 (command routing). Values: 'settings' | 'setup' | 'music'
916
919
  * @param {boolean} [opts._testMode=false] - Internal: skip render in test environments.
917
920
  * @returns {Promise<AgentVibesConsole>}
918
921
  */
@@ -19,10 +19,6 @@ export const FOOTER_CONFIG = {
19
19
  color: '#2196f3',
20
20
  text: ` ${key('↑↓')} Navigate ${key('←→')} Same Row ${key('Enter')} Activate ${key('Space')} Preview ${key('Esc')} Cancel`,
21
21
  },
22
- voices: {
23
- color: '#00bcd4',
24
- text: ` ${key('1-6')} Sort ${key('/')} Search ${key('P')} Provider ${key('F')} Favorites ${key('Space')} Preview ${key('*')} Fav ${key('I')} Install`,
25
- },
26
22
  music: {
27
23
  color: '#ff9800',
28
24
  text: ` ${key('Space')} Preview ${key('Enter')} Select ${key('M')} Toggle ${key('*')} Fav ${key('F')} Filter ${key('↑↓')} Navigate`,
@@ -9,7 +9,6 @@
9
9
  /** Map of key → tab ID for global tab shortcut keys */
10
10
  const KEY_TO_TAB = {
11
11
  's': 'settings', 'S': 'settings',
12
- 'v': 'voices', 'V': 'voices',
13
12
  'm': 'music', 'M': 'music',
14
13
  'b': 'agents', 'B': 'agents',
15
14
  'x': 'receiver', 'X': 'receiver',
@@ -28,6 +28,7 @@ import crypto from 'node:crypto';
28
28
  import fs from 'node:fs';
29
29
  import os from 'node:os';
30
30
  import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
31
32
  import { spawn } from 'node:child_process';
32
33
 
33
34
  // Max pretext length to prevent excessively long TTS utterances
@@ -176,6 +177,33 @@ function createTestStub() {
176
177
  /**
177
178
  * Create the Agents tab component.
178
179
  */
180
+ // Module-level so the process 'exit'/'SIGINT' handlers are registered EXACTLY
181
+ // once per process, no matter how many times createAgentsTab() is called (the
182
+ // test suite creates many). Registering them per-call leaked listeners
183
+ // (MaxListenersExceededWarning) and left stale SIGINT handlers that each called
184
+ // process.exit(). Each tab adds its patched-files map here; the single handler
185
+ // restores every registered map.
186
+ const _allPatchedConfigMaps = new Set();
187
+ let _patchHandlersRegistered = false;
188
+ function _restoreAllRegisteredPatchedConfigMaps() {
189
+ for (const map of _allPatchedConfigMaps) {
190
+ for (const filePath of [...map.keys()]) {
191
+ const original = map.get(filePath);
192
+ try {
193
+ if (original !== null) fs.writeFileSync(filePath, original);
194
+ else fs.unlinkSync(filePath);
195
+ } catch { /* best-effort restore */ }
196
+ map.delete(filePath);
197
+ }
198
+ }
199
+ }
200
+ function _ensurePatchExitHandlers() {
201
+ if (_patchHandlersRegistered) return;
202
+ _patchHandlersRegistered = true;
203
+ process.on('exit', _restoreAllRegisteredPatchedConfigMaps);
204
+ process.on('SIGINT', () => { _restoreAllRegisteredPatchedConfigMaps(); process.exit(130); });
205
+ }
206
+
179
207
  export function createAgentsTab(screen, services) {
180
208
  if (IS_TEST) return createTestStub();
181
209
 
@@ -208,6 +236,48 @@ ${_tl('bmadDesc')}
208
236
  // Capture cwd once at construction (L1 fix)
209
237
  const _projectRoot = process.cwd();
210
238
 
239
+ // Preview hooks: prefer the CURRENT package copy over the project-local
240
+ // .claude/hooks, which a tarball reinstall does NOT refresh (npm updates
241
+ // node_modules only). A stale project hook routes previews through an outdated
242
+ // sender (e.g. the old agentvibes-receiver→legacy-sender path). CLAUDE_PROJECT_DIR
243
+ // still points at _projectRoot so the hook reads the project's config/provider.
244
+ const _pkgClaudeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.claude');
245
+ function _hookScript(subdir, name) {
246
+ const pkg = path.join(_pkgClaudeDir, subdir, name);
247
+ return fs.existsSync(pkg) ? pkg : path.join(_projectRoot, '.claude', subdir, name);
248
+ }
249
+
250
+ // Story 8.3 fix: Windows agent-preview temp-patches config files (personality.txt,
251
+ // reverb-level.txt) to apply per-agent settings during a preview. Track every
252
+ // patched file + its pre-patch content here so ALL of them can be restored —
253
+ // not just on normal preview completion, but also on abnormal exit (Ctrl+C,
254
+ // crash, or quitting mid-preview). Non-Destructive Configuration Rule: a
255
+ // preview must never permanently mutate the user's config.
256
+ const _patchedConfigFiles = new Map(); // filePath -> original content (string) | null (file did not exist)
257
+
258
+ /** Record a file's pre-patch content the first time it's touched (idempotent). */
259
+ function _trackPatchedConfigFile(filePath, originalContent) {
260
+ if (!_patchedConfigFiles.has(filePath)) _patchedConfigFiles.set(filePath, originalContent);
261
+ }
262
+
263
+ /** Restore a single tracked file to its pre-patch state (or remove it if it didn't exist before). */
264
+ function _restorePatchedConfigFile(filePath) {
265
+ if (!_patchedConfigFiles.has(filePath)) return;
266
+ const original = _patchedConfigFiles.get(filePath);
267
+ try {
268
+ if (original !== null) fs.writeFileSync(filePath, original);
269
+ else fs.unlinkSync(filePath);
270
+ } catch { /* best-effort restore */ }
271
+ _patchedConfigFiles.delete(filePath);
272
+ }
273
+
274
+ // Abnormal-exit safety net: if the process dies mid-preview (Ctrl+C, crash,
275
+ // or the TUI quitting before the preview process's own 'exit' handler runs),
276
+ // still restore any temp-patched config files. Registered once per process
277
+ // via a module-level registry (see top of file) to avoid listener leaks.
278
+ _allPatchedConfigMaps.add(_patchedConfigFiles);
279
+ _ensurePatchExitHandlers();
280
+
211
281
  let _bmadDetected = false;
212
282
  let _agents = [];
213
283
  let _playingProcess = null;
@@ -1058,7 +1128,7 @@ ${_tl('bmadDesc')}
1058
1128
 
1059
1129
  if (_isWin) {
1060
1130
  // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
1061
- const playTtsScript = path.join(_projectRoot, '.claude', 'hooks-windows', 'play-tts.ps1');
1131
+ const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
1062
1132
  if (!fs.existsSync(playTtsScript)) return;
1063
1133
  _previewVoiceId = voiceId;
1064
1134
  if (!_vpClosed) { vpPreviewLine.setContent(`{bright-cyan-fg}♪ Playing: ${voiceId}...{/bright-cyan-fg}`); _refreshVP(); }
@@ -1073,7 +1143,7 @@ ${_tl('bmadDesc')}
1073
1143
  }
1074
1144
 
1075
1145
  // Non-Windows: use bash play-tts.sh
1076
- const playTtsScript = path.join(_projectRoot, '.claude', 'hooks', 'play-tts.sh');
1146
+ const playTtsScript = _hookScript('hooks', 'play-tts.sh');
1077
1147
  if (!fs.existsSync(playTtsScript)) return;
1078
1148
 
1079
1149
  const remoteLlm = detectRemoteLlm();
@@ -1425,10 +1495,14 @@ ${_tl('bmadDesc')}
1425
1495
  }
1426
1496
 
1427
1497
  try {
1428
- if (profile.personality && profile.personality !== 'none')
1498
+ if (profile.personality && profile.personality !== 'none') {
1499
+ _trackPatchedConfigFile(personalityFile, origPersonality);
1429
1500
  fs.writeFileSync(personalityFile, profile.personality);
1430
- if (profile.reverbPreset)
1501
+ }
1502
+ if (profile.reverbPreset) {
1503
+ _trackPatchedConfigFile(reverbFile, origReverb);
1431
1504
  fs.writeFileSync(reverbFile, profile.reverbPreset);
1505
+ }
1432
1506
  } catch { /* degrade gracefully */ }
1433
1507
 
1434
1508
  const voiceId = profile.voice || '';
@@ -1440,16 +1514,21 @@ ${_tl('bmadDesc')}
1440
1514
  });
1441
1515
  _playingProcess = proc;
1442
1516
 
1517
+ // Restore BOTH temp-patched files (personality + reverb) — transactional:
1518
+ // whichever files were actually patched above get restored/unlinked here.
1519
+ // Also covered by the module-level process 'exit'/SIGINT handlers if the
1520
+ // process dies before this fires (see the module-level exit/SIGINT handler).
1443
1521
  function _restore() {
1444
- try {
1445
- if (origPersonality !== null) fs.writeFileSync(personalityFile, origPersonality);
1446
- else try { fs.unlinkSync(personalityFile); } catch {}
1447
- if (origReverb !== null) fs.writeFileSync(reverbFile, origReverb);
1448
- } catch {}
1522
+ _restorePatchedConfigFile(personalityFile);
1523
+ _restorePatchedConfigFile(reverbFile);
1449
1524
  }
1450
1525
 
1451
- proc.on('exit', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1452
- proc.on('error', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); } _restore(); if (onComplete) onComplete(); });
1526
+ // Gate restore + onComplete behind the generation check too: a superseded
1527
+ // preview's late exit must NOT restore config (it would clobber the newer
1528
+ // preview's in-flight patch and fire onComplete out of order). The current
1529
+ // generation's process — or the module-level exit/SIGINT handler — restores.
1530
+ proc.on('exit', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); _restore(); if (onComplete) onComplete(); } });
1531
+ proc.on('error', () => { if (gen === _playGeneration) { _playingProcess = null; _stopSpinner(); _restore(); if (onComplete) onComplete(); } });
1453
1532
  }
1454
1533
 
1455
1534
  // -------------------------------------------------------------------------
@@ -1627,9 +1706,15 @@ ${_tl('bmadDesc')}
1627
1706
  function _closeMenu(callback) {
1628
1707
  if (_menuClosed) return;
1629
1708
  _menuClosed = true;
1709
+ navigationService?.closeModal();
1630
1710
  destroyList(menuList, screen, callback);
1631
1711
  }
1632
1712
 
1713
+ // Register as an open modal so the global 'q' quit guard (app.js) blocks
1714
+ // quit while this menu is up — otherwise 'q' falls through and exits the
1715
+ // whole TUI instead of closing the menu.
1716
+ navigationService?.openModal(null, () => _closeMenu(() => { agentList.focus(); screen.render(); }));
1717
+
1633
1718
  menuList.key(['enter'], () => {
1634
1719
  const action = BULK_ACTIONS[menuList.selected];
1635
1720
  if (!action) return;
@@ -12,16 +12,43 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import os from 'node:os';
15
+ import { spawn } from 'node:child_process';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
17
18
  import { t } from '../../i18n/strings.js';
18
19
 
20
+ const _MUSIC_TAB_DIR = path.dirname(fileURLToPath(import.meta.url));
21
+
19
22
  // Package-relative tracks dir — used as fallback when cwd has no .claude/audio/tracks/
20
23
  const _PKG_TRACKS_DIR = path.resolve(
21
- path.dirname(fileURLToPath(import.meta.url)),
22
- '..', '..', '..', '.claude', 'audio', 'tracks'
24
+ _MUSIC_TAB_DIR, '..', '..', '..', '.claude', 'audio', 'tracks'
23
25
  );
24
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'];
30
+
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
+ }
51
+
25
52
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
26
53
 
27
54
  let blessed;
@@ -540,6 +567,10 @@ export function createMusicTab(screen, services) {
540
567
 
541
568
  let _playingProcess = null;
542
569
  let _playingTrackId = null;
570
+ // Remote playback is fire-and-forget (the local sender exits in ms while the
571
+ // receiver keeps playing), so the on/off toggle tracks the intended remote
572
+ // track here rather than relying on a live local process.
573
+ let _remotePlayingTrackId = null;
543
574
 
544
575
  function _killPlayingProcess() {
545
576
  if (_playingProcess) {
@@ -550,6 +581,51 @@ export function createMusicTab(screen, services) {
550
581
 
551
582
  const _spawnEnv = buildAudioEnv();
552
583
 
584
+ /**
585
+ * Spawn the SSH sender to play a music track on the receiver, or stop the
586
+ * current one. Fire-and-forget (the sender exits after handing the payload to
587
+ * SSH; the receiver plays/stops asynchronously). Returns true if the send was
588
+ * launched. On a play error the remote-playing state is cleared so the toggle
589
+ * doesn't get stuck "on".
590
+ * @param {{remote:boolean, projectDir:string}} mp
591
+ * @param {{track?:string, stop?:boolean}} opts
592
+ */
593
+ 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
+ let rproc;
602
+ try {
603
+ rproc = spawn('bash', [senderPath, '', ''], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env }); // NOSONAR
604
+ } catch {
605
+ previewLine.setContent('{red-fg}Remote music preview failed{/red-fg}');
606
+ screen.render();
607
+ setTimeout(() => { previewLine.setContent(_listFocused ? _hintText() : ''); screen.render(); }, 4000);
608
+ return false;
609
+ }
610
+ let _rerr = '';
611
+ if (rproc.stderr) rproc.stderr.on('data', d => { _rerr += d.toString(); });
612
+ rproc.on('exit', (code) => {
613
+ if (stop || code === 0) return;
614
+ if (_remotePlayingTrackId === track) _remotePlayingTrackId = null;
615
+ const msg = _rerr.trim().split('\n').pop() || 'Remote preview failed';
616
+ previewLine.setContent(`{red-fg}♪ ${msg}{/red-fg}`);
617
+ screen.render();
618
+ setTimeout(() => { previewLine.setContent(_listFocused ? _hintText() : ''); screen.render(); }, 4000);
619
+ });
620
+ rproc.on('error', () => {
621
+ if (stop) return;
622
+ if (_remotePlayingTrackId === track) _remotePlayingTrackId = null;
623
+ previewLine.setContent('{red-fg}Remote music preview failed{/red-fg}');
624
+ screen.render();
625
+ });
626
+ return true;
627
+ }
628
+
553
629
  process.on('exit', () => { _killPlayingProcess(); });
554
630
 
555
631
  /**
@@ -566,6 +642,29 @@ export function createMusicTab(screen, services) {
566
642
  return;
567
643
  }
568
644
 
645
+ // Remote provider: forward to the receiver instead of local playback (which
646
+ // is silent on a headless box). The receiver auto-stops any prior track when
647
+ // a new one arrives; pressing Space on the currently-playing track sends an
648
+ // explicit stop (toggle off).
649
+ const _mp = _resolveMusicProvider();
650
+ if (_mp.remote) {
651
+ if (_remotePlayingTrackId === trackId) {
652
+ _sendMusicRemote(_mp, { stop: true });
653
+ _remotePlayingTrackId = null;
654
+ previewLine.setContent(_listFocused ? _hintText() : '');
655
+ screen.render();
656
+ return;
657
+ }
658
+ const rlabel = _allTracks.find(t => t.id === trackId)?.label ?? formatTrackLabel(trackId);
659
+ if (_sendMusicRemote(_mp, { track: trackId })) {
660
+ _remotePlayingTrackId = trackId;
661
+ previewLine.setContent(`{${COLORS.playingFg}-fg}♪ Playing on receiver: ${rlabel} (Space to stop){/${COLORS.playingFg}-fg}`);
662
+ }
663
+ screen.render();
664
+ return;
665
+ }
666
+
667
+ // ── Local playback ──────────────────────────────────────────────────────
569
668
  // Toggle: second press on the same track → stop
570
669
  if (_playingTrackId === trackId) {
571
670
  _killPlayingProcess();
@@ -780,7 +879,13 @@ export function createMusicTab(screen, services) {
780
879
  }
781
880
 
782
881
  function _saveGlobally() {
783
- configService.setGlobal('backgroundMusic', { track: trackId });
882
+ // Merge into the existing global backgroundMusic object a bare
883
+ // setGlobal('backgroundMusic', { track }) would replace the whole
884
+ // object and silently drop volume/enabled (Non-Destructive Rule).
885
+ const currentGlobal = configService.getGlobalConfig?.().backgroundMusic ?? {};
886
+ // Saving a track implies enabling music (mirrors _saveLocally), while
887
+ // preserving any existing volume/other fields on the global object.
888
+ configService.setGlobal('backgroundMusic', { ...currentGlobal, track: trackId, enabled: true });
784
889
  }
785
890
 
786
891
  const okLocalBtn = _makeBtn('Save Locally', COLORS.btnDefault, 2, 5, () => {
@@ -38,7 +38,6 @@ export function createPlaceholderTab(contentArea, label) {
38
38
  /** Map of tabId → display label for all tabs */
39
39
  export const TAB_DISPLAY_LABELS = {
40
40
  settings: 'Settings',
41
- voices: 'Voices',
42
41
  music: 'Music',
43
42
  agents: 'BMad',
44
43
  receiver: 'Receiver',
@@ -66,7 +65,6 @@ export function getTabLabel(id, lang = 'en') {
66
65
  const keyMap = {
67
66
  setup: 'tabSetup',
68
67
  settings: 'tabSettings',
69
- voices: 'tabVoices',
70
68
  music: 'tabMusic',
71
69
  agents: 'tabBmad',
72
70
  receiver: 'tabReceiver',
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import os from 'node:os';
20
20
  import crypto from 'node:crypto';
21
21
  import { spawn } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
22
23
  import {
23
24
  scanInstalledVoices, getVoiceMeta, genderIconTag, PIPER_VOICES_DIR, SAMPLE_PHRASES, parseMultiSpeaker,
24
25
  } from './voices-tab.js';
@@ -127,6 +128,19 @@ export function createSettingsTab(screen, services) {
127
128
 
128
129
  const { configService, providerService, navigationService, focusMainTabBar, languageService } = services;
129
130
 
131
+ // Capture cwd once at construction (matches agents-tab.js pattern)
132
+ const _projectRoot = process.cwd();
133
+
134
+ // Preview hooks: prefer the CURRENT package copy over the project-local
135
+ // .claude/hooks (a tarball reinstall refreshes node_modules only, not the
136
+ // project's .claude), so previews never run a stale sender. CLAUDE_PROJECT_DIR
137
+ // still points at _projectRoot so the hook reads the project's config/provider.
138
+ const _pkgClaudeDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.claude');
139
+ function _hookScript(subdir, name) {
140
+ const pkg = path.join(_pkgClaudeDir, subdir, name);
141
+ return fs.existsSync(pkg) ? pkg : path.join(_projectRoot, '.claude', subdir, name);
142
+ }
143
+
130
144
  // ── Container ────────────────────────────────────────────────────────────
131
145
 
132
146
  const box = blessed.box({
@@ -646,7 +660,32 @@ export function createSettingsTab(screen, services) {
646
660
  _killVP();
647
661
 
648
662
  const phrase = SAMPLE_PHRASES[Math.floor(Math.random() * SAMPLE_PHRASES.length)]; // NOSONAR
649
- const playTtsScript = path.join(_projectRoot, '.claude', 'hooks', 'play-tts.sh');
663
+
664
+ if (_isWin) {
665
+ // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
666
+ const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
667
+ if (!fs.existsSync(playTtsScript)) return;
668
+ _previewVoiceId = voiceId;
669
+ if (!_vpClosed) {
670
+ vpPreviewLine.setContent(`{cyan-fg}♪ Playing: ${voiceId}...{/cyan-fg}`);
671
+ _refreshVP();
672
+ }
673
+ _previewProc = spawn('powershell', [ // NOSONAR
674
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
675
+ ], { stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv });
676
+ _previewProc.on('exit', () => {
677
+ if (_previewVoiceId === voiceId) {
678
+ _previewVoiceId = null;
679
+ _previewProc = null;
680
+ if (!_vpClosed) { vpPreviewLine.setContent(''); _refreshVP(); }
681
+ }
682
+ });
683
+ _previewProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
684
+ return;
685
+ }
686
+
687
+ // Non-Windows: use bash play-tts.sh
688
+ const playTtsScript = _hookScript('hooks', 'play-tts.sh');
650
689
  if (!fs.existsSync(playTtsScript)) return;
651
690
 
652
691
  const remoteLlm = detectRemoteLlm();
@@ -656,7 +695,7 @@ export function createSettingsTab(screen, services) {
656
695
  _previewProc = spawn('bash', args, { // NOSONAR
657
696
  stdio: 'ignore',
658
697
  detached: true,
659
- env: _spawnEnv,
698
+ env: { ..._spawnEnv, CLAUDE_PROJECT_DIR: _projectRoot },
660
699
  cwd: _projectRoot,
661
700
  });
662
701
  _previewVoiceId = voiceId;
@@ -1144,7 +1144,7 @@ export function createSetupTab(screen, services) {
1144
1144
  pretext: llmConfig.pretext || '',
1145
1145
  reverbPreset: llmConfig.effects || 'off',
1146
1146
  bgTrack: llmConfig.bgTrack || '',
1147
- bgVolume: llmConfig.bgVolume || '0.15',
1147
+ bgVolume: llmConfig.bgVolume || '0.20',
1148
1148
  // Hermes SSH fields
1149
1149
  mode: currentCfg.mode === 'remote' ? 'remote' : 'local',
1150
1150
  sshKey: currentCfg.sshKey || '',
@@ -1330,7 +1330,7 @@ export function createSetupTab(screen, services) {
1330
1330
  const previewBtn = _modalBtn('Preview', 4, _playPreview);
1331
1331
  const resetBtn = _modalBtn('Reset', 18, () => {
1332
1332
  draft.ttsEngine = ''; draft.voice = ''; draft.pretext = '';
1333
- draft.reverbPreset = 'off'; draft.bgTrack = ''; draft.bgVolume = '0.15';
1333
+ draft.reverbPreset = 'off'; draft.bgTrack = ''; draft.bgVolume = '0.20';
1334
1334
  _autoSave();
1335
1335
  fieldList.setItems(_fieldItems());
1336
1336
  fieldList.focus();
@@ -2036,7 +2036,7 @@ export function createSetupTab(screen, services) {
2036
2036
  pretext: config.pretext || defaultPretext[llmKey] || '',
2037
2037
  reverbPreset: config.effects || 'off',
2038
2038
  bgTrack: config.bgTrack || '',
2039
- bgVolume: config.bgVolume || '0.15',
2039
+ bgVolume: config.bgVolume || '0.20',
2040
2040
  // SSH destination fields
2041
2041
  mode: sshCfg.mode === 'remote' ? 'remote' : 'local',
2042
2042
  sshKey: sshCfg.sshKey || '',
@@ -2329,7 +2329,7 @@ export function createSetupTab(screen, services) {
2329
2329
  draft.pretext = defaultPretext[llmKey] || '';
2330
2330
  draft.reverbPreset = 'off';
2331
2331
  draft.bgTrack = '';
2332
- draft.bgVolume = '0.15';
2332
+ draft.bgVolume = '0.20';
2333
2333
  draft.mode = 'local';
2334
2334
  draft.connType = 'manual';
2335
2335
  draft.sshKey = '';
@@ -2852,11 +2852,21 @@ export function createSetupTab(screen, services) {
2852
2852
  let _kSpinInterval = null;
2853
2853
  let _kSpinFrame = 0;
2854
2854
  let _kSpinningIdx = -1;
2855
+ let _kSpinStartTs = 0;
2856
+ let _kFloorTimer = null; // pending _stopKSpinnerWithFloor timer (tracked so it can be cancelled)
2857
+ // Remote SSH preview is fire-and-forget: play-tts-ssh-remote.sh backgrounds
2858
+ // the ssh call and exits within milliseconds, so the row spinner would be
2859
+ // torn down before it ever paints a frame while the receiver plays the audio
2860
+ // a beat later — the user hears sound but never sees the spinner. Hold the
2861
+ // spinner on-screen for this minimum window so the remote path still gives a
2862
+ // visible "preview sent" cue (fire-and-forget has no playback signal to await).
2863
+ const _K_MIN_SPIN_MS = 1100;
2855
2864
 
2856
2865
  function _startKSpinner(listIdx) {
2857
2866
  _stopKSpinner();
2858
2867
  _kSpinningIdx = listIdx;
2859
2868
  _kSpinFrame = 0;
2869
+ _kSpinStartTs = Date.now();
2860
2870
  _kSpinInterval = setInterval(() => {
2861
2871
  if (_kClosed) { _stopKSpinner(); return; }
2862
2872
  const spin = `{cyan-fg}${_K_SPIN[_kSpinFrame++ % _K_SPIN.length]}{/cyan-fg}`;
@@ -2866,6 +2876,9 @@ export function createSetupTab(screen, services) {
2866
2876
  }
2867
2877
 
2868
2878
  function _stopKSpinner() {
2879
+ // Cancel any pending floor timer so a stale one can't fire against a newer
2880
+ // preview's spinner (rapid Space presses) or after the picker closes.
2881
+ if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
2869
2882
  if (_kSpinInterval) { clearInterval(_kSpinInterval); _kSpinInterval = null; }
2870
2883
  if (_kSpinningIdx >= 0 && !_kClosed) {
2871
2884
  kPicker.setItem(_kSpinningIdx, _kokoroItem(voices[_kSpinningIdx]));
@@ -2874,6 +2887,21 @@ export function createSetupTab(screen, services) {
2874
2887
  _kSpinningIdx = -1;
2875
2888
  }
2876
2889
 
2890
+ // Stop the spinner but keep it on-screen for at least _K_MIN_SPIN_MS so a
2891
+ // fire-and-forget remote send stays visible; runs `after` once the floor is
2892
+ // met. No-ops safely if the picker closes in the meantime (_stopKSpinner and
2893
+ // the _kClosed guard both short-circuit). Used only by the remote path — the
2894
+ // local synth path already spins for the whole (multi-second) synthesis.
2895
+ function _stopKSpinnerWithFloor(after) {
2896
+ if (_kFloorTimer) { clearTimeout(_kFloorTimer); _kFloorTimer = null; }
2897
+ const wait = Math.max(0, _K_MIN_SPIN_MS - (Date.now() - _kSpinStartTs));
2898
+ _kFloorTimer = setTimeout(() => {
2899
+ _kFloorTimer = null;
2900
+ _stopKSpinner();
2901
+ if (!_kClosed && after) after();
2902
+ }, wait);
2903
+ }
2904
+
2877
2905
  const LEGEND_H = 3;
2878
2906
  const pickerH = Math.min(voices.length + LEGEND_H + 3, 24);
2879
2907
 
@@ -3226,8 +3254,7 @@ export function createSetupTab(screen, services) {
3226
3254
  _kPreviewProc = remoteProc;
3227
3255
  remoteProc.on('exit', (code) => {
3228
3256
  _kPreviewProc = null;
3229
- _stopKSpinner();
3230
- if (_kClosed) return;
3257
+ _stopKSpinnerWithFloor(() => {
3231
3258
  if (code !== 0 && _isCjkVoice(voiceId)) {
3232
3259
  // CJK voice failed on receiver — needs the language-specific misaki extra there.
3233
3260
  // Still try to download the .pt file locally so the picker shows ✓.
@@ -3272,6 +3299,7 @@ export function createSetupTab(screen, services) {
3272
3299
  screen.render();
3273
3300
  setTimeout(() => { if (!_kClosed) { kBox.setLabel(IDLE_LABEL); screen.render(); } }, 3000);
3274
3301
  }
3302
+ });
3275
3303
  });
3276
3304
  remoteProc.on('error', () => {
3277
3305
  _kPreviewProc = null;
@@ -967,15 +967,23 @@ export function createVoicesTab(screen, services) {
967
967
  // NOTE: projectRoot is AgentVibes repo root (3 levels up from src/console/tabs/)
968
968
  const projectRoot = path.resolve(__dirname, '..', '..', '..');
969
969
  let activeProvider = '';
970
+ // Capture the dir the provider was read from so we can forward it to
971
+ // play-tts.sh as --project-dir. Without it the script re-resolves the
972
+ // provider from its OWN cwd/package .claude (which has no tts-provider.txt),
973
+ // falls back to piper-local, and synthesizes to a silent local device on a
974
+ // headless/remote box — i.e. the preview appears to do nothing. Passing the
975
+ // exact dir we matched keeps the JS check and the shell routing in agreement.
976
+ let activeProjectDir = '';
970
977
  try {
971
- const providerPaths = [
972
- process.env.CLAUDE_PROJECT_DIR && path.join(process.env.CLAUDE_PROJECT_DIR, '.claude', 'tts-provider.txt'),
973
- path.join(process.cwd(), '.claude', 'tts-provider.txt'),
974
- path.join(projectRoot, '.claude', 'tts-provider.txt'),
975
- path.join(os.homedir(), '.claude', 'tts-provider.txt'),
978
+ const providerDirs = [
979
+ process.env.CLAUDE_PROJECT_DIR,
980
+ process.cwd(),
981
+ projectRoot,
982
+ os.homedir(),
976
983
  ].filter(Boolean);
977
- for (const p of providerPaths) {
978
- if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); break; }
984
+ for (const d of providerDirs) {
985
+ const p = path.join(d, '.claude', 'tts-provider.txt');
986
+ if (fs.existsSync(p)) { activeProvider = fs.readFileSync(p, 'utf8').trim(); activeProjectDir = d; break; }
979
987
  }
980
988
  } catch {}
981
989
 
@@ -994,12 +1002,14 @@ export function createVoicesTab(screen, services) {
994
1002
  let proc;
995
1003
  if (isWindows) {
996
1004
  const playTts = path.join(hooksBase, '.claude', 'hooks-windows', 'play-tts.ps1');
997
- proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId], { // NOSONAR
1005
+ const _pdArgs = activeProjectDir ? ['-ProjectDir', activeProjectDir] : [];
1006
+ proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
998
1007
  stdio: 'ignore', detached: false, windowsHide: true, env: _spawnEnv,
999
1008
  });
1000
1009
  } else {
1001
1010
  const playTts = path.join(hooksBase, '.claude', 'hooks', 'play-tts.sh');
1002
- proc = spawn('bash', [playTts, phrase, voiceId], { // NOSONAR
1011
+ const _pdArgs = activeProjectDir ? ['--project-dir', activeProjectDir] : [];
1012
+ proc = spawn('bash', [playTts, phrase, voiceId, ..._pdArgs], { // NOSONAR
1003
1013
  stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: _spawnEnv,
1004
1014
  cwd: process.cwd(),
1005
1015
  });
@@ -33,9 +33,9 @@ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
33
33
  * @param {Function} [onClose] - called when modal closes (confirm or cancel)
34
34
  */
35
35
  export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
36
- if (IS_TEST) { onConfirm(currentVol ?? 70); return; }
36
+ if (IS_TEST) { onConfirm(currentVol ?? 20); return; }
37
37
  let vol = (Number.isFinite(currentVol) && currentVol >= 0 && currentVol <= 100)
38
- ? currentVol : 70;
38
+ ? currentVol : 20;
39
39
 
40
40
  const box = blessed.box({
41
41
  parent: screen,
@@ -141,7 +141,7 @@ const BUILT_IN_TRACKS = [
141
141
  *
142
142
  * @param {object} screen - blessed screen
143
143
  * @param {string} currentTrack - currently selected track filename
144
- * @param {number} currentVolume - currently set volume (0-100, default 70)
144
+ * @param {number} currentVolume - currently set volume (0-100, default 20)
145
145
  * @param {Function} onSelect - called with (trackFile, volume)
146
146
  * @param {Function} [onClose] - called after modal fully closes
147
147
  */