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.
- package/.claude/config/audio-effects.cfg +3 -2
- package/.claude/config/background-music-position.txt +1 -1
- package/.claude/hooks/audio-processor.sh +87 -43
- package/.claude/hooks/bmad-speak.sh +184 -27
- package/.claude/hooks/play-tts-enhanced.sh +40 -5
- package/.claude/hooks/play-tts-macos.sh +29 -6
- package/.claude/hooks/play-tts-piper.sh +174 -67
- package/.claude/hooks/play-tts-soprano.sh +42 -6
- package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
- package/.claude/hooks/play-tts.sh +12 -9
- package/.claude/hooks/session-start-tts.sh +10 -0
- package/.claude/hooks/stop-tts.sh +84 -0
- package/.claude/hooks/tts-queue-worker.sh +51 -20
- package/.claude/hooks/tts-queue.sh +37 -8
- package/.claude/hooks/voice-manager.sh +5 -1
- package/CLAUDE.md +0 -11
- package/README.md +176 -78
- package/RELEASE_NOTES.md +1197 -60
- package/bin/agentvibes-voice-browser.js +35 -21
- package/mcp-server/server.py +36 -0
- package/package.json +1 -3
- package/src/console/app.js +23 -5
- package/src/console/constants/personalities.js +44 -0
- package/src/console/footer-config.js +8 -0
- package/src/console/navigation.js +3 -1
- package/src/console/tabs/agents-tab.js +1219 -72
- package/src/console/tabs/install-tab.js +2 -1
- package/src/console/tabs/placeholder-tab.js +9 -1
- package/src/console/tabs/receiver-tab.js +1212 -0
- package/src/console/tabs/settings-tab.js +33 -323
- package/src/console/widgets/destroy-list.js +25 -0
- package/src/console/widgets/format-utils.js +89 -0
- package/src/console/widgets/notice.js +55 -0
- package/src/console/widgets/personality-picker.js +185 -0
- package/src/console/widgets/reverb-picker.js +94 -0
- package/src/console/widgets/track-picker.js +285 -0
- package/src/installer.js +54 -2
- package/src/services/agent-voice-store.js +282 -22
- package/src/services/config-service.js +24 -0
- package/src/services/navigation-service.js +1 -1
- package/src/utils/music-file-validator.js +41 -31
- 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
|
|
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
|
};
|