agentvibes 5.1.3 → 5.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 (34) hide show
  1. package/.agentvibes/config.json +23 -13
  2. package/.claude/commands/agent-vibes/verbosity.md +98 -89
  3. package/.claude/config/audio-effects.cfg +6 -1
  4. package/.claude/hooks/bmad-speak.sh +2 -2
  5. package/.claude/hooks/piper-download-voices.sh +233 -225
  6. package/.claude/hooks/piper-installer.sh +1 -1
  7. package/.claude/hooks/piper-voice-manager.sh +125 -0
  8. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +97 -90
  9. package/.claude/hooks/play-tts-enhanced.sh +1 -1
  10. package/.claude/hooks/play-tts-piper.sh +16 -5
  11. package/.claude/hooks/play-tts-ssh-remote.sh +168 -167
  12. package/.claude/hooks/play-tts.sh +31 -9
  13. package/.claude/hooks/session-start-tts.sh +4 -1
  14. package/.claude/hooks/stop-tts.sh +1 -1
  15. package/.claude/hooks/verbosity-manager.sh +185 -178
  16. package/.claude/hooks-windows/download-extra-voices.ps1 +243 -185
  17. package/.claude/hooks-windows/play-tts-piper.ps1 +7 -2
  18. package/.claude/hooks-windows/play-tts.ps1 +219 -65
  19. package/.claude/hooks-windows/session-start-tts.ps1 +2 -1
  20. package/.claude/hooks-windows/verbosity-manager.ps1 +126 -119
  21. package/README.md +24 -1
  22. package/RELEASE_NOTES.md +113 -0
  23. package/bin/agentvibes-voice-browser.js +1939 -1840
  24. package/mcp-server/server.py +75 -25
  25. package/package.json +1 -1
  26. package/src/console/tabs/receiver-tab.js +1527 -1483
  27. package/src/console/tabs/settings-tab.js +2 -2
  28. package/src/console/tabs/setup-tab.js +122 -20
  29. package/src/console/tabs/voices-tab.js +130 -13
  30. package/src/i18n/en.js +202 -202
  31. package/src/installer.js +29 -25
  32. package/src/services/llm-provider-service.js +114 -11
  33. package/src/services/verbosity-service.js +159 -157
  34. package/templates/agentvibes-receiver.sh +3 -2
@@ -1,1483 +1,1527 @@
1
- /**
2
- * AgentVibes TUI Console — Receiver Tab
3
- * SSH Receiver — setup, enable/disable, and live message monitor.
4
- *
5
- * Implements the Tab Component Contract:
6
- * createReceiverTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
- *
8
- * Uses scrollable text boxes (not lists) so users can highlight and copy
9
- * with their mouse in the terminal.
10
- */
11
-
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, chmodSync, unlinkSync, watchFile, unwatchFile } from 'node:fs';
13
- import { execSync, spawnSync, spawn } from 'node:child_process';
14
- import path from 'node:path';
15
- import { homedir } from 'node:os';
16
- import { fileURLToPath } from 'node:url';
17
- import { t } from '../../i18n/strings.js';
18
-
19
- const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
20
-
21
- let blessed;
22
- if (!IS_TEST) {
23
- const { default: b } = await import('blessed');
24
- blessed = b;
25
- }
26
-
27
- // ---------------------------------------------------------------------------
28
-
29
- const COLORS = {
30
- contentBg: '#0a0e1a',
31
- sectionHdr: '#00897b',
32
- labelFg: '#e3f2fd',
33
- valueFg: '#ffff00',
34
- activeFg: '#80cbc4',
35
- borderFg: '#00897b',
36
- footerBg: '#00897b',
37
- noticeFg: '#90a4ae',
38
- };
39
-
40
- const FOOTER_TEXT = 'SSH Receiver [Q] Quit';
41
-
42
- // ---------------------------------------------------------------------------
43
-
44
- function createTestStub() {
45
- return {
46
- box: {},
47
- show: () => {},
48
- hide: () => {},
49
- onFocus: () => {},
50
- onBlur: () => {},
51
- getFooterText: () => FOOTER_TEXT,
52
- getFooterColor: () => COLORS.footerBg,
53
- };
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
-
58
- const _thisDir = IS_TEST ? '' : path.dirname(fileURLToPath(import.meta.url));
59
- const TEMPLATE_PATH = IS_TEST ? '' : path.resolve(_thisDir, '..', '..', '..', 'templates', 'agentvibes-receiver.sh');
60
-
61
- /**
62
- * Get the machine's Tailscale IP (if available) and SSH port.
63
- */
64
- function _getNetworkInfo() {
65
- let tailscaleIp = '';
66
- let localIp = '';
67
- let sshPort = '22';
68
- try {
69
- tailscaleIp = execSync('tailscale ip -4 2>/dev/null', { timeout: 3000 }).toString().trim();
70
- } catch { /* tailscale not installed */ }
71
- try {
72
- localIp = execSync("hostname -I 2>/dev/null | awk '{print $1}'", { timeout: 3000 }).toString().trim();
73
- } catch { /* ignore */ }
74
- try {
75
- const portLine = execSync("grep -E '^Port ' /etc/ssh/sshd_config 2>/dev/null || echo 'Port 22'", { timeout: 3000 }).toString().trim();
76
- const m = portLine.match(/^Port\s+(\d+)/);
77
- if (m) sshPort = m[1];
78
- } catch { /* default 22 */ }
79
- return { tailscaleIp, localIp, sshPort };
80
- }
81
-
82
- /**
83
- * Detect current receiver setup state — returns an object with boolean checks.
84
- * Used to determine whether instructions should show full setup or just verification.
85
- */
86
- function _detectSetupState() {
87
- const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
88
- const state = {
89
- receiverUserExists: false,
90
- receiverScriptInstalled: false,
91
- voiceModelsPresent: false,
92
- pipewireTcpConfigured: false,
93
- flatVolumesDisabled: false,
94
- pulseCookieShared: false,
95
- forceCommandConfigured: false,
96
- tcpModuleLoaded: false,
97
- isWindows: isWin,
98
- sshdRunning: false,
99
- ffmpegInstalled: false,
100
- piperInstalled: false,
101
- };
102
- try {
103
- if (isWin) {
104
- // Windows detection
105
- const home = homedir();
106
- state.receiverScriptInstalled = existsSync(path.join(home, '.agentvibes', 'play-remote.ps1'));
107
- state.receiverUserExists = true; // Windows uses the current user, no separate user needed
108
-
109
- // Check voice models
110
- const voicesDir = path.join(home, '.claude', 'piper-voices');
111
- try {
112
- const files = require('fs').readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
113
- state.voiceModelsPresent = files.length > 0;
114
- } catch { /* no voices */ }
115
-
116
- // Check sshd running
117
- try {
118
- const svc = execSync('powershell -NoProfile -Command "(Get-Service sshd -EA SilentlyContinue).Status"',
119
- { timeout: 5000, stdio: 'pipe' }).toString().trim();
120
- state.sshdRunning = svc === 'Running';
121
- } catch { /* sshd not installed */ }
122
-
123
- // Check ForceCommand in Windows sshd_config
124
- try {
125
- const sshdConf = readFileSync('C:\\ProgramData\\ssh\\sshd_config', 'utf-8');
126
- state.forceCommandConfigured = sshdConf.includes('ForceCommand') && sshdConf.includes('play-remote.ps1');
127
- } catch { /* no read access */ }
128
-
129
- // Check ffmpeg
130
- try {
131
- execSync('where ffmpeg', { timeout: 3000, stdio: 'pipe' });
132
- state.ffmpegInstalled = true;
133
- } catch { /* not found */ }
134
-
135
- // Check piper
136
- try {
137
- execSync('where piper', { timeout: 3000, stdio: 'pipe' });
138
- state.piperInstalled = true;
139
- } catch {
140
- const piperPath = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Piper', 'piper.exe');
141
- state.piperInstalled = existsSync(piperPath);
142
- }
143
-
144
- // Windows doesn't need PipeWire/PulseAudio — mark as N/A
145
- state.pipewireTcpConfigured = true;
146
- state.flatVolumesDisabled = true;
147
- state.pulseCookieShared = true;
148
- state.tcpModuleLoaded = true;
149
- } else {
150
- // Linux/macOS detection (original)
151
- let receiverHome = '';
152
- try {
153
- execSync('id agentvibes-receiver', { timeout: 3000, stdio: 'pipe' });
154
- state.receiverUserExists = true;
155
- try {
156
- receiverHome = execSync("getent passwd agentvibes-receiver 2>/dev/null | cut -d: -f6 || echo '/home/agentvibes-receiver'",
157
- { timeout: 3000, stdio: 'pipe' }).toString().trim();
158
- } catch { receiverHome = '/home/agentvibes-receiver'; }
159
- } catch { /* user does not exist */ }
160
-
161
- if (receiverHome) {
162
- state.receiverScriptInstalled = existsSync(path.join(receiverHome, '.agentvibes/play-remote.sh'));
163
- }
164
-
165
- if (receiverHome) {
166
- try {
167
- const voices = execSync(`ls ${receiverHome}/.claude/piper-voices/*.onnx 2>/dev/null | wc -l`,
168
- { timeout: 3000, stdio: 'pipe' }).toString().trim();
169
- state.voiceModelsPresent = parseInt(voices, 10) > 0;
170
- } catch { /* no access or no voices */ }
171
- }
172
-
173
- const home = homedir();
174
- state.pipewireTcpConfigured = existsSync(
175
- path.join(home, '.config/pipewire/pipewire-pulse.conf.d/agentvibes-tcp.conf'));
176
- state.flatVolumesDisabled = existsSync(
177
- path.join(home, '.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf'));
178
-
179
- if (receiverHome) {
180
- state.pulseCookieShared = existsSync(path.join(receiverHome, '.config/pulse/cookie'));
181
- }
182
-
183
- try {
184
- const sshdConf = readFileSync('/etc/ssh/sshd_config', 'utf-8');
185
- state.forceCommandConfigured = sshdConf.includes('Match User agentvibes-receiver');
186
- } catch { /* no read access */ }
187
-
188
- try {
189
- const modules = execSync('pactl list modules short 2>/dev/null', { timeout: 3000, stdio: 'pipe' }).toString();
190
- state.tcpModuleLoaded = modules.includes('module-native-protocol-tcp');
191
- } catch { /* pactl not available */ }
192
- }
193
- } catch { /* detection failed, assume not set up */ }
194
- return state;
195
- }
196
-
197
- /**
198
- * Build detailed setup instructions (cross-platform).
199
- * Organized: explanation → server instructions (for copying) → local setup.
200
- * Designed to be self-contained so an AI agent can execute all steps.
201
- * Detects existing setup and shows verification-only instructions when ready.
202
- */
203
- function _buildDetailedInstructions(receiverAlias, receiverScript, networkInfo) {
204
- // Show detected values as hints but always use placeholders in instructions
205
- // so the AI agent asks the user to confirm/provide their actual values
206
- const detectedIp = networkInfo.tailscaleIp || networkInfo.localIp || '';
207
- const detectedPort = networkInfo.sshPort || '22';
208
- const state = _detectSetupState();
209
- const isWin = state.isWindows;
210
- const allReady = isWin
211
- ? (state.receiverScriptInstalled && state.voiceModelsPresent &&
212
- state.sshdRunning && state.forceCommandConfigured)
213
- : (state.receiverUserExists && state.receiverScriptInstalled &&
214
- state.voiceModelsPresent && state.pipewireTcpConfigured &&
215
- state.flatVolumesDisabled && state.pulseCookieShared &&
216
- state.forceCommandConfigured && state.tcpModuleLoaded);
217
-
218
- // Build status header showing what's detected
219
- const check = (ok) => ok ? '[OK]' : '[--]';
220
- const statusLines = isWin ? [
221
- '============================================================',
222
- 'SETUP STATUS Windows (auto-detected)',
223
- '============================================================',
224
- '',
225
- ' ' + check(state.sshdRunning) + ' OpenSSH Server running',
226
- ' ' + check(state.forceCommandConfigured) + ' SSH ForceCommand configured',
227
- ' ' + check(state.receiverScriptInstalled) + ' Receiver script (play-remote.ps1)',
228
- ' ' + check(state.voiceModelsPresent) + ' Piper voice models installed',
229
- ' ' + check(state.piperInstalled) + ' Piper TTS installed',
230
- ' ' + check(state.ffmpegInstalled) + ' ffmpeg installed (background music)',
231
- '',
232
- ] : [
233
- '============================================================',
234
- 'SETUP STATUS (auto-detected)',
235
- '============================================================',
236
- '',
237
- ' ' + check(state.receiverUserExists) + ' Receiver user (agentvibes-receiver)',
238
- ' ' + check(state.receiverScriptInstalled) + ' Receiver script (play-remote.sh)',
239
- ' ' + check(state.voiceModelsPresent) + ' Voice models copied',
240
- ' ' + check(state.pipewireTcpConfigured) + ' PipeWire TCP audio (port 34567)',
241
- ' ' + check(state.flatVolumesDisabled) + ' Flat-volumes disabled',
242
- ' ' + check(state.pulseCookieShared) + ' PulseAudio cookie shared',
243
- ' ' + check(state.forceCommandConfigured) + ' SSH ForceCommand configured',
244
- ' ' + check(state.tcpModuleLoaded) + ' TCP audio module loaded',
245
- '',
246
- ];
247
-
248
- if (allReady) {
249
- if (isWin) {
250
- return [
251
- 'Press [A] to copy all text to your clipboard.',
252
- '',
253
- ...statusLines,
254
- 'All checks passed! Windows receiver is ready.',
255
- '',
256
- '============================================================',
257
- 'SERVER SETUP (the remote machine running Claude)',
258
- '============================================================',
259
- '',
260
- ' 1. Add SSH alias (~/.ssh/config on the server):',
261
- '',
262
- ' Host <RECEIVER_NAME>',
263
- ' HostName ' + (detectedIp || '<RECEIVER_IP>'),
264
- ' Port 45123',
265
- ' User ' + (process.env.USERNAME || '<WINDOWS_USER>'),
266
- ' IdentityFile ~/.ssh/id_ed25519',
267
- '',
268
- ' 2. Tell AgentVibes where to send TTS:',
269
- ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
270
- '',
271
- ' 3. Switch to ssh-remote provider:',
272
- ' echo "ssh-remote" > .claude/tts-provider.txt',
273
- '',
274
- '',
275
- '============================================================',
276
- 'VERIFICATION',
277
- '============================================================',
278
- '',
279
- 'From the server:',
280
- ' echo \'{"text":"Hello","voice":"en_US-lessac-medium"}\' \\',
281
- ' | base64 | xargs ssh <RECEIVER_NAME>',
282
- '',
283
- 'Check log: type %USERPROFILE%\\.agentvibes\\receiver.log',
284
- '',
285
- ].join('\n');
286
- }
287
- return [
288
- 'Press [A] to copy all text to your clipboard.',
289
- '',
290
- ...statusLines,
291
- 'All checks passed! Setup is complete.',
292
- 'Below are verification tests and server-side instructions.',
293
- '',
294
- '',
295
- '============================================================',
296
- 'SERVER SETUP (the remote machine running Claude)',
297
- '============================================================',
298
- 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
299
- '',
300
- '1. Add an SSH alias for the receiver (~/.ssh/config):',
301
- '',
302
- ' Host <RECEIVER_NAME>',
303
- ' HostName <RECEIVER_IP>',
304
- detectedIp ? ' # detected: ' + detectedIp : '',
305
- ' Port ' + detectedPort,
306
- ' User agentvibes-receiver',
307
- ' IdentityFile ~/.ssh/id_ed25519',
308
- '',
309
- '2. Tell AgentVibes where to send TTS:',
310
- ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
311
- '',
312
- '3. Switch to ssh-remote provider:',
313
- ' echo "ssh-remote" > .claude/tts-provider.txt',
314
- '',
315
- '',
316
- '============================================================',
317
- 'VERIFICATION',
318
- '============================================================',
319
- '',
320
- 'Test from server:',
321
- ' echo \'{"text":"Hello","voice":"en_US-lessac-medium"}\' \\',
322
- ' | base64 | xargs ssh <RECEIVER_NAME>',
323
- '',
324
- 'Full pipeline:',
325
- ' bash .claude/hooks/play-tts.sh "Receiver test complete"',
326
- '',
327
- ].join('\n');
328
- }
329
-
330
- // Full setup instructions (when not everything is detected)
331
-
332
- // ---- WINDOWS INSTRUCTIONS ----
333
- if (isWin) {
334
- return [
335
- 'Press [A] to copy all text to your clipboard.',
336
- 'Give this to an AI agent on your server to set up the sender.',
337
- '',
338
- ...statusLines,
339
- '',
340
- '============================================================',
341
- 'BEFORE YOU BEGIN — Ask the user for these values:',
342
- '============================================================',
343
- '',
344
- ' RECEIVER_IP: This Windows machine\'s Tailscale IP',
345
- detectedIp ? ' (detected: ' + detectedIp + ' — ask user to confirm)' : ' (run: tailscale ip -4)',
346
- ' RECEIVER_PORT: 45123 (hardened non-standard port)',
347
- ' RECEIVER_NAME: SSH alias name (default: my-receiver)',
348
- ' WINDOWS_USER: Windows username (e.g. Paul)',
349
- '',
350
- '',
351
- '============================================================',
352
- 'WHAT IS SSH RECEIVER?',
353
- '============================================================',
354
- '',
355
- 'AgentVibes SSH Receiver lets remote servers (cloud VPS, dev',
356
- 'servers) send TTS audio to this Windows machine. The server',
357
- 'sends text + voice config over SSH, and this machine generates',
358
- 'and plays audio locally through its speakers.',
359
- '',
360
- 'Server AI --[SSH/Tailscale]--> Windows --[Piper+ffmpeg]--> Speakers',
361
- '',
362
- 'Security: SSH is hardened with key-only auth, ForceCommand',
363
- '(no shell access), non-standard port, Tailscale-only binding.',
364
- '',
365
- '',
366
- '============================================================',
367
- 'PART 1: WINDOWS RECEIVER SETUP (this machine)',
368
- '============================================================',
369
- 'Setup script: setup-ssh-receiver.ps1',
370
- 'Receiver script: templates/agentvibes-receiver.ps1',
371
- '',
372
- 'Step 1: Install prerequisites (if not already done)',
373
- '',
374
- ' a) Install Tailscale (for secure networking):',
375
- ' winget install --id Tailscale.Tailscale -e',
376
- ' Then sign in with your Tailscale account.',
377
- '',
378
- ' b) Install OpenSSH Server (admin PowerShell):',
379
- ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0',
380
- '',
381
- ' c) Install Piper TTS and ffmpeg (if not installed):',
382
- ' Run the AgentVibes installer: node bin/agent-vibes install',
383
- ' It will check and install ffmpeg automatically.',
384
- '',
385
- 'Step 2: Run the automated setup script (admin PowerShell)',
386
- '',
387
- ' cd C:\\path\\to\\AgentVibes',
388
- ' powershell -ExecutionPolicy Bypass -File setup-ssh-receiver.ps1',
389
- '',
390
- ' This script handles everything:',
391
- ' - Deploys hardened sshd_config (port 45123, key-only, ForceCommand)',
392
- ' - Installs receiver script to ~/.agentvibes/play-remote.ps1',
393
- ' - Adds firewall rule (Tailscale IPs only)',
394
- ' - Restarts sshd',
395
- '',
396
- 'Step 3: Add the sender\'s SSH public key',
397
- '',
398
- ' Get the public key from your server:',
399
- ' ssh your-server "cat ~/.ssh/id_ed25519.pub"',
400
- '',
401
- ' Then in admin PowerShell on this Windows machine:',
402
- ' Set-Content -Path "C:\\ProgramData\\ssh\\administrators_authorized_keys" `',
403
- ' -Value "paste-the-public-key-here"',
404
- ' cmd /c \'icacls C:\\ProgramData\\ssh\\administrators_authorized_keys `',
405
- ' /inheritance:r /grant "SYSTEM:F" /grant "BUILTIN\\Administrators:F"\'',
406
- ' Restart-Service sshd',
407
- '',
408
- 'Step 4: Security hardening details',
409
- '',
410
- ' The setup script configures:',
411
- ' Port: 45123 (non-standard)',
412
- ' ListenAddress: Tailscale IP only (not 0.0.0.0)',
413
- ' Auth: SSH key only (no passwords)',
414
- ' ForceCommand: Can ONLY run the receiver script (no shell)',
415
- ' Forwarding: All disabled (TCP, agent, X11, tunnel)',
416
- ' Firewall: Port 45123 from 100.0.0.0/8 (Tailscale) only',
417
- '',
418
- '',
419
- '============================================================',
420
- 'PART 2: SERVER SETUP (the remote machine running Claude)',
421
- '============================================================',
422
- '',
423
- ' 1. Add SSH alias (~/.ssh/config on the server):',
424
- '',
425
- ' Host <RECEIVER_NAME>',
426
- ' HostName <RECEIVER_IP>',
427
- ' Port 45123',
428
- ' User <WINDOWS_USER>',
429
- ' IdentityFile ~/.ssh/id_ed25519',
430
- '',
431
- ' 2. Tell AgentVibes where to send TTS:',
432
- ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
433
- '',
434
- ' 3. Switch to ssh-remote provider:',
435
- ' echo "ssh-remote" > .claude/tts-provider.txt',
436
- '',
437
- '',
438
- '============================================================',
439
- 'PART 3: VERIFICATION',
440
- '============================================================',
441
- '',
442
- 'Test from the server:',
443
- '',
444
- ' echo \'{"text":"Hello from server","voice":"en_US-lessac-medium"}\' \\',
445
- ' | base64 | xargs ssh <RECEIVER_NAME>',
446
- '',
447
- ' Expected: Audio plays on Windows speakers.',
448
- ' Check log: type %USERPROFILE%\\.agentvibes\\receiver.log',
449
- '',
450
- 'Test full AgentVibes pipeline:',
451
- ' bash .claude/hooks/play-tts.sh "Receiver test complete"',
452
- '',
453
- ].join('\n');
454
- }
455
-
456
- // ---- LINUX/macOS INSTRUCTIONS (original) ----
457
- return [
458
- 'Press [A] to copy all text to your clipboard.',
459
- 'Give this to an AI agent on your server AND local machine',
460
- 'to set up and verify the SSH receiver end-to-end.',
461
- '',
462
- ...statusLines,
463
- '',
464
- '============================================================',
465
- 'BEFORE YOU BEGIN — Ask the user for these values:',
466
- '============================================================',
467
- '',
468
- ' RECEIVER_IP: The receiver machine IP or Tailscale hostname',
469
- detectedIp ? ' (detected: ' + detectedIp + ' — ask user to confirm)' : ' (could not auto-detect — user must provide)',
470
- ' RECEIVER_PORT: The SSH port on the receiver machine',
471
- ' (detected: ' + detectedPort + ' — ask user to confirm)',
472
- ' RECEIVER_NAME: SSH alias name (default: my-receiver)',
473
- ' TTS_PROVIDER: Which TTS engine on the receiver?',
474
- ' Options: piper (default, Linux), soprano (neural TTS, any platform),',
475
- ' macos (macOS say), windows-sapi (Windows SAPI)',
476
- '',
477
- 'Replace <RECEIVER_IP>, <RECEIVER_PORT>, <RECEIVER_NAME>,',
478
- 'and <TTS_PROVIDER> in commands below with user-provided values.',
479
- '',
480
- '',
481
- '============================================================',
482
- 'WHAT IS SSH RECEIVER?',
483
- '============================================================',
484
- '',
485
- 'AgentVibes SSH Receiver lets remote servers (cloud VPS, dev',
486
- 'servers) send TTS audio to your local machine. The server AI',
487
- 'sends text + voice config over SSH, and this machine generates',
488
- 'and plays audio locally through its speakers.',
489
- '',
490
- 'Server AI --[SSH payload]--> This Machine --[piper+sox+ffmpeg]--> Speakers',
491
- '',
492
- 'Prerequisites:',
493
- ' - Both machines can reach each other via SSH (Tailscale VPN',
494
- ' recommended for easy setup: https://tailscale.com/download)',
495
- ' - A TTS engine on the receiver (this machine):',
496
- ' Linux: piper-tts (default, offline, fast)',
497
- ' Windows: soprano or windows-sapi (uses system voices)',
498
- ' macOS: macos (uses built-in say command)',
499
- ' - sox and ffmpeg recommended for audio effects and music',
500
- '',
501
- '',
502
- '============================================================',
503
- 'PART 1: LOCAL SETUP (this machine — the receiver)',
504
- '============================================================',
505
- 'Setup script: scripts/setup-receiver-user.sh',
506
- 'Receiver template: templates/agentvibes-receiver.sh',
507
- 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
508
- '',
509
- 'Run these steps on the machine that has speakers attached.',
510
- '',
511
- 'IMPORTANT: Steps that require sudo cannot be run by an AI',
512
- 'agent. The USER must run sudo commands in a separate terminal.',
513
- 'The AI agent should tell the user what to run, then verify',
514
- 'the results using the detection checks shown above.',
515
- '',
516
- '--- Option A: Automated Setup (recommended) ---',
517
- '',
518
- 'Ask the USER to run this in a separate terminal:',
519
- '',
520
- ' sudo bash /path/to/AgentVibes/scripts/setup-receiver-user.sh',
521
- '',
522
- 'This single script handles everything:',
523
- ' - Creates agentvibes-receiver user (groups: audio + your group)',
524
- ' - Copies piper voice models and music tracks',
525
- ' - Installs the receiver script (play-remote.sh)',
526
- ' - Configures PipeWire TCP audio on localhost:34567',
527
- ' - Disables flat-volumes (prevents volume hijacking)',
528
- ' - Shares PulseAudio cookie for cross-user auth',
529
- ' - Tests audio playback',
530
- '',
531
- 'After the user confirms it ran successfully, verify with:',
532
- ' id agentvibes-receiver # user exists?',
533
- ' ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx # voices?',
534
- ' pactl list modules short | grep tcp # TCP module?',
535
- '',
536
- 'Then skip to Step 3 (ForceCommand) below.',
537
- '',
538
- '--- Option B: Manual Setup (step by step) ---',
539
- '',
540
- 'Step 1: Enable receiver script',
541
- ' Press [E] in this tab (installs play-remote.sh to ~/.agentvibes/)',
542
- '',
543
- 'Step 2: Create the receiver user',
544
- '',
545
- ' Ask the USER to run these sudo commands in a terminal:',
546
- '',
547
- ' Linux/WSL:',
548
- ' sudo useradd -m -s /bin/bash agentvibes-receiver',
549
- ' sudo usermod -aG audio,$(id -gn) agentvibes-receiver',
550
- ' # Create directories for voices and music:',
551
- ' sudo mkdir -p /home/agentvibes-receiver/.claude/piper-voices',
552
- ' sudo mkdir -p /home/agentvibes-receiver/.claude/audio/tracks',
553
- ' sudo mkdir -p /home/agentvibes-receiver/.agentvibes',
554
- ' # Copy voice models (required for TTS):',
555
- ' sudo cp ~/.claude/piper-voices/*.onnx /home/agentvibes-receiver/.claude/piper-voices/',
556
- ' sudo cp ~/.claude/piper-voices/*.onnx.json /home/agentvibes-receiver/.claude/piper-voices/',
557
- ' # Copy music tracks (optional, for background music):',
558
- ' sudo cp ~/.claude/audio/tracks/*.mp3 /home/agentvibes-receiver/.claude/audio/tracks/ 2>/dev/null',
559
- ' # Install receiver script:',
560
- ' sudo cp ~/.agentvibes/play-remote.sh /home/agentvibes-receiver/.agentvibes/play-remote.sh',
561
- ' sudo chmod +x /home/agentvibes-receiver/.agentvibes/play-remote.sh',
562
- ' # Fix ownership:',
563
- ' sudo chown -R agentvibes-receiver:agentvibes-receiver /home/agentvibes-receiver/.claude',
564
- ' sudo chown -R agentvibes-receiver:agentvibes-receiver /home/agentvibes-receiver/.agentvibes',
565
- '',
566
- ' macOS:',
567
- ' sudo dscl . -create /Users/agentvibes-receiver',
568
- ' sudo dscl . -create /Users/agentvibes-receiver UserShell /bin/bash',
569
- ' sudo dscl . -create /Users/agentvibes-receiver NFSHomeDirectory /Users/agentvibes-receiver',
570
- ' sudo mkdir -p /Users/agentvibes-receiver',
571
- ' # Enable Remote Login in System Settings > General > Sharing',
572
- ' # Then copy voices/script as above (adjust paths to /Users/)',
573
- '',
574
- ' AI agent: After user confirms, verify:',
575
- ' id agentvibes-receiver # user exists?',
576
- ' ls /home/agentvibes-receiver/.agentvibes/play-remote.sh # script?',
577
- ' ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx # voices?',
578
- '',
579
- 'Step 3: ForceCommand (recommended — limits SSH to audio only)',
580
- '',
581
- ' Ask the USER to add this to /etc/ssh/sshd_config:',
582
- '',
583
- ' Match User agentvibes-receiver',
584
- ' ForceCommand ' + receiverScript,
585
- ' PasswordAuthentication no',
586
- ' PermitTTY no',
587
- '',
588
- ' Then ask user to reload sshd:',
589
- ' Linux/WSL: sudo systemctl reload sshd',
590
- ' macOS: sudo launchctl kickstart -k system/com.openssh.sshd',
591
- '',
592
- ' AI agent: Verify (no sudo needed):',
593
- ' grep "Match User agentvibes-receiver" /etc/ssh/sshd_config',
594
- '',
595
- 'Step 4: Audio access (required for dedicated user)',
596
- '',
597
- ' The receiver user runs as a different UID and cannot access',
598
- ' your audio via Unix sockets. TCP localhost solves this.',
599
- '',
600
- ' Linux (PipeWire — most modern distros):',
601
- '',
602
- ' a) Enable TCP audio listener (AI agent CAN do this — no sudo):',
603
- ' mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d',
604
- ' cat > ~/.config/pipewire/pipewire-pulse.conf.d/agentvibes-tcp.conf << \'EOF\'',
605
- ' pulse.cmd = [',
606
- ' { cmd = "load-module" args = "module-native-protocol-tcp auth-cookie-enabled=1 auth-anonymous=0 listen=127.0.0.1 port=34567" }',
607
- ' ]',
608
- ' EOF',
609
- '',
610
- ' b) Disable flat-volumes (AI agent CAN do this — no sudo):',
611
- ' cat > ~/.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf << \'EOF\'',
612
- ' pulse.properties = {',
613
- ' pulse.flat.volumes = false',
614
- ' }',
615
- ' EOF',
616
- '',
617
- ' c) Share PulseAudio cookie (REQUIRES sudoask USER):',
618
- ' sudo mkdir -p /home/agentvibes-receiver/.config/pulse',
619
- ' sudo cp ~/.config/pulse/cookie /home/agentvibes-receiver/.config/pulse/',
620
- ' sudo chown -R agentvibes-receiver:agentvibes-receiver \\',
621
- ' /home/agentvibes-receiver/.config/pulse',
622
- ' sudo chmod 600 /home/agentvibes-receiver/.config/pulse/cookie',
623
- '',
624
- ' d) Load TCP module now (AI agent CAN do this — no sudo):',
625
- ' pactl load-module module-native-protocol-tcp \\',
626
- ' auth-cookie-enabled=1 auth-anonymous=0 \\',
627
- ' listen=127.0.0.1 port=34567',
628
- '',
629
- ' AI agent: Verify audio setup:',
630
- ' pactl list modules short | grep tcp # TCP loaded?',
631
- ' ls /home/agentvibes-receiver/.config/pulse/cookie # cookie?',
632
- ' PULSE_SERVER=tcp:127.0.0.1:34567 pactl info # TCP works?',
633
- '',
634
- ' Linux (PulseAudio older distros):',
635
- ' # Add to /etc/pulse/default.pa or ~/.config/pulse/default.pa:',
636
- ' load-module module-native-protocol-tcp auth-cookie-enabled=1 \\',
637
- ' auth-anonymous=0 listen=127.0.0.1 port=34567',
638
- ' # Then share the cookie as above (step c — requires sudo)',
639
- ' # Restart: pulseaudio --kill && pulseaudio --start',
640
- '',
641
- ' macOS:',
642
- ' # macOS uses coreaudiod which is system-wide.',
643
- ' # The receiver user should have audio access if in the',
644
- ' # "audio" group. No TCP workaround needed.',
645
- '',
646
- ' WSL2:',
647
- ' # Audio routes through WSLg PulseServer at /mnt/wslg/PulseServer.',
648
- ' # Set in receiver script: export PULSE_SERVER=unix:/mnt/wslg/PulseServer',
649
- ' # Cross-user access may require the TCP approach above.',
650
- '',
651
- 'Step 5: Add server SSH key',
652
- '',
653
- ' On the server, generate a key if needed:',
654
- ' ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""',
655
- '',
656
- ' Copy the public key to the receiver:',
657
- ' ssh-copy-id -i ~/.ssh/id_ed25519.pub \\',
658
- ' agentvibes-receiver@<RECEIVER_IP>',
659
- '',
660
- '',
661
- '============================================================',
662
- 'PART 2: SERVER SETUP (the remote machine running Claude)',
663
- '============================================================',
664
- 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
665
- 'Config file: .agentvibes/config/agentvibes.json',
666
- '',
667
- 'Run these steps on the remote server that needs TTS.',
668
- '',
669
- '1. Add an SSH alias for the receiver (~/.ssh/config):',
670
- '',
671
- ' Host <RECEIVER_NAME>',
672
- ' HostName <RECEIVER_IP>',
673
- ' Port <RECEIVER_PORT>',
674
- ' User agentvibes-receiver',
675
- ' IdentityFile ~/.ssh/id_ed25519',
676
- '',
677
- '2. Tell AgentVibes where to send TTS:',
678
- '',
679
- ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
680
- '',
681
- '3. Switch to the ssh-remote provider:',
682
- '',
683
- ' # In .agentvibes/config/agentvibes.json set "provider": "ssh-remote"',
684
- ' # Or run: agentvibes provider switch ssh-remote',
685
- '',
686
- 'The sender hook at .claude/hooks/play-tts-ssh-remote.sh',
687
- 'bundles voice, effects, and music into a single JSON payload',
688
- 'and sends it over SSH. No TTS software needed on the server.',
689
- '',
690
- '',
691
- '============================================================',
692
- 'PART 3: VERIFICATION (test end-to-end)',
693
- '============================================================',
694
- '',
695
- 'Use tmux to test both sides simultaneously:',
696
- '',
697
- ' tmux new-session -d -s agentvibes-verify',
698
- ' # Left pane: watch receiver log on LOCAL machine',
699
- ' tmux send-keys "tail -f /home/agentvibes-receiver/.agentvibes/receiver.log \\',
700
- ' || tail -f ~/.agentvibes/receiver.log" Enter',
701
- ' # Right pane: send test from SERVER',
702
- ' tmux split-window -h',
703
- ' tmux send-keys "ssh <your-server>" Enter',
704
- ' tmux attach -t agentvibes-verify',
705
- '',
706
- 'Then in the server pane, run these tests in order:',
707
- '',
708
- 'Test 1 — SSH connectivity:',
709
- ' ssh <RECEIVER_NAME> "echo hello"',
710
- ' # Expected: ForceCommand runs, you see RECEIVED in the log pane',
711
- '',
712
- 'Test 2 TTS from server:',
713
- ' echo \'{"text":"Hello from server test","voice":"en_US-lessac-medium"}\' \\',
714
- ' | base64 | xargs ssh <RECEIVER_NAME>',
715
- ' # Expected: Audio plays on receiver speakers, log shows DONE',
716
- '',
717
- 'Test 3 — Full AgentVibes pipeline:',
718
- ' bash .claude/hooks/play-tts.sh "Testing AgentVibes receiver"',
719
- ' # Expected: TTS with configured voice, effects, and music',
720
- '',
721
- 'Or test locally on the receiver machine without SSH:',
722
- '',
723
- ' sudo -u agentvibes-receiver \\',
724
- ' PULSE_SERVER=tcp:127.0.0.1:34567 \\',
725
- ' paplay /usr/share/sounds/freedesktop/stereo/bell.oga',
726
- ' # Expected: Bell sound plays through your speakers',
727
- '',
728
- ' sudo -u agentvibes-receiver \\',
729
- ' PULSE_SERVER=tcp:127.0.0.1:34567 \\',
730
- ' /home/agentvibes-receiver/.agentvibes/play-remote.sh \\',
731
- ' "$(echo \'{"text":"Local pipeline test","voice":"en_US-lessac-medium"}\' | base64)"',
732
- ' # Expected: TTS audio plays, receiver.log shows RECEIVED → PLAYING → DONE',
733
- '',
734
- '',
735
- '============================================================',
736
- 'TROUBLESHOOTING',
737
- '============================================================',
738
- '',
739
- 'SSH connection refused:',
740
- ' - Check sshd is running: systemctl status sshd',
741
- ' - Check firewall allows <RECEIVER_PORT>: sudo ufw status',
742
- ' - Check authorized_keys: cat /home/agentvibes-receiver/.ssh/authorized_keys',
743
- '',
744
- 'No audio / connection refused on audio:',
745
- ' - Check TCP module: pactl list modules short | grep tcp',
746
- ' - Check cookie exists: ls -la /home/agentvibes-receiver/.config/pulse/cookie',
747
- ' - Test TCP directly: PULSE_SERVER=tcp:127.0.0.1:34567 pactl info',
748
- '',
749
- 'Volume hijacked / wrong speaker:',
750
- ' - Verify flat-volumes disabled:',
751
- ' cat ~/.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf',
752
- ' - Select specific sink: echo "sink_name" > \\',
753
- ' /home/agentvibes-receiver/.agentvibes/receiver-sink.txt',
754
- ' - List available sinks: pactl list sinks short',
755
- '',
756
- 'No voice models:',
757
- ' - Check: ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx',
758
- ' - Re-copy: sudo cp ~/.claude/piper-voices/*.onnx* \\',
759
- ' /home/agentvibes-receiver/.claude/piper-voices/',
760
- '',
761
- 'ForceCommand not working:',
762
- ' - Check sshd_config syntax: sudo sshd -t',
763
- ' - Reload sshd: sudo systemctl reload sshd',
764
- ' - Test manually: ssh agentvibes-receiver@localhost',
765
- ].join('\n');
766
- }
767
-
768
- export function createReceiverTab(screen, services) {
769
- if (IS_TEST) return createTestStub();
770
-
771
- const { languageService, focusMainTabBar } = services || {};
772
- const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
773
-
774
- const AGENTVIBES_DIR = path.join(homedir(), '.agentvibes');
775
- const RECEIVER_SCRIPT = path.join(AGENTVIBES_DIR, 'play-remote.sh');
776
- const RECEIVER_ALIAS = 'my-receiver';
777
-
778
- // Log file: check receiver user's home first, fall back to current user's
779
- const RECEIVER_USER_LOG = '/home/agentvibes-receiver/.agentvibes/receiver.log';
780
- const LOCAL_LOG = path.join(AGENTVIBES_DIR, 'receiver.log');
781
- const LOG_FILE = existsSync(RECEIVER_USER_LOG) ? RECEIVER_USER_LOG : LOCAL_LOG;
782
-
783
- // Sink config shared with receiver script via receiver user's home
784
- const RECEIVER_SINK_FILE = '/home/agentvibes-receiver/.agentvibes/receiver-sink.txt';
785
- const LOCAL_SINK_FILE = path.join(AGENTVIBES_DIR, 'receiver-sink.txt');
786
- const SINK_FILE = existsSync('/home/agentvibes-receiver/.agentvibes') ? RECEIVER_SINK_FILE : LOCAL_SINK_FILE;
787
-
788
- // -------------------------------------------------------------------------
789
- // Container
790
-
791
- const box = blessed.box({
792
- parent: screen,
793
- top: 5,
794
- left: 0,
795
- width: '100%',
796
- bottom: 2,
797
- hidden: true,
798
- keys: true,
799
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
800
- border: { type: 'line' },
801
- borderStyle: { fg: COLORS.borderFg },
802
- });
803
-
804
- box.key(['escape'], () => {
805
- if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
806
- });
807
-
808
- // -------------------------------------------------------------------------
809
- // Description text (collapsible)
810
-
811
- const descBox = blessed.box({
812
- parent: box,
813
- top: 0,
814
- left: 2,
815
- width: '96%',
816
- height: 9,
817
- tags: true,
818
- hidden: true,
819
- border: { type: 'line' },
820
- label: ` {bold}${_tl('receiverWhatIsTitle')}{/bold} `,
821
- style: {
822
- fg: COLORS.labelFg,
823
- bg: '#111827',
824
- border: { fg: COLORS.sectionHdr },
825
- },
826
- });
827
-
828
- const descText = blessed.text({
829
- parent: descBox,
830
- top: 0,
831
- left: 1,
832
- tags: true,
833
- content: _tl('receiverDesc'),
834
- style: { fg: '#b0bec5', bg: '#111827' },
835
- });
836
-
837
- blessed.text({
838
- parent: descBox,
839
- top: 6,
840
- right: 2,
841
- tags: true,
842
- content: '{#90a4ae-fg}Press {bold}[?]{/bold} to close{/#90a4ae-fg}',
843
- style: { bg: '#111827' },
844
- });
845
-
846
- // -------------------------------------------------------------------------
847
- // Top: actions row + status row + info row + feedback
848
- // Positions are dynamic — shift down when description is open
849
-
850
- const _topOffset = () => _showDescription ? 10 : 0;
851
-
852
- const actionsLine = blessed.text({
853
- parent: box,
854
- top: 0, // updated dynamically
855
- left: 4,
856
- tags: true,
857
- content: '',
858
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
859
- });
860
-
861
- const statusLine = blessed.text({
862
- parent: box,
863
- top: 1, // updated dynamically
864
- left: 4,
865
- tags: true,
866
- content: '',
867
- style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
868
- });
869
-
870
- const infoLine = blessed.text({
871
- parent: box,
872
- top: 2, // updated dynamically
873
- left: 4,
874
- tags: true,
875
- content: '',
876
- style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
877
- });
878
-
879
- const feedbackLine = blessed.text({
880
- parent: box,
881
- top: 3, // updated dynamically
882
- left: 4,
883
- tags: true,
884
- content: '',
885
- style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
886
- });
887
-
888
- // -------------------------------------------------------------------------
889
- // Separator + section label + main content
890
-
891
- const separatorLine = blessed.text({
892
- parent: box,
893
- top: 5, // updated dynamically
894
- left: 2,
895
- content: `{${COLORS.sectionHdr}-fg}${'─'.repeat(68)}{/${COLORS.sectionHdr}-fg}`,
896
- tags: true,
897
- style: { bg: COLORS.contentBg },
898
- });
899
-
900
- const sectionLabel = blessed.text({
901
- parent: box,
902
- top: 5, // updated dynamically
903
- left: 4,
904
- tags: true,
905
- content: '',
906
- style: { bg: COLORS.contentBg },
907
- });
908
-
909
- const contentBox = blessed.box({
910
- parent: box,
911
- top: 7, // updated dynamically
912
- left: 2,
913
- width: '96%',
914
- bottom: 2,
915
- tags: true,
916
- scrollable: true,
917
- alwaysScroll: true,
918
- scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
919
- border: { type: 'line' },
920
- focusable: true,
921
- style: {
922
- fg: COLORS.labelFg,
923
- bg: COLORS.contentBg,
924
- border: { fg: COLORS.borderFg },
925
- },
926
- });
927
-
928
- // -------------------------------------------------------------------------
929
- // State
930
-
931
- let _messages = [];
932
- let _watchActive = false;
933
- let _showDetails = false;
934
- let _showDescription = true; // Show description on first visit
935
-
936
- // -------------------------------------------------------------------------
937
- // Receiver management
938
-
939
- function _isReceiverEnabled() {
940
- return existsSync(RECEIVER_SCRIPT);
941
- }
942
-
943
- function _enableReceiver() {
944
- try {
945
- mkdirSync(AGENTVIBES_DIR, { recursive: true, mode: 0o700 });
946
- if (existsSync(TEMPLATE_PATH)) {
947
- copyFileSync(TEMPLATE_PATH, RECEIVER_SCRIPT);
948
- chmodSync(RECEIVER_SCRIPT, 0o755);
949
- return true;
950
- }
951
- return false;
952
- } catch {
953
- return false;
954
- }
955
- }
956
-
957
- function _disableReceiver() {
958
- try {
959
- unlinkSync(RECEIVER_SCRIPT);
960
- return true;
961
- } catch {
962
- return false;
963
- }
964
- }
965
-
966
- /**
967
- * Send a test TTS message from this machine to the receiver via SSH.
968
- * Mirrors the payload format used by play-tts-ssh-remote.sh.
969
- */
970
- function _sendTest() {
971
- // Read SSH host
972
- const projectRoot = path.resolve(_thisDir, '..', '..', '..');
973
- const hostPaths = [
974
- path.join(projectRoot, '.claude', 'ssh-remote-host.txt'),
975
- path.join(homedir(), '.claude', 'ssh-remote-host.txt'),
976
- ];
977
- let sshHost = '';
978
- for (const p of hostPaths) {
979
- try { sshHost = readFileSync(p, 'utf-8').trim(); break; } catch { /* next */ }
980
- }
981
- if (!sshHost) {
982
- _showFeedback('{red-fg}No SSH host configured set .claude/ssh-remote-host.txt{/red-fg}');
983
- return;
984
- }
985
- // Validate host format
986
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sshHost)) {
987
- _showFeedback('{red-fg}Invalid SSH host format{/red-fg}');
988
- return;
989
- }
990
-
991
- // Read voice (best-effort, fall back to default)
992
- let voice = 'en_US-lessac-medium';
993
- const voicePaths = [
994
- path.join(projectRoot, '.claude', 'tts-voice.txt'),
995
- path.join(homedir(), '.agentvibes', 'config', 'voice.txt'),
996
- ];
997
- for (const p of voicePaths) {
998
- try { const v = readFileSync(p, 'utf-8').trim(); if (v) { voice = v; break; } } catch { /* next */ }
999
- }
1000
-
1001
- const payload = JSON.stringify({
1002
- text: 'AgentVibes receiver test — if you hear this, it works!',
1003
- voice,
1004
- effects: '',
1005
- music: '',
1006
- volume: '0.10',
1007
- project: 'agentvibes-tui',
1008
- pretext: '',
1009
- speed: '1.0',
1010
- provider: 'piper',
1011
- });
1012
-
1013
- const encoded = Buffer.from(payload).toString('base64');
1014
-
1015
- _showFeedback('{yellow-fg}Sending test to ' + sshHost + '...{/yellow-fg}');
1016
- screen.render();
1017
-
1018
- // Fire SSH in background — don't block the TUI
1019
- const child = spawn('ssh', ['-o', 'ConnectTimeout=5', sshHost, encoded], {
1020
- stdio: 'ignore',
1021
- detached: true,
1022
- });
1023
-
1024
- child.on('close', (code) => {
1025
- if (code === 0) {
1026
- _showFeedback('{green-fg}Test sent! Check receiver for audio playback.{/green-fg}');
1027
- } else {
1028
- _showFeedback(`{red-fg}SSH failed (exit ${code}) — check host and key config{/red-fg}`);
1029
- }
1030
- screen.render();
1031
- });
1032
-
1033
- child.on('error', (err) => {
1034
- _showFeedback(`{red-fg}SSH error: ${err.message}{/red-fg}`);
1035
- screen.render();
1036
- });
1037
-
1038
- child.unref();
1039
- }
1040
-
1041
- // -------------------------------------------------------------------------
1042
- // Log parsing
1043
-
1044
- function _parseLogFile() {
1045
- if (!existsSync(LOG_FILE)) return [];
1046
- try {
1047
- const content = readFileSync(LOG_FILE, 'utf-8');
1048
- const lines = content.trim().split('\n').filter(l => l.length > 0);
1049
- return lines
1050
- .filter(line => line.includes('|')) // Skip v1 format lines
1051
- .map(line => {
1052
- const parts = line.split('|');
1053
- // Extract music from detail field (e.g., "effects=none music=track.mp3")
1054
- const detail = parts[5] || '';
1055
- const musicMatch = detail.match(/music=(\S+)/);
1056
- const musicRaw = musicMatch ? musicMatch[1] : '';
1057
- // Convert filename to friendly name: "agentvibes_soft_flamenco_loop.mp3" → "Soft Flamenco Loop"
1058
- let music = '';
1059
- if (musicRaw && musicRaw !== 'none') {
1060
- music = musicRaw
1061
- .replace(/\.[^.]+$/, '') // strip extension
1062
- .replace(/^agent_?vibes_/i, '') // strip agent_vibes_ or agentvibes_ prefix
1063
- .replace(/_?loop$/i, '') // strip _loop suffix
1064
- .replace(/_v\d+$/i, '') // strip _v1, _v2 etc
1065
- .replace(/_/g, ' ') // underscores to spaces
1066
- .replace(/\b\w/g, c => c.toUpperCase()); // title case
1067
- }
1068
- return {
1069
- timestamp: parts[0] || '',
1070
- status: parts[1] || '',
1071
- project: parts[2] || 'unknown',
1072
- voice: parts[3] || '',
1073
- textPreview: parts[4] || '',
1074
- detail,
1075
- music,
1076
- ip: parts[6] || '',
1077
- logId: parts[7] || '',
1078
- };
1079
- });
1080
- } catch {
1081
- return [];
1082
- }
1083
- }
1084
-
1085
- function _formatMessage(msg) {
1086
- const [date = '', time = ''] = (msg.timestamp || '').split('T');
1087
- const statusRaw = msg.status === 'DONE' ? 'OK ' :
1088
- msg.status === 'ERROR' ? 'ERR ' :
1089
- msg.status === 'PLAYING' ? 'PLAY' :
1090
- msg.status === 'RECEIVED' ? 'RECV' :
1091
- msg.status === 'WARN' ? 'WARN' :
1092
- msg.status.substring(0, 4).padEnd(4);
1093
- // Color-coded status
1094
- const statusColor = msg.status === 'DONE' ? 'green' :
1095
- msg.status === 'ERROR' ? 'red' :
1096
- msg.status === 'WARN' ? 'yellow' :
1097
- msg.status === 'PLAYING' ? 'cyan' : 'white';
1098
- const status = `{${statusColor}-fg}${statusRaw}{/${statusColor}-fg}`;
1099
- const logId = `{#607d8b-fg}${(msg.logId || '—').padEnd(5)}{/#607d8b-fg}`;
1100
- const ip = `{#ce93d8-fg}${(msg.ip || '—').substring(0, 15).padEnd(15)}{/#ce93d8-fg}`;
1101
- const project = `{#4fc3f7-fg}${msg.project.substring(0, 12).padEnd(12)}{/#4fc3f7-fg}`;
1102
- const voice = `{#ffb74d-fg}${msg.voice.substring(0, 18).padEnd(18)}{/#ffb74d-fg}`;
1103
- const music = `{#a5d6a7-fg}${(msg.music || '').substring(0, 15).padEnd(15)}{/#a5d6a7-fg}`;
1104
- // Parse playback detail (sink, vol, pulse) from PLAYING log line
1105
- const pd = msg.playDetail || '';
1106
- const sinkMatch = pd.match(/sink=(\S+)/);
1107
- const volMatch = pd.match(/vol=(\S+)/);
1108
- const sinkName = sinkMatch ? sinkMatch[1].replace(/^alsa_output\./, '').substring(0, 20) : '—';
1109
- const vol = volMatch ? volMatch[1] : '';
1110
- const sink = `{#b39ddb-fg}${sinkName.padEnd(20)}{/#b39ddb-fg}`;
1111
- const volume = `{#ef9a9a-fg}${vol.padEnd(5)}{/#ef9a9a-fg}`;
1112
- const text = `{red-fg}${msg.textPreview}{/red-fg}`;
1113
- return `${logId} {#90a4ae-fg}${date} ${time}{/#90a4ae-fg} ${status} ${ip} ${project} ${voice} ${sink} ${volume} ${music} ${text}`;
1114
- }
1115
-
1116
- // -------------------------------------------------------------------------
1117
- // Health check
1118
-
1119
- function _getToolChecks() {
1120
- const checks = [];
1121
- const cmdCheck = (cmd) => {
1122
- try {
1123
- execSync(`command -v ${cmd}`, { stdio: 'pipe' });
1124
- return true;
1125
- } catch {
1126
- return false;
1127
- }
1128
- };
1129
-
1130
- checks.push(cmdCheck('piper') ? '{green-fg}piper{/green-fg}' : '{red-fg}piper{/red-fg}');
1131
- checks.push(cmdCheck('sox') ? '{green-fg}sox{/green-fg}' : '{yellow-fg}sox{/yellow-fg}');
1132
- checks.push(cmdCheck('ffmpeg') ? '{green-fg}ffmpeg{/green-fg}' : '{yellow-fg}ffmpeg{/yellow-fg}');
1133
-
1134
- let player = 'none';
1135
- for (const p of ['pw-play', 'paplay', 'aplay']) {
1136
- if (cmdCheck(p)) { player = p; break; }
1137
- }
1138
- checks.push(player !== 'none' ? `{green-fg}${player}{/green-fg}` : '{red-fg}no player{/red-fg}');
1139
- return checks.join(' ');
1140
- }
1141
-
1142
- // -------------------------------------------------------------------------
1143
- // Feedback flash (shows a message for 3 seconds)
1144
-
1145
- let _feedbackTimer = null;
1146
- function _showFeedback(msg) {
1147
- feedbackLine.setContent(' ' + msg);
1148
- screen.render();
1149
- if (_feedbackTimer) clearTimeout(_feedbackTimer);
1150
- _feedbackTimer = setTimeout(() => {
1151
- _updateFeedbackDefault();
1152
- screen.render();
1153
- }, 3000);
1154
- }
1155
-
1156
- function _updateFeedbackDefault() {
1157
- feedbackLine.setContent('');
1158
- }
1159
-
1160
- // -------------------------------------------------------------------------
1161
- // Refresh display
1162
-
1163
- // Cache network info and tool checks (refresh every 30s, not every render)
1164
- let _networkInfo = { tailscaleIp: '', localIp: '', sshPort: '22' };
1165
- let _toolChecksCache = '';
1166
- let _lastCacheTime = 0;
1167
- const CACHE_TTL_MS = 30000;
1168
-
1169
- function _refreshCachedInfo() {
1170
- const now = Date.now();
1171
- if (now - _lastCacheTime > CACHE_TTL_MS) {
1172
- _networkInfo = _getNetworkInfo();
1173
- _toolChecksCache = _getToolChecks();
1174
- _lastCacheTime = now;
1175
- }
1176
- }
1177
-
1178
- function refreshDisplay() {
1179
- const enabled = _isReceiverEnabled();
1180
- _refreshCachedInfo();
1181
-
1182
- // Toggle description box
1183
- if (_showDescription) {
1184
- descBox.show();
1185
- } else {
1186
- descBox.hide();
1187
- }
1188
-
1189
- // Dynamic positioning based on description visibility
1190
- const offset = _showDescription ? 10 : 0;
1191
- actionsLine.top = offset;
1192
- statusLine.top = offset + 1;
1193
- infoLine.top = offset + 2;
1194
- feedbackLine.top = offset + 3;
1195
- separatorLine.top = offset + 5;
1196
- sectionLabel.top = offset + 5;
1197
- contentBox.top = offset + 7;
1198
-
1199
- // Actions row — each action a different color
1200
- const enableLabel = enabled
1201
- ? '{#ef5350-fg}{bold}[E]{/bold} Turn Off{/#ef5350-fg}'
1202
- : '{#66bb6a-fg}{bold}[E]{/bold} Turn On{/#66bb6a-fg}';
1203
- const speakerKey = '{#ce93d8-fg}{bold}[O]{/bold} Speaker{/#ce93d8-fg}';
1204
- const detailLabel = _showDetails
1205
- ? '{#4fc3f7-fg}{bold}[D]{/bold} Messages{/#4fc3f7-fg}'
1206
- : '{#4fc3f7-fg}{bold}[D]{/bold} Setup Guide{/#4fc3f7-fg}';
1207
- const testKey = '{#ffd54f-fg}{bold}[P]{/bold} Test{/#ffd54f-fg}';
1208
- const clearKey = '{#ffb74d-fg}{bold}[C]{/bold} Clear Log{/#ffb74d-fg}';
1209
- const copyKey = '{#a5d6a7-fg}{bold}[A]{/bold} Copy{/#a5d6a7-fg}';
1210
- const descLabel = _showDescription
1211
- ? '{#90a4ae-fg}{bold}[?]{/bold} Hide Info{/#90a4ae-fg}'
1212
- : '{#90a4ae-fg}{bold}[?]{/bold} What is this?{/#90a4ae-fg}';
1213
- actionsLine.setContent(` ${enableLabel} ${speakerKey} ${testKey} ${detailLabel} ${clearKey} ${copyKey} ${descLabel}`);
1214
-
1215
- // Status + Speaker
1216
- const statusIcon = enabled ? '{green-fg}● ON{/green-fg}' : '{yellow-fg}● OFF{/yellow-fg}';
1217
- let speakerDisplay = '{#90a4ae-fg}(default){/#90a4ae-fg}';
1218
- try {
1219
- const configured = readFileSync(SINK_FILE, 'utf-8').trim();
1220
- if (configured) speakerDisplay = `{bold}${configured.replace(/^alsa_output\./, '')}{/bold}`;
1221
- } catch { /* no config */ }
1222
- statusLine.setContent(` Status: ${statusIcon} Speaker: ${speakerDisplay}`);
1223
-
1224
- // Network + tools + log — IP yellow, port cyan
1225
- const ipDisplay = _networkInfo.tailscaleIp || _networkInfo.localIp || 'unknown';
1226
- infoLine.setContent(` IP: {yellow-fg}{bold}${ipDisplay}{/bold}{/yellow-fg} Port: {#4fc3f7-fg}{bold}${_networkInfo.sshPort}{/bold}{/#4fc3f7-fg} Tools: ${_toolChecksCache} Log: {#90a4ae-fg}${LOG_FILE}{/#90a4ae-fg}`);
1227
-
1228
- _updateFeedbackDefault();
1229
-
1230
- // Main content
1231
- _messages = _parseLogFile();
1232
-
1233
- if (_showDetails) {
1234
- sectionLabel.setContent(`{${COLORS.sectionHdr}-fg} Setup Instructions {/${COLORS.sectionHdr}-fg}`);
1235
- contentBox.setContent(_buildDetailedInstructions(RECEIVER_ALIAS, RECEIVER_SCRIPT, _networkInfo));
1236
- } else {
1237
- sectionLabel.setContent(`{${COLORS.sectionHdr}-fg} Messages {/${COLORS.sectionHdr}-fg}`);
1238
-
1239
- if (_messages.length === 0) {
1240
- const text = [
1241
- 'No messages received yet. Waiting for SSH TTS requests...',
1242
- '',
1243
- 'Press [D] above for setup guide.',
1244
- ].join('\n');
1245
- contentBox.setContent(text);
1246
- } else {
1247
- const header = `{#607d8b-fg}${'ID'.padEnd(5)}{/#607d8b-fg} {#90a4ae-fg}${'DATE'.padEnd(10)} ${'TIME'.padEnd(8)}{/#90a4ae-fg} {bold}${'STAT'.padEnd(4)}{/bold} {#ce93d8-fg}${'IP'.padEnd(15)}{/#ce93d8-fg} {#4fc3f7-fg}${'PROJECT'.padEnd(12)}{/#4fc3f7-fg} {#ffb74d-fg}${'VOICE'.padEnd(18)}{/#ffb74d-fg} {#b39ddb-fg}${'SPEAKER'.padEnd(20)}{/#b39ddb-fg} {#ef9a9a-fg}${'VOL'.padEnd(5)}{/#ef9a9a-fg} {#a5d6a7-fg}${'MUSIC'.padEnd(15)}{/#a5d6a7-fg} {red-fg}TEXT{/red-fg}`;
1248
- const separator = '─'.repeat(78);
1249
- const lines = [header, separator];
1250
- // Group log lines per request — show one row with final status
1251
- // Each request produces RECEIVED → PLAYING → DONE/ERROR
1252
- const grouped = [];
1253
- let current = null;
1254
- for (const msg of _messages) {
1255
- if (msg.status === 'RECEIVED') {
1256
- current = { ...msg };
1257
- } else if (current && msg.status === 'PLAYING') {
1258
- // Merge PLAYING detail (sink, vol, pulse) into grouped row
1259
- current.playDetail = msg.detail;
1260
- } else if (current && (msg.status === 'DONE' || msg.status === 'ERROR' || msg.status === 'WARN')) {
1261
- current.status = msg.status;
1262
- current.timestamp = msg.timestamp;
1263
- grouped.push(current);
1264
- current = null;
1265
- } else if (!current && (msg.status === 'DONE' || msg.status === 'ERROR')) {
1266
- // Orphaned status — show as-is
1267
- grouped.push(msg);
1268
- }
1269
- }
1270
- // If a request is still in-progress, show it
1271
- if (current) {
1272
- grouped.push(current);
1273
- }
1274
- const recent = grouped.slice(-50).reverse();
1275
- for (const msg of recent) {
1276
- lines.push(_formatMessage(msg));
1277
- }
1278
- contentBox.setContent(lines.join('\n'));
1279
- }
1280
- }
1281
-
1282
- contentBox.scrollTo(0);
1283
- screen.render();
1284
- }
1285
-
1286
- // -------------------------------------------------------------------------
1287
- // File watcher
1288
-
1289
- function _startWatching() {
1290
- if (_watchActive) return;
1291
- _watchActive = true;
1292
- try {
1293
- watchFile(LOG_FILE, { interval: 2000 }, () => refreshDisplay());
1294
- } catch { /* file may not exist yet */ }
1295
- }
1296
-
1297
- function _stopWatching() {
1298
- if (!_watchActive) return;
1299
- _watchActive = false;
1300
- try { unwatchFile(LOG_FILE); } catch { /* ignore */ }
1301
- }
1302
-
1303
- // -------------------------------------------------------------------------
1304
- // Scroll bindings
1305
- box.key(['up'], () => { contentBox.scroll(-1); screen.render(); });
1306
- box.key(['down'], () => { contentBox.scroll(1); screen.render(); });
1307
- box.key(['pageup'], () => { contentBox.scroll(-contentBox.height); screen.render(); });
1308
- box.key(['pagedown'], () => { contentBox.scroll(contentBox.height); screen.render(); });
1309
-
1310
- // -------------------------------------------------------------------------
1311
- // Action key bindings
1312
-
1313
- box.key(['e', 'E'], () => {
1314
- if (_isReceiverEnabled()) {
1315
- _disableReceiver();
1316
- _showFeedback('{yellow-fg}Receiver disabled{/yellow-fg}');
1317
- } else {
1318
- if (_enableReceiver()) {
1319
- _showFeedback('{green-fg}Receiver enabled! play-remote.sh installed.{/green-fg}');
1320
- } else {
1321
- _showFeedback('{red-fg}Failed to enable template not found{/red-fg}');
1322
- }
1323
- }
1324
- refreshDisplay();
1325
- });
1326
-
1327
- box.key(['d', 'D'], () => {
1328
- _showDetails = !_showDetails;
1329
- refreshDisplay();
1330
- });
1331
-
1332
- box.key(['a', 'A'], () => {
1333
- // Copy all visible content to clipboard — strip blessed markup tags
1334
- const text = contentBox.getContent().replace(/\{[^}]*\}/g, '');
1335
- // Try platform-appropriate clipboard command
1336
- const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1337
- const clipCmds = _isWin
1338
- ? [['clip', []]]
1339
- : [['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']], ['wl-copy', []], ['pbcopy', []]];
1340
- let copied = false;
1341
- for (const [cmd, args] of clipCmds) {
1342
- const r = spawnSync(cmd, args, { input: text, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
1343
- if (r.status === 0) {
1344
- _showFeedback('{green-fg}Copied to clipboard!{/green-fg}');
1345
- copied = true;
1346
- break;
1347
- }
1348
- }
1349
- if (!copied) {
1350
- // Last resort: save to file
1351
- const filePath = path.join(AGENTVIBES_DIR, 'receiver-clipboard.txt');
1352
- try {
1353
- mkdirSync(AGENTVIBES_DIR, { recursive: true });
1354
- writeFileSync(filePath, text + '\n');
1355
- _showFeedback(`{yellow-fg}Saved to ${filePath}{/yellow-fg}`);
1356
- } catch {
1357
- _showFeedback('{red-fg}Failed to copy{/red-fg}');
1358
- }
1359
- }
1360
- });
1361
-
1362
- box.key(['?'], () => {
1363
- _showDescription = !_showDescription;
1364
- refreshDisplay();
1365
- });
1366
-
1367
- box.key(['o', 'O'], () => {
1368
- // List available audio sinks and let user pick one
1369
- let sinks;
1370
- try {
1371
- const out = execSync('pactl --server=tcp:127.0.0.1:34567 list sinks short 2>/dev/null || pactl list sinks short 2>/dev/null', { timeout: 5000 }).toString().trim();
1372
- sinks = out.split('\n').filter(l => l.length > 0).map(line => {
1373
- const parts = line.split('\t');
1374
- return { id: parts[0], name: parts[1] || '', driver: parts[2] || '', state: parts[4] || '' };
1375
- });
1376
- } catch {
1377
- _showFeedback('{red-fg}Failed to list audio outputs{/red-fg}');
1378
- return;
1379
- }
1380
- if (sinks.length === 0) {
1381
- _showFeedback('{red-fg}No audio outputs found{/red-fg}');
1382
- return;
1383
- }
1384
-
1385
- // Read current configured sink
1386
- let currentSink = '';
1387
- try { currentSink = readFileSync(SINK_FILE, 'utf-8').trim(); } catch { /* none set */ }
1388
-
1389
- const sinkList = blessed.list({
1390
- parent: screen,
1391
- top: 'center',
1392
- left: 'center',
1393
- width: '80%',
1394
- height: Math.min(sinks.length + 4, 20),
1395
- tags: true,
1396
- border: { type: 'line' },
1397
- label: ' Select Audio Output (Enter to confirm, Esc to cancel) ',
1398
- style: {
1399
- fg: COLORS.labelFg,
1400
- bg: '#1a1a2e',
1401
- border: { fg: COLORS.sectionHdr },
1402
- selected: { fg: '#000000', bg: '#80cbc4' },
1403
- item: { fg: COLORS.labelFg, bg: '#1a1a2e' },
1404
- },
1405
- keys: true,
1406
- vi: true,
1407
- items: sinks.map(s => {
1408
- const marker = s.name === currentSink ? ' {green-fg}◆{/green-fg}' : ' ';
1409
- const stateColor = s.state === 'RUNNING' ? 'green' : s.state === 'SUSPENDED' ? 'yellow' : 'gray';
1410
- // Strip alsa_output. prefix for readability
1411
- const shortName = s.name.replace(/^alsa_output\./, '');
1412
- return `${marker} {bold}${shortName}{/bold} {${stateColor}-fg}${s.state}{/${stateColor}-fg}`;
1413
- }),
1414
- });
1415
-
1416
- sinkList.focus();
1417
- screen.render();
1418
-
1419
- sinkList.on('select', (_item, index) => {
1420
- const chosen = sinks[index].name;
1421
- try {
1422
- writeFileSync(SINK_FILE, chosen + '\n');
1423
- // Also write to receiver user's config if accessible
1424
- if (SINK_FILE !== RECEIVER_SINK_FILE) {
1425
- try { writeFileSync(RECEIVER_SINK_FILE, chosen + '\n'); } catch { /* no access */ }
1426
- }
1427
- _showFeedback(`{green-fg}Speaker set: ${chosen.replace(/^alsa_output\./, '')}{/green-fg}`);
1428
- } catch (e) {
1429
- _showFeedback(`{red-fg}Failed to save speaker: ${e.message}{/red-fg}`);
1430
- }
1431
- sinkList.destroy();
1432
- box.focus();
1433
- refreshDisplay();
1434
- });
1435
-
1436
- sinkList.key(['escape', 'q'], () => {
1437
- sinkList.destroy();
1438
- box.focus();
1439
- screen.render();
1440
- });
1441
- });
1442
-
1443
- box.key(['p', 'P'], () => {
1444
- _sendTest();
1445
- });
1446
-
1447
- box.key(['c', 'C'], () => {
1448
- try { writeFileSync(LOG_FILE, ''); } catch { /* ignore */ }
1449
- _showDetails = false;
1450
- _showFeedback('{green-fg}Log cleared{/green-fg}');
1451
- refreshDisplay();
1452
- });
1453
-
1454
- // -------------------------------------------------------------------------
1455
- // Language change handler
1456
-
1457
- if (languageService) {
1458
- languageService.onChange(() => {
1459
- descBox.setLabel(` {bold}${_tl('receiverWhatIsTitle')}{/bold} `);
1460
- descText.setContent(_tl('receiverDesc'));
1461
- screen.render();
1462
- });
1463
- }
1464
-
1465
- // Tab Component Contract
1466
-
1467
- return {
1468
- box,
1469
- show() {
1470
- box.show();
1471
- refreshDisplay();
1472
- _startWatching();
1473
- },
1474
- hide() {
1475
- box.hide();
1476
- _stopWatching();
1477
- },
1478
- onFocus() { box.focus(); },
1479
- onBlur() {},
1480
- getFooterText: () => _tl('receiverFooter'),
1481
- getFooterColor: () => COLORS.footerBg,
1482
- };
1483
- }
1
+ /**
2
+ * AgentVibes TUI Console — Receiver Tab
3
+ * SSH Receiver — setup, enable/disable, and live message monitor.
4
+ *
5
+ * Implements the Tab Component Contract:
6
+ * createReceiverTab(screen, services) → { box, show, hide, onFocus, onBlur, getFooterText, getFooterColor }
7
+ *
8
+ * Uses scrollable text boxes (not lists) so users can highlight and copy
9
+ * with their mouse in the terminal.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, chmodSync, unlinkSync, watchFile, unwatchFile } from 'node:fs';
13
+ import { execSync, spawnSync, spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { t } from '../../i18n/strings.js';
18
+
19
+ const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
20
+
21
+ let blessed;
22
+ if (!IS_TEST) {
23
+ const { default: b } = await import('blessed');
24
+ blessed = b;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const COLORS = {
30
+ contentBg: '#0a0e1a',
31
+ sectionHdr: '#00897b',
32
+ labelFg: '#e3f2fd',
33
+ valueFg: '#ffff00',
34
+ activeFg: '#80cbc4',
35
+ borderFg: '#00897b',
36
+ footerBg: '#00897b',
37
+ noticeFg: '#90a4ae',
38
+ };
39
+
40
+ const FOOTER_TEXT = 'SSH Receiver [Q] Quit';
41
+
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function createTestStub() {
45
+ return {
46
+ box: {},
47
+ show: () => {},
48
+ hide: () => {},
49
+ onFocus: () => {},
50
+ onBlur: () => {},
51
+ getFooterText: () => FOOTER_TEXT,
52
+ getFooterColor: () => COLORS.footerBg,
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const _thisDir = IS_TEST ? '' : path.dirname(fileURLToPath(import.meta.url));
59
+ const TEMPLATE_PATH = IS_TEST ? '' : path.resolve(_thisDir, '..', '..', '..', 'templates', 'agentvibes-receiver.sh');
60
+
61
+ /**
62
+ * Get the machine's Tailscale IP (if available) and SSH port.
63
+ */
64
+ function _getNetworkInfo() {
65
+ let tailscaleIp = '';
66
+ let localIp = '';
67
+ let sshPort = '22';
68
+ const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
69
+ try {
70
+ tailscaleIp = execSync(isWin ? 'tailscale ip -4' : 'tailscale ip -4 2>/dev/null',
71
+ { timeout: 3000, stdio: 'pipe' }).toString().trim();
72
+ } catch { /* tailscale not installed */ }
73
+ try {
74
+ if (isWin) {
75
+ // Use PowerShell to get local IP on Windows
76
+ localIp = execSync('powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notmatch \'Loopback\' } | Select-Object -First 1).IPAddress"',
77
+ { timeout: 5000, stdio: 'pipe' }).toString().trim();
78
+ } else {
79
+ localIp = execSync("hostname -I 2>/dev/null | awk '{print $1}'", { timeout: 3000, stdio: 'pipe' }).toString().trim();
80
+ }
81
+ } catch { /* ignore */ }
82
+ try {
83
+ if (isWin) {
84
+ // Windows SSH port from sshd_config
85
+ const sshdConf = readFileSync('C:\\ProgramData\\ssh\\sshd_config', 'utf-8');
86
+ const m = sshdConf.match(/^Port\s+(\d+)/m);
87
+ if (m) sshPort = m[1];
88
+ } else {
89
+ const portLine = execSync("grep -E '^Port ' /etc/ssh/sshd_config 2>/dev/null || echo 'Port 22'", { timeout: 3000, stdio: 'pipe' }).toString().trim();
90
+ const m = portLine.match(/^Port\s+(\d+)/);
91
+ if (m) sshPort = m[1];
92
+ }
93
+ } catch { /* default 22 */ }
94
+ return { tailscaleIp, localIp, sshPort };
95
+ }
96
+
97
+ /**
98
+ * Detect current receiver setup state — returns an object with boolean checks.
99
+ * Used to determine whether instructions should show full setup or just verification.
100
+ */
101
+ function _detectSetupState() {
102
+ const isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
103
+ const state = {
104
+ receiverUserExists: false,
105
+ receiverScriptInstalled: false,
106
+ voiceModelsPresent: false,
107
+ pipewireTcpConfigured: false,
108
+ flatVolumesDisabled: false,
109
+ pulseCookieShared: false,
110
+ forceCommandConfigured: false,
111
+ tcpModuleLoaded: false,
112
+ isWindows: isWin,
113
+ sshdRunning: false,
114
+ ffmpegInstalled: false,
115
+ piperInstalled: false,
116
+ };
117
+ try {
118
+ if (isWin) {
119
+ // Windows detection
120
+ const home = homedir();
121
+ state.receiverScriptInstalled = existsSync(path.join(home, '.agentvibes', 'play-remote.ps1'));
122
+ state.receiverUserExists = true; // Windows uses the current user, no separate user needed
123
+
124
+ // Check voice models
125
+ const voicesDir = path.join(home, '.claude', 'piper-voices');
126
+ try {
127
+ const files = require('fs').readdirSync(voicesDir).filter(f => f.endsWith('.onnx'));
128
+ state.voiceModelsPresent = files.length > 0;
129
+ } catch { /* no voices */ }
130
+
131
+ // Check sshd running
132
+ try {
133
+ const svc = execSync('powershell -NoProfile -Command "(Get-Service sshd -EA SilentlyContinue).Status"',
134
+ { timeout: 5000, stdio: 'pipe' }).toString().trim();
135
+ state.sshdRunning = svc === 'Running';
136
+ } catch { /* sshd not installed */ }
137
+
138
+ // Check ForceCommand in Windows sshd_config
139
+ try {
140
+ const sshdConf = readFileSync('C:\\ProgramData\\ssh\\sshd_config', 'utf-8');
141
+ state.forceCommandConfigured = sshdConf.includes('ForceCommand') && sshdConf.includes('play-remote.ps1');
142
+ } catch { /* no read access */ }
143
+
144
+ // Check ffmpeg
145
+ try {
146
+ execSync('where ffmpeg', { timeout: 3000, stdio: 'pipe' });
147
+ state.ffmpegInstalled = true;
148
+ } catch { /* not found */ }
149
+
150
+ // Check piper
151
+ try {
152
+ execSync('where piper', { timeout: 3000, stdio: 'pipe' });
153
+ state.piperInstalled = true;
154
+ } catch {
155
+ const piperPath = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Piper', 'piper.exe');
156
+ state.piperInstalled = existsSync(piperPath);
157
+ }
158
+
159
+ // Windows doesn't need PipeWire/PulseAudio mark as N/A
160
+ state.pipewireTcpConfigured = true;
161
+ state.flatVolumesDisabled = true;
162
+ state.pulseCookieShared = true;
163
+ state.tcpModuleLoaded = true;
164
+ } else {
165
+ // Linux/macOS detection (original)
166
+ let receiverHome = '';
167
+ try {
168
+ execSync('id agentvibes-receiver', { timeout: 3000, stdio: 'pipe' });
169
+ state.receiverUserExists = true;
170
+ try {
171
+ receiverHome = execSync("getent passwd agentvibes-receiver 2>/dev/null | cut -d: -f6 || echo '/home/agentvibes-receiver'",
172
+ { timeout: 3000, stdio: 'pipe' }).toString().trim();
173
+ } catch { receiverHome = '/home/agentvibes-receiver'; }
174
+ } catch { /* user does not exist */ }
175
+
176
+ if (receiverHome) {
177
+ state.receiverScriptInstalled = existsSync(path.join(receiverHome, '.agentvibes/play-remote.sh'));
178
+ }
179
+
180
+ if (receiverHome) {
181
+ try {
182
+ const voices = execSync(`ls ${receiverHome}/.claude/piper-voices/*.onnx 2>/dev/null | wc -l`,
183
+ { timeout: 3000, stdio: 'pipe' }).toString().trim();
184
+ state.voiceModelsPresent = parseInt(voices, 10) > 0;
185
+ } catch { /* no access or no voices */ }
186
+ }
187
+
188
+ const home = homedir();
189
+ state.pipewireTcpConfigured = existsSync(
190
+ path.join(home, '.config/pipewire/pipewire-pulse.conf.d/agentvibes-tcp.conf'));
191
+ state.flatVolumesDisabled = existsSync(
192
+ path.join(home, '.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf'));
193
+
194
+ if (receiverHome) {
195
+ state.pulseCookieShared = existsSync(path.join(receiverHome, '.config/pulse/cookie'));
196
+ }
197
+
198
+ try {
199
+ const sshdConf = readFileSync('/etc/ssh/sshd_config', 'utf-8');
200
+ state.forceCommandConfigured = sshdConf.includes('Match User agentvibes-receiver');
201
+ } catch { /* no read access */ }
202
+
203
+ try {
204
+ const modules = execSync('pactl list modules short 2>/dev/null', { timeout: 3000, stdio: 'pipe' }).toString();
205
+ state.tcpModuleLoaded = modules.includes('module-native-protocol-tcp');
206
+ } catch { /* pactl not available */ }
207
+ }
208
+ } catch { /* detection failed, assume not set up */ }
209
+ return state;
210
+ }
211
+
212
+ /**
213
+ * Build detailed setup instructions (cross-platform).
214
+ * Organized: explanation → server instructions (for copying) → local setup.
215
+ * Designed to be self-contained so an AI agent can execute all steps.
216
+ * Detects existing setup and shows verification-only instructions when ready.
217
+ */
218
+ function _buildDetailedInstructions(receiverAlias, receiverScript, networkInfo) {
219
+ // Show detected values as hints but always use placeholders in instructions
220
+ // so the AI agent asks the user to confirm/provide their actual values
221
+ const detectedIp = networkInfo.tailscaleIp || networkInfo.localIp || '';
222
+ const detectedPort = networkInfo.sshPort || '22';
223
+ const state = _detectSetupState();
224
+ const isWin = state.isWindows;
225
+ const allReady = isWin
226
+ ? (state.receiverScriptInstalled && state.voiceModelsPresent &&
227
+ state.sshdRunning && state.forceCommandConfigured)
228
+ : (state.receiverUserExists && state.receiverScriptInstalled &&
229
+ state.voiceModelsPresent && state.pipewireTcpConfigured &&
230
+ state.flatVolumesDisabled && state.pulseCookieShared &&
231
+ state.forceCommandConfigured && state.tcpModuleLoaded);
232
+
233
+ // Build status header showing what's detected
234
+ const check = (ok) => ok ? '[OK]' : '[--]';
235
+ const statusLines = isWin ? [
236
+ '============================================================',
237
+ 'SETUP STATUS Windows (auto-detected)',
238
+ '============================================================',
239
+ '',
240
+ ' ' + check(state.sshdRunning) + ' OpenSSH Server running',
241
+ ' ' + check(state.forceCommandConfigured) + ' SSH ForceCommand configured',
242
+ ' ' + check(state.receiverScriptInstalled) + ' Receiver script (play-remote.ps1)',
243
+ ' ' + check(state.voiceModelsPresent) + ' Piper voice models installed',
244
+ ' ' + check(state.piperInstalled) + ' Piper TTS installed',
245
+ ' ' + check(state.ffmpegInstalled) + ' ffmpeg installed (background music)',
246
+ '',
247
+ ] : [
248
+ '============================================================',
249
+ 'SETUP STATUS (auto-detected)',
250
+ '============================================================',
251
+ '',
252
+ ' ' + check(state.receiverUserExists) + ' Receiver user (agentvibes-receiver)',
253
+ ' ' + check(state.receiverScriptInstalled) + ' Receiver script (play-remote.sh)',
254
+ ' ' + check(state.voiceModelsPresent) + ' Voice models copied',
255
+ ' ' + check(state.pipewireTcpConfigured) + ' PipeWire TCP audio (port 34567)',
256
+ ' ' + check(state.flatVolumesDisabled) + ' Flat-volumes disabled',
257
+ ' ' + check(state.pulseCookieShared) + ' PulseAudio cookie shared',
258
+ ' ' + check(state.forceCommandConfigured) + ' SSH ForceCommand configured',
259
+ ' ' + check(state.tcpModuleLoaded) + ' TCP audio module loaded',
260
+ '',
261
+ ];
262
+
263
+ if (allReady) {
264
+ if (isWin) {
265
+ return [
266
+ 'Press [A] to copy all text to your clipboard.',
267
+ '',
268
+ ...statusLines,
269
+ 'All checks passed! Windows receiver is ready.',
270
+ '',
271
+ '============================================================',
272
+ 'SERVER SETUP (the remote machine running Claude)',
273
+ '============================================================',
274
+ '',
275
+ ' 1. Add SSH alias (~/.ssh/config on the server):',
276
+ '',
277
+ ' Host <RECEIVER_NAME>',
278
+ ' HostName ' + (detectedIp || '<RECEIVER_IP>'),
279
+ ' Port 45123',
280
+ ' User ' + (process.env.USERNAME || '<WINDOWS_USER>'),
281
+ ' IdentityFile ~/.ssh/id_ed25519',
282
+ '',
283
+ ' 2. Tell AgentVibes where to send TTS:',
284
+ ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
285
+ '',
286
+ ' 3. Switch to ssh-remote provider:',
287
+ ' echo "ssh-remote" > .claude/tts-provider.txt',
288
+ '',
289
+ '',
290
+ '============================================================',
291
+ 'VERIFICATION',
292
+ '============================================================',
293
+ '',
294
+ 'From the server:',
295
+ ' echo \'{"text":"Hello","voice":"en_US-lessac-medium"}\' \\',
296
+ ' | base64 | xargs ssh <RECEIVER_NAME>',
297
+ '',
298
+ 'Check log: type %USERPROFILE%\\.agentvibes\\receiver.log',
299
+ '',
300
+ ].join('\n');
301
+ }
302
+ return [
303
+ 'Press [A] to copy all text to your clipboard.',
304
+ '',
305
+ ...statusLines,
306
+ 'All checks passed! Setup is complete.',
307
+ 'Below are verification tests and server-side instructions.',
308
+ '',
309
+ '',
310
+ '============================================================',
311
+ 'SERVER SETUP (the remote machine running Claude)',
312
+ '============================================================',
313
+ 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
314
+ '',
315
+ '1. Add an SSH alias for the receiver (~/.ssh/config):',
316
+ '',
317
+ ' Host <RECEIVER_NAME>',
318
+ ' HostName <RECEIVER_IP>',
319
+ detectedIp ? ' # detected: ' + detectedIp : '',
320
+ ' Port ' + detectedPort,
321
+ ' User agentvibes-receiver',
322
+ ' IdentityFile ~/.ssh/id_ed25519',
323
+ '',
324
+ '2. Tell AgentVibes where to send TTS:',
325
+ ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
326
+ '',
327
+ '3. Switch to ssh-remote provider:',
328
+ ' echo "ssh-remote" > .claude/tts-provider.txt',
329
+ '',
330
+ '',
331
+ '============================================================',
332
+ 'VERIFICATION',
333
+ '============================================================',
334
+ '',
335
+ 'Test from server:',
336
+ ' echo \'{"text":"Hello","voice":"en_US-lessac-medium"}\' \\',
337
+ ' | base64 | xargs ssh <RECEIVER_NAME>',
338
+ '',
339
+ 'Full pipeline:',
340
+ ' bash .claude/hooks/play-tts.sh "Receiver test complete"',
341
+ '',
342
+ ].join('\n');
343
+ }
344
+
345
+ // Full setup instructions (when not everything is detected)
346
+
347
+ // ---- WINDOWS INSTRUCTIONS ----
348
+ if (isWin) {
349
+ return [
350
+ 'Press [A] to copy all text to your clipboard.',
351
+ 'Give this to an AI agent on your server to set up the sender.',
352
+ '',
353
+ ...statusLines,
354
+ '',
355
+ '============================================================',
356
+ 'BEFORE YOU BEGIN Ask the user for these values:',
357
+ '============================================================',
358
+ '',
359
+ ' RECEIVER_IP: This Windows machine\'s Tailscale IP',
360
+ detectedIp ? ' (detected: ' + detectedIp + ' — ask user to confirm)' : ' (run: tailscale ip -4)',
361
+ ' RECEIVER_PORT: 45123 (hardened non-standard port)',
362
+ ' RECEIVER_NAME: SSH alias name (default: my-receiver)',
363
+ ' WINDOWS_USER: Windows username (e.g. Paul)',
364
+ '',
365
+ '',
366
+ '============================================================',
367
+ 'WHAT IS SSH RECEIVER?',
368
+ '============================================================',
369
+ '',
370
+ 'AgentVibes SSH Receiver lets remote servers (cloud VPS, dev',
371
+ 'servers) send TTS audio to this Windows machine. The server',
372
+ 'sends text + voice config over SSH, and this machine generates',
373
+ 'and plays audio locally through its speakers.',
374
+ '',
375
+ 'Server AI --[SSH/Tailscale]--> Windows --[Piper+ffmpeg]--> Speakers',
376
+ '',
377
+ 'Security: SSH is hardened with key-only auth, ForceCommand',
378
+ '(no shell access), non-standard port, Tailscale-only binding.',
379
+ '',
380
+ '',
381
+ '============================================================',
382
+ 'PART 1: WINDOWS RECEIVER SETUP (this machine)',
383
+ '============================================================',
384
+ 'Setup script: setup-ssh-receiver.ps1',
385
+ 'Receiver script: templates/agentvibes-receiver.ps1',
386
+ '',
387
+ 'Step 1: Install prerequisites (if not already done)',
388
+ '',
389
+ ' a) Install Tailscale (for secure networking):',
390
+ ' winget install --id Tailscale.Tailscale -e',
391
+ ' Then sign in with your Tailscale account.',
392
+ '',
393
+ ' b) Install OpenSSH Server (admin PowerShell):',
394
+ ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0',
395
+ '',
396
+ ' c) Install Piper TTS and ffmpeg (if not installed):',
397
+ ' Run the AgentVibes installer: node bin/agent-vibes install',
398
+ ' It will check and install ffmpeg automatically.',
399
+ '',
400
+ 'Step 2: Run the automated setup script (admin PowerShell)',
401
+ '',
402
+ ' cd C:\\path\\to\\AgentVibes',
403
+ ' powershell -ExecutionPolicy Bypass -File setup-ssh-receiver.ps1',
404
+ '',
405
+ ' This script handles everything:',
406
+ ' - Deploys hardened sshd_config (port 45123, key-only, ForceCommand)',
407
+ ' - Installs receiver script to ~/.agentvibes/play-remote.ps1',
408
+ ' - Adds firewall rule (Tailscale IPs only)',
409
+ ' - Restarts sshd',
410
+ '',
411
+ 'Step 3: Add the sender\'s SSH public key',
412
+ '',
413
+ ' Get the public key from your server:',
414
+ ' ssh your-server "cat ~/.ssh/id_ed25519.pub"',
415
+ '',
416
+ ' Then in admin PowerShell on this Windows machine:',
417
+ ' Set-Content -Path "C:\\ProgramData\\ssh\\administrators_authorized_keys" `',
418
+ ' -Value "paste-the-public-key-here"',
419
+ ' cmd /c \'icacls C:\\ProgramData\\ssh\\administrators_authorized_keys `',
420
+ ' /inheritance:r /grant "SYSTEM:F" /grant "BUILTIN\\Administrators:F"\'',
421
+ ' Restart-Service sshd',
422
+ '',
423
+ 'Step 4: Security hardening details',
424
+ '',
425
+ ' The setup script configures:',
426
+ ' Port: 45123 (non-standard)',
427
+ ' ListenAddress: Tailscale IP only (not 0.0.0.0)',
428
+ ' Auth: SSH key only (no passwords)',
429
+ ' ForceCommand: Can ONLY run the receiver script (no shell)',
430
+ ' Forwarding: All disabled (TCP, agent, X11, tunnel)',
431
+ ' Firewall: Port 45123 from 100.0.0.0/8 (Tailscale) only',
432
+ '',
433
+ '',
434
+ '============================================================',
435
+ 'PART 2: SERVER SETUP (the remote machine running Claude)',
436
+ '============================================================',
437
+ '',
438
+ ' 1. Add SSH alias (~/.ssh/config on the server):',
439
+ '',
440
+ ' Host <RECEIVER_NAME>',
441
+ ' HostName <RECEIVER_IP>',
442
+ ' Port 45123',
443
+ ' User <WINDOWS_USER>',
444
+ ' IdentityFile ~/.ssh/id_ed25519',
445
+ '',
446
+ ' 2. Tell AgentVibes where to send TTS:',
447
+ ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
448
+ '',
449
+ ' 3. Switch to ssh-remote provider:',
450
+ ' echo "ssh-remote" > .claude/tts-provider.txt',
451
+ '',
452
+ '',
453
+ '============================================================',
454
+ 'PART 3: VERIFICATION',
455
+ '============================================================',
456
+ '',
457
+ 'Test from the server:',
458
+ '',
459
+ ' echo \'{"text":"Hello from server","voice":"en_US-lessac-medium"}\' \\',
460
+ ' | base64 | xargs ssh <RECEIVER_NAME>',
461
+ '',
462
+ ' Expected: Audio plays on Windows speakers.',
463
+ ' Check log: type %USERPROFILE%\\.agentvibes\\receiver.log',
464
+ '',
465
+ 'Test full AgentVibes pipeline:',
466
+ ' bash .claude/hooks/play-tts.sh "Receiver test complete"',
467
+ '',
468
+ ].join('\n');
469
+ }
470
+
471
+ // ---- LINUX/macOS INSTRUCTIONS (original) ----
472
+ return [
473
+ 'Press [A] to copy all text to your clipboard.',
474
+ 'Give this to an AI agent on your server AND local machine',
475
+ 'to set up and verify the SSH receiver end-to-end.',
476
+ '',
477
+ ...statusLines,
478
+ '',
479
+ '============================================================',
480
+ 'BEFORE YOU BEGIN — Ask the user for these values:',
481
+ '============================================================',
482
+ '',
483
+ ' RECEIVER_IP: The receiver machine IP or Tailscale hostname',
484
+ detectedIp ? ' (detected: ' + detectedIp + ' — ask user to confirm)' : ' (could not auto-detect — user must provide)',
485
+ ' RECEIVER_PORT: The SSH port on the receiver machine',
486
+ ' (detected: ' + detectedPort + ' ask user to confirm)',
487
+ ' RECEIVER_NAME: SSH alias name (default: my-receiver)',
488
+ ' TTS_PROVIDER: Which TTS engine on the receiver?',
489
+ ' Options: piper (default, Linux), soprano (neural TTS, any platform),',
490
+ ' macos (macOS say), windows-sapi (Windows SAPI)',
491
+ '',
492
+ 'Replace <RECEIVER_IP>, <RECEIVER_PORT>, <RECEIVER_NAME>,',
493
+ 'and <TTS_PROVIDER> in commands below with user-provided values.',
494
+ '',
495
+ '',
496
+ '============================================================',
497
+ 'WHAT IS SSH RECEIVER?',
498
+ '============================================================',
499
+ '',
500
+ 'AgentVibes SSH Receiver lets remote servers (cloud VPS, dev',
501
+ 'servers) send TTS audio to your local machine. The server AI',
502
+ 'sends text + voice config over SSH, and this machine generates',
503
+ 'and plays audio locally through its speakers.',
504
+ '',
505
+ 'Server AI --[SSH payload]--> This Machine --[piper+sox+ffmpeg]--> Speakers',
506
+ '',
507
+ 'Prerequisites:',
508
+ ' - Both machines can reach each other via SSH (Tailscale VPN',
509
+ ' recommended for easy setup: https://tailscale.com/download)',
510
+ ' - A TTS engine on the receiver (this machine):',
511
+ ' Linux: piper-tts (default, offline, fast)',
512
+ ' Windows: soprano or windows-sapi (uses system voices)',
513
+ ' macOS: macos (uses built-in say command)',
514
+ ' - sox and ffmpeg recommended for audio effects and music',
515
+ '',
516
+ '',
517
+ '============================================================',
518
+ 'PART 1: LOCAL SETUP (this machine the receiver)',
519
+ '============================================================',
520
+ 'Setup script: scripts/setup-receiver-user.sh',
521
+ 'Receiver template: templates/agentvibes-receiver.sh',
522
+ 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
523
+ '',
524
+ 'Run these steps on the machine that has speakers attached.',
525
+ '',
526
+ 'IMPORTANT: Steps that require sudo cannot be run by an AI',
527
+ 'agent. The USER must run sudo commands in a separate terminal.',
528
+ 'The AI agent should tell the user what to run, then verify',
529
+ 'the results using the detection checks shown above.',
530
+ '',
531
+ '--- Option A: Automated Setup (recommended) ---',
532
+ '',
533
+ 'Ask the USER to run this in a separate terminal:',
534
+ '',
535
+ ' sudo bash /path/to/AgentVibes/scripts/setup-receiver-user.sh',
536
+ '',
537
+ 'This single script handles everything:',
538
+ ' - Creates agentvibes-receiver user (groups: audio + your group)',
539
+ ' - Copies piper voice models and music tracks',
540
+ ' - Installs the receiver script (play-remote.sh)',
541
+ ' - Configures PipeWire TCP audio on localhost:34567',
542
+ ' - Disables flat-volumes (prevents volume hijacking)',
543
+ ' - Shares PulseAudio cookie for cross-user auth',
544
+ ' - Tests audio playback',
545
+ '',
546
+ 'After the user confirms it ran successfully, verify with:',
547
+ ' id agentvibes-receiver # user exists?',
548
+ ' ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx # voices?',
549
+ ' pactl list modules short | grep tcp # TCP module?',
550
+ '',
551
+ 'Then skip to Step 3 (ForceCommand) below.',
552
+ '',
553
+ '--- Option B: Manual Setup (step by step) ---',
554
+ '',
555
+ 'Step 1: Enable receiver script',
556
+ ' Press [E] in this tab (installs play-remote.sh to ~/.agentvibes/)',
557
+ '',
558
+ 'Step 2: Create the receiver user',
559
+ '',
560
+ ' Ask the USER to run these sudo commands in a terminal:',
561
+ '',
562
+ ' Linux/WSL:',
563
+ ' sudo useradd -m -s /bin/bash agentvibes-receiver',
564
+ ' sudo usermod -aG audio,$(id -gn) agentvibes-receiver',
565
+ ' # Create directories for voices and music:',
566
+ ' sudo mkdir -p /home/agentvibes-receiver/.claude/piper-voices',
567
+ ' sudo mkdir -p /home/agentvibes-receiver/.claude/audio/tracks',
568
+ ' sudo mkdir -p /home/agentvibes-receiver/.agentvibes',
569
+ ' # Copy voice models (required for TTS):',
570
+ ' sudo cp ~/.claude/piper-voices/*.onnx /home/agentvibes-receiver/.claude/piper-voices/',
571
+ ' sudo cp ~/.claude/piper-voices/*.onnx.json /home/agentvibes-receiver/.claude/piper-voices/',
572
+ ' # Copy music tracks (optional, for background music):',
573
+ ' sudo cp ~/.claude/audio/tracks/*.mp3 /home/agentvibes-receiver/.claude/audio/tracks/ 2>/dev/null',
574
+ ' # Install receiver script:',
575
+ ' sudo cp ~/.agentvibes/play-remote.sh /home/agentvibes-receiver/.agentvibes/play-remote.sh',
576
+ ' sudo chmod +x /home/agentvibes-receiver/.agentvibes/play-remote.sh',
577
+ ' # Fix ownership:',
578
+ ' sudo chown -R agentvibes-receiver:agentvibes-receiver /home/agentvibes-receiver/.claude',
579
+ ' sudo chown -R agentvibes-receiver:agentvibes-receiver /home/agentvibes-receiver/.agentvibes',
580
+ '',
581
+ ' macOS:',
582
+ ' sudo dscl . -create /Users/agentvibes-receiver',
583
+ ' sudo dscl . -create /Users/agentvibes-receiver UserShell /bin/bash',
584
+ ' sudo dscl . -create /Users/agentvibes-receiver NFSHomeDirectory /Users/agentvibes-receiver',
585
+ ' sudo mkdir -p /Users/agentvibes-receiver',
586
+ ' # Enable Remote Login in System Settings > General > Sharing',
587
+ ' # Then copy voices/script as above (adjust paths to /Users/)',
588
+ '',
589
+ ' AI agent: After user confirms, verify:',
590
+ ' id agentvibes-receiver # user exists?',
591
+ ' ls /home/agentvibes-receiver/.agentvibes/play-remote.sh # script?',
592
+ ' ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx # voices?',
593
+ '',
594
+ 'Step 3: ForceCommand (recommended — limits SSH to audio only)',
595
+ '',
596
+ ' Ask the USER to add this to /etc/ssh/sshd_config:',
597
+ '',
598
+ ' Match User agentvibes-receiver',
599
+ ' ForceCommand ' + receiverScript,
600
+ ' PasswordAuthentication no',
601
+ ' PermitTTY no',
602
+ '',
603
+ ' Then ask user to reload sshd:',
604
+ ' Linux/WSL: sudo systemctl reload sshd',
605
+ ' macOS: sudo launchctl kickstart -k system/com.openssh.sshd',
606
+ '',
607
+ ' AI agent: Verify (no sudo needed):',
608
+ ' grep "Match User agentvibes-receiver" /etc/ssh/sshd_config',
609
+ '',
610
+ 'Step 4: Audio access (required for dedicated user)',
611
+ '',
612
+ ' The receiver user runs as a different UID and cannot access',
613
+ ' your audio via Unix sockets. TCP localhost solves this.',
614
+ '',
615
+ ' Linux (PipeWire — most modern distros):',
616
+ '',
617
+ ' a) Enable TCP audio listener (AI agent CAN do this no sudo):',
618
+ ' mkdir -p ~/.config/pipewire/pipewire-pulse.conf.d',
619
+ ' cat > ~/.config/pipewire/pipewire-pulse.conf.d/agentvibes-tcp.conf << \'EOF\'',
620
+ ' pulse.cmd = [',
621
+ ' { cmd = "load-module" args = "module-native-protocol-tcp auth-cookie-enabled=1 auth-anonymous=0 listen=127.0.0.1 port=34567" }',
622
+ ' ]',
623
+ ' EOF',
624
+ '',
625
+ ' b) Disable flat-volumes (AI agent CAN do this — no sudo):',
626
+ ' cat > ~/.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf << \'EOF\'',
627
+ ' pulse.properties = {',
628
+ ' pulse.flat.volumes = false',
629
+ ' }',
630
+ ' EOF',
631
+ '',
632
+ ' c) Share PulseAudio cookie (REQUIRES sudo — ask USER):',
633
+ ' sudo mkdir -p /home/agentvibes-receiver/.config/pulse',
634
+ ' sudo cp ~/.config/pulse/cookie /home/agentvibes-receiver/.config/pulse/',
635
+ ' sudo chown -R agentvibes-receiver:agentvibes-receiver \\',
636
+ ' /home/agentvibes-receiver/.config/pulse',
637
+ ' sudo chmod 600 /home/agentvibes-receiver/.config/pulse/cookie',
638
+ '',
639
+ ' d) Load TCP module now (AI agent CAN do this — no sudo):',
640
+ ' pactl load-module module-native-protocol-tcp \\',
641
+ ' auth-cookie-enabled=1 auth-anonymous=0 \\',
642
+ ' listen=127.0.0.1 port=34567',
643
+ '',
644
+ ' AI agent: Verify audio setup:',
645
+ ' pactl list modules short | grep tcp # TCP loaded?',
646
+ ' ls /home/agentvibes-receiver/.config/pulse/cookie # cookie?',
647
+ ' PULSE_SERVER=tcp:127.0.0.1:34567 pactl info # TCP works?',
648
+ '',
649
+ ' Linux (PulseAudio older distros):',
650
+ ' # Add to /etc/pulse/default.pa or ~/.config/pulse/default.pa:',
651
+ ' load-module module-native-protocol-tcp auth-cookie-enabled=1 \\',
652
+ ' auth-anonymous=0 listen=127.0.0.1 port=34567',
653
+ ' # Then share the cookie as above (step c — requires sudo)',
654
+ ' # Restart: pulseaudio --kill && pulseaudio --start',
655
+ '',
656
+ ' macOS:',
657
+ ' # macOS uses coreaudiod which is system-wide.',
658
+ ' # The receiver user should have audio access if in the',
659
+ ' # "audio" group. No TCP workaround needed.',
660
+ '',
661
+ ' WSL2:',
662
+ ' # Audio routes through WSLg PulseServer at /mnt/wslg/PulseServer.',
663
+ ' # Set in receiver script: export PULSE_SERVER=unix:/mnt/wslg/PulseServer',
664
+ ' # Cross-user access may require the TCP approach above.',
665
+ '',
666
+ 'Step 5: Add server SSH key',
667
+ '',
668
+ ' On the server, generate a key if needed:',
669
+ ' ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""',
670
+ '',
671
+ ' Copy the public key to the receiver:',
672
+ ' ssh-copy-id -i ~/.ssh/id_ed25519.pub \\',
673
+ ' agentvibes-receiver@<RECEIVER_IP>',
674
+ '',
675
+ '',
676
+ '============================================================',
677
+ 'PART 2: SERVER SETUP (the remote machine running Claude)',
678
+ '============================================================',
679
+ 'Sender hook: .claude/hooks/play-tts-ssh-remote.sh',
680
+ 'Config file: .agentvibes/config/agentvibes.json',
681
+ '',
682
+ 'Run these steps on the remote server that needs TTS.',
683
+ '',
684
+ '1. Add an SSH alias for the receiver (~/.ssh/config):',
685
+ '',
686
+ ' Host <RECEIVER_NAME>',
687
+ ' HostName <RECEIVER_IP>',
688
+ ' Port <RECEIVER_PORT>',
689
+ ' User agentvibes-receiver',
690
+ ' IdentityFile ~/.ssh/id_ed25519',
691
+ '',
692
+ '2. Tell AgentVibes where to send TTS:',
693
+ '',
694
+ ' echo "<RECEIVER_NAME>" > .claude/ssh-remote-host.txt',
695
+ '',
696
+ '3. Switch to the ssh-remote provider:',
697
+ '',
698
+ ' # In .agentvibes/config/agentvibes.json set "provider": "ssh-remote"',
699
+ ' # Or run: agentvibes provider switch ssh-remote',
700
+ '',
701
+ 'The sender hook at .claude/hooks/play-tts-ssh-remote.sh',
702
+ 'bundles voice, effects, and music into a single JSON payload',
703
+ 'and sends it over SSH. No TTS software needed on the server.',
704
+ '',
705
+ '',
706
+ '============================================================',
707
+ 'PART 3: VERIFICATION (test end-to-end)',
708
+ '============================================================',
709
+ '',
710
+ 'Use tmux to test both sides simultaneously:',
711
+ '',
712
+ ' tmux new-session -d -s agentvibes-verify',
713
+ ' # Left pane: watch receiver log on LOCAL machine',
714
+ ' tmux send-keys "tail -f /home/agentvibes-receiver/.agentvibes/receiver.log \\',
715
+ ' || tail -f ~/.agentvibes/receiver.log" Enter',
716
+ ' # Right pane: send test from SERVER',
717
+ ' tmux split-window -h',
718
+ ' tmux send-keys "ssh <your-server>" Enter',
719
+ ' tmux attach -t agentvibes-verify',
720
+ '',
721
+ 'Then in the server pane, run these tests in order:',
722
+ '',
723
+ 'Test 1 SSH connectivity:',
724
+ ' ssh <RECEIVER_NAME> "echo hello"',
725
+ ' # Expected: ForceCommand runs, you see RECEIVED in the log pane',
726
+ '',
727
+ 'Test 2 — TTS from server:',
728
+ ' echo \'{"text":"Hello from server test","voice":"en_US-lessac-medium"}\' \\',
729
+ ' | base64 | xargs ssh <RECEIVER_NAME>',
730
+ ' # Expected: Audio plays on receiver speakers, log shows DONE',
731
+ '',
732
+ 'Test 3 Full AgentVibes pipeline:',
733
+ ' bash .claude/hooks/play-tts.sh "Testing AgentVibes receiver"',
734
+ ' # Expected: TTS with configured voice, effects, and music',
735
+ '',
736
+ 'Or test locally on the receiver machine without SSH:',
737
+ '',
738
+ ' sudo -u agentvibes-receiver \\',
739
+ ' PULSE_SERVER=tcp:127.0.0.1:34567 \\',
740
+ ' paplay /usr/share/sounds/freedesktop/stereo/bell.oga',
741
+ ' # Expected: Bell sound plays through your speakers',
742
+ '',
743
+ ' sudo -u agentvibes-receiver \\',
744
+ ' PULSE_SERVER=tcp:127.0.0.1:34567 \\',
745
+ ' /home/agentvibes-receiver/.agentvibes/play-remote.sh \\',
746
+ ' "$(echo \'{"text":"Local pipeline test","voice":"en_US-lessac-medium"}\' | base64)"',
747
+ ' # Expected: TTS audio plays, receiver.log shows RECEIVED → PLAYING → DONE',
748
+ '',
749
+ '',
750
+ '============================================================',
751
+ 'TROUBLESHOOTING',
752
+ '============================================================',
753
+ '',
754
+ 'SSH connection refused:',
755
+ ' - Check sshd is running: systemctl status sshd',
756
+ ' - Check firewall allows <RECEIVER_PORT>: sudo ufw status',
757
+ ' - Check authorized_keys: cat /home/agentvibes-receiver/.ssh/authorized_keys',
758
+ '',
759
+ 'No audio / connection refused on audio:',
760
+ ' - Check TCP module: pactl list modules short | grep tcp',
761
+ ' - Check cookie exists: ls -la /home/agentvibes-receiver/.config/pulse/cookie',
762
+ ' - Test TCP directly: PULSE_SERVER=tcp:127.0.0.1:34567 pactl info',
763
+ '',
764
+ 'Volume hijacked / wrong speaker:',
765
+ ' - Verify flat-volumes disabled:',
766
+ ' cat ~/.config/pipewire/pipewire-pulse.conf.d/no-flat-volumes.conf',
767
+ ' - Select specific sink: echo "sink_name" > \\',
768
+ ' /home/agentvibes-receiver/.agentvibes/receiver-sink.txt',
769
+ ' - List available sinks: pactl list sinks short',
770
+ '',
771
+ 'No voice models:',
772
+ ' - Check: ls /home/agentvibes-receiver/.claude/piper-voices/*.onnx',
773
+ ' - Re-copy: sudo cp ~/.claude/piper-voices/*.onnx* \\',
774
+ ' /home/agentvibes-receiver/.claude/piper-voices/',
775
+ '',
776
+ 'ForceCommand not working:',
777
+ ' - Check sshd_config syntax: sudo sshd -t',
778
+ ' - Reload sshd: sudo systemctl reload sshd',
779
+ ' - Test manually: ssh agentvibes-receiver@localhost',
780
+ ].join('\n');
781
+ }
782
+
783
+ export function createReceiverTab(screen, services) {
784
+ if (IS_TEST) return createTestStub();
785
+
786
+ const { languageService, focusMainTabBar } = services || {};
787
+ const _tl = (key) => languageService ? languageService.t(key) : t('en', key);
788
+
789
+ const AGENTVIBES_DIR = path.join(homedir(), '.agentvibes');
790
+ const RECEIVER_SCRIPT = path.join(AGENTVIBES_DIR, 'play-remote.sh');
791
+ const RECEIVER_ALIAS = 'my-receiver';
792
+
793
+ // Log file: check receiver user's home first, fall back to current user's
794
+ const RECEIVER_USER_LOG = '/home/agentvibes-receiver/.agentvibes/receiver.log';
795
+ const LOCAL_LOG = path.join(AGENTVIBES_DIR, 'receiver.log');
796
+ const LOG_FILE = existsSync(RECEIVER_USER_LOG) ? RECEIVER_USER_LOG : LOCAL_LOG;
797
+
798
+ // Sink config — shared with receiver script via receiver user's home
799
+ const RECEIVER_SINK_FILE = '/home/agentvibes-receiver/.agentvibes/receiver-sink.txt';
800
+ const LOCAL_SINK_FILE = path.join(AGENTVIBES_DIR, 'receiver-sink.txt');
801
+ const SINK_FILE = existsSync('/home/agentvibes-receiver/.agentvibes') ? RECEIVER_SINK_FILE : LOCAL_SINK_FILE;
802
+
803
+ // -------------------------------------------------------------------------
804
+ // Container
805
+
806
+ const box = blessed.box({
807
+ parent: screen,
808
+ top: 5,
809
+ left: 0,
810
+ width: '100%',
811
+ bottom: 2,
812
+ hidden: true,
813
+ keys: true,
814
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
815
+ border: { type: 'line' },
816
+ borderStyle: { fg: COLORS.borderFg },
817
+ });
818
+
819
+ box.key(['escape'], () => {
820
+ if (typeof focusMainTabBar === 'function') { focusMainTabBar(); screen.render(); }
821
+ });
822
+
823
+ // -------------------------------------------------------------------------
824
+ // Description text (collapsible)
825
+
826
+ const descBox = blessed.box({
827
+ parent: box,
828
+ top: 0,
829
+ left: 2,
830
+ width: '96%',
831
+ height: 9,
832
+ tags: true,
833
+ hidden: true,
834
+ border: { type: 'line' },
835
+ label: ` {bold}${_tl('receiverWhatIsTitle')}{/bold} `,
836
+ style: {
837
+ fg: COLORS.labelFg,
838
+ bg: '#111827',
839
+ border: { fg: COLORS.sectionHdr },
840
+ },
841
+ });
842
+
843
+ const descText = blessed.text({
844
+ parent: descBox,
845
+ top: 0,
846
+ left: 1,
847
+ tags: true,
848
+ content: _tl('receiverDesc'),
849
+ style: { fg: '#b0bec5', bg: '#111827' },
850
+ });
851
+
852
+ blessed.text({
853
+ parent: descBox,
854
+ top: 6,
855
+ right: 2,
856
+ tags: true,
857
+ content: '{#90a4ae-fg}Press {bold}[?]{/bold} to close{/#90a4ae-fg}',
858
+ style: { bg: '#111827' },
859
+ });
860
+
861
+ // -------------------------------------------------------------------------
862
+ // Top: actions row + status row + info row + feedback
863
+ // Positions are dynamic — shift down when description is open
864
+
865
+ const _topOffset = () => _showDescription ? 10 : 0;
866
+
867
+ const actionsLine = blessed.text({
868
+ parent: box,
869
+ top: 0, // updated dynamically
870
+ left: 4,
871
+ tags: true,
872
+ content: '',
873
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
874
+ });
875
+
876
+ const statusLine = blessed.text({
877
+ parent: box,
878
+ top: 1, // updated dynamically
879
+ left: 4,
880
+ tags: true,
881
+ content: '',
882
+ style: { fg: COLORS.labelFg, bg: COLORS.contentBg },
883
+ });
884
+
885
+ const infoLine = blessed.text({
886
+ parent: box,
887
+ top: 2, // updated dynamically
888
+ left: 4,
889
+ tags: true,
890
+ content: '',
891
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
892
+ });
893
+
894
+ const feedbackLine = blessed.text({
895
+ parent: box,
896
+ top: 3, // updated dynamically
897
+ left: 4,
898
+ tags: true,
899
+ content: '',
900
+ style: { fg: COLORS.noticeFg, bg: COLORS.contentBg },
901
+ });
902
+
903
+ // -------------------------------------------------------------------------
904
+ // Separator + section label + main content
905
+
906
+ const separatorLine = blessed.text({
907
+ parent: box,
908
+ top: 5, // updated dynamically
909
+ left: 2,
910
+ content: `{${COLORS.sectionHdr}-fg}${'─'.repeat(68)}{/${COLORS.sectionHdr}-fg}`,
911
+ tags: true,
912
+ style: { bg: COLORS.contentBg },
913
+ });
914
+
915
+ const sectionLabel = blessed.text({
916
+ parent: box,
917
+ top: 5, // updated dynamically
918
+ left: 4,
919
+ tags: true,
920
+ content: '',
921
+ style: { bg: COLORS.contentBg },
922
+ });
923
+
924
+ const contentBox = blessed.box({
925
+ parent: box,
926
+ top: 7, // updated dynamically
927
+ left: 2,
928
+ width: '96%',
929
+ bottom: 2,
930
+ tags: true,
931
+ scrollable: true,
932
+ alwaysScroll: true,
933
+ scrollbar: { ch: '│', style: { fg: COLORS.sectionHdr } },
934
+ border: { type: 'line' },
935
+ focusable: true,
936
+ style: {
937
+ fg: COLORS.labelFg,
938
+ bg: COLORS.contentBg,
939
+ border: { fg: COLORS.borderFg },
940
+ },
941
+ });
942
+
943
+ // -------------------------------------------------------------------------
944
+ // State
945
+
946
+ let _messages = [];
947
+ let _watchActive = false;
948
+ let _showDetails = null; // null = auto (based on receiver state), true/false = manual override
949
+ let _showDescription = true; // Show description on first visit
950
+
951
+ // -------------------------------------------------------------------------
952
+ // Receiver management
953
+
954
+ function _isReceiverEnabled() {
955
+ return existsSync(RECEIVER_SCRIPT);
956
+ }
957
+
958
+ function _enableReceiver() {
959
+ try {
960
+ mkdirSync(AGENTVIBES_DIR, { recursive: true, mode: 0o700 });
961
+ if (existsSync(TEMPLATE_PATH)) {
962
+ copyFileSync(TEMPLATE_PATH, RECEIVER_SCRIPT);
963
+ chmodSync(RECEIVER_SCRIPT, 0o755);
964
+ return true;
965
+ }
966
+ return false;
967
+ } catch {
968
+ return false;
969
+ }
970
+ }
971
+
972
+ function _disableReceiver() {
973
+ try {
974
+ unlinkSync(RECEIVER_SCRIPT);
975
+ return true;
976
+ } catch {
977
+ return false;
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Send a test TTS message from this machine to the receiver via SSH.
983
+ * Mirrors the payload format used by play-tts-ssh-remote.sh.
984
+ */
985
+ function _sendTest() {
986
+ // Read SSH host
987
+ const projectRoot = path.resolve(_thisDir, '..', '..', '..');
988
+ const hostPaths = [
989
+ path.join(projectRoot, '.claude', 'ssh-remote-host.txt'),
990
+ path.join(homedir(), '.claude', 'ssh-remote-host.txt'),
991
+ ];
992
+ let sshHost = '';
993
+ for (const p of hostPaths) {
994
+ try { sshHost = readFileSync(p, 'utf-8').trim(); break; } catch { /* next */ }
995
+ }
996
+ if (!sshHost) {
997
+ _showFeedback('{red-fg}No SSH host configured — set .claude/ssh-remote-host.txt{/red-fg}');
998
+ return;
999
+ }
1000
+ // Validate host format
1001
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sshHost)) {
1002
+ _showFeedback('{red-fg}Invalid SSH host format{/red-fg}');
1003
+ return;
1004
+ }
1005
+
1006
+ // Read voice (best-effort, fall back to default)
1007
+ let voice = 'en_US-lessac-medium';
1008
+ const voicePaths = [
1009
+ path.join(projectRoot, '.claude', 'tts-voice.txt'),
1010
+ path.join(homedir(), '.agentvibes', 'config', 'voice.txt'),
1011
+ ];
1012
+ for (const p of voicePaths) {
1013
+ try { const v = readFileSync(p, 'utf-8').trim(); if (v) { voice = v; break; } } catch { /* next */ }
1014
+ }
1015
+
1016
+ const payload = JSON.stringify({
1017
+ text: 'AgentVibes receiver test — if you hear this, it works!',
1018
+ voice,
1019
+ effects: '',
1020
+ music: '',
1021
+ volume: '0.10',
1022
+ project: 'agentvibes-tui',
1023
+ pretext: '',
1024
+ speed: '1.0',
1025
+ provider: 'piper',
1026
+ });
1027
+
1028
+ const encoded = Buffer.from(payload).toString('base64');
1029
+
1030
+ _showFeedback('{yellow-fg}Sending test to ' + sshHost + '...{/yellow-fg}');
1031
+ screen.render();
1032
+
1033
+ // Fire SSH in background — don't block the TUI
1034
+ const child = spawn('ssh', ['-o', 'ConnectTimeout=5', sshHost, encoded], {
1035
+ stdio: 'ignore',
1036
+ detached: true,
1037
+ });
1038
+
1039
+ child.on('close', (code) => {
1040
+ if (code === 0) {
1041
+ _showFeedback('{green-fg}Test sent! Check receiver for audio playback.{/green-fg}');
1042
+ } else {
1043
+ _showFeedback(`{red-fg}SSH failed (exit ${code}) — check host and key config{/red-fg}`);
1044
+ }
1045
+ screen.render();
1046
+ });
1047
+
1048
+ child.on('error', (err) => {
1049
+ _showFeedback(`{red-fg}SSH error: ${err.message}{/red-fg}`);
1050
+ screen.render();
1051
+ });
1052
+
1053
+ child.unref();
1054
+ }
1055
+
1056
+ // -------------------------------------------------------------------------
1057
+ // Log parsing
1058
+
1059
+ function _parseLogFile() {
1060
+ if (!existsSync(LOG_FILE)) return [];
1061
+ try {
1062
+ const content = readFileSync(LOG_FILE, 'utf-8');
1063
+ const lines = content.trim().split('\n').filter(l => l.length > 0);
1064
+ return lines
1065
+ .filter(line => line.includes('|')) // Skip v1 format lines
1066
+ .map(line => {
1067
+ const parts = line.split('|');
1068
+ // Extract music from detail field (e.g., "effects=none music=track.mp3")
1069
+ const detail = parts[5] || '';
1070
+ const musicMatch = detail.match(/music=(\S+)/);
1071
+ const musicRaw = musicMatch ? musicMatch[1] : '';
1072
+ // Convert filename to friendly name: "agentvibes_soft_flamenco_loop.mp3" "Soft Flamenco Loop"
1073
+ let music = '';
1074
+ if (musicRaw && musicRaw !== 'none') {
1075
+ music = musicRaw
1076
+ .replace(/\.[^.]+$/, '') // strip extension
1077
+ .replace(/^agent_?vibes_/i, '') // strip agent_vibes_ or agentvibes_ prefix
1078
+ .replace(/_?loop$/i, '') // strip _loop suffix
1079
+ .replace(/_v\d+$/i, '') // strip _v1, _v2 etc
1080
+ .replace(/_/g, ' ') // underscores to spaces
1081
+ .replace(/\b\w/g, c => c.toUpperCase()); // title case
1082
+ }
1083
+ return {
1084
+ timestamp: parts[0] || '',
1085
+ status: parts[1] || '',
1086
+ project: parts[2] || 'unknown',
1087
+ voice: parts[3] || '',
1088
+ textPreview: parts[4] || '',
1089
+ detail,
1090
+ music,
1091
+ ip: parts[6] || '',
1092
+ logId: parts[7] || '',
1093
+ };
1094
+ });
1095
+ } catch {
1096
+ return [];
1097
+ }
1098
+ }
1099
+
1100
+ function _formatMessage(msg) {
1101
+ const [date = '', time = ''] = (msg.timestamp || '').split('T');
1102
+ const statusRaw = msg.status === 'DONE' ? 'OK ' :
1103
+ msg.status === 'ERROR' ? 'ERR ' :
1104
+ msg.status === 'PLAYING' ? 'PLAY' :
1105
+ msg.status === 'RECEIVED' ? 'RECV' :
1106
+ msg.status === 'WARN' ? 'WARN' :
1107
+ msg.status.substring(0, 4).padEnd(4);
1108
+ // Color-coded status
1109
+ const statusColor = msg.status === 'DONE' ? 'green' :
1110
+ msg.status === 'ERROR' ? 'red' :
1111
+ msg.status === 'WARN' ? 'yellow' :
1112
+ msg.status === 'PLAYING' ? 'cyan' : 'white';
1113
+ const status = `{${statusColor}-fg}${statusRaw}{/${statusColor}-fg}`;
1114
+ const logId = `{#607d8b-fg}${(msg.logId || '—').padEnd(5)}{/#607d8b-fg}`;
1115
+ const ip = `{#ce93d8-fg}${(msg.ip || '—').substring(0, 15).padEnd(15)}{/#ce93d8-fg}`;
1116
+ const project = `{#4fc3f7-fg}${msg.project.substring(0, 12).padEnd(12)}{/#4fc3f7-fg}`;
1117
+ const voice = `{#ffb74d-fg}${msg.voice.substring(0, 18).padEnd(18)}{/#ffb74d-fg}`;
1118
+ const music = `{#a5d6a7-fg}${(msg.music || '—').substring(0, 15).padEnd(15)}{/#a5d6a7-fg}`;
1119
+ // Parse playback detail (sink, vol, pulse) from PLAYING log line
1120
+ const pd = msg.playDetail || '';
1121
+ const sinkMatch = pd.match(/sink=(\S+)/);
1122
+ const volMatch = pd.match(/vol=(\S+)/);
1123
+ const sinkName = sinkMatch ? sinkMatch[1].replace(/^alsa_output\./, '').substring(0, 20) : '—';
1124
+ const vol = volMatch ? volMatch[1] : '—';
1125
+ const sink = `{#b39ddb-fg}${sinkName.padEnd(20)}{/#b39ddb-fg}`;
1126
+ const volume = `{#ef9a9a-fg}${vol.padEnd(5)}{/#ef9a9a-fg}`;
1127
+ const text = `{red-fg}${msg.textPreview}{/red-fg}`;
1128
+ return `${logId} {#90a4ae-fg}${date} ${time}{/#90a4ae-fg} ${status} ${ip} ${project} ${voice} ${sink} ${volume} ${music} ${text}`;
1129
+ }
1130
+
1131
+ // -------------------------------------------------------------------------
1132
+ // Health check
1133
+
1134
+ function _getToolChecks() {
1135
+ const checks = [];
1136
+ const _isWinCheck = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1137
+ const cmdCheck = (cmd) => {
1138
+ try {
1139
+ execSync(_isWinCheck ? `where ${cmd}` : `command -v ${cmd}`, { stdio: 'pipe', timeout: 3000 });
1140
+ return true;
1141
+ } catch {
1142
+ return false;
1143
+ }
1144
+ };
1145
+
1146
+ checks.push(cmdCheck('piper') ? '{green-fg}piper{/green-fg}' : '{red-fg}piper{/red-fg}');
1147
+ checks.push(cmdCheck('sox') ? '{green-fg}sox{/green-fg}' : '{yellow-fg}sox{/yellow-fg}');
1148
+ checks.push(cmdCheck('ffmpeg') ? '{green-fg}ffmpeg{/green-fg}' : '{yellow-fg}ffmpeg{/yellow-fg}');
1149
+
1150
+ let player = 'none';
1151
+ for (const p of ['pw-play', 'paplay', 'aplay']) {
1152
+ if (cmdCheck(p)) { player = p; break; }
1153
+ }
1154
+ checks.push(player !== 'none' ? `{green-fg}${player}{/green-fg}` : '{red-fg}no player{/red-fg}');
1155
+ return checks.join(' ');
1156
+ }
1157
+
1158
+ // -------------------------------------------------------------------------
1159
+ // Feedback flash (shows a message for 3 seconds)
1160
+
1161
+ let _feedbackTimer = null;
1162
+ function _showFeedback(msg) {
1163
+ feedbackLine.setContent(' ' + msg);
1164
+ screen.render();
1165
+ if (_feedbackTimer) clearTimeout(_feedbackTimer);
1166
+ _feedbackTimer = setTimeout(() => {
1167
+ _updateFeedbackDefault();
1168
+ screen.render();
1169
+ }, 3000);
1170
+ }
1171
+
1172
+ function _updateFeedbackDefault() {
1173
+ feedbackLine.setContent('');
1174
+ }
1175
+
1176
+ // -------------------------------------------------------------------------
1177
+ // Refresh display
1178
+
1179
+ // Cache network info and tool checks (refresh every 30s, not every render)
1180
+ let _networkInfo = { tailscaleIp: '', localIp: '', sshPort: '22' };
1181
+ let _toolChecksCache = '';
1182
+ let _lastCacheTime = 0;
1183
+ const CACHE_TTL_MS = 30000;
1184
+
1185
+ function _refreshCachedInfo() {
1186
+ const now = Date.now();
1187
+ if (now - _lastCacheTime > CACHE_TTL_MS) {
1188
+ _networkInfo = _getNetworkInfo();
1189
+ _toolChecksCache = _getToolChecks();
1190
+ _lastCacheTime = now;
1191
+ }
1192
+ }
1193
+
1194
+ function refreshDisplay() {
1195
+ const enabled = _isReceiverEnabled();
1196
+ _refreshCachedInfo();
1197
+
1198
+ // Toggle description box
1199
+ if (_showDescription) {
1200
+ descBox.show();
1201
+ } else {
1202
+ descBox.hide();
1203
+ }
1204
+
1205
+ // Dynamic positioning based on description visibility
1206
+ const offset = _showDescription ? 10 : 0;
1207
+ actionsLine.top = offset;
1208
+ statusLine.top = offset + 1;
1209
+ infoLine.top = offset + 2;
1210
+ feedbackLine.top = offset + 3;
1211
+ separatorLine.top = offset + 5;
1212
+ sectionLabel.top = offset + 5;
1213
+ contentBox.top = offset + 7;
1214
+
1215
+ // Auto-show instructions when receiver isn't set up; manual toggle overrides
1216
+ const showingDetails = _showDetails !== null ? _showDetails : !enabled;
1217
+
1218
+ // Actions row — each action a different color
1219
+ const enableLabel = enabled
1220
+ ? '{#ef5350-fg}{bold}[E]{/bold} Turn Off{/#ef5350-fg}'
1221
+ : '{#66bb6a-fg}{bold}[E]{/bold} Turn On{/#66bb6a-fg}';
1222
+ const speakerKey = '{#ce93d8-fg}{bold}[O]{/bold} Speaker{/#ce93d8-fg}';
1223
+ const detailLabel = showingDetails
1224
+ ? '{#4fc3f7-fg}{bold}[D]{/bold} Messages{/#4fc3f7-fg}'
1225
+ : '{#4fc3f7-fg}{bold}[D]{/bold} Setup Guide{/#4fc3f7-fg}';
1226
+ const testKey = '{#ffd54f-fg}{bold}[P]{/bold} Test{/#ffd54f-fg}';
1227
+ const clearKey = '{#ffb74d-fg}{bold}[C]{/bold} Clear Log{/#ffb74d-fg}';
1228
+ const copyKey = showingDetails ? '{#a5d6a7-fg}{bold}[A]{/bold} Copy Setup{/#a5d6a7-fg}' : '';
1229
+ const descLabel = _showDescription
1230
+ ? '{#90a4ae-fg}{bold}[?]{/bold} Hide Info{/#90a4ae-fg}'
1231
+ : '{#90a4ae-fg}{bold}[?]{/bold} What is this?{/#90a4ae-fg}';
1232
+ const actions = [enableLabel, speakerKey, testKey, detailLabel, clearKey, copyKey, descLabel].filter(Boolean);
1233
+ actionsLine.setContent(' ' + actions.join(' '));
1234
+
1235
+ // Status + Speaker
1236
+ const statusIcon = enabled ? '{green-fg} ON{/green-fg}' : '{yellow-fg}● OFF{/yellow-fg}';
1237
+ let speakerDisplay = '{#90a4ae-fg}(default){/#90a4ae-fg}';
1238
+ try {
1239
+ const configured = readFileSync(SINK_FILE, 'utf-8').trim();
1240
+ if (configured) speakerDisplay = `{bold}${configured.replace(/^alsa_output\./, '')}{/bold}`;
1241
+ } catch { /* no config */ }
1242
+ statusLine.setContent(` Status: ${statusIcon} Speaker: ${speakerDisplay}`);
1243
+
1244
+ // Network + tools + log — IP yellow, port cyan
1245
+ const ipDisplay = _networkInfo.tailscaleIp || _networkInfo.localIp || 'unknown';
1246
+ infoLine.setContent(` IP: {yellow-fg}{bold}${ipDisplay}{/bold}{/yellow-fg} Port: {#4fc3f7-fg}{bold}${_networkInfo.sshPort}{/bold}{/#4fc3f7-fg} Tools: ${_toolChecksCache} Log: {#90a4ae-fg}${LOG_FILE}{/#90a4ae-fg}`);
1247
+
1248
+ _updateFeedbackDefault();
1249
+
1250
+ // Main content
1251
+ _messages = _parseLogFile();
1252
+
1253
+ if (showingDetails) {
1254
+ sectionLabel.setContent(`{${COLORS.sectionHdr}-fg} Setup Instructions {/${COLORS.sectionHdr}-fg}`);
1255
+ const copyHint = '{#a5d6a7-fg}Press {bold}[A]{/bold} to copy these instructions to clipboard{/#a5d6a7-fg}\n';
1256
+ contentBox.setContent(copyHint + _buildDetailedInstructions(RECEIVER_ALIAS, RECEIVER_SCRIPT, _networkInfo));
1257
+ } else {
1258
+ sectionLabel.setContent(`{${COLORS.sectionHdr}-fg} Messages {/${COLORS.sectionHdr}-fg}`);
1259
+
1260
+ if (_messages.length === 0) {
1261
+ const text = [
1262
+ 'No messages received yet. Waiting for SSH TTS requests...',
1263
+ '',
1264
+ 'Press [D] above for setup guide.',
1265
+ ].join('\n');
1266
+ contentBox.setContent(text);
1267
+ } else {
1268
+ const header = `{#607d8b-fg}${'ID'.padEnd(5)}{/#607d8b-fg} {#90a4ae-fg}${'DATE'.padEnd(10)} ${'TIME'.padEnd(8)}{/#90a4ae-fg} {bold}${'STAT'.padEnd(4)}{/bold} {#ce93d8-fg}${'IP'.padEnd(15)}{/#ce93d8-fg} {#4fc3f7-fg}${'PROJECT'.padEnd(12)}{/#4fc3f7-fg} {#ffb74d-fg}${'VOICE'.padEnd(18)}{/#ffb74d-fg} {#b39ddb-fg}${'SPEAKER'.padEnd(20)}{/#b39ddb-fg} {#ef9a9a-fg}${'VOL'.padEnd(5)}{/#ef9a9a-fg} {#a5d6a7-fg}${'MUSIC'.padEnd(15)}{/#a5d6a7-fg} {red-fg}TEXT{/red-fg}`;
1269
+ const separator = '─'.repeat(78);
1270
+ const lines = [header, separator];
1271
+ // Group log lines per request — show one row with final status
1272
+ // Each request produces RECEIVED → PLAYING → DONE/ERROR
1273
+ const grouped = [];
1274
+ let current = null;
1275
+ for (const msg of _messages) {
1276
+ if (msg.status === 'RECEIVED') {
1277
+ current = { ...msg };
1278
+ } else if (current && msg.status === 'PLAYING') {
1279
+ // Merge PLAYING detail (sink, vol, pulse) into grouped row
1280
+ current.playDetail = msg.detail;
1281
+ } else if (current && (msg.status === 'DONE' || msg.status === 'ERROR' || msg.status === 'WARN')) {
1282
+ current.status = msg.status;
1283
+ current.timestamp = msg.timestamp;
1284
+ grouped.push(current);
1285
+ current = null;
1286
+ } else if (!current && (msg.status === 'DONE' || msg.status === 'ERROR')) {
1287
+ // Orphaned status — show as-is
1288
+ grouped.push(msg);
1289
+ }
1290
+ }
1291
+ // If a request is still in-progress, show it
1292
+ if (current) {
1293
+ grouped.push(current);
1294
+ }
1295
+ const recent = grouped.slice(-50).reverse();
1296
+ for (const msg of recent) {
1297
+ lines.push(_formatMessage(msg));
1298
+ }
1299
+ contentBox.setContent(lines.join('\n'));
1300
+ }
1301
+ }
1302
+
1303
+ contentBox.scrollTo(0);
1304
+ screen.render();
1305
+ }
1306
+
1307
+ // -------------------------------------------------------------------------
1308
+ // File watcher
1309
+
1310
+ function _startWatching() {
1311
+ if (_watchActive) return;
1312
+ _watchActive = true;
1313
+ try {
1314
+ watchFile(LOG_FILE, { interval: 2000 }, () => refreshDisplay());
1315
+ } catch { /* file may not exist yet */ }
1316
+ }
1317
+
1318
+ function _stopWatching() {
1319
+ if (!_watchActive) return;
1320
+ _watchActive = false;
1321
+ try { unwatchFile(LOG_FILE); } catch { /* ignore */ }
1322
+ }
1323
+
1324
+ // -------------------------------------------------------------------------
1325
+ // Scroll bindings
1326
+ box.key(['up'], () => { contentBox.scroll(-1); screen.render(); });
1327
+ box.key(['down'], () => { contentBox.scroll(1); screen.render(); });
1328
+ box.key(['pageup'], () => { contentBox.scroll(-contentBox.height); screen.render(); });
1329
+ box.key(['pagedown'], () => { contentBox.scroll(contentBox.height); screen.render(); });
1330
+
1331
+ // -------------------------------------------------------------------------
1332
+ // Action key bindings
1333
+
1334
+ box.key(['e', 'E'], () => {
1335
+ if (_isReceiverEnabled()) {
1336
+ _disableReceiver();
1337
+ _showFeedback('{yellow-fg}Receiver disabled{/yellow-fg}');
1338
+ } else {
1339
+ if (_enableReceiver()) {
1340
+ _showFeedback('{green-fg}Receiver enabled! play-remote.sh installed.{/green-fg}');
1341
+ } else {
1342
+ _showFeedback('{red-fg}Failed to enable template not found{/red-fg}');
1343
+ }
1344
+ }
1345
+ refreshDisplay();
1346
+ });
1347
+
1348
+ box.key(['d', 'D'], () => {
1349
+ // Manual override: toggle between instructions and messages
1350
+ const currentlyShowing = _showDetails !== null ? _showDetails : !_isReceiverEnabled();
1351
+ _showDetails = !currentlyShowing;
1352
+ refreshDisplay();
1353
+ });
1354
+
1355
+ box.key(['a', 'A'], () => {
1356
+ // Copy setup instructions to clipboard (only available when instructions are shown)
1357
+ const _currentlyShowingDetails = _showDetails !== null ? _showDetails : !_isReceiverEnabled();
1358
+ if (!_currentlyShowingDetails) {
1359
+ _showFeedback('{yellow-fg}Nothing to copy — setup instructions not shown{/yellow-fg}');
1360
+ return;
1361
+ }
1362
+ _refreshCachedInfo();
1363
+ const text = _buildDetailedInstructions(RECEIVER_ALIAS, RECEIVER_SCRIPT, _networkInfo)
1364
+ .replace(/\{[^}]*\}/g, '')
1365
+ // eslint-disable-next-line no-control-regex
1366
+ .replace(/\x1b\[[0-9;]*m/g, '');
1367
+ // Try platform-appropriate clipboard command
1368
+ const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1369
+ let copied = false;
1370
+ if (_isWin) {
1371
+ // Windows clip.exe mangles UTF-8 use temp file + PowerShell with explicit encoding
1372
+ const tmpFile = path.join(AGENTVIBES_DIR, 'clip-tmp.txt');
1373
+ try {
1374
+ mkdirSync(AGENTVIBES_DIR, { recursive: true });
1375
+ writeFileSync(tmpFile, '\ufeff' + text, 'utf-8');
1376
+ const psCmd = `Get-Content -Path "${tmpFile.replace(/\\/g, '/')}" -Encoding UTF8 -Raw | Set-Clipboard; Remove-Item "${tmpFile.replace(/\\/g, '/')}"`;
1377
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', psCmd], { timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
1378
+ if (r.status === 0) {
1379
+ _showFeedback('{green-fg}Copied to clipboard!{/green-fg}');
1380
+ copied = true;
1381
+ }
1382
+ } catch { /* fall through to file fallback */ }
1383
+ } else {
1384
+ const clipCmds = [['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']], ['wl-copy', []], ['pbcopy', []]];
1385
+ for (const [cmd, args] of clipCmds) {
1386
+ const r = spawnSync(cmd, args, { input: text, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] });
1387
+ if (r.status === 0) {
1388
+ _showFeedback('{green-fg}Copied to clipboard!{/green-fg}');
1389
+ copied = true;
1390
+ break;
1391
+ }
1392
+ }
1393
+ }
1394
+ if (!copied) {
1395
+ // Last resort: save to file
1396
+ const filePath = path.join(AGENTVIBES_DIR, 'receiver-clipboard.txt');
1397
+ try {
1398
+ mkdirSync(AGENTVIBES_DIR, { recursive: true });
1399
+ writeFileSync(filePath, text + '\n');
1400
+ _showFeedback(`{yellow-fg}Saved to ${filePath}{/yellow-fg}`);
1401
+ } catch {
1402
+ _showFeedback('{red-fg}Failed to copy{/red-fg}');
1403
+ }
1404
+ }
1405
+ });
1406
+
1407
+ box.key(['?'], () => {
1408
+ _showDescription = !_showDescription;
1409
+ refreshDisplay();
1410
+ });
1411
+
1412
+ box.key(['o', 'O'], () => {
1413
+ // List available audio sinks and let user pick one
1414
+ let sinks;
1415
+ try {
1416
+ const out = execSync('pactl --server=tcp:127.0.0.1:34567 list sinks short 2>/dev/null || pactl list sinks short 2>/dev/null', { timeout: 5000 }).toString().trim();
1417
+ sinks = out.split('\n').filter(l => l.length > 0).map(line => {
1418
+ const parts = line.split('\t');
1419
+ return { id: parts[0], name: parts[1] || '', driver: parts[2] || '', state: parts[4] || '' };
1420
+ });
1421
+ } catch {
1422
+ _showFeedback('{red-fg}Failed to list audio outputs{/red-fg}');
1423
+ return;
1424
+ }
1425
+ if (sinks.length === 0) {
1426
+ _showFeedback('{red-fg}No audio outputs found{/red-fg}');
1427
+ return;
1428
+ }
1429
+
1430
+ // Read current configured sink
1431
+ let currentSink = '';
1432
+ try { currentSink = readFileSync(SINK_FILE, 'utf-8').trim(); } catch { /* none set */ }
1433
+
1434
+ const sinkList = blessed.list({
1435
+ parent: screen,
1436
+ top: 'center',
1437
+ left: 'center',
1438
+ width: '80%',
1439
+ height: Math.min(sinks.length + 4, 20),
1440
+ tags: true,
1441
+ border: { type: 'line' },
1442
+ label: ' Select Audio Output (Enter to confirm, Esc to cancel) ',
1443
+ style: {
1444
+ fg: COLORS.labelFg,
1445
+ bg: '#1a1a2e',
1446
+ border: { fg: COLORS.sectionHdr },
1447
+ selected: { fg: '#000000', bg: '#80cbc4' },
1448
+ item: { fg: COLORS.labelFg, bg: '#1a1a2e' },
1449
+ },
1450
+ keys: true,
1451
+ vi: true,
1452
+ items: sinks.map(s => {
1453
+ const marker = s.name === currentSink ? ' {green-fg}◆{/green-fg}' : ' ';
1454
+ const stateColor = s.state === 'RUNNING' ? 'green' : s.state === 'SUSPENDED' ? 'yellow' : 'gray';
1455
+ // Strip alsa_output. prefix for readability
1456
+ const shortName = s.name.replace(/^alsa_output\./, '');
1457
+ return `${marker} {bold}${shortName}{/bold} {${stateColor}-fg}${s.state}{/${stateColor}-fg}`;
1458
+ }),
1459
+ });
1460
+
1461
+ sinkList.focus();
1462
+ screen.render();
1463
+
1464
+ sinkList.on('select', (_item, index) => {
1465
+ const chosen = sinks[index].name;
1466
+ try {
1467
+ writeFileSync(SINK_FILE, chosen + '\n');
1468
+ // Also write to receiver user's config if accessible
1469
+ if (SINK_FILE !== RECEIVER_SINK_FILE) {
1470
+ try { writeFileSync(RECEIVER_SINK_FILE, chosen + '\n'); } catch { /* no access */ }
1471
+ }
1472
+ _showFeedback(`{green-fg}Speaker set: ${chosen.replace(/^alsa_output\./, '')}{/green-fg}`);
1473
+ } catch (e) {
1474
+ _showFeedback(`{red-fg}Failed to save speaker: ${e.message}{/red-fg}`);
1475
+ }
1476
+ sinkList.destroy();
1477
+ box.focus();
1478
+ refreshDisplay();
1479
+ });
1480
+
1481
+ sinkList.key(['escape', 'q'], () => {
1482
+ sinkList.destroy();
1483
+ box.focus();
1484
+ screen.render();
1485
+ });
1486
+ });
1487
+
1488
+ box.key(['p', 'P'], () => {
1489
+ _sendTest();
1490
+ });
1491
+
1492
+ box.key(['c', 'C'], () => {
1493
+ try { writeFileSync(LOG_FILE, ''); } catch { /* ignore */ }
1494
+ _showFeedback('{green-fg}Log cleared{/green-fg}');
1495
+ refreshDisplay();
1496
+ });
1497
+
1498
+ // -------------------------------------------------------------------------
1499
+ // Language change handler
1500
+
1501
+ if (languageService) {
1502
+ languageService.onChange(() => {
1503
+ descBox.setLabel(` {bold}${_tl('receiverWhatIsTitle')}{/bold} `);
1504
+ descText.setContent(_tl('receiverDesc'));
1505
+ screen.render();
1506
+ });
1507
+ }
1508
+
1509
+ // Tab Component Contract
1510
+
1511
+ return {
1512
+ box,
1513
+ show() {
1514
+ box.show();
1515
+ refreshDisplay();
1516
+ _startWatching();
1517
+ },
1518
+ hide() {
1519
+ box.hide();
1520
+ _stopWatching();
1521
+ },
1522
+ onFocus() { box.focus(); },
1523
+ onBlur() {},
1524
+ getFooterText: () => _tl('receiverFooter'),
1525
+ getFooterColor: () => COLORS.footerBg,
1526
+ };
1527
+ }