agentvibes 4.2.0 → 4.4.1

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