agentvibes 5.12.0 → 5.13.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 (73) hide show
  1. package/.agentvibes/install-manifest.json +111 -91
  2. package/.claude/commands/agent-vibes/commands.json +0 -20
  3. package/.claude/commands/agent-vibes/unmute.md +6 -2
  4. package/.claude/config/audio-effects.cfg +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/agentvibes-session-id.sh +69 -0
  7. package/.claude/hooks/bmad-party-speak.sh +20 -4
  8. package/.claude/hooks/bmad-speak.sh +60 -2
  9. package/.claude/hooks/bmad-tts-injector.sh +20 -1
  10. package/.claude/hooks/bmad-voice-manager.sh +25 -3
  11. package/.claude/hooks/clawdbot-receiver-SECURE.sh +21 -2
  12. package/.claude/hooks/clawdbot-receiver.sh +19 -1
  13. package/.claude/hooks/elevenlabs-voices.sh +62 -0
  14. package/.claude/hooks/kokoro-installer.sh +20 -10
  15. package/.claude/hooks/language-manager.sh +10 -3
  16. package/.claude/hooks/party-set-room.sh +71 -0
  17. package/.claude/hooks/party-stage-roster.py +328 -0
  18. package/.claude/hooks/personality-manager.sh +19 -2
  19. package/.claude/hooks/piper-voice-manager.sh +3 -2
  20. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +24 -5
  21. package/.claude/hooks/play-tts-elevenlabs.sh +38 -118
  22. package/.claude/hooks/play-tts-kokoro.sh +33 -6
  23. package/.claude/hooks/play-tts-soprano.sh +3 -2
  24. package/.claude/hooks/play-tts-ssh-remote.sh +37 -29
  25. package/.claude/hooks/play-tts-termux-ssh.sh +5 -4
  26. package/.claude/hooks/play-tts.sh +66 -61
  27. package/.claude/hooks/provider-catalog.json +352 -0
  28. package/.claude/hooks/provider-catalog.sh +161 -0
  29. package/.claude/hooks/provider-commands.sh +2 -1
  30. package/.claude/hooks/provider-manager.sh +47 -9
  31. package/.claude/hooks/python-resolver.sh +117 -0
  32. package/.claude/hooks/session-id.sh +56 -0
  33. package/.claude/hooks/session-start-tts.sh +39 -0
  34. package/.claude/hooks/speed-manager.sh +1 -1
  35. package/.claude/hooks/translate-manager.sh +3 -2
  36. package/.claude/hooks/translator.py +1 -1
  37. package/.claude/hooks/voice-manager.sh +242 -10
  38. package/.claude/hooks-windows/language-manager.ps1 +7 -1
  39. package/.claude/hooks-windows/personality-manager.ps1 +16 -1
  40. package/.claude/hooks-windows/play-tts-kokoro.ps1 +20 -4
  41. package/.claude/hooks-windows/play-tts.ps1 +32 -3
  42. package/.claude/hooks-windows/provider-catalog.ps1 +140 -0
  43. package/.claude/hooks-windows/provider-manager.ps1 +63 -8
  44. package/.claude/hooks-windows/tts-watcher.ps1 +33 -12
  45. package/.claude/hooks-windows/voice-manager-windows.ps1 +49 -0
  46. package/.mcp.json +0 -7
  47. package/README.md +12 -3
  48. package/RELEASE_NOTES.md +61 -0
  49. package/mcp-server/server.py +146 -49
  50. package/mcp-server/test_mcp_correctness.py +20 -2
  51. package/mcp-server/test_windows_script_parity.py +0 -2
  52. package/package.json +2 -2
  53. package/src/cli/list-voices.js +218 -114
  54. package/src/console/bling.js +71 -0
  55. package/src/console/music-preview.js +79 -0
  56. package/src/console/tabs/music-tab.js +16 -39
  57. package/src/console/tabs/settings-tab.js +195 -13
  58. package/src/console/tabs/setup-tab.js +9 -34
  59. package/src/console/tabs/voices-tab.js +83 -14
  60. package/src/console/widgets/track-picker.js +82 -0
  61. package/src/installer.js +136 -18
  62. package/src/services/provider-catalog.js +412 -0
  63. package/src/services/provider-voice-catalog.js +52 -73
  64. package/src/services/tts-engine-service.js +29 -0
  65. package/src/utils/provider-validator.js +62 -12
  66. package/.claude/commands/agent-vibes/language.md +0 -23
  67. package/.claude/commands/agent-vibes/learn.md +0 -67
  68. package/.claude/commands/agent-vibes/replay-target.md +0 -14
  69. package/.claude/commands/agent-vibes/target-voice.md +0 -26
  70. package/.claude/commands/agent-vibes/target.md +0 -30
  71. package/.claude/hooks/learn-manager.sh +0 -492
  72. package/.claude/hooks/replay-target-audio.sh +0 -95
  73. package/.claude/hooks-windows/learn-manager.ps1 +0 -241
@@ -15,6 +15,40 @@ import { spawn } from 'node:child_process';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { buildAudioEnv, detectWavPlayer, getAllWavPlayers } from '../audio-env.js';
17
17
  import { SURNAME_POOL, uniquifyVoiceName } from '../../utils/voice-names.js';
18
+ import { voicesForProvider, ELEVENLABS_VOICES, KOKORO_VOICE_IDS, WINDOWS_SAPI_VOICES, MACOS_VOICES, kokoroGender } from '../../services/provider-voice-catalog.js';
19
+ import { getProvider as _catalogGetProvider } from '../../services/provider-catalog.js';
20
+
21
+ /**
22
+ * Resolve display name / gender / provider label for a NON-Piper catalog voice
23
+ * (Kokoro or ElevenLabs) from the Provider Catalog SSOT. Returns null for Piper
24
+ * ids so getVoiceMeta falls through to its Piper resolution. This keeps the
25
+ * picker's labels/gender correct once non-Piper voices are listed — otherwise
26
+ * a raw ElevenLabs id (EXAVITQu4vr4xnSDxMaL) would render as its own name,
27
+ * gender '—', provider 'Piper'.
28
+ */
29
+ function catalogVoiceMeta(voiceId) {
30
+ const el = ELEVENLABS_VOICES.find(v => v.id === voiceId);
31
+ if (el) return { displayName: el.name || voiceId, gender: el.gender || '—', provider: 'ElevenLabs' };
32
+ if (KOKORO_VOICE_IDS.includes(voiceId)) {
33
+ // Kokoro ids look like af_heart / am_michael; title-case for display.
34
+ const pretty = voiceId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
35
+ return { displayName: pretty, gender: kokoroGender(voiceId) || '—', provider: 'Kokoro' };
36
+ }
37
+ // OS-native voices (Windows SAPI "Microsoft David Desktop", macOS "Samantha").
38
+ const sapi = WINDOWS_SAPI_VOICES.find(v => v.id === voiceId);
39
+ if (sapi) return { displayName: sapi.name, gender: sapi.gender || '—', provider: 'Windows SAPI' };
40
+ const mac = MACOS_VOICES.find(v => v.id === voiceId);
41
+ if (mac) return { displayName: mac.name, gender: mac.gender || '—', provider: 'macOS Say' };
42
+ // Single-voice / native providers whose voice id IS the provider id or alias
43
+ // (soprano, windows-sapi/sapi, macos-say/say). Without this they fall through to
44
+ // the Piper resolution below and get mislabeled "Piper" (the soprano bug).
45
+ const prov = _catalogGetProvider(voiceId);
46
+ if (prov && prov.engineId !== 'piper') {
47
+ const label = prov.displayName.replace(/\s+TTS$/, '');
48
+ return { displayName: label, gender: '—', provider: label };
49
+ }
50
+ return null;
51
+ }
18
52
 
19
53
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
54
 
@@ -603,6 +637,12 @@ export function getVoiceMeta(voiceId) {
603
637
  // Lazy-load the catalog so callers from any tab get uniquified names
604
638
  loadCatalog();
605
639
 
640
+ // Non-Piper catalog voices (Kokoro / ElevenLabs) resolve from the Provider
641
+ // Catalog SSOT — real name, real gender, correct provider label — before the
642
+ // Piper-only paths below (which would mislabel them).
643
+ const nonPiper = catalogVoiceMeta(voiceId);
644
+ if (nonPiper) { _metaCache.set(voiceId, nonPiper); return nonPiper; }
645
+
606
646
  const ms = parseMultiSpeaker(voiceId);
607
647
  if (ms.isMultiSpeaker) {
608
648
  if (!ms.speakerName) {
@@ -987,7 +1027,15 @@ export function createVoicesTab(screen, services) {
987
1027
  }
988
1028
  } catch {}
989
1029
 
990
- if (_remoteProviders.includes(activeProvider)) {
1030
+ // Route through the engine-aware play-tts for ANY non-Piper provider — remote
1031
+ // transports (ssh-remote / agentvibes-receiver) AND local non-Piper engines
1032
+ // (kokoro / elevenlabs / soprano / macos). Only Piper (and unset = default)
1033
+ // uses the fast local piper path below; play-tts.sh/.ps1 dispatches to the
1034
+ // correct engine so Kokoro/ElevenLabs previews actually synthesize instead of
1035
+ // erroring against a nonexistent <id>.onnx piper model.
1036
+ const _isPiperLocal = (activeProvider === '' || activeProvider === 'piper' || activeProvider === 'windows-piper');
1037
+ if (!_isPiperLocal) {
1038
+ const _remote = _remoteProviders.includes(activeProvider);
991
1039
  const isWindows = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
992
1040
  const phrase = `Hi, my name is ${getVoiceMeta(voiceId).displayName}.`;
993
1041
  // Hooks live in the AgentVibes package (projectRoot), not the user's project dir.
@@ -1017,7 +1065,7 @@ export function createVoicesTab(screen, services) {
1017
1065
  _playingProcess = proc;
1018
1066
  _playingVoiceId = voiceId;
1019
1067
  refreshDisplay();
1020
- previewLine.setContent('{bright-magenta-fg}♪ Synthesizing on remote...{/bright-magenta-fg}');
1068
+ previewLine.setContent(`{bright-magenta-fg}♪ Synthesizing${_remote ? ' on remote' : ''}...{/bright-magenta-fg}`);
1021
1069
  screen.render();
1022
1070
  let _stderrBuf = '';
1023
1071
  if (proc.stderr) {
@@ -1688,10 +1736,16 @@ export function createVoicesTab(screen, services) {
1688
1736
  // genderIconTag has invisible color tags — pad with literal spaces (1 visible char + 3 spaces = 4)
1689
1737
  const gIcon = genderIconTag(gender);
1690
1738
  if (!installed) {
1691
- // Greyed-out row for uninstalled catalog voices close grey wrap around the icon so its colors show
1692
- return `{bright-black-fg} ${star} ${name}{/bright-black-fg}${gIcon} {bright-black-fg}${provider}{/bright-black-fg}`;
1739
+ // Greyed-out row for uninstalled catalog voices. Use a mid blue-grey (not
1740
+ // bright-black) so the row stays legible when selected — bright-black on the
1741
+ // green selection bg is unreadable. Close the grey wrap around the icon so
1742
+ // its own colors show.
1743
+ return `{#90a4ae-fg} ${star} ${name}{/#90a4ae-fg}${gIcon} {#90a4ae-fg}${provider}{/#90a4ae-fg}`;
1693
1744
  }
1694
- return `{${COLORS.labelFg}-fg} ${star}${dot} ${name}{/${COLORS.labelFg}-fg}${gIcon} {${COLORS.labelFg}-fg}${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}{/${COLORS.labelFg}-fg}`;
1745
+ // No fg wrap on the name/provider: unselected rows inherit the list's item.fg
1746
+ // (labelFg); the selected row inherits selected.fg (#ffffff bold) so the
1747
+ // highlight is white-on-green and readable, instead of pale text on green.
1748
+ return ` ${star}${dot} ${name}${gIcon} ${provider}${isPrev ? ` ${_tl('voicePlaying')}` : ''}`;
1695
1749
  });
1696
1750
  }
1697
1751
 
@@ -1742,15 +1796,30 @@ export function createVoicesTab(screen, services) {
1742
1796
  _metaCache.clear();
1743
1797
  }
1744
1798
 
1745
- // Installed voices (from local disk)
1746
- const installed = scanInstalledVoices();
1747
- _installedSet = new Set(installed);
1748
-
1749
- // Merge: installed voices first, then uninstalled catalog voices
1750
- const catalogOnly = _catalogEntries
1751
- .filter(c => !_installedSet.has(c.voiceId))
1752
- .map(c => c.voiceId);
1753
- _allVoices = [...installed, ...catalogOnly];
1799
+ // Provider-aware voice list. Piper (disk-discovered) keeps the installer
1800
+ // behavior — installed voices plus uninstalled catalog voices to download.
1801
+ // For Kokoro/ElevenLabs/soprano the voices come from the Provider Catalog
1802
+ // (via voicesForProvider) and are all available (nothing to "install"), so
1803
+ // mark them installed to suppress the download UI.
1804
+ // Prefer the explicitly-chosen Default TTS Engine (config.ttsEngine) over the
1805
+ // provider (which may be a transport like agentvibes-receiver, not an engine).
1806
+ const _vtProvider = configService?.getConfig?.()?.ttsEngine
1807
+ || providerService?.getActiveProvider?.() || 'piper';
1808
+ const _vtIsDisk = (_vtProvider === 'piper' || _vtProvider === 'windows-piper');
1809
+ if (_vtIsDisk) {
1810
+ // Installed voices (from local disk)
1811
+ const installed = scanInstalledVoices();
1812
+ _installedSet = new Set(installed);
1813
+
1814
+ // Merge: installed voices first, then uninstalled catalog voices
1815
+ const catalogOnly = _catalogEntries
1816
+ .filter(c => !_installedSet.has(c.voiceId))
1817
+ .map(c => c.voiceId);
1818
+ _allVoices = [...installed, ...catalogOnly];
1819
+ } else {
1820
+ _allVoices = voicesForProvider(_vtProvider, { scanInstalledVoices, getVoiceMeta }).map(v => v.id);
1821
+ _installedSet = new Set(_allVoices);
1822
+ }
1754
1823
 
1755
1824
  const active = providerService.getActiveVoiceId();
1756
1825
  const favorites = getFavorites(configService);
@@ -8,11 +8,19 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
11
12
  import { destroyList } from './destroy-list.js';
12
13
  import { BRAND_PINK } from '../brand-colors.js';
13
14
  import { renderHelpBar, selectorTitle } from './help-bar.js';
14
15
  import { formatTrackName } from './format-utils.js';
15
16
  import { buildAudioEnv, spawnMp3Player } from '../audio-env.js';
17
+ import { resolveMusicProvider, spawnMusicRemote } from '../music-preview.js';
18
+ import { playBlingCue } from '../bling.js';
19
+
20
+ // AgentVibes package/repo root — resolves bundled assets (bling cue) and is the
21
+ // package fallback when resolving the active provider for remote forwarding.
22
+ const _WIDGET_DIR = path.dirname(fileURLToPath(import.meta.url));
23
+ const _PKG_ROOT = path.resolve(_WIDGET_DIR, '..', '..', '..');
16
24
 
17
25
  const IS_TEST = process.env.AGENTVIBES_TEST_MODE === 'true';
18
26
  let blessed;
@@ -237,6 +245,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
237
245
  const _spawnEnv = buildAudioEnv();
238
246
  let _previewProc = null;
239
247
  let _previewTrackId = null;
248
+ // Remote playback is fire-and-forget (the local sender exits in ms while the
249
+ // receiver keeps playing), so the toggle tracks the intended remote track
250
+ // here rather than relying on a live local process.
251
+ let _remotePreviewTrackId = null;
240
252
 
241
253
  function _killPreview() {
242
254
  if (_previewProc) {
@@ -244,9 +256,75 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
244
256
  _previewProc = null;
245
257
  }
246
258
  _previewTrackId = null;
259
+ // Stop any remote preview so closing/selecting doesn't leave music playing
260
+ // on the receiver (the local equivalent of killing the player process).
261
+ if (_remotePreviewTrackId) {
262
+ const mp = resolveMusicProvider(_PKG_ROOT);
263
+ if (mp.remote) _sendRemote(mp, { stop: true });
264
+ _remotePreviewTrackId = null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Forward a music track to the receiver (or stop it), mirroring the Music
270
+ * tab's remote-preview behavior. Fire-and-forget; UI errors surface in the
271
+ * title status area. Returns true if the send was launched.
272
+ * @param {{remote:boolean, projectDir:string}} mp
273
+ * @param {{track?:string, stop?:boolean}} opts
274
+ */
275
+ function _sendRemote(mp, { track = null, stop = false } = {}) {
276
+ let proc;
277
+ try {
278
+ proc = spawnMusicRemote({ packageRoot: _PKG_ROOT, projectDir: mp.projectDir, env: _spawnEnv, track, stop });
279
+ } catch {
280
+ if (!stop) {
281
+ _setStatus(' {red-fg}Remote preview failed{/red-fg} ');
282
+ setTimeout(() => { _setStatus(); }, 4000);
283
+ }
284
+ return false;
285
+ }
286
+ let _rerr = '';
287
+ if (proc.stderr) proc.stderr.on('data', d => { _rerr += d.toString(); });
288
+ proc.on('exit', (code) => {
289
+ if (stop || code === 0) return;
290
+ if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
291
+ const msg = _rerr.trim().split('\n').pop() || 'Remote preview failed';
292
+ _setStatus(` {red-fg}♪ ${msg}{/red-fg} `);
293
+ setTimeout(() => { _setStatus(); }, 4000);
294
+ });
295
+ proc.on('error', () => {
296
+ if (stop) return;
297
+ if (_remotePreviewTrackId === track) _remotePreviewTrackId = null;
298
+ _setStatus(' {red-fg}Remote preview failed{/red-fg} ');
299
+ setTimeout(() => { _setStatus(); }, 4000);
300
+ });
301
+ return true;
247
302
  }
248
303
 
249
304
  function _previewTrack(trackFile) {
305
+ // Transport provider: forward to the receiver instead of local playback
306
+ // (which is silent on a headless box) — parity with the Music tab.
307
+ const _mp = resolveMusicProvider(_PKG_ROOT);
308
+ if (_mp.remote) {
309
+ // Toggle off if same track → explicit music-stop signal
310
+ if (_remotePreviewTrackId === trackFile) {
311
+ _sendRemote(_mp, { stop: true });
312
+ _remotePreviewTrackId = null;
313
+ _setStatus();
314
+ return;
315
+ }
316
+ // Bling first (fire-and-forget, plays locally like the voice preview),
317
+ // then forward the track to the receiver.
318
+ playBlingCue(_PKG_ROOT);
319
+ if (_sendRemote(_mp, { track: trackFile })) {
320
+ _remotePreviewTrackId = trackFile;
321
+ const rlabel = tracks.find(t => t.file === trackFile)?.label ?? trackFile;
322
+ _setStatus(` {bright-cyan-fg}♪ ${rlabel} (Space to stop){/bright-cyan-fg} `);
323
+ }
324
+ return;
325
+ }
326
+
327
+ // ── Local playback ──────────────────────────────────────────────────────
250
328
  // Toggle off if same track
251
329
  if (_previewTrackId === trackFile) {
252
330
  _killPreview();
@@ -266,6 +344,10 @@ export function openTrackPicker(screen, currentTrack, currentVolume, onSelect, o
266
344
  return;
267
345
  }
268
346
 
347
+ // Bling first (fire-and-forget) — same readiness cue as the voice preview —
348
+ // then start local playback.
349
+ playBlingCue(_PKG_ROOT);
350
+
269
351
  const proc = spawnMp3Player(trackPath, _spawnEnv);
270
352
  if (!proc) {
271
353
  _setStatus(' {red-fg}No MP3 player found{/red-fg} ');
package/src/installer.js CHANGED
@@ -141,6 +141,33 @@ async function saveManifest(manifestPath, files) {
141
141
  // • dest hash == manifest hash → copy (action: 'updated')
142
142
  // • dest hash != manifest hash → skip (action: 'skipped', .user.bak saved)
143
143
  // • no manifest entry yet → copy (action: 'updated')
144
+ // Preserve destPath's CURRENT content in a sidecar backup before anything
145
+ // overwrites it. Never clobbers an existing .user.bak — that backup may hold the
146
+ // only surviving copy of an earlier customization; a second backup gets a
147
+ // content-stamped name instead. Returns true only when the current content is
148
+ // safely stored somewhere, so callers can refuse to overwrite when it isn't.
149
+ async function backupUserFile(destPath) {
150
+ const bak = `${destPath}.user.bak`;
151
+ try {
152
+ const currentHash = await computeFileHash(destPath);
153
+ if (!currentHash) return false;
154
+
155
+ const existingHash = await computeFileHash(bak);
156
+ if (existingHash === currentHash) return true; // already backed up
157
+
158
+ if (existingHash) {
159
+ // A different backup exists — keep it and stamp this one.
160
+ await fs.copyFile(destPath, `${destPath}.user.bak.${currentHash.slice(0, 8)}`);
161
+ return true;
162
+ }
163
+
164
+ await fs.copyFile(destPath, bak);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
144
171
  async function manifestSafeCopy(srcPath, destPath, manifest) {
145
172
  const srcHash = await computeFileHash(srcPath);
146
173
  if (!srcHash) return { action: 'skipped', hash: null }; // src missing
@@ -157,15 +184,29 @@ async function manifestSafeCopy(srcPath, destPath, manifest) {
157
184
  }
158
185
 
159
186
  const manifestHash = manifest[destPath]?.hash;
160
- if (manifestHash && destHash !== manifestHash) {
161
- // User modified the file since we last installed it — preserve it
162
- try { await fs.copyFile(destPath, destPath + '.user.bak'); } catch { /* best effort */ }
163
- return { action: 'skipped', hash: destHash };
187
+
188
+ if (manifestHash) {
189
+ if (destHash !== manifestHash) {
190
+ // User modified the file since we last installed it — preserve it
191
+ await backupUserFile(destPath);
192
+ return { action: 'skipped', hash: destHash };
193
+ }
194
+ // Byte-identical to what we last installed — untouched, ours to update
195
+ await fs.copyFile(srcPath, destPath);
196
+ return { action: 'updated', hash: srcHash };
164
197
  }
165
198
 
166
- // Stock file (or no manifest entry yet) safe to overwrite
199
+ // No manifest entry, and dest differs from what we ship. This is either a
200
+ // user's customized file or an older stock copy predating the manifest — the
201
+ // two are indistinguishable, so we must assume it could be a customization.
202
+ // Back it up before overwriting, and if the backup can't be secured, refuse
203
+ // to overwrite at all rather than destroy the only copy.
204
+ // (CLAUDE.md: never overwrite existing user .claude/ config.)
205
+ if (!(await backupUserFile(destPath))) {
206
+ return { action: 'skipped', hash: destHash };
207
+ }
167
208
  await fs.copyFile(srcPath, destPath);
168
- return { action: 'updated', hash: srcHash };
209
+ return { action: 'updated', hash: srcHash, backedUp: true };
169
210
  }
170
211
 
171
212
  // Delete only the files listed in manifest that reside under baseDir.
@@ -3593,14 +3634,17 @@ async function copyCommandFiles(targetDir, spinner) {
3593
3634
  */
3594
3635
  function shouldIncludeHookFile(file, stat) {
3595
3636
  if (isNativeWindows()) {
3596
- // Include .ps1 scripts, .py helpers, and hooks.json; exclude dotfiles and prepare-release
3637
+ // Include .ps1 scripts, .py helpers, hooks.json, and the generated
3638
+ // provider-catalog.json; exclude dotfiles and prepare-release
3597
3639
  return stat.isFile() &&
3598
- (file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json') &&
3640
+ (file.endsWith('.ps1') || file.endsWith('.py') || file === 'hooks.json' ||
3641
+ file === 'provider-catalog.json') &&
3599
3642
  !file.includes('prepare-release') &&
3600
3643
  !file.startsWith('.');
3601
3644
  }
3602
3645
  return stat.isFile() &&
3603
- (file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json') &&
3646
+ (file.endsWith('.sh') || file.endsWith('.py') || file === 'hooks.json' ||
3647
+ file === 'provider-catalog.json') &&
3604
3648
  !file.includes('prepare-release') &&
3605
3649
  !file.startsWith('.');
3606
3650
  }
@@ -5606,10 +5650,14 @@ async function performUpdateOperations(targetDir, spinner) {
5606
5650
  console.log(chalk.green(`✓ Updated ${resolverResult.count} resolver bundle files`));
5607
5651
  }
5608
5652
 
5609
- // Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs)
5610
- const hooksSubdir = isNativeWindows() ? 'hooks-windows' : 'hooks';
5611
- const srcHooksDir = path.join(__dirname, '..', '.claude', hooksSubdir);
5612
- const globalHooksUpdated = await updateGlobalHooks(srcHooksDir);
5653
+ // Also update critical hooks in global ~/.claude/hooks/ if present (fixes stale installs).
5654
+ // ALWAYS pass the unix hooks dir: updateGlobalHooks copies CRITICAL_HOOKS (all .sh)
5655
+ // from here and derives hooks-windows itself for CRITICAL_HOOKS_WINDOWS. Passing
5656
+ // hooks-windows on Windows made every .sh lookup miss and hit the "src missing"
5657
+ // catch, so ~/.claude/hooks/*.sh — which git-bash hooks actually execute on
5658
+ // Windows — silently never updated (installs stayed frozen at their first version).
5659
+ const srcGlobalHooksDir = path.join(__dirname, '..', '.claude', 'hooks');
5660
+ const globalHooksUpdated = await updateGlobalHooks(srcGlobalHooksDir);
5613
5661
  if (globalHooksUpdated > 0) {
5614
5662
  console.log(chalk.green(`✓ Updated ${globalHooksUpdated} critical scripts in ~/.claude/hooks/`));
5615
5663
  }
@@ -5668,6 +5716,16 @@ async function performUpdateOperations(targetDir, spinner) {
5668
5716
  spinner.text = 'Checking for old configuration...';
5669
5717
  await detectAndMigrateOldConfig(targetDir, spinner);
5670
5718
 
5719
+ // Upgrade safety for the new opt-in injection gate (session-start-tts.sh):
5720
+ // a project that was already installed and talking pre-gate has no marker, so
5721
+ // the gate would newly silence it. Backfill the enable marker for a PROJECT
5722
+ // update so existing users keep their audio. A GLOBAL (home-dir) update stays
5723
+ // opt-in — and we say so, otherwise the user's voice just stops with no clue.
5724
+ if ((await configureInjectionScope(targetDir)) === 'global') {
5725
+ console.log('');
5726
+ warnGlobalInjectionScope();
5727
+ }
5728
+
5671
5729
  return {
5672
5730
  commandCount,
5673
5731
  hookCount: hookResult.count,
@@ -5721,6 +5779,61 @@ async function updateAgentVibes(targetDir, options) {
5721
5779
  }
5722
5780
  }
5723
5781
 
5782
+ /**
5783
+ * Configure the session-start injection opt-in marker for this install/update.
5784
+ *
5785
+ * - PROJECT install → drop `.claude/agentvibes-enabled` so session-start-tts.sh
5786
+ * injects the TTS protocol for THIS project (and only this one), preserving the
5787
+ * "install in a project and it just works" experience.
5788
+ * - GLOBAL install (target === home dir) → write nothing; injecting into every
5789
+ * session is exactly the "cacophony of agents" we are preventing.
5790
+ *
5791
+ * Uses `agentvibes-enabled`, NOT `agentvibes-unmuted`, on purpose: the unmuted
5792
+ * marker OVERRIDES a global mute in play-tts.sh, so writing it here would let an
5793
+ * install/update silently defeat a user's `~/.agentvibes-muted` kill-switch.
5794
+ * Non-destructive: never touches an existing mute/unmute/enabled choice.
5795
+ *
5796
+ * @param {string} targetDir - install target
5797
+ * @returns {Promise<'global'|'enabled'|'kept'|'error'>} what happened (for messaging)
5798
+ */
5799
+ async function configureInjectionScope(targetDir) {
5800
+ try {
5801
+ // path.relative is case-insensitive on win32 and normalizes separators, so a
5802
+ // lowercase drive letter or trailing slash no longer misdetects a home
5803
+ // install; realpath (best-effort) also collapses a symlinked $HOME.
5804
+ let resolvedTarget = path.resolve(targetDir);
5805
+ let resolvedHome = path.resolve(os.homedir());
5806
+ try { resolvedTarget = fsSync.realpathSync.native(resolvedTarget); } catch { /* dir may not exist yet */ }
5807
+ try { resolvedHome = fsSync.realpathSync.native(resolvedHome); } catch { /* ignore */ }
5808
+ if (path.relative(resolvedHome, resolvedTarget) === '') return 'global';
5809
+
5810
+ const claudeDir = path.join(targetDir, '.claude');
5811
+ // Respect any prior explicit choice — enabled OR either mute/unmute marker.
5812
+ for (const m of ['agentvibes-enabled', 'agentvibes-unmuted', 'agentvibes-muted']) {
5813
+ if (await fs.access(path.join(claudeDir, m)).then(() => true).catch(() => false)) {
5814
+ return 'kept';
5815
+ }
5816
+ }
5817
+ await fs.mkdir(claudeDir, { recursive: true });
5818
+ await fs.writeFile(path.join(claudeDir, 'agentvibes-enabled'), '', 'utf8');
5819
+ return 'enabled';
5820
+ } catch (err) {
5821
+ // Never fail install/update over the marker, but don't swallow silently —
5822
+ // a read-only FS here would otherwise produce a mysterious "silent" project.
5823
+ console.log(chalk.gray(` (Could not set TTS injection marker: ${err.code || err.message}. Run /agent-vibes:unmute to enable.)`));
5824
+ return 'error';
5825
+ }
5826
+ }
5827
+
5828
+ // Print the "global install stays silent" guidance (shared by install + update).
5829
+ function warnGlobalInjectionScope() {
5830
+ console.log(chalk.yellow.bold(' ⚠ Global install detected (installed at your home directory)'));
5831
+ console.log(chalk.gray(' TTS stays OFF by default so it will NOT talk in every session at once.'));
5832
+ console.log(chalk.gray(' Enable it in a specific project with: ') + chalk.cyan('/agent-vibes:unmute'));
5833
+ console.log(chalk.gray(' (or re-run the installer inside a project folder, not your home dir).'));
5834
+ console.log('');
5835
+ }
5836
+
5724
5837
  // Installation function
5725
5838
  async function install(options = {}) {
5726
5839
  const currentDir = process.env.INIT_CWD || process.cwd();
@@ -5842,10 +5955,10 @@ async function install(options = {}) {
5842
5955
  await copyCodexFiles(targetDir, silentSpinner);
5843
5956
 
5844
5957
  // Populate global ~/.claude/hooks[/-windows]/ so $HOME hook paths resolve
5845
- // on first install (not just on update).
5846
- const hooksSubdirInstall = isNativeWindows() ? 'hooks-windows' : 'hooks';
5847
- const srcHooksDirInstall = path.join(__dirname, '..', '.claude', hooksSubdirInstall);
5848
- await updateGlobalHooks(srcHooksDirInstall);
5958
+ // on first install (not just on update). Always the unix hooks dir — see the
5959
+ // note in the update path; updateGlobalHooks derives hooks-windows itself.
5960
+ const srcGlobalHooksDirInstall = path.join(__dirname, '..', '.claude', 'hooks');
5961
+ await updateGlobalHooks(srcGlobalHooksDirInstall);
5849
5962
  // Populate global ~/.claude/ resolver bundle too, same reasoning as above (AVI-S8.5 Stage 2)
5850
5963
  await updateGlobalResolverBundle();
5851
5964
 
@@ -6243,6 +6356,11 @@ def _strip_markdown(text: str) -> str:
6243
6356
  console.log(chalk.magenta(' \u2661 Sponsor this Developer github.com/sponsors/paulpreibisch'));
6244
6357
  console.log('');
6245
6358
 
6359
+ // Opt-in injection marker (prevents the global-install cacophony). See
6360
+ // configureInjectionScope: project installs enable THIS project; a home-dir
6361
+ // install stays silent. Uses agentvibes-enabled so it can't override a global mute.
6362
+ if ((await configureInjectionScope(targetDir)) === 'global') warnGlobalInjectionScope();
6363
+
6246
6364
  if (!(options.nonInteractive || process.env.AGENT_VIBES_NON_INTERACTIVE === '1')) {
6247
6365
  // Clean final summary
6248
6366
  console.log('');
@@ -6997,7 +7115,7 @@ export {
6997
7115
  CRITICAL_HOOKS, CRITICAL_HOOKS_WINDOWS,
6998
7116
  // Manifest utilities (used by tests and external tooling)
6999
7117
  getProjectManifestPath, getGlobalManifestPath,
7000
- loadManifest, saveManifest, computeFileHash, manifestSafeCopy, removeManifestFiles,
7118
+ loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
7001
7119
  // Pure helper functions exported for testing
7002
7120
  readJsonConfigSafe, backupConfigFile,
7003
7121
  isPiperProvider, supportsEmoji, getPersonalityIcon,