agentvibes 5.1.3 → 5.1.4

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.
@@ -51,4 +51,6 @@ _party_mode|compand 0.3,1 6:-70,-60,-20|agent_vibes_dark_chill_step_loop.mp3|0.4
51
51
  # Default (no agent specified) - clean with Bachata background|||
52
52
  default||agent_vibes_chillwave_v2_loop.mp3|0.15
53
53
  analyst|reverb 70 50 100|agentvibes_soft_flamenco_loop.mp3|0.30
54
- llm:claude-code|light|agent_vibes_chillwave_v2_loop.mp3|0.15|en_US-lessac-high|Claude Code here|piper
54
+ llm:claude-code|light|agent_vibes_chillwave_v2_loop.mp3|0.15|en_US-lessac-high|Claude Code here|piper
55
+ llm:copilot|light|agent_vibes_bossa_nova_v2_loop.mp3|0.15|en_US-libritts-high::Anna-11|Copilot here|piper
56
+ llm:codex|light|agent_vibes_chillwave_v2_loop.mp3|0.15|en_US-lessac-high|Codex here|piper
@@ -126,6 +126,16 @@ TEXT="${TEXT//\\?/?}" # Remove \?
126
126
  TEXT="${TEXT//\\,/,}" # Remove \,
127
127
  TEXT="${TEXT//\\./.}" # Remove \. (keep the period)
128
128
 
129
+ # When no --llm is supplied, route through the "default" pseudo-LLM so the
130
+ # user-managed `llm:default` row in audio-effects.cfg becomes the global
131
+ # fallback for voice / pretext / music / effects. This is configured via
132
+ # Setup → Default → Configure in the TUI. If `llm:default` doesn't exist,
133
+ # the lookup returns empty and the script falls through to the legacy
134
+ # global config chain (project / user .agentvibes/config.json).
135
+ if [[ -z "$LLM_PROVIDER" ]]; then
136
+ LLM_PROVIDER="default"
137
+ fi
138
+
129
139
  # Per-LLM config lookup: if --llm is passed, look up llm:<name> in audio-effects.cfg
130
140
  # Format: llm:<name>|REVERB_PRESET|BACKGROUND_FILE|BACKGROUND_VOLUME|VOICE|PRETEXT
131
141
  _LLM_VOICE=""
@@ -17,18 +17,141 @@ param(
17
17
  )
18
18
 
19
19
  # Security: Validate LLM provider name (alphanumeric, hyphens, underscores
20
- # only) mirrors play-tts.sh line 92. This prevents weird values from
20
+ # only) -- mirrors play-tts.sh line 92. This prevents weird values from
21
21
  # poisoning the audio-effects.cfg lookup or the AGENTVIBES_LLM_KEY env var
22
22
  # we export to child scripts. An invalid value is treated as unset rather
23
23
  # than aborting, so the script falls back to the default config and the
24
24
  # rest of TTS still works.
25
25
  if ($llm -and $llm -notmatch '^[a-zA-Z0-9_-]+$') {
26
- Write-Error "Invalid LLM provider name: '$llm' must match ^[a-zA-Z0-9_-]+$. Falling back to default config."
26
+ Write-Error ("Invalid LLM provider name: '{0}' - must match {1}. Falling back to default config." -f $llm, '^[a-zA-Z0-9_-]+$')
27
27
  $llm = ""
28
28
  }
29
29
 
30
+ # When no -llm is supplied, route through the "default" pseudo-LLM so the
31
+ # user-managed `llm:default` row in audio-effects.cfg becomes the global
32
+ # fallback for voice / pretext / music / effects. This is configured via
33
+ # Setup -> Default -> Configure in the TUI. If `llm:default` doesn't exist,
34
+ # the lookup will return empty and the script falls through to the
35
+ # legacy global config chain (project / user .agentvibes/config.json).
36
+ if (-not $llm) {
37
+ $llm = "default"
38
+ }
39
+
40
+ # --- Cross-process playback serialization ---
41
+ # Without this, any two callers of play-tts.ps1 (Claude Code PostToolUse hook,
42
+ # Codex MCP text_to_speech, Copilot MCP text_to_speech, direct CLI) race each
43
+ # other and produce overlapping / interleaved audio. Party mode already has
44
+ # its own mutex (AgentVibesPartyModeTTSQueue) at the bmad-party-speak.ps1
45
+ # level, but MCP-initiated calls bypass it entirely.
46
+ #
47
+ # We use a DIFFERENT mutex name ("AgentVibesPlaybackLock") so there's no
48
+ # deadlock risk with the party-mode mutex -- they can be held independently
49
+ # by nested processes.
50
+ #
51
+ # The mutex is acquired immediately before PlaySync() and released right
52
+ # after, so CPU-bound synthesis/ffmpeg work can overlap with another
53
+ # process's playback.
54
+ $_PlaybackMutex = New-Object System.Threading.Mutex($false, "AgentVibesPlaybackLock")
55
+
56
+ # --- Script-level watchdog ---
57
+ # If anything in this script hangs (SoundPlayer deadlock, audio device
58
+ # locked, ffmpeg stuck, etc.), a sibling PowerShell job waits 25 seconds
59
+ # and force-kills this process. Without this, a stuck play-tts.ps1 holds
60
+ # the playback mutex forever and silently blocks every subsequent TTS
61
+ # call across all LLMs. The watchdog guarantees forward progress.
62
+ #
63
+ # 25s is chosen to be LONGER than the mutex timeout (15s) but SHORT
64
+ # enough that a stuck process clears before the user's next turn. If
65
+ # you fire two calls per turn and the first is stuck, the watchdog kills
66
+ # it before the second turn arrives so the audio subsystem recovers
67
+ # without manual intervention. Long legitimate messages (>25s of speech)
68
+ # are rare at default verbosity levels; when they do occur the watchdog
69
+ # kills playback mid-sentence, which is acceptable degradation vs. a
70
+ # deadlocked queue.
71
+ $_WatchdogJob = $null
72
+ try {
73
+ $_WatchdogJob = Start-Job -ArgumentList $PID -ScriptBlock {
74
+ param($parentPid)
75
+ Start-Sleep -Seconds 25
76
+ try {
77
+ # Only kill if still alive -- harmless if already exited
78
+ $p = Get-Process -Id $parentPid -ErrorAction SilentlyContinue
79
+ if ($p) {
80
+ [Console]::Error.WriteLine("[AgentVibes] play-tts.ps1 watchdog fired -- force-killing pid $parentPid after 25s")
81
+ Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue
82
+ }
83
+ } catch { }
84
+ }
85
+ } catch {
86
+ # If Start-Job fails (rare), just continue without the watchdog -- no
87
+ # regression from pre-watchdog behavior.
88
+ $_WatchdogJob = $null
89
+ }
90
+
91
+ function Invoke-SerializedPlay {
92
+ param([Parameter(Mandatory)][string]$WavPath)
93
+ $acquired = $false
94
+ try {
95
+ try {
96
+ # 15s timeout to acquire the playback mutex. If we can't get
97
+ # it in 15s, the holder is almost certainly a stuck/crashed
98
+ # prior run. AbandonedMutexException means the holder's
99
+ # process actually died -- we inherit ownership.
100
+ $acquired = $_PlaybackMutex.WaitOne(15000)
101
+ } catch [System.Threading.AbandonedMutexException] {
102
+ $acquired = $true
103
+ }
104
+ if (-not $acquired) {
105
+ # Self-heal: kill any stuck play-tts.ps1 processes (other than
106
+ # ourselves) that have been alive longer than 20 seconds. This
107
+ # frees the mutex so the NEXT call can succeed without the user
108
+ # running taskkill manually. We still exit with code 2 because
109
+ # this call's audio is lost, but the queue recovers immediately.
110
+ try {
111
+ $myPid = $PID
112
+ $cutoff = (Get-Date).AddSeconds(-20)
113
+ $stuck = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
114
+ Where-Object {
115
+ $_.Name -eq 'powershell.exe' -and
116
+ $_.ProcessId -ne $myPid -and
117
+ $_.CommandLine -like '*play-tts.ps1*' -and
118
+ $_.CreationDate -lt $cutoff
119
+ }
120
+ foreach ($p in $stuck) {
121
+ [Console]::Error.WriteLine("[AgentVibes] Self-heal: killing stuck play-tts.ps1 pid $($p.ProcessId) (alive since $($p.CreationDate))")
122
+ Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
123
+ }
124
+ } catch { }
125
+ [Console]::Error.WriteLine("[AgentVibes] ERROR: play-tts.ps1 could not acquire playback mutex within 15s. A prior play-tts.ps1 process was stuck holding it and has been killed; the next TTS call should succeed.")
126
+ exit 2
127
+ }
128
+ $player = $null
129
+ try {
130
+ $player = New-Object System.Media.SoundPlayer $WavPath
131
+ $player.PlaySync()
132
+ } finally {
133
+ if ($player) { $player.Dispose() }
134
+ }
135
+ } finally {
136
+ if ($acquired) {
137
+ try { $_PlaybackMutex.ReleaseMutex() } catch { }
138
+ }
139
+ }
140
+ }
141
+
142
+ # Register an exit handler that stops the watchdog job on normal exit so
143
+ # it doesn't fire on successful short runs.
144
+ Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
145
+ try {
146
+ if ($_WatchdogJob) {
147
+ Stop-Job -Job $_WatchdogJob -ErrorAction SilentlyContinue
148
+ Remove-Job -Job $_WatchdogJob -Force -ErrorAction SilentlyContinue
149
+ }
150
+ } catch { }
151
+ } | Out-Null
152
+
30
153
  # Configuration paths
31
- # Priority: CLAUDE_PROJECT_DIR env var script's parent project user profile
154
+ # Priority: CLAUDE_PROJECT_DIR env var -> script's parent project -> user profile
32
155
  # Local project settings ALWAYS override global (~/.claude)
33
156
  $ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
34
157
 
@@ -58,16 +181,19 @@ if (Test-Path $MuteFile) {
58
181
  }
59
182
 
60
183
  # Per-LLM config lookup: if --llm is passed, look up llm:<name> in audio-effects.cfg
61
- # Format: llm:<name>|SOX_EFFECTS|BACKGROUND_FILE|BACKGROUND_VOLUME|VOICE|PRETEXT
184
+ # Format: llm:<name>|REVERB|BG_FILE|BG_VOLUME|VOICE|PRETEXT|ENGINE
62
185
  $LlmVoice = ""
63
186
  $LlmPretext = ""
64
187
  $LlmReverb = ""
65
188
  $LlmEngine = ""
189
+ $LlmBgTrack = ""
190
+ $LlmBgVolume = ""
66
191
  $ProjectRoot = Split-Path -Parent $ClaudeDir
67
192
  $ConfigDir = "$ClaudeDir\config"
68
193
 
69
194
  if ($llm) {
70
195
  $llmKey = "llm:$llm"
196
+ $llmKeyPattern = '^' + [regex]::Escape($llmKey) + '\|'
71
197
  # Check project-local audio-effects.cfg first, then global
72
198
  $cfgPaths = @(
73
199
  "$ConfigDir\audio-effects.cfg",
@@ -76,12 +202,18 @@ if ($llm) {
76
202
  foreach ($cfgPath in $cfgPaths) {
77
203
  if (-not $LlmVoice -and -not $LlmPretext -and (Test-Path $cfgPath)) {
78
204
  foreach ($line in (Get-Content $cfgPath)) {
79
- if ($line -match "^$([regex]::Escape($llmKey))\|") {
205
+ if ($line -match $llmKeyPattern) {
80
206
  $parts = $line -split '\|'
81
- # parts: [0]=key [1]=reverb_preset [2]=bg_file [3]=bg_vol [4]=voice [5]=pretext
207
+ # parts: [0]=key [1]=reverb [2]=bg_file [3]=bg_vol [4]=voice [5]=pretext [6]=engine
82
208
  if ($parts.Length -ge 2 -and $parts[1].Trim()) {
83
209
  $LlmReverb = $parts[1].Trim()
84
210
  }
211
+ if ($parts.Length -ge 3 -and $parts[2].Trim()) {
212
+ $LlmBgTrack = $parts[2].Trim()
213
+ }
214
+ if ($parts.Length -ge 4 -and $parts[3].Trim()) {
215
+ $LlmBgVolume = $parts[3].Trim()
216
+ }
85
217
  if ($parts.Length -ge 5 -and $parts[4].Trim()) {
86
218
  $LlmVoice = $parts[4].Trim()
87
219
  }
@@ -96,8 +228,21 @@ if ($llm) {
96
228
  }
97
229
  }
98
230
  }
99
- # Apply LLM voice override (only if no explicit VoiceOverride was passed)
100
- if ($LlmVoice -and -not $VoiceOverride) {
231
+ # LLM per-LLM voice routing.
232
+ #
233
+ # PRIORITY CHANGE: when -llm is passed AND the llm row has a voice,
234
+ # the per-LLM voice always wins — even over an explicit VoiceOverride
235
+ # parameter passed by the MCP caller. Rationale: Codex / Copilot /
236
+ # Claude Code all call `get_config` at session start and then echo
237
+ # the global voice back on every `text_to_speech` call. With the
238
+ # old "explicit wins" priority, that global voice overrode our
239
+ # per-LLM routing and broke the entire point of having llm:<key>
240
+ # rows in audio-effects.cfg.
241
+ #
242
+ # To request a specific voice for a specific call that bypasses the
243
+ # LLM routing, the caller should NOT pass -llm, or should use the
244
+ # `llm:default` row (which has no voice column to override).
245
+ if ($LlmVoice) {
101
246
  $VoiceOverride = $LlmVoice
102
247
  }
103
248
  # Export LLM key for child scripts (process-local, not system-wide)
@@ -105,8 +250,8 @@ if ($llm) {
105
250
  }
106
251
 
107
252
  # Prepend pretext if configured
108
- # Priority: LLM-specific pretext project .agentvibes/config.json project .claude/config/tts-pretext.txt
109
- # global ~/.agentvibes/config.json global ~/.claude/config/tts-pretext.txt
253
+ # Priority: LLM-specific pretext -> project .agentvibes/config.json -> project .claude/config/tts-pretext.txt
254
+ # -> global ~/.agentvibes/config.json -> global ~/.claude/config/tts-pretext.txt
110
255
  $Pretext = $LlmPretext
111
256
  if (-not $Pretext) {
112
257
  $PretextSources = @(
@@ -132,7 +277,6 @@ if (-not $Pretext) {
132
277
  if ($Pretext) {
133
278
  $Text = "$Pretext, $Text"
134
279
  }
135
-
136
280
  # Determine active provider
137
281
  # LLM-specific engine overrides global provider
138
282
  $ActiveProvider = "sapi"
@@ -194,6 +338,15 @@ if (Test-Path $AgentVibesConfig) {
194
338
  }
195
339
  }
196
340
 
341
+ # When a per-LLM row in audio-effects.cfg has a background track configured,
342
+ # that's an implicit "bg music enabled for this LLM" — force it on regardless
343
+ # of the global backgroundMusic.enabled flag. Without this, setting a per-LLM
344
+ # track in the TUI's Configure modal would have no effect unless the user
345
+ # ALSO toggled global bg music on.
346
+ if ($LlmBgTrack) {
347
+ $BgEnabled = $true
348
+ }
349
+
197
350
  # Check if reverb is enabled (allowlist validation)
198
351
  # LLM-specific reverb overrides global setting
199
352
  $ReverbLevel = "off"
@@ -227,7 +380,7 @@ if ($BgEnabled -or $HasReverb) {
227
380
  }
228
381
  }
229
382
 
230
- # Check for pre-synthesized WAV (party mode optimization synthesis done before mutex acquisition)
383
+ # Check for pre-synthesized WAV (party mode optimization -- synthesis done before mutex acquisition)
231
384
  $PreSynthWav = $env:AGENTVIBES_PRESYNTHESIZED_WAV
232
385
  $UsePreSynth = $PreSynthWav -and (Test-Path $PreSynthWav) -and
233
386
  (Get-Item $PreSynthWav -ErrorAction SilentlyContinue).Length -gt 0
@@ -245,14 +398,10 @@ if ($UsePreSynth) {
245
398
  Write-Host "[SYNTH] Using pre-synthesized audio..." -ForegroundColor Cyan
246
399
  # If no post-processing needed, play the pre-synth file directly and exit
247
400
  if (-not $NeedsPostProcess) {
248
- $player = $null
249
401
  try {
250
- $player = New-Object System.Media.SoundPlayer $PreSynthWav
251
- $player.PlaySync()
402
+ Invoke-SerializedPlay -WavPath $PreSynthWav
252
403
  } catch {
253
404
  Write-Host "[WARNING] Pre-synth playback failed: $_" -ForegroundColor Yellow
254
- } finally {
255
- if ($player) { $player.Dispose() }
256
405
  }
257
406
  Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
258
407
  exit 0
@@ -279,6 +428,25 @@ if ($UsePreSynth) {
279
428
  Write-Host "$item"
280
429
  }
281
430
  }
431
+ # Parse the provider output for "[OK] Saved to: <path>" so we can
432
+ # use the EXACT file the provider just wrote. This replaces the
433
+ # old "pick most recent tts-XXXXXXXX.wav" heuristic which would
434
+ # silently replay stale audio whenever synthesis failed.
435
+ $FreshSynthFile = $null
436
+ foreach ($item in $providerOutput) {
437
+ $line = if ($item -is [System.Management.Automation.InformationRecord]) {
438
+ $m = $item.MessageData
439
+ if ($m -is [System.Management.Automation.HostInformationMessage]) { $m.Message } else { "$item" }
440
+ } else { "$item" }
441
+ if ($line -match '^\[OK\] Saved to:\s*(.+\.wav)\s*$') {
442
+ $FreshSynthFile = $Matches[1].Trim()
443
+ }
444
+ }
445
+ if (-not $FreshSynthFile -or -not (Test-Path $FreshSynthFile)) {
446
+ [Console]::Error.WriteLine("[AgentVibes] ERROR: Provider synthesis did not produce an output file. NOT falling back to stale audio. Check provider logs above.")
447
+ Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
448
+ exit 3
449
+ }
282
450
  } else {
283
451
  if ($VoiceOverride) {
284
452
  & $ProviderScript $Text $VoiceOverride
@@ -298,13 +466,17 @@ if ($UsePreSynth) {
298
466
  if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
299
467
  Remove-Item env:AGENTVIBES_NO_PLAY -ErrorAction SilentlyContinue
300
468
 
301
- # Find the WAV to post-process: use pre-synthesized file if available, else most recent
469
+ # Use the EXACT file the provider script just wrote (captured from its
470
+ # "[OK] Saved to: <path>" output line above). The old "pick most recent
471
+ # tts-XXXXXXXX.wav" heuristic silently replayed stale audio whenever
472
+ # synthesis failed — there is no safe way to guess which file is fresh.
302
473
  $AudioDir = "$ClaudeDir\audio"
303
474
  $RecentWav = if ($UsePreSynth) {
304
475
  Get-Item $PreSynthWav -ErrorAction SilentlyContinue
476
+ } elseif ($FreshSynthFile -and (Test-Path $FreshSynthFile)) {
477
+ Get-Item $FreshSynthFile -ErrorAction SilentlyContinue
305
478
  } else {
306
- Get-ChildItem -Path $AudioDir -Filter "tts-*.wav" -ErrorAction SilentlyContinue |
307
- Sort-Object LastWriteTime -Descending | Select-Object -First 1
479
+ $null
308
480
  }
309
481
 
310
482
  if ($RecentWav -and $RecentWav.Length -gt 0) {
@@ -320,7 +492,10 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
320
492
  default { "" }
321
493
  }
322
494
  if ($reverbFilter) {
323
- $reverbedFile = "$AudioDir\tts-reverbed.wav"
495
+ # Use a fixed name OUTSIDE the `tts-XXXXXXXX` random-name
496
+ # namespace so the "pick most recent tts-*.wav" logic can't
497
+ # accidentally pick this post-processed file as a synth input.
498
+ $reverbedFile = "$AudioDir\av-reverbed-scratch.wav"
324
499
  $reverbArgs = "-y -i `"$voicePath`" -af `"$reverbFilter`" `"$reverbedFile`""
325
500
  $proc = Start-Process -FilePath "ffmpeg" -ArgumentList $reverbArgs -NoNewWindow -Wait -PassThru -RedirectStandardError "$env:TEMP\agentvibes-ffmpeg-stderr.txt"
326
501
  if ($proc.ExitCode -eq 0 -and (Test-Path $reverbedFile)) {
@@ -340,7 +515,7 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
340
515
  if (Test-Path $AudioEffectsCfg) {
341
516
  # Try agent-specific config first, then fall back to default
342
517
  # Format: AGENT_NAME|SOX_EFFECTS|BACKGROUND_FILE|BACKGROUND_VOLUME
343
- # Lookup order: agent name llm:<name> default
518
+ # Lookup order: agent name -> llm:<name> -> default
344
519
  $agentName = $env:AGENTVIBES_AGENT_NAME
345
520
  $configLine = $null
346
521
 
@@ -403,7 +578,14 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
403
578
  }
404
579
 
405
580
  if (Test-Path $BgTrackPath) {
406
- $MixedFile = $RecentWav.FullName -replace '\.wav$', '-mixed.wav'
581
+ # Mixed output goes to a fixed name OUTSIDE the tts-XXXXXXXX
582
+ # random-name namespace so the "pick most recent tts-*.wav"
583
+ # logic can't accidentally pick this as a synth input in the
584
+ # next invocation. (Previously we'd name this as
585
+ # "$voicePath-mixed.wav" which generated files like
586
+ # tts-xxx.wav.effected-mixed.wav that kept re-matching and
587
+ # compounding on every run.)
588
+ $MixedFile = "$AudioDir\av-mixed-scratch.wav"
407
589
 
408
590
  try {
409
591
  # Get voice duration to calculate total length
@@ -426,62 +608,28 @@ if (($BgEnabled -or $HasReverb) -and $HasFfmpeg) {
426
608
  $proc = Start-Process -FilePath "ffmpeg" -ArgumentList $ffmpegArgs -NoNewWindow -Wait -PassThru -RedirectStandardError "$env:TEMP\agentvibes-ffmpeg-stderr.txt"
427
609
 
428
610
  if ($proc.ExitCode -eq 0 -and (Test-Path $MixedFile) -and (Get-Item $MixedFile).Length -gt 0) {
429
- # Play the mixed audio
430
- $player = $null
611
+ # Play the mixed audio (via serialized mutex)
431
612
  try {
432
- $player = New-Object System.Media.SoundPlayer $MixedFile
433
- $player.PlaySync()
613
+ Invoke-SerializedPlay -WavPath $MixedFile
434
614
  } catch {
435
615
  Write-Host "[WARNING] Mixed playback failed, playing voice only" -ForegroundColor Yellow
436
- $player2 = $null
437
- try {
438
- $player2 = New-Object System.Media.SoundPlayer $voicePath
439
- $player2.PlaySync()
440
- } finally {
441
- if ($player2) { $player2.Dispose() }
442
- }
443
- } finally {
444
- if ($player) { $player.Dispose() }
616
+ Invoke-SerializedPlay -WavPath $voicePath
445
617
  }
446
618
  } else {
447
619
  # Mixing failed, play voice only
448
- $player = $null
449
- try {
450
- $player = New-Object System.Media.SoundPlayer $voicePath
451
- $player.PlaySync()
452
- } finally {
453
- if ($player) { $player.Dispose() }
454
- }
620
+ Invoke-SerializedPlay -WavPath $voicePath
455
621
  }
456
622
  } catch {
457
623
  # ffmpeg failed, play voice only
458
- $player = $null
459
- try {
460
- $player = New-Object System.Media.SoundPlayer $voicePath
461
- $player.PlaySync()
462
- } finally {
463
- if ($player) { $player.Dispose() }
464
- }
624
+ Invoke-SerializedPlay -WavPath $voicePath
465
625
  }
466
626
  } else {
467
627
  # No background track found, play voice only
468
- $player = $null
469
- try {
470
- $player = New-Object System.Media.SoundPlayer $voicePath
471
- $player.PlaySync()
472
- } finally {
473
- if ($player) { $player.Dispose() }
474
- }
628
+ Invoke-SerializedPlay -WavPath $voicePath
475
629
  }
476
630
  } else {
477
631
  # No background music, play the (possibly reverbed) voice
478
- $player = $null
479
- try {
480
- $player = New-Object System.Media.SoundPlayer $voicePath
481
- $player.PlaySync()
482
- } finally {
483
- if ($player) { $player.Dispose() }
484
- }
632
+ Invoke-SerializedPlay -WavPath $voicePath
485
633
  }
486
634
  }
487
635
  } else {
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Publish](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml/badge.svg)](https://github.com/paulpreibisch/AgentVibes/actions/workflows/publish.yml)
12
12
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
13
13
 
14
- **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.1.3
14
+ **Author**: Paul Preibisch ([@997Fire](https://x.com/997Fire)) | **Version**: v5.1.4
15
15
 
16
16
  ---
17
17
 
@@ -43,6 +43,21 @@ Whether you're using Claude Code, GitHub Copilot, OpenAI Codex, Claude Desktop,
43
43
 
44
44
  ---
45
45
 
46
+ ## 🛡️ NEW IN v5.1.4 — TTS Resilience Overhaul + Default LLM Provider
47
+
48
+ - **Default LLM provider** — New fallback entry at the bottom of Setup → Providers. Config-only; opens the standard Configure modal. Used when a tool calls TTS without identifying its LLM.
49
+ - **Per-LLM background music auto-enables** — Setting a bg track on the per-LLM Configure modal actually plays it now (no need to also toggle global bg music).
50
+ - **Copilot CLI support** — `installCopilotMcp` now writes both `.vscode/mcp.json` (Copilot Chat) AND `~/.copilot/mcp-config.json` (Copilot CLI — different product, different config path).
51
+ - **Per-client routing architecture** — `.mcp.json` no longer sets `AGENTVIBES_LLM`. Claude Code is auto-detected via `CLAUDECODE=1` env var. Copilot CLI reads its own global config. No more client config conflicts.
52
+ - **Self-healing TTS mutex** — When a stuck `play-tts.ps1` process blocks the playback queue, the next caller auto-kills it (no manual `taskkill` needed). 25-second watchdog guarantees forward progress.
53
+ - **No more stale audio replay** — `play-tts.ps1` captures the exact synth output filename from provider stdout instead of guessing "most recent `tts-*.wav`". Silent replay of old audio is gone.
54
+ - **Per-LLM voice wins over explicit `VoiceOverride`** — LLMs echo back `get_config` results on every call, which was overriding per-LLM routing. Fixed.
55
+ - **`lessac-medium` → `lessac-high`** default for codex — Silent synthesis failure workaround.
56
+ - **Scratch file rename + ASCII-only encoding** — Eliminates accumulating compound audio files and CP1252 parse errors on Windows.
57
+ - **Setup → Install confirmation** now advances focus to the next provider row (Install → Install → Install flow).
58
+
59
+ ---
60
+
46
61
  ## 🛡️ v5.1.3 — Hardening Pass (Adversarial Review Followup)
47
62
 
48
63
  - **Existing `.mcp.json` is now auto-migrated** — v5.1.2's installer detected an existing `.mcp.json` and printed instructions instead of fixing it, leaving v5.1.0/v5.1.1 users still broken after upgrade. v5.1.3 merges the `AGENTVIBES_LLM` env var into existing configs in-place.
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## 🛡️ v5.1.4 — TTS Resilience Overhaul + Default LLM Provider + Per-Client Routing
4
+
5
+ **Release Date:** April 2026
6
+
7
+ This release closes a long-running cluster of bugs around per-LLM TTS routing, parallel audio playback, stuck-process deadlocks, and stale-audio replay. It also adds a new "Default" provider slot in the Setup tab for fallback audio configuration, and switches to a per-client config scheme that correctly routes Claude Code, GitHub Copilot (Chat + CLI), and OpenAI Codex to their own voices and pretexts.
8
+
9
+ ### New Features
10
+
11
+ - **Default LLM provider** — New entry at the bottom of Setup → Providers. Config-only (no install/remove buttons) with a Configure button that opens the standard per-LLM audio modal. When any tool calls TTS without identifying its LLM, the `llm:default` row in `audio-effects.cfg` provides the fallback voice, pretext, music, reverb, and engine. Empty pretext by default — users opt in.
12
+
13
+ - **Per-LLM background music now auto-enables** — Setting a `bg_track` on any per-LLM Configure modal now actually plays that track. Previously you also had to toggle the global `backgroundMusic.enabled` flag, which made the per-LLM bg track field silently dead.
14
+
15
+ - **Copilot CLI support** — `installCopilotMcp` now writes BOTH `.vscode/mcp.json` (for VS Code Copilot Chat) AND `~/.copilot/mcp-config.json` (for the standalone `copilot` CLI, which is a different product). Fresh installs support both tools automatically.
16
+
17
+ ### Per-Client Routing Architecture
18
+
19
+ Previously `AGENTVIBES_LLM=claude-code` was set in `.mcp.json`, which broke Copilot CLI because Copilot CLI also reads `.mcp.json` with precedence over its own `~/.copilot/mcp-config.json` — so it adopted Claude Code's env and mis-routed.
20
+
21
+ The new architecture splits per-LLM identification per-client:
22
+
23
+ - `.mcp.json` (project) has **no `AGENTVIBES_LLM` env block**
24
+ - `~/.copilot/mcp-config.json` sets `AGENTVIBES_LLM=copilot` for GitHub Copilot CLI
25
+ - `~/.codex/config.toml` sets `AGENTVIBES_LLM=codex` for OpenAI Codex
26
+ - The MCP server (`mcp-server/server.py`) **auto-detects Claude Code** via the `CLAUDECODE=1` env var that Claude Code sets on every subprocess it spawns. Copilot CLI and Codex don't set this var, so each client reliably routes to its own config.
27
+
28
+ Upgrade path: re-run the AgentVibes installer in any existing project. The new `installClaudeMcp` auto-strips any stale `AGENTVIBES_LLM` env block from existing `.mcp.json` files so Copilot CLI stops mis-routing.
29
+
30
+ ### TTS Resilience Overhaul (`play-tts.ps1`)
31
+
32
+ - **Cross-process playback mutex** — `AgentVibesPlaybackLock` (named mutex) serializes playback across all callers: Claude Code hooks, MCP `text_to_speech`, direct CLI, party mode. No more overlapping or parallel audio when multiple LLMs run in the same project.
33
+
34
+ - **Self-healing on mutex timeout** — When the 15-second mutex acquisition fails, the new code queries `Win32_Process` for any stuck `play-tts.ps1` process older than 20 seconds, calls `Stop-Process -Force` on each, and logs the kill to stderr. The next TTS call succeeds immediately — no manual `taskkill` needed.
35
+
36
+ - **25-second script watchdog** — A sibling PowerShell job force-kills `play-tts.ps1` after 25 seconds regardless of where it's stuck (SoundPlayer deadlock, locked audio device, zombie ffmpeg). Guarantees forward progress.
37
+
38
+ - **Hard error on mutex timeout** — Replaces the old "play anyway" fallback which just stacked more stuck processes behind the first. Now exits cleanly with code 2 and a diagnostic message.
39
+
40
+ - **Exact filename capture from provider stdout** — `play-tts.ps1` parses the `[OK] Saved to: <path>` line from `play-tts-piper.ps1` and uses that exact file. Replaces the old "pick most recent `tts-*.wav` in the audio dir" heuristic which silently replayed stale audio from earlier sessions whenever synthesis failed. Root cause of the "Codex speaks Claude Code's audio" bug.
41
+
42
+ - **Synthesis-failure hard error** — When the provider doesn't produce an output file, `play-tts.ps1` exits with code 3 and a loud error instead of falling back to any older file in the cache.
43
+
44
+ - **Scratch file rename** — Reverb post-processing now writes to `av-reverbed-scratch.wav` and mix post-processing writes to `av-mixed-scratch.wav`. Fixed names outside the `tts-XXXXXXXX` random namespace so the file lookup can never pick them up as a synth input. Eliminates the compound `tts-xxx.wav.effected-mixed-mixed-mixed-mixed.wav` chain that accumulated on every run.
45
+
46
+ - **Per-LLM voice overrides explicit `VoiceOverride`** — LLMs call `get_config` at session start and echo the returned voice back on every `text_to_speech` call as an explicit MCP parameter. With the old "explicit wins" priority, that global voice overrode per-LLM routing. Now the `llm:<key>` voice row always wins when `-llm` is set.
47
+
48
+ - **`$LlmBgTrack` and `$LlmBgVolume` parsed from cfg row** — Previously fields 2 and 3 of the `llm:<key>` line were ignored in the PowerShell version (the bash version already parsed them).
49
+
50
+ - **Per-LLM `bg_track` force-enables `$BgEnabled`** — When a per-LLM row specifies a background track, it's auto-enabled regardless of the global `backgroundMusic.enabled` flag.
51
+
52
+ - **ASCII-only encoding** — Removed em-dash `—` and right-arrow `→` from `play-tts.ps1`. PowerShell on some Windows locales loads scripts in CP1252 and choked on the UTF-8 bytes with a misleading "Missing closing `}`" parse error.
53
+
54
+ - **`lessac-medium` → `lessac-high` default for codex** — `en_US-lessac-medium` silently fails to synthesize on some Windows Piper installs (loads the model, exits with empty output). `lessac-high` works reliably and is the new default in `DEFAULT_LLM_CONFIGS.codex` and the packaged `audio-effects.cfg`.
55
+
56
+ ### UX Improvements
57
+
58
+ - **Setup → Providers Install confirmation** — Pressing Enter to dismiss the post-install confirmation page now advances focus to the NEXT provider row's same-column button (Install → Install, Configure → Configure), instead of snapping back to Claude Code's Install. Installing all three providers is now a natural Enter-Enter-Enter flow.
59
+
60
+ ### Testing & Hardening
61
+
62
+ - **30 new/updated regression tests** in `test/unit/llm-provider-mcp-routing.test.js` and `test/unit/windows-tts.test.js` covering:
63
+ - Default LLM provider config shape + setup-tab wiring
64
+ - `CLAUDECODE` auto-detection in `server.py`
65
+ - `.mcp.json` template MUST NOT contain `AGENTVIBES_LLM`
66
+ - `~/.copilot/mcp-config.json` writer in `installCopilotMcp`
67
+ - `play-tts.ps1` PowerShell-parseable (catches em-dash / encoding regressions via `[System.Management.Automation.Language.Parser]`)
68
+
69
+ ### How to Update
70
+
71
+ ```
72
+ npm cache clean --force
73
+ npx --yes agentvibes@5.1.4
74
+ ```
75
+
76
+ If you have any existing project with AgentVibes installed, re-run the installer there once so the per-client config migration takes effect. The `.mcp.json` in each project will be auto-upgraded to strip the stale `AGENTVIBES_LLM` env block, and `~/.copilot/mcp-config.json` will be created if you have the Copilot provider enabled.
77
+
78
+ ---
79
+
3
80
  ## 🛡️ v5.1.3 — Hardening Pass (Adversarial Review Followup)
4
81
 
5
82
  **Release Date:** April 2026
@@ -51,8 +51,6 @@ from typing import Optional
51
51
  from mcp.server import Server
52
52
  from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
53
53
  import mcp.server.stdio
54
-
55
-
56
54
  class AgentVibesServer:
57
55
  """MCP Server for AgentVibes TTS functionality"""
58
56
 
@@ -196,29 +194,38 @@ class AgentVibesServer:
196
194
 
197
195
  # Call the TTS script via appropriate shell.
198
196
  #
199
- # The LLM key is read from the AGENTVIBES_LLM env var so each
200
- # caller (Claude Code, GitHub Copilot, OpenAI Codex) gets routed
201
- # to its own per-LLM voice / pretext / music / effects config.
202
- # The MCP launcher in each provider's config file should set
203
- # this env var (e.g. .codex/config.toml [mcp_servers.agentvibes]
204
- # env = { AGENTVIBES_LLM = "codex" }).
197
+ # The LLM key is determined with the following priority:
198
+ #
199
+ # 1. AGENTVIBES_LLM env var (explicit wins over everything).
200
+ # Set in each provider's MCP launcher config:
201
+ # ~/.codex/config.toml -> "codex"
202
+ # ~/.copilot/mcp-config.json -> "copilot"
203
+ #
204
+ # 2. Auto-detection via CLAUDECODE=1 env var (Claude Code sets
205
+ # this automatically when it spawns any subprocess). This
206
+ # lets project-level .mcp.json files omit the env block —
207
+ # which is critical because GitHub Copilot CLI also reads
208
+ # .mcp.json and would otherwise pick up claude-code's env.
209
+ #
210
+ # 3. Unset — play-tts falls back to llm:default or global config.
205
211
  #
206
- # If unset OR invalid, no -llm flag is passed and play-tts falls
207
- # back to the project / global default config. Validation
208
- # mirrors play-tts.sh line 92 alphanumeric / hyphen /
209
- # underscore only. This prevents weird values from poisoning
210
- # the audio-effects.cfg lookup or being forwarded as ambiguous
211
- # arguments to the child shell.
212
+ # Validation mirrors play-tts.sh line 92 alphanumeric /
213
+ # hyphen / underscore only. Prevents weird values from
214
+ # poisoning the audio-effects.cfg lookup or child-shell args.
212
215
  import re as _re
213
216
  llm_key = os.environ.get("AGENTVIBES_LLM", "").strip()
214
217
  if llm_key and not _re.match(r"^[a-zA-Z0-9_-]+$", llm_key):
215
- # Invalid value — log to stderr and treat as unset
216
218
  print(
217
219
  f"[AgentVibes] WARN: Ignoring invalid AGENTVIBES_LLM='{llm_key}' "
218
- "(must match ^[a-zA-Z0-9_-]+$); falling back to default config",
220
+ "(must match ^[a-zA-Z0-9_-]+$); falling back to auto-detect",
219
221
  file=__import__('sys').stderr,
220
222
  )
221
223
  llm_key = ""
224
+ # Claude Code sets CLAUDECODE=1 when it spawns subprocesses.
225
+ # Use that as a fallback identifier when no explicit env var
226
+ # was provided. Copilot CLI and Codex do NOT set this.
227
+ if not llm_key and os.environ.get("CLAUDECODE", "").strip() == "1":
228
+ llm_key = "claude-code"
222
229
  tts_script = "play-tts.ps1" if self.is_windows else "play-tts.sh"
223
230
  play_tts = self.hooks_dir / tts_script
224
231
  if self.is_windows:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.1.3",
4
+ "version": "5.1.4",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -565,6 +565,13 @@ export function createSetupTab(screen, services) {
565
565
  },
566
566
  });
567
567
 
568
+ // The "default" provider is config-only — it has no install/remove
569
+ // semantics. Hide those buttons and only show Configure.
570
+ if (provider.isDefault) {
571
+ installBtn.hide();
572
+ removeBtn.hide();
573
+ }
574
+
568
575
  // Wire actions
569
576
  installBtn.on('press', async () => { await handleProviderInstall(provider); });
570
577
  installBtn.key(['enter', 'space'], async () => { await handleProviderInstall(provider); });
@@ -575,8 +582,10 @@ export function createSetupTab(screen, services) {
575
582
  configBtn.on('press', async () => { await handleProviderConfigure(provider); });
576
583
  configBtn.key(['enter', 'space'], async () => { await handleProviderConfigure(provider); });
577
584
 
578
- // Navigation on each button
579
- for (const btn of [installBtn, removeBtn, configBtn]) {
585
+ // Navigation on each button — for the default provider, only Configure
586
+ // is focusable since install/remove are hidden.
587
+ const navButtons = provider.isDefault ? [configBtn] : [installBtn, removeBtn, configBtn];
588
+ for (const btn of navButtons) {
580
589
  btn.key(['tab', 'right'], () => { cycleFocus(1); });
581
590
  btn.key(['S-tab', 'left'], () => { cycleFocus(-1); });
582
591
  btn.key(['escape'], () => {
@@ -609,10 +618,17 @@ export function createSetupTab(screen, services) {
609
618
  return { installBtn, removeBtn, configBtn };
610
619
  }
611
620
 
612
- // Build all provider rows
621
+ // Build all provider rows.
622
+ // For the default provider, install/remove are hidden — push configBtn
623
+ // three times so the row-of-3 arrow-nav arithmetic still works (every
624
+ // "slot" in the default row lands on Configure, the only visible button).
613
625
  for (let i = 0; i < PROVIDERS.length; i++) {
614
626
  const { installBtn, removeBtn, configBtn } = createProviderRow(PROVIDERS[i], i);
615
- providerFocusableItems.push(installBtn, removeBtn, configBtn);
627
+ if (PROVIDERS[i].isDefault) {
628
+ providerFocusableItems.push(configBtn, configBtn, configBtn);
629
+ } else {
630
+ providerFocusableItems.push(installBtn, removeBtn, configBtn);
631
+ }
616
632
  }
617
633
 
618
634
  function cycleFocus(dir) {
@@ -624,6 +640,10 @@ export function createSetupTab(screen, services) {
624
640
  // ── Provider install/remove handlers ──────────────────────────────────────
625
641
 
626
642
  async function handleProviderInstall(provider) {
643
+ // Remember which button the user was on so we can advance focus to
644
+ // the NEXT row (same column) after they dismiss the info page.
645
+ _preInfoFocusIndex = providerFocusIndex;
646
+
627
647
  if (provider.id === 'claude-code') {
628
648
  const wasInstalled = installedState[provider.id];
629
649
  const result = await installClaudeMcp(targetDir);
@@ -643,7 +663,6 @@ export function createSetupTab(screen, services) {
643
663
  if (provider.id === 'openai-codex') {
644
664
  const wasInstalled = installedState[provider.id];
645
665
  const result = await installCodexMcp(targetDir);
646
- await installCopilotMcp(targetDir);
647
666
  await installCodexInstructions(targetDir, packageDir);
648
667
  await installCodexHooks(targetDir, packageDir);
649
668
  await refreshInstalledState();
@@ -652,6 +671,10 @@ export function createSetupTab(screen, services) {
652
671
  }
653
672
 
654
673
  async function handleProviderRemove(provider) {
674
+ // Remember which button the user was on so we can advance focus to
675
+ // the NEXT row (same column) after they dismiss the info page.
676
+ _preInfoFocusIndex = providerFocusIndex;
677
+
655
678
  if (provider.id === 'claude-code') {
656
679
  const result = await uninstallClaude(targetDir);
657
680
  await refreshInstalledState();
@@ -668,7 +691,6 @@ export function createSetupTab(screen, services) {
668
691
 
669
692
  if (provider.id === 'openai-codex') {
670
693
  await removeCodexMcp(targetDir);
671
- await removeCopilotMcp(targetDir);
672
694
  await removeCodexInstructions(targetDir);
673
695
  await removeCodexHooks(targetDir);
674
696
  await refreshInstalledState();
@@ -683,6 +705,7 @@ export function createSetupTab(screen, services) {
683
705
  'claude-code': 'claude-code',
684
706
  'github-copilot': 'copilot',
685
707
  'openai-codex': 'codex',
708
+ 'default': 'default',
686
709
  };
687
710
  const llmKey = llmKeyMap[provider.id] || provider.id;
688
711
  const config = loadLlmConfigSync(llmKey, targetDir);
@@ -701,6 +724,7 @@ export function createSetupTab(screen, services) {
701
724
  'claude-code': 'Claude Code here',
702
725
  'copilot': 'Copilot here',
703
726
  'codex': 'Codex here',
727
+ 'default': '', // empty by default — user sets via Configure
704
728
  };
705
729
 
706
730
  // Read global defaults for display
@@ -1432,10 +1456,18 @@ export function createSetupTab(screen, services) {
1432
1456
  function showAllProviderRows() {
1433
1457
  providerHeader.show();
1434
1458
  for (const row of providerRows) {
1459
+ const provider = PROVIDERS.find(p => p.id === row.id);
1435
1460
  row.label.show();
1436
1461
  row.statusText.show();
1437
- row.installBtn.show();
1438
- row.removeBtn.show();
1462
+ // The "default" provider has no install/remove semantics — keep its
1463
+ // install/remove buttons hidden so only Configure shows.
1464
+ if (provider?.isDefault) {
1465
+ row.installBtn.hide();
1466
+ row.removeBtn.hide();
1467
+ } else {
1468
+ row.installBtn.show();
1469
+ row.removeBtn.show();
1470
+ }
1439
1471
  row.configBtn.show();
1440
1472
  }
1441
1473
  }
@@ -1587,28 +1619,53 @@ export function createSetupTab(screen, services) {
1587
1619
  screen.render();
1588
1620
  }
1589
1621
 
1590
- function showProviderListView() {
1622
+ function showProviderListView(targetIdx = 0) {
1591
1623
  providerView = 'list';
1592
1624
  infoBox.hide();
1593
1625
  contentBox.hide();
1594
1626
  showAllProviderRows();
1595
- providerFocusIndex = 0;
1596
- if (providerFocusableItems.length) providerFocusableItems[0].focus();
1627
+ const max = providerFocusableItems.length;
1628
+ if (max > 0) {
1629
+ providerFocusIndex = ((targetIdx % max) + max) % max;
1630
+ providerFocusableItems[providerFocusIndex].focus();
1631
+ }
1597
1632
  screen.render();
1598
1633
  }
1599
1634
 
1600
1635
  infoBox.key(['escape', 'enter'], () => {
1601
- showProviderListView();
1636
+ // After dismissing the install/remove info page, advance focus to the
1637
+ // NEXT provider row but keep the same column (Install stays on Install,
1638
+ // Remove stays on Remove, Configure stays on Configure). Each row has
1639
+ // 3 focusable slots so +3 moves one full row down with wraparound.
1640
+ const max = providerFocusableItems.length;
1641
+ const nextIdx = max > 0 ? (_preInfoFocusIndex + 3) % max : 0;
1642
+ showProviderListView(nextIdx);
1602
1643
  });
1603
1644
 
1645
+ // Captured by handleProviderInstall/Remove right before showing info.
1646
+ // Defaults to 0 so the first-time flow still lands on Claude Code Install.
1647
+ let _preInfoFocusIndex = 0;
1648
+
1604
1649
  async function refreshInstalledState() {
1605
1650
  for (const p of PROVIDERS) {
1651
+ // The "default" provider is config-only — always treat as available.
1652
+ if (p.isDefault) {
1653
+ installedState[p.id] = true;
1654
+ continue;
1655
+ }
1606
1656
  const checkFn = p.id === 'claude-code' ? checkClaudeInstalled
1607
1657
  : p.id === 'github-copilot' ? checkCopilotInstalled
1608
1658
  : checkCodexInstalled;
1609
1659
  installedState[p.id] = await checkFn(targetDir);
1610
1660
  }
1611
1661
  for (const row of providerRows) {
1662
+ const provider = PROVIDERS.find(p => p.id === row.id);
1663
+ // The default provider has no install state to display — show its
1664
+ // config-only nature instead.
1665
+ if (provider?.isDefault) {
1666
+ row.statusText.setContent('{cyan-fg}[Config Only]{/cyan-fg}');
1667
+ continue;
1668
+ }
1612
1669
  const installed = installedState[row.id];
1613
1670
  row.statusText.setContent(
1614
1671
  installed
package/src/installer.js CHANGED
@@ -4298,14 +4298,17 @@ async function handleMcpConfiguration(targetDir, options) {
4298
4298
  const mcpConfigPath = path.join(targetDir, '.mcp.json');
4299
4299
 
4300
4300
  // MCP server configuration for AgentVibes.
4301
- // The AGENTVIBES_LLM env var tells the MCP server which LLM is calling
4302
- // so per-LLM voice / pretext / music / effects routing works correctly.
4301
+ //
4302
+ // No `env.AGENTVIBES_LLM` block: GitHub Copilot CLI also reads project
4303
+ // `.mcp.json` with precedence over its own `~/.copilot/mcp-config.json`,
4304
+ // so setting `claude-code` here would mis-route Copilot CLI too.
4305
+ // Instead, the MCP server auto-detects Claude Code via the `CLAUDECODE=1`
4306
+ // env var that Claude Code sets on every subprocess it spawns.
4303
4307
  const mcpConfig = {
4304
4308
  mcpServers: {
4305
4309
  agentvibes: {
4306
4310
  command: 'npx',
4307
- args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
4308
- env: { AGENTVIBES_LLM: 'claude-code' }
4311
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server']
4309
4312
  }
4310
4313
  }
4311
4314
  };
@@ -4320,10 +4323,13 @@ async function handleMcpConfiguration(targetDir, options) {
4320
4323
  }
4321
4324
 
4322
4325
  if (mcpExists) {
4323
- // Existing config: try to migrate it in-place so users upgrading from
4324
- // v5.1.x get the AGENTVIBES_LLM env var added without manual editing.
4325
- // This is the v5.1.2 follow-up fix — the prior version returned here
4326
- // and only printed instructions, leaving existing users broken.
4326
+ // Existing config: upgrade it in-place. Two jobs:
4327
+ // 1. Ensure the agentvibes server entry exists.
4328
+ // 2. STRIP any stale `env.AGENTVIBES_LLM` from earlier versions
4329
+ // (v5.1.2..v5.1.4) setting it in `.mcp.json` broke Copilot CLI
4330
+ // routing because Copilot CLI also reads `.mcp.json` and would
4331
+ // adopt claude-code's env. Claude Code is now auto-detected
4332
+ // downstream via the CLAUDECODE=1 env var.
4327
4333
  let migrated = false;
4328
4334
  let migrationError = null;
4329
4335
  try {
@@ -4334,20 +4340,21 @@ async function handleMcpConfiguration(targetDir, options) {
4334
4340
  existingCfg.mcpServers = {};
4335
4341
  }
4336
4342
  const current = existingCfg.mcpServers.agentvibes;
4337
- const wantEnv = { AGENTVIBES_LLM: 'claude-code' };
4338
- // Decide whether we need to write — only if the agentvibes entry
4339
- // is missing OR its env doesn't include the AGENTVIBES_LLM key.
4340
- const needsWrite =
4341
- !current ||
4342
- !current.env ||
4343
- current.env.AGENTVIBES_LLM !== 'claude-code';
4343
+ const hasStaleEnv = current?.env?.AGENTVIBES_LLM !== undefined;
4344
+ const needsWrite = !current || hasStaleEnv;
4344
4345
  if (needsWrite) {
4345
- existingCfg.mcpServers.agentvibes = {
4346
+ // Preserve any OTHER env keys the user added manually (rare) but
4347
+ // drop AGENTVIBES_LLM so Copilot CLI doesn't mis-route.
4348
+ const cleanEnv = { ...(current?.env ?? {}) };
4349
+ delete cleanEnv.AGENTVIBES_LLM;
4350
+ const newEntry = {
4346
4351
  command: 'npx',
4347
4352
  args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
4348
- // Preserve any other env keys the user added manually.
4349
- env: { ...(current?.env ?? {}), ...wantEnv },
4350
4353
  };
4354
+ if (Object.keys(cleanEnv).length > 0) {
4355
+ newEntry.env = cleanEnv;
4356
+ }
4357
+ existingCfg.mcpServers.agentvibes = newEntry;
4351
4358
  await fs.writeFile(mcpConfigPath, JSON.stringify(existingCfg, null, 2) + '\n');
4352
4359
  migrated = true;
4353
4360
  }
@@ -4360,9 +4367,8 @@ async function handleMcpConfiguration(targetDir, options) {
4360
4367
  console.log(
4361
4368
  boxen(
4362
4369
  chalk.green.bold('✅ MCP Configuration Updated\n\n') +
4363
- chalk.white('Your existing ') + chalk.cyan('.mcp.json') + chalk.white(' has been updated\n') +
4364
- chalk.white('with the AgentVibes MCP server entry, including the\n') +
4365
- chalk.cyan('AGENTVIBES_LLM=claude-code') + chalk.white(' env var for per-LLM routing.'),
4370
+ chalk.white('Your existing ') + chalk.cyan('.mcp.json') + chalk.white(' has been updated.\n') +
4371
+ chalk.white('Claude Code is auto-detected via ') + chalk.cyan('CLAUDECODE=1') + chalk.white(' at runtime.'),
4366
4372
  {
4367
4373
  padding: 1,
4368
4374
  margin: { top: 1, bottom: 1, left: 0, right: 0 },
@@ -4397,8 +4403,7 @@ async function handleMcpConfiguration(targetDir, options) {
4397
4403
  console.log(
4398
4404
  '\n"agentvibes": {\n' +
4399
4405
  ' "command": "npx",\n' +
4400
- ' "args": ["-y", "--package=agentvibes", "agentvibes-mcp-server"],\n' +
4401
- ' "env": { "AGENTVIBES_LLM": "claude-code" }\n' +
4406
+ ' "args": ["-y", "--package=agentvibes", "agentvibes-mcp-server"]\n' +
4402
4407
  '}\n'
4403
4408
  );
4404
4409
 
@@ -4503,8 +4508,7 @@ async function handleMcpConfiguration(targetDir, options) {
4503
4508
  ' "mcpServers": {\n' +
4504
4509
  ' "agentvibes": {\n' +
4505
4510
  ' "command": "npx",\n' +
4506
- ' "args": ["-y", "--package=agentvibes", "agentvibes-mcp-server"],\n' +
4507
- ' "env": { "AGENTVIBES_LLM": "claude-code" }\n' +
4511
+ ' "args": ["-y", "--package=agentvibes", "agentvibes-mcp-server"]\n' +
4508
4512
  ' }\n' +
4509
4513
  ' }\n' +
4510
4514
  '}\n'
@@ -27,8 +27,66 @@ export const PROVIDERS = [
27
27
  name: 'OpenAI Codex',
28
28
  desc: 'OpenAI CLI agent — .codex/config.toml + AGENTS.md',
29
29
  },
30
+ {
31
+ id: 'default',
32
+ name: 'Default (Fallback)',
33
+ desc: 'Used when any tool calls TTS without identifying its LLM',
34
+ // No install/uninstall — this is a config-only entry
35
+ isDefault: true,
36
+ },
30
37
  ];
31
38
 
39
+ const DEFAULT_LLM_CONFIGS = {
40
+ // Fallback used when play-tts is invoked with no -llm flag. Pretext is
41
+ // empty by default — users edit it via Setup → Default → Configure. When
42
+ // empty, no prefix is prepended at all.
43
+ default: {
44
+ effects: 'light',
45
+ bgTrack: '',
46
+ bgVolume: '0.15',
47
+ voice: 'en_US-lessac-high',
48
+ pretext: '',
49
+ ttsEngine: 'piper',
50
+ },
51
+ 'claude-code': {
52
+ effects: 'light',
53
+ bgTrack: 'agent_vibes_chillwave_v2_loop.mp3',
54
+ bgVolume: '0.15',
55
+ voice: 'en_US-lessac-high',
56
+ pretext: 'Claude Code here',
57
+ ttsEngine: 'piper',
58
+ },
59
+ copilot: {
60
+ effects: 'light',
61
+ bgTrack: 'agent_vibes_bossa_nova_v2_loop.mp3',
62
+ bgVolume: '0.15',
63
+ voice: 'en_US-libritts-high::Anna-11',
64
+ pretext: 'Copilot here',
65
+ ttsEngine: 'piper',
66
+ },
67
+ codex: {
68
+ effects: 'light',
69
+ bgTrack: 'agent_vibes_chillwave_v2_loop.mp3',
70
+ bgVolume: '0.15',
71
+ // NOTE: lessac-medium appears to silently fail to synthesize on some
72
+ // Windows Piper installs (loads the model, exits with no output).
73
+ // lessac-high works reliably, so use it as the default for codex.
74
+ voice: 'en_US-lessac-high',
75
+ pretext: 'Codex here',
76
+ ttsEngine: 'piper',
77
+ },
78
+ };
79
+
80
+ function ensureDefaultLlmConfigSync(llmKey, targetDir) {
81
+ const existing = loadLlmConfigSync(llmKey, targetDir);
82
+ if (existing.sourcePath) return;
83
+
84
+ const defaults = DEFAULT_LLM_CONFIGS[llmKey];
85
+ if (!defaults) return;
86
+
87
+ saveLlmConfigSync(llmKey, defaults, targetDir);
88
+ }
89
+
32
90
  // ── Provider install-checks ─────────────────────────────────────────────────
33
91
 
34
92
  export async function checkClaudeInstalled(targetDir) {
@@ -73,12 +131,24 @@ export async function checkCodexInstalled(targetDir) {
73
131
  export async function installClaudeMcp(targetDir) {
74
132
  const mcpConfigPath = path.join(targetDir, '.mcp.json');
75
133
 
134
+ // The agentvibes server entry for Claude Code's .mcp.json.
135
+ //
136
+ // IMPORTANT: no `env.AGENTVIBES_LLM` block here. GitHub Copilot CLI
137
+ // also reads project-level `.mcp.json` with precedence over its own
138
+ // `~/.copilot/mcp-config.json` — so if we set `AGENTVIBES_LLM=claude-code`
139
+ // in `.mcp.json`, Copilot CLI picks up that value too and mis-routes.
140
+ // Instead, the MCP server (mcp-server/server.py) auto-detects Claude
141
+ // Code via the `CLAUDECODE=1` env var that Claude Code sets on every
142
+ // subprocess it spawns. Copilot CLI does NOT set that var, so its
143
+ // spawned MCP server correctly falls back to its own config.
144
+ const agentvibesServer = {
145
+ command: 'npx',
146
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
147
+ };
148
+
76
149
  const mcpConfig = {
77
150
  mcpServers: {
78
- agentvibes: {
79
- command: 'npx',
80
- args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
81
- },
151
+ agentvibes: agentvibesServer,
82
152
  },
83
153
  };
84
154
 
@@ -86,15 +156,15 @@ export async function installClaudeMcp(targetDir) {
86
156
  let mcpCreated = false;
87
157
  try {
88
158
  await fs.access(mcpConfigPath);
89
- // Already exists — merge agentvibes key if missing
159
+ // Already exists — merge / upgrade the agentvibes entry. This also
160
+ // STRIPS any stale AGENTVIBES_LLM env block left over from v5.1.2..4
161
+ // so Copilot CLI stops mis-routing.
90
162
  try {
91
163
  const existing = JSON.parse(await fs.readFile(mcpConfigPath, 'utf8'));
92
- if (!existing.mcpServers?.agentvibes) {
93
- existing.mcpServers = existing.mcpServers || {};
94
- existing.mcpServers.agentvibes = mcpConfig.mcpServers.agentvibes;
95
- await fs.writeFile(mcpConfigPath, JSON.stringify(existing, null, 2) + '\n');
96
- mcpCreated = true;
97
- }
164
+ existing.mcpServers = existing.mcpServers || {};
165
+ existing.mcpServers.agentvibes = { ...agentvibesServer };
166
+ await fs.writeFile(mcpConfigPath, JSON.stringify(existing, null, 2) + '\n');
167
+ mcpCreated = true;
98
168
  } catch { /* parse error — don't corrupt */ }
99
169
  } catch {
100
170
  // File doesn't exist — create it
@@ -112,6 +182,7 @@ export async function installClaudeMcp(targetDir) {
112
182
  await installer.copyPluginFiles(targetDir, silentSpinner);
113
183
  await installer.copyBmadConfigFiles(targetDir, silentSpinner);
114
184
  await installer.copyBackgroundMusicFiles(targetDir, silentSpinner);
185
+ ensureDefaultLlmConfigSync('claude-code', targetDir);
115
186
 
116
187
  return { success: true, mcpCreated };
117
188
  } catch (err) {
@@ -247,6 +318,37 @@ export async function installCopilotMcp(targetDir) {
247
318
 
248
319
  mcpConfig.servers.agentvibes = agentvibesServer;
249
320
  await fs.writeFile(mcpJsonPath, JSON.stringify(mcpConfig, null, 2) + '\n');
321
+
322
+ // Also write ~/.copilot/mcp-config.json so the GitHub Copilot CLI
323
+ // (different product from VS Code Copilot Chat!) can find the
324
+ // agentvibes MCP server. VS Code reads .vscode/mcp.json, but the
325
+ // CLI reads ONLY from ~/.copilot/mcp-config.json per docs:
326
+ // https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers
327
+ try {
328
+ const copilotHome = process.env.COPILOT_HOME ||
329
+ path.join(process.env.USERPROFILE || process.env.HOME || '', '.copilot');
330
+ const copilotMcpPath = path.join(copilotHome, 'mcp-config.json');
331
+ await fs.mkdir(copilotHome, { recursive: true });
332
+ let cliConfig = { mcpServers: {} };
333
+ try {
334
+ const existingCli = await fs.readFile(copilotMcpPath, 'utf8');
335
+ const parsedCli = JSON.parse(existingCli);
336
+ if (parsedCli && typeof parsedCli === 'object') {
337
+ cliConfig = parsedCli;
338
+ if (!cliConfig.mcpServers) cliConfig.mcpServers = {};
339
+ }
340
+ } catch { /* new file */ }
341
+ cliConfig.mcpServers.agentvibes = {
342
+ type: 'local',
343
+ command: 'npx',
344
+ args: ['-y', '--package=agentvibes', 'agentvibes-mcp-server'],
345
+ env: { AGENTVIBES_LLM: 'copilot' },
346
+ tools: ['*'],
347
+ };
348
+ await fs.writeFile(copilotMcpPath, JSON.stringify(cliConfig, null, 2) + '\n');
349
+ } catch { /* best effort — CLI might not be installed */ }
350
+
351
+ ensureDefaultLlmConfigSync('copilot', targetDir);
250
352
  return { success: true };
251
353
  } catch (err) {
252
354
  return { success: false, error: err.message };
@@ -300,6 +402,7 @@ export async function installCodexMcp(targetDir) {
300
402
  try { existing = await fs.readFile(tomlPath, 'utf8'); } catch { /* new file */ }
301
403
  const content = buildCodexToml(existing);
302
404
  await fs.writeFile(tomlPath, content);
405
+ ensureDefaultLlmConfigSync('codex', targetDir);
303
406
  return { success: true };
304
407
  } catch (err) {
305
408
  return { success: false, error: err.message };