agentvibes 4.0.0 → 4.2.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 (42) hide show
  1. package/.claude/config/audio-effects.cfg +3 -2
  2. package/.claude/config/background-music-position.txt +1 -1
  3. package/.claude/hooks/audio-processor.sh +87 -43
  4. package/.claude/hooks/bmad-speak.sh +184 -27
  5. package/.claude/hooks/play-tts-enhanced.sh +40 -5
  6. package/.claude/hooks/play-tts-macos.sh +29 -6
  7. package/.claude/hooks/play-tts-piper.sh +174 -67
  8. package/.claude/hooks/play-tts-soprano.sh +42 -6
  9. package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
  10. package/.claude/hooks/play-tts.sh +12 -9
  11. package/.claude/hooks/session-start-tts.sh +10 -0
  12. package/.claude/hooks/stop-tts.sh +84 -0
  13. package/.claude/hooks/tts-queue-worker.sh +51 -20
  14. package/.claude/hooks/tts-queue.sh +37 -8
  15. package/.claude/hooks/voice-manager.sh +5 -1
  16. package/CLAUDE.md +0 -11
  17. package/README.md +176 -78
  18. package/RELEASE_NOTES.md +1197 -60
  19. package/bin/agentvibes-voice-browser.js +35 -21
  20. package/mcp-server/server.py +36 -0
  21. package/package.json +1 -3
  22. package/src/console/app.js +23 -5
  23. package/src/console/constants/personalities.js +44 -0
  24. package/src/console/footer-config.js +8 -0
  25. package/src/console/navigation.js +3 -1
  26. package/src/console/tabs/agents-tab.js +1219 -72
  27. package/src/console/tabs/install-tab.js +2 -1
  28. package/src/console/tabs/placeholder-tab.js +9 -1
  29. package/src/console/tabs/receiver-tab.js +1212 -0
  30. package/src/console/tabs/settings-tab.js +33 -323
  31. package/src/console/widgets/destroy-list.js +25 -0
  32. package/src/console/widgets/format-utils.js +89 -0
  33. package/src/console/widgets/notice.js +55 -0
  34. package/src/console/widgets/personality-picker.js +185 -0
  35. package/src/console/widgets/reverb-picker.js +94 -0
  36. package/src/console/widgets/track-picker.js +285 -0
  37. package/src/installer.js +54 -2
  38. package/src/services/agent-voice-store.js +282 -22
  39. package/src/services/config-service.js +24 -0
  40. package/src/services/navigation-service.js +1 -1
  41. package/src/utils/music-file-validator.js +41 -31
  42. package/templates/agentvibes-receiver.sh +431 -111
@@ -0,0 +1,185 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Personality Picker
3
+ *
4
+ * Inline modal list for selecting voice personalities with TTS preview.
5
+ * Extracted from settings-tab.js for reuse across tabs.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { destroyList } from './destroy-list.js';
11
+ import { buildAudioEnv } from '../audio-env.js';
12
+ import { BRAND_PINK } from '../brand-colors.js';
13
+ import { PERSONALITY_EMOJIS, PERSONALITIES } from '../constants/personalities.js';
14
+
15
+ export { PERSONALITY_EMOJIS, PERSONALITIES };
16
+
17
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
18
+ let blessed;
19
+ if (!IS_TEST) {
20
+ const { default: b } = await import('blessed');
21
+ blessed = b;
22
+ }
23
+
24
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
25
+
26
+ const PERSONALITY_PREVIEW_PHRASES = Object.freeze({
27
+ angry: "UNACCEPTABLE! This build time is a DISASTER! Fix it NOW or so help me!",
28
+ annoying: "Oh oh oh! Can I tell you something? Can I? Can I? PLEASE? It is so important!",
29
+ crass: "Well damn, that code runs like my uncle's truck. Barely, and it smells funny.",
30
+ dramatic: "The tests... have failed. I don't know how much longer I can do this.",
31
+ 'dry-humor': "Your code worked. I too am surprised.",
32
+ flirty: "Ooh, a clean merge? You know exactly how to make my heart race.",
33
+ funny: "Why do programmers hate nature? Too many bugs. I will show myself out.",
34
+ grandpa: "Back in my day, we compiled by hand. Uphill. In the snow. Both ways.",
35
+ millennial: "I literally cannot even with this error. I am so done. Like, actually deceased.",
36
+ moody: "...It works. Whatever. Do not get used to it.",
37
+ pirate: "Arrr! The build be sailin' smooth today, matey! No barnacles in sight!",
38
+ poetic: "Like rivers to the sea, your code flows toward eventual compilation.",
39
+ professional: "I have completed the requested task and am prepared to document outcomes.",
40
+ rapper: "Yo! Clean code flowin', tests are glowin', no bugs showin'!",
41
+ robot: "TASK COMPLETE. EFFICIENCY: OPTIMAL. PROBABILITY OF SUCCESS: 97.3 PERCENT. BEEP.",
42
+ sarcastic: "Oh wow, another bug. What a completely unexpected surprise. Truly shocking.",
43
+ sassy: "Honey, whoever told you that was good code was not your friend.",
44
+ 'surfer-dude':"Duuude! That commit totally shredded! Gnarly clean code, bro!",
45
+ zen: "The bug is not the enemy. The bug is the teacher. Breathe. Commit.",
46
+ random: "Who will I be today? Even I do not know. Expect the unexpected.",
47
+ });
48
+
49
+ /**
50
+ * Open the personality picker modal.
51
+ *
52
+ * @param {object} screen - blessed screen
53
+ * @param {string} currentPersonality - current personality value
54
+ * @param {Function} onSelect - called with selected personality
55
+ * @param {Function} [onClose] - called after modal closes
56
+ */
57
+ export function openPersonalityPicker(screen, currentPersonality, onSelect, onClose) {
58
+ const current = currentPersonality ?? 'none';
59
+ const currentIdx = Math.max(0, PERSONALITIES.indexOf(current));
60
+
61
+ const COLORS = {
62
+ btnFocus: '#00e5ff',
63
+ btnFocusFg: '#000000',
64
+ };
65
+
66
+ const list = blessed.list({
67
+ parent: screen,
68
+ top: 'center',
69
+ left: 'center',
70
+ width: 44,
71
+ height: Math.min(PERSONALITIES.length + 4, 22),
72
+ border: { type: 'line' },
73
+ tags: true,
74
+ label: _modalTitle('Select Personality'),
75
+ items: PERSONALITIES.map((p, i) => {
76
+ const emoji = PERSONALITY_EMOJIS[p] ?? '✨';
77
+ const label = p === 'none' ? 'None' : p.charAt(0).toUpperCase() + p.slice(1);
78
+ const mark = i === currentIdx ? '✅' : ' ';
79
+ return `${mark} ${emoji} ${label}`;
80
+ }),
81
+ keys: true,
82
+ vi: true,
83
+ mouse: true,
84
+ style: {
85
+ border: { fg: COLORS.btnFocus },
86
+ selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
87
+ item: { fg: '#e3f2fd' },
88
+ },
89
+ });
90
+
91
+ list.select(currentIdx);
92
+ list.focus();
93
+ screen.render();
94
+
95
+ // TTS preview on hover
96
+ let _pickerTtsProc = null;
97
+ let _playingItemIdx = -1;
98
+
99
+ function _setItemPlaying(idx, playing) {
100
+ const item = list.items?.[idx];
101
+ if (!item) return;
102
+ const base = (item.content ?? '').replace(/ █$/, '').replace(/ \(playing\)$/, '');
103
+ item.setContent(playing ? `${base} (playing)` : base);
104
+ }
105
+
106
+ function _killPickerTts() {
107
+ if (_pickerTtsProc) {
108
+ try { process.kill(-_pickerTtsProc.pid, 'SIGTERM'); } catch {}
109
+ _pickerTtsProc = null;
110
+ }
111
+ if (_playingItemIdx >= 0) {
112
+ _setItemPlaying(_playingItemIdx, false);
113
+ _playingItemIdx = -1;
114
+ }
115
+ }
116
+
117
+ function _speakPreview(personality) {
118
+ _killPickerTts();
119
+ const phrase = PERSONALITY_PREVIEW_PHRASES[personality];
120
+ if (!phrase) return;
121
+ const ttsScript = path.join(process.cwd(), '.claude', 'hooks', 'play-tts.sh');
122
+ _pickerTtsProc = spawn('bash', [ttsScript, phrase], {
123
+ stdio: 'ignore',
124
+ detached: true,
125
+ env: buildAudioEnv(),
126
+ });
127
+ _playingItemIdx = list.selected;
128
+ _setItemPlaying(_playingItemIdx, true);
129
+ screen.render();
130
+ _pickerTtsProc.on('exit', () => {
131
+ if (_playingItemIdx >= 0) {
132
+ _setItemPlaying(_playingItemIdx, false);
133
+ _playingItemIdx = -1;
134
+ screen.render();
135
+ }
136
+ _pickerTtsProc = null;
137
+ });
138
+ _pickerTtsProc.unref();
139
+ }
140
+
141
+ list.on('select item', () => {
142
+ _speakPreview(PERSONALITIES[list.selected]);
143
+ });
144
+
145
+ list.key(['space'], () => {
146
+ if (_pickerTtsProc && _playingItemIdx === list.selected) {
147
+ _killPickerTts();
148
+ } else {
149
+ _speakPreview(PERSONALITIES[list.selected]);
150
+ }
151
+ });
152
+
153
+ // Type-to-jump
154
+ const _jumpBlocked = new Set(['j', 'k', 'g', 'h', 'l', 'd', 'u', 'q']);
155
+ list.on('keypress', (ch, key) => {
156
+ if (!ch || key.ctrl || key.meta) return;
157
+ const lower = ch.toLowerCase();
158
+ if (!/^[a-z]$/.test(lower)) return;
159
+ if (_jumpBlocked.has(lower)) return;
160
+ const count = PERSONALITIES.length;
161
+ const start = list.selected ?? 0;
162
+ for (let i = 1; i <= count; i++) {
163
+ const idx = (start + i) % count;
164
+ if (PERSONALITIES[idx].startsWith(lower)) {
165
+ list.select(idx);
166
+ screen.render();
167
+ break;
168
+ }
169
+ }
170
+ });
171
+
172
+ list.key(['enter'], () => {
173
+ const selected = PERSONALITIES[list.selected];
174
+ if (!selected) return;
175
+ _killPickerTts();
176
+ // Call onSelect before destroying to avoid stale-state re-renders
177
+ onSelect(selected);
178
+ destroyList(list, screen, onClose);
179
+ });
180
+
181
+ list.key(['escape', 'q'], () => {
182
+ _killPickerTts();
183
+ destroyList(list, screen, onClose);
184
+ });
185
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Reverb Preset Picker
3
+ *
4
+ * Inline modal list for selecting reverb presets.
5
+ * Extracted from settings-tab.js for reuse across tabs.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { destroyList } from './destroy-list.js';
11
+ import { BRAND_PINK } from '../brand-colors.js';
12
+
13
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
14
+ let blessed;
15
+ if (!IS_TEST) {
16
+ const { default: b } = await import('blessed');
17
+ blessed = b;
18
+ }
19
+
20
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
21
+
22
+ export const REVERB_PRESETS = Object.freeze([
23
+ { label: 'Off (Dry, no reverb)', value: 'off' },
24
+ { label: 'Light (Small room)', value: 'light' },
25
+ { label: 'Medium (Conference room)', value: 'medium' },
26
+ { label: 'Heavy (Large hall)', value: 'heavy' },
27
+ { label: 'Cathedral (Epic space)', value: 'cathedral' },
28
+ ]);
29
+
30
+ /**
31
+ * Open the reverb preset picker modal.
32
+ *
33
+ * @param {object} screen - blessed screen
34
+ * @param {string} currentPreset - current reverb preset value
35
+ * @param {Function} onSelect - called with selected preset value
36
+ * @param {Function} [onClose] - called after modal closes
37
+ * @param {object} [opts] - options
38
+ * @param {boolean} [opts.applyToEffectsManager=true] - whether to apply via effects-manager.sh
39
+ */
40
+ export function openReverbPicker(screen, currentPreset, onSelect, onClose, opts = {}) {
41
+ const applyToEffectsManager = opts.applyToEffectsManager !== false;
42
+ const currentIdx = Math.max(0, REVERB_PRESETS.findIndex(p => p.value === currentPreset));
43
+
44
+ const COLORS = {
45
+ btnFocus: '#00e5ff',
46
+ btnFocusFg: '#000000',
47
+ };
48
+
49
+ const list = blessed.list({
50
+ parent: screen,
51
+ top: 'center',
52
+ left: 'center',
53
+ width: 40,
54
+ height: REVERB_PRESETS.length + 4,
55
+ border: { type: 'line' },
56
+ tags: true,
57
+ label: _modalTitle('Select Reverb Preset'),
58
+ items: REVERB_PRESETS.map((p, i) => (i === currentIdx ? `● ${p.label}` : ` ${p.label}`)),
59
+ keys: true,
60
+ vi: false,
61
+ mouse: true,
62
+ style: {
63
+ border: { fg: COLORS.btnFocus },
64
+ selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
65
+ item: { fg: '#e3f2fd' },
66
+ },
67
+ });
68
+
69
+ list.select(currentIdx);
70
+ list.focus();
71
+ screen.render();
72
+
73
+ list.key(['enter', 'space'], () => {
74
+ const selected = REVERB_PRESETS[list.selected];
75
+ if (!selected) return;
76
+
77
+ if (applyToEffectsManager) {
78
+ const effectsScript = path.join(process.cwd(), '.claude', 'hooks', 'effects-manager.sh');
79
+ spawnSync('bash', [effectsScript, 'set-reverb', selected.value, 'default'], {
80
+ stdio: 'ignore',
81
+ timeout: 5000,
82
+ env: { ...process.env },
83
+ });
84
+ }
85
+
86
+ // Call onSelect before destroying to avoid stale-state re-renders
87
+ onSelect(selected.value);
88
+ destroyList(list, screen, onClose);
89
+ });
90
+
91
+ list.key(['escape', 'q'], () => {
92
+ destroyList(list, screen, onClose);
93
+ });
94
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * AgentVibes TUI — Shared Widget: Background Music Track Picker
3
+ *
4
+ * Inline modal list for selecting background music tracks.
5
+ * Extracted from settings-tab.js for reuse across tabs.
6
+ * Space previews track, Enter selects.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { spawn } from 'node:child_process';
12
+ import { destroyList } from './destroy-list.js';
13
+ import { BRAND_PINK } from '../brand-colors.js';
14
+ import { formatTrackName } from './format-utils.js';
15
+ import { buildAudioEnv, detectMp3Player } from '../audio-env.js';
16
+
17
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
18
+ let blessed;
19
+ if (!IS_TEST) {
20
+ const { default: b } = await import('blessed');
21
+ blessed = b;
22
+ }
23
+
24
+ const _modalTitle = (text) => ` {${BRAND_PINK}-fg}${text}{/${BRAND_PINK}-fg} `;
25
+ const _hintLabel = '{#455a64-fg}[Space] Preview [Enter] Select [Esc] Cancel{/#455a64-fg}';
26
+
27
+ /**
28
+ * Open a small volume input modal (0–100).
29
+ * Left/Right arrows adjust by 5; type a number directly; Enter confirms.
30
+ *
31
+ * @param {object} screen - blessed screen
32
+ * @param {number} currentVol - current volume (0-100)
33
+ * @param {Function} onConfirm - called with volume (number) on Enter
34
+ * @param {Function} [onClose] - called when modal closes (confirm or cancel)
35
+ */
36
+ export function openVolumeInput(screen, currentVol, onConfirm, onClose) {
37
+ if (IS_TEST) { onConfirm(currentVol ?? 70); return; }
38
+ let vol = (Number.isFinite(currentVol) && currentVol >= 0 && currentVol <= 100)
39
+ ? currentVol : 70;
40
+
41
+ const box = blessed.box({
42
+ parent: screen,
43
+ top: 'center',
44
+ left: 'center',
45
+ width: 38,
46
+ height: 8,
47
+ border: { type: 'line' },
48
+ tags: true,
49
+ label: _modalTitle('Music Volume'),
50
+ style: { border: { fg: '#00e5ff' } },
51
+ });
52
+
53
+ const barText = blessed.text({
54
+ parent: box,
55
+ top: 1,
56
+ left: 2,
57
+ width: 32,
58
+ tags: true,
59
+ content: '',
60
+ });
61
+
62
+ const hint = blessed.text({
63
+ parent: box,
64
+ top: 5,
65
+ left: 1,
66
+ width: 34,
67
+ tags: true,
68
+ content: '{#455a64-fg}[←→] ±5 [1-9] type [Enter] OK [Esc] Cancel{/#455a64-fg}',
69
+ });
70
+
71
+ function _renderBar() {
72
+ const filled = Math.round(vol / 5);
73
+ const empty = 20 - filled;
74
+ const bar = '{#00e5ff-fg}' + '█'.repeat(filled) + '{/#00e5ff-fg}' +
75
+ '{#263238-fg}' + '░'.repeat(empty) + '{/#263238-fg}';
76
+ barText.setContent(`{#90a4ae-fg}Volume:{/#90a4ae-fg} ${bar} {bold}${vol}%{/bold}`);
77
+ screen.render();
78
+ }
79
+ _renderBar();
80
+
81
+ // Capture keypress directly on screen to avoid input mode issues
82
+ let _digits = '';
83
+ function _onKey(ch, key) {
84
+ const name = key?.name ?? '';
85
+ if (name === 'enter') { _close(true); return; }
86
+ if (name === 'escape') { _close(false); return; }
87
+ if (name === 'left') { vol = Math.max(0, vol - 5); _digits = ''; _renderBar(); return; }
88
+ if (name === 'right') { vol = Math.min(100, vol + 5); _digits = ''; _renderBar(); return; }
89
+ if (ch && /^[0-9]$/.test(ch)) {
90
+ _digits += ch;
91
+ const n = parseInt(_digits, 10);
92
+ if (n >= 0 && n <= 100) { vol = n; _renderBar(); }
93
+ if (_digits.length >= 3) _digits = '';
94
+ }
95
+ }
96
+ screen.on('keypress', _onKey);
97
+
98
+ function _close(confirm) {
99
+ screen.removeListener('keypress', _onKey);
100
+ box.destroy();
101
+ screen.render();
102
+ if (confirm && onConfirm) onConfirm(vol);
103
+ if (onClose) onClose();
104
+ }
105
+ }
106
+
107
+ const BUILT_IN_TRACKS = [
108
+ { label: '🎻 Soft Flamenco', file: 'agentvibes_soft_flamenco_loop.mp3' },
109
+ { label: '🌸 Bossa Nova', file: 'agent_vibes_bossa_nova_v2_loop.mp3' },
110
+ { label: '🌊 Chillwave', file: 'agent_vibes_chillwave_v2_loop.mp3' },
111
+ { label: '🪘 Gnawa Ambient', file: 'agent_vibes_ganawa_ambient_v2_loop.mp3' },
112
+ ];
113
+
114
+ /**
115
+ * Open the background music track picker modal.
116
+ * After selecting a track, prompts for volume (0-100) via openVolumeInput.
117
+ *
118
+ * @param {object} screen - blessed screen
119
+ * @param {string} currentTrack - currently selected track filename
120
+ * @param {number} currentVolume - currently set volume (0-100, default 70)
121
+ * @param {Function} onSelect - called with (trackFile, volume)
122
+ * @param {Function} [onClose] - called after modal fully closes
123
+ */
124
+ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, onClose) {
125
+ const tracksDir = path.join(process.cwd(), '.claude', 'audio', 'tracks');
126
+ let tracks;
127
+ try {
128
+ const files = fs.readdirSync(tracksDir);
129
+ tracks = files
130
+ .filter(f => /\.mp3$/i.test(f))
131
+ .sort()
132
+ .map(f => ({ file: f, label: formatTrackName(f) }));
133
+ } catch {
134
+ tracks = BUILT_IN_TRACKS;
135
+ }
136
+
137
+ const COLORS = {
138
+ btnFocus: '#00e5ff',
139
+ btnFocusFg: '#000000',
140
+ };
141
+
142
+ const items = tracks.map(t =>
143
+ t.file === currentTrack ? `● ${t.label}` : ` ${t.label}`
144
+ );
145
+ const currentIdx = tracks.findIndex(t => t.file === currentTrack);
146
+
147
+ const listHeight = Math.min(tracks.length + 6, Math.floor(screen.rows * 0.7));
148
+ const list = blessed.list({
149
+ parent: screen,
150
+ top: 'center',
151
+ left: 'center',
152
+ width: 54,
153
+ height: listHeight,
154
+ border: { type: 'line' },
155
+ tags: true,
156
+ label: _modalTitle('Select Track'),
157
+ items,
158
+ keys: true,
159
+ vi: false,
160
+ mouse: true,
161
+ scrollable: true,
162
+ scrollbar: { ch: '│', track: { bg: '#1e2a3a' }, style: { fg: COLORS.btnFocus } },
163
+ style: {
164
+ border: { fg: COLORS.btnFocus },
165
+ selected: { bg: COLORS.btnFocus, fg: COLORS.btnFocusFg, bold: true },
166
+ item: { fg: '#e3f2fd' },
167
+ },
168
+ });
169
+
170
+ // Helper: update hint text in the bottom border label
171
+ function _setHint(text) {
172
+ list.setLabel({ text: _modalTitle('Select Track'), side: 'left' });
173
+ // Use _ prefix convention for bottom border content (blessed doesn't have setFooter)
174
+ list._label2 && list._label2.destroy();
175
+ list._label2 = blessed.text({
176
+ parent: list,
177
+ bottom: -1,
178
+ left: 1,
179
+ width: 50,
180
+ height: 1,
181
+ tags: true,
182
+ content: text,
183
+ style: { fg: '#e3f2fd' },
184
+ });
185
+ screen.render();
186
+ }
187
+
188
+ _setHint(_hintLabel);
189
+
190
+ if (currentIdx >= 0) list.select(currentIdx);
191
+ list.focus();
192
+ screen.render();
193
+
194
+ // Preview playback state
195
+ const _spawnEnv = buildAudioEnv();
196
+ const _mp3Player = detectMp3Player(_spawnEnv);
197
+ let _previewProc = null;
198
+ let _previewTrackId = null;
199
+
200
+ function _killPreview() {
201
+ if (_previewProc) {
202
+ try { process.kill(-_previewProc.pid, 'SIGTERM'); } catch {}
203
+ _previewProc = null;
204
+ }
205
+ _previewTrackId = null;
206
+ }
207
+
208
+ function _previewTrack(trackFile) {
209
+ // Toggle off if same track
210
+ if (_previewTrackId === trackFile) {
211
+ _killPreview();
212
+ _setHint(_hintLabel);
213
+ return;
214
+ }
215
+
216
+ _killPreview();
217
+
218
+ const trackPath = path.resolve(tracksDir, trackFile);
219
+ const safeBase = path.resolve(tracksDir);
220
+ if (!trackPath.startsWith(safeBase + path.sep) && trackPath !== safeBase) return;
221
+
222
+ if (!_mp3Player || !fs.existsSync(trackPath)) {
223
+ _setHint('{red-fg}No MP3 player found or track missing{/red-fg}');
224
+ setTimeout(() => {
225
+ _setHint(_hintLabel);
226
+ }, 3000);
227
+ return;
228
+ }
229
+
230
+ _previewProc = spawn(_mp3Player.bin, _mp3Player.args(trackPath), {
231
+ stdio: 'ignore', detached: true, env: _spawnEnv,
232
+ });
233
+ _previewTrackId = trackFile;
234
+
235
+ const label = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
236
+ _setHint(`{#00e5ff-fg}♪ Previewing: ${label} (Space to stop){/#00e5ff-fg}`);
237
+
238
+ _previewProc.on('exit', () => {
239
+ if (_previewTrackId === trackFile) {
240
+ _previewTrackId = null;
241
+ _previewProc = null;
242
+ _setHint(_hintLabel);
243
+ }
244
+ });
245
+
246
+ _previewProc.on('error', () => {
247
+ _previewTrackId = null;
248
+ _previewProc = null;
249
+ });
250
+ }
251
+
252
+ function _close(callback) {
253
+ _killPreview();
254
+ if (list._label2) list._label2.destroy();
255
+ if (callback) {
256
+ callback();
257
+ destroyList(list, screen, onClose);
258
+ } else {
259
+ destroyList(list, screen, onClose);
260
+ }
261
+ }
262
+
263
+ // Space = preview
264
+ list.key(['space'], () => {
265
+ const selected = tracks[list.selected];
266
+ if (selected) _previewTrack(selected.file);
267
+ });
268
+
269
+ // Enter = select track, then prompt for volume
270
+ list.key(['enter'], () => {
271
+ const selected = tracks[list.selected];
272
+ if (!selected) return;
273
+ // Close the track list first (without firing onClose yet), then open volume input
274
+ _killPreview();
275
+ if (list._label2) list._label2.destroy();
276
+ destroyList(list, screen, null);
277
+ openVolumeInput(screen, currentVolume ?? 70, (volume) => {
278
+ onSelect(selected.file, volume);
279
+ }, onClose);
280
+ });
281
+
282
+ list.key(['escape', 'q'], () => {
283
+ _close();
284
+ });
285
+ }
package/src/installer.js CHANGED
@@ -4717,6 +4717,45 @@ async function updateCommandFiles(targetDir, spinner) {
4717
4717
  return commandFiles.length;
4718
4718
  }
4719
4719
 
4720
+ /**
4721
+ * Critical hooks that must always be kept up-to-date in every installation,
4722
+ * including the global ~/.claude/hooks/ directory.
4723
+ * These hooks contain bug fixes (e.g. markdown stripping) that must propagate
4724
+ * on every `npx agentvibes update` regardless of target directory.
4725
+ */
4726
+ const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'session-start-tts.sh'];
4727
+
4728
+ /**
4729
+ * Update critical hooks in the global ~/.claude/hooks/ directory if it exists.
4730
+ * Runs silently during every `update` — only touches files that are already installed.
4731
+ * @param {string} srcHooksDir - Source hooks directory from the package
4732
+ * @param {string} [homeDirOverride] - Override home dir (for testing only)
4733
+ * @returns {Promise<number>} Number of hooks updated
4734
+ */
4735
+ async function updateGlobalHooks(srcHooksDir, homeDirOverride) {
4736
+ const globalHooksDir = path.join(homeDirOverride || os.homedir(), '.claude', 'hooks');
4737
+ let updated = 0;
4738
+ try {
4739
+ await fs.access(globalHooksDir);
4740
+ } catch {
4741
+ return 0; // global hooks dir not present — nothing to do
4742
+ }
4743
+
4744
+ for (const hook of CRITICAL_HOOKS) {
4745
+ const destPath = path.join(globalHooksDir, hook);
4746
+ const srcPath = path.join(srcHooksDir, hook);
4747
+ try {
4748
+ await fs.access(destPath); // only update if already installed
4749
+ await fs.copyFile(srcPath, destPath);
4750
+ await fs.chmod(destPath, 0o750);
4751
+ updated++;
4752
+ } catch {
4753
+ // file not in global dir or src missing — skip silently
4754
+ }
4755
+ }
4756
+ return updated;
4757
+ }
4758
+
4720
4759
  /**
4721
4760
  * Perform all update operations
4722
4761
  * @param {string} targetDir - Target installation directory
@@ -4735,6 +4774,14 @@ async function performUpdateOperations(targetDir, spinner) {
4735
4774
  const hookResult = await copyHookFiles(targetDir, silentSpinner);
4736
4775
  console.log(chalk.green(`✓ Updated ${hookResult.count} TTS scripts`));
4737
4776
 
4777
+ // Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs)
4778
+ const hooksSubdir = isNativeWindows() ? 'hooks-windows' : 'hooks';
4779
+ const srcHooksDir = path.join(__dirname, '..', '.claude', hooksSubdir);
4780
+ const globalHooksUpdated = await updateGlobalHooks(srcHooksDir);
4781
+ if (globalHooksUpdated > 0) {
4782
+ console.log(chalk.green(`✓ Updated ${globalHooksUpdated} critical scripts in ~/.claude/hooks/`));
4783
+ }
4784
+
4738
4785
  // Update personalities
4739
4786
  spinner.text = 'Updating personality templates...';
4740
4787
  const srcPersonalitiesDir = path.join(__dirname, '..', '.claude', 'personalities');
@@ -4937,15 +4984,19 @@ async function install(options = {}) {
4937
4984
  await fs.writeFile(sshHostConfigPath, userConfig.sshHost);
4938
4985
  }
4939
4986
 
4940
- // Set up receiver script if in receiver mode (Termux)
4987
+ // Set up receiver script if in receiver mode
4941
4988
  if (userConfig.isReceiver) {
4942
4989
  const receiverDir = path.join(process.env.HOME || process.env.USERPROFILE, '.agentvibes');
4943
4990
  await fs.mkdir(receiverDir, { recursive: true, mode: 0o700 });
4944
- const receiverScriptPath = path.join(receiverDir, 'receiver.sh');
4945
4991
  const templatePath = path.join(__dirname, '..', 'templates', 'agentvibes-receiver.sh');
4946
4992
  try {
4947
4993
  const templateContent = await fs.readFile(templatePath, 'utf8');
4994
+ // Install as play-remote.sh (ForceCommand target)
4995
+ const receiverScriptPath = path.join(receiverDir, 'play-remote.sh');
4948
4996
  await fs.writeFile(receiverScriptPath, templateContent, { mode: 0o755 });
4997
+ // Also install as receiver.sh for backward compatibility
4998
+ const legacyPath = path.join(receiverDir, 'receiver.sh');
4999
+ await fs.writeFile(legacyPath, templateContent, { mode: 0o755 });
4949
5000
  } catch {
4950
5001
  // Receiver script install failed — non-fatal
4951
5002
  }
@@ -5774,4 +5825,5 @@ export {
5774
5825
  copyPluginFiles, copyBmadConfigFiles, copyBackgroundMusicFiles,
5775
5826
  copyConfigFiles, configureSessionStartHook, ensureGitRepo,
5776
5827
  installPluginManifest, checkAndInstallPiper,
5828
+ updateGlobalHooks, CRITICAL_HOOKS,
5777
5829
  };