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.
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/audio-effects.cfg.bak-kokoro +7 -0
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/audio-processor.sh +1 -1
- package/.claude/hooks/background-music-manager.sh +2 -1
- package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
- package/.claude/hooks/bmad-speak.sh +26 -8
- package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
- package/.claude/hooks/play-tts-kokoro.sh +9 -0
- package/.claude/hooks/play-tts-piper.sh +13 -1
- package/.claude/hooks/play-tts-ssh-remote.sh +117 -7
- package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
- package/.claude/hooks/play-tts.sh +106 -13
- package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
- package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
- package/.claude/hooks-windows/play-tts.ps1 +163 -30
- package/.claude/hooks-windows/tts-watcher.ps1 +55 -0
- package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
- package/README.md +11 -6
- package/RELEASE_NOTES.md +67 -0
- package/bin/mcp-server.sh +6 -19
- package/bin/resolve-utterance.js +137 -0
- package/mcp-server/server.py +280 -99
- package/mcp-server/test_mcp_correctness.py +486 -0
- package/mcp-server/test_windows_script_parity.py +341 -336
- package/package.json +3 -2
- package/setup-windows.ps1 +867 -815
- package/src/console/app.js +14 -11
- package/src/console/footer-config.js +0 -4
- package/src/console/navigation.js +0 -1
- package/src/console/tabs/agents-tab.js +96 -11
- package/src/console/tabs/music-tab.js +108 -3
- package/src/console/tabs/placeholder-tab.js +0 -2
- package/src/console/tabs/settings-tab.js +41 -2
- package/src/console/tabs/setup-tab.js +34 -6
- package/src/console/tabs/voices-tab.js +19 -9
- package/src/console/widgets/track-picker.js +3 -3
- package/src/i18n/de.js +0 -1
- package/src/i18n/en.js +0 -1
- package/src/i18n/es.js +0 -1
- package/src/i18n/fr.js +0 -1
- package/src/i18n/hi.js +0 -1
- package/src/i18n/ja.js +0 -1
- package/src/i18n/ko.js +0 -1
- package/src/i18n/pt.js +0 -1
- package/src/i18n/zh-CN.js +0 -1
- package/src/installer.js +205 -15
- package/src/services/config-service.js +264 -264
- package/src/services/llm-provider-service.js +7 -7
- package/src/services/navigation-service.js +1 -1
- package/src/services/utterance-loader.js +280 -0
- package/src/services/utterance-resolver.js +526 -0
- package/templates/agentvibes-receiver.sh +74 -12
- package/voice-assignments.json +8245 -0
package/src/console/app.js
CHANGED
|
@@ -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
|
-
|
|
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' | '
|
|
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`,
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
972
|
-
process.env.CLAUDE_PROJECT_DIR
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
978
|
+
const providerDirs = [
|
|
979
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
980
|
+
process.cwd(),
|
|
981
|
+
projectRoot,
|
|
982
|
+
os.homedir(),
|
|
976
983
|
].filter(Boolean);
|
|
977
|
-
for (const
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
36
|
+
if (IS_TEST) { onConfirm(currentVol ?? 20); return; }
|
|
37
37
|
let vol = (Number.isFinite(currentVol) && currentVol >= 0 && currentVol <= 100)
|
|
38
|
-
? currentVol :
|
|
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
|
|
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
|
*/
|