agentvibes 5.13.1 → 5.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/agentvibes-session-id.ps1 +83 -0
  16. package/.claude/hooks-windows/kokoro-server.py +219 -219
  17. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  18. package/.claude/hooks-windows/play-tts.ps1 +112 -1
  19. package/.claude/hooks-windows/session-start-tts.ps1 +36 -0
  20. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  21. package/.claude/verbosity.txt +1 -1
  22. package/.clawdbot/README.md +105 -105
  23. package/CLAUDE.md +84 -176
  24. package/README.md +35 -2
  25. package/RELEASE_NOTES.md +139 -0
  26. package/WINDOWS-SETUP.md +208 -208
  27. package/bin/agent-vibes +39 -39
  28. package/bin/mcp-server.js +121 -121
  29. package/bin/test-bmad-pr +78 -78
  30. package/mcp-server/QUICK_START.md +203 -203
  31. package/mcp-server/README.md +345 -345
  32. package/mcp-server/examples/claude_desktop_config.json +11 -11
  33. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  34. package/mcp-server/examples/custom_instructions.md +169 -169
  35. package/mcp-server/install-deps.js +12 -2
  36. package/mcp-server/server.py +2111 -2085
  37. package/mcp-server/test_mcp_correctness.py +504 -504
  38. package/mcp-server/test_windows_script_parity.py +339 -339
  39. package/package.json +6 -3
  40. package/setup-ssh-receiver.ps1 +259 -0
  41. package/setup-windows.ps1 +39 -29
  42. package/src/cli/list-personalities.js +110 -110
  43. package/src/commands/bmad-voices.js +394 -394
  44. package/src/console/brand-colors.js +13 -13
  45. package/src/console/constants/personalities.js +44 -44
  46. package/src/console/tabs/agents-tab.js +14 -4
  47. package/src/console/tabs/help-tab.js +314 -314
  48. package/src/console/tabs/readme-tab.js +272 -272
  49. package/src/console/tabs/settings-tab.js +31 -6
  50. package/src/console/tabs/setup-tab.js +154 -32
  51. package/src/console/tabs/voices-tab.js +48 -6
  52. package/src/console/widgets/destroy-list.js +25 -25
  53. package/src/console/widgets/notice.js +55 -55
  54. package/src/installer/language-screen.js +31 -31
  55. package/src/installer/music-file-input.js +304 -304
  56. package/src/installer.js +98 -37
  57. package/src/services/language-service.js +47 -47
  58. package/src/services/llm-provider-service.js +4 -1
  59. package/src/services/provider-voice-catalog.js +9 -5
  60. package/src/utils/audio-format-validator.js +277 -277
  61. package/src/utils/dependency-checker.js +469 -469
  62. package/src/utils/file-ownership-verifier.js +358 -358
  63. package/src/utils/music-file-validator.js +285 -285
  64. package/src/utils/preview-list-prompt.js +144 -144
  65. package/src/utils/secure-music-storage.js +412 -412
  66. package/templates/agentvibes-receiver.ps1 +311 -0
  67. package/templates/agentvibes-receiver.sh +297 -297
  68. package/voice-assignments.json +8244 -8244
  69. package/.agentvibes/config.json +0 -17
  70. package/.agentvibes/install-manifest.json +0 -362
  71. package/.claude/config/audio-effects.cfg +0 -7
  72. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  73. package/.claude/config/background-music-enabled.txt +0 -1
  74. package/.claude/config/background-music-position.txt +0 -27
  75. package/.claude/config/background-music-volume.txt +0 -1
  76. package/.claude/config/background-music.cfg +0 -1
  77. package/.claude/config/background-music.txt +0 -1
  78. package/.claude/config/language.txt +0 -1
  79. package/.claude/config/personality.txt +0 -1
  80. package/.claude/config/reverb-level.txt +0 -1
  81. package/.claude/config/tts-speech-rate.txt +0 -1
  82. package/.claude/config/tts-verbosity.txt +0 -1
  83. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  84. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
package/src/installer.js CHANGED
@@ -2874,7 +2874,7 @@ async function collectConfiguration(options = {}) {
2874
2874
  // Navigation with page titles
2875
2875
  const navChoices = [];
2876
2876
  if (currentPage < totalPages - 1) {
2877
- const nextPageTitle = getPageTitle(currentPage + 1).replace(/[🔧🎙️🎤😎💧🔊]\s*/, ''); // Remove emoji
2877
+ const nextPageTitle = getPageTitle(currentPage + 1).replace(/(?:🔧|🎙️|🎤|😎|💧|🔊)\s*/u, ''); // Remove emoji (alternation: 🎙️ is a combined char)
2878
2878
  navChoices.push({ name: chalk.green('Next →') + chalk.gray(` (${nextPageTitle})`), value: 'next' });
2879
2879
  } else {
2880
2880
  navChoices.push({ name: chalk.cyan('✓ Continue to Installation'), value: 'continue' });
@@ -2884,7 +2884,7 @@ async function collectConfiguration(options = {}) {
2884
2884
  if (currentPage === 0) {
2885
2885
  navChoices.push({ name: chalk.magentaBright('← Back to Welcome'), value: 'back' });
2886
2886
  } else {
2887
- const prevPageTitle = getPageTitle(currentPage - 1).replace(/[🔧🎙️🎤😎💧🔊]\s*/, ''); // Remove emoji
2887
+ const prevPageTitle = getPageTitle(currentPage - 1).replace(/(?:🔧|🎙️|🎤|😎|💧|🔊)\s*/u, ''); // Remove emoji (alternation: 🎙️ is a combined char)
2888
2888
  navChoices.push({ name: chalk.magentaBright('← Previous') + chalk.gray(` (${prevPageTitle})`), value: 'prev' });
2889
2889
  }
2890
2890
 
@@ -3192,16 +3192,40 @@ function execScript(scriptPath, options = {}) {
3192
3192
  const scriptFile = parts[0];
3193
3193
  const args = parts.slice(1);
3194
3194
 
3195
- // Validate that the script file doesn't contain shell metacharacters
3196
- if (scriptFile.match(/[;&|`$(){}[\]<>'"\\]/)) {
3195
+ // Validate that the script file doesn't contain shell metacharacters.
3196
+ // NOTE: `\` and `:` are deliberately NOT in this class — the script is run via
3197
+ // execFileSync(..., { shell: false }) at the bottom of this function, so the
3198
+ // path never reaches a shell and backslashes are not a metacharacter. Rejecting
3199
+ // `\` made EVERY Windows path ("C:\Users\...") throw before the directory check
3200
+ // was even reached. The real control is the allow-list below.
3201
+ if (scriptFile.match(/[;&|`$(){}[\]<>'"]/)) {
3197
3202
  throw new Error('Invalid characters in script path');
3198
3203
  }
3199
3204
 
3200
- // Validate path is within expected directory (defense in depth)
3205
+ // Validate the path is under a directory we're willing to execute from.
3206
+ //
3207
+ // This used to allow ONLY the *package's own* .claude/hooks. But every install
3208
+ // caller passes a path under the TARGET project (targetDir/.claude/hooks/...),
3209
+ // and under npx/global installs the package dir is never the target dir — so
3210
+ // the check threw for every real user. The throw was swallowed by each caller's
3211
+ // catch and surfaced as "Piper installation failed or was cancelled" / "Voice
3212
+ // download failed", blaming the script for something we never ran. It only
3213
+ // worked in a linked dev checkout, where package === target. Callers now pass
3214
+ // the base they legitimately execute from.
3215
+ const pkgRoot = path.resolve(__dirname, '..');
3216
+ const allowedDirs = [
3217
+ path.join(pkgRoot, '.claude', 'hooks'),
3218
+ path.join(pkgRoot, 'bin'),
3219
+ ...(options.allowedDirs || []).map((d) => path.resolve(d)),
3220
+ ];
3201
3221
  const resolvedPath = path.resolve(scriptFile);
3202
- const allowedDir = path.resolve(__dirname, '..', '.claude', 'hooks');
3203
- if (!resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir) {
3204
- throw new Error('Script path outside allowed directory');
3222
+ const permitted = allowedDirs.some(
3223
+ (dir) => resolvedPath === dir || resolvedPath.startsWith(dir + path.sep)
3224
+ );
3225
+ if (!permitted) {
3226
+ throw new Error(
3227
+ `Script path outside allowed directories: ${resolvedPath}\n allowed: ${allowedDirs.join(', ')}`
3228
+ );
3205
3229
  }
3206
3230
 
3207
3231
  // Security: Validate shell and shellConfig don't contain dangerous characters
@@ -3233,8 +3257,10 @@ function execScript(scriptPath, options = {}) {
3233
3257
  // Note: This means shell aliases/functions won't be available, but that's safer
3234
3258
  // S8701: scriptFile is validated to live under .claude/hooks (above), args are
3235
3259
  // passed as an array and shell:false disables shell interpretation. Risk handled.
3260
+ // allowedDirs is ours, not execFileSync's — strip it before handing options on.
3261
+ const { allowedDirs: _ignored, ...execOptions } = options;
3236
3262
  return execFileSync(scriptFile, args, { // NOSONAR
3237
- ...options,
3263
+ ...execOptions,
3238
3264
  shell: false // Don't use shell to avoid injection risks
3239
3265
  });
3240
3266
  }
@@ -4172,33 +4198,41 @@ async function copyCodexFiles(targetDir, spinner) {
4172
4198
  const srcCodexDir = path.join(__dirname, '..', '.codex');
4173
4199
  const destCodexDir = path.join(targetDir, '.codex');
4174
4200
 
4201
+ const codexManifestPath = getProjectManifestPath(targetDir);
4202
+ const codexManifest = await loadManifest(codexManifestPath);
4203
+ const codexManifestUpdates = { ...codexManifest };
4204
+
4175
4205
  let copiedFiles = [];
4176
4206
  try {
4177
4207
  await fs.mkdir(destCodexDir, { recursive: true });
4178
4208
  await fs.mkdir(path.join(destCodexDir, 'hooks'), { recursive: true });
4179
4209
 
4180
- // Copy AGENTS.md
4181
- const agentsSrc = path.join(srcCodexDir, 'AGENTS.md');
4182
- try {
4183
- const content = await fs.readFile(agentsSrc, 'utf8');
4184
- await fs.writeFile(path.join(destCodexDir, 'AGENTS.md'), content);
4185
- copiedFiles.push('.codex/AGENTS.md');
4186
- } catch { /* source not found */ }
4187
-
4188
- // Copy hook scripts
4189
- for (const hookFile of ['init-agentvibes.sh', 'init-agentvibes.ps1']) {
4190
- const hookSrc = path.join(srcCodexDir, 'hooks', hookFile);
4210
+ // Non-Destructive Configuration Rule: a user-edited AGENTS.md or Codex hook
4211
+ // must survive re-install/update. manifestSafeCopy skips (and .user.bak's) a
4212
+ // file the user changed instead of rewriting it from the template every run.
4213
+ const codexTargets = [
4214
+ { src: path.join(srcCodexDir, 'AGENTS.md'), dest: path.join(destCodexDir, 'AGENTS.md'), label: '.codex/AGENTS.md', exec: false },
4215
+ { src: path.join(srcCodexDir, 'hooks', 'init-agentvibes.sh'), dest: path.join(destCodexDir, 'hooks', 'init-agentvibes.sh'), label: '.codex/hooks/init-agentvibes.sh', exec: true },
4216
+ { src: path.join(srcCodexDir, 'hooks', 'init-agentvibes.ps1'), dest: path.join(destCodexDir, 'hooks', 'init-agentvibes.ps1'), label: '.codex/hooks/init-agentvibes.ps1', exec: false },
4217
+ ];
4218
+
4219
+ for (const { src, dest, label, exec } of codexTargets) {
4191
4220
  try {
4192
- const content = await fs.readFile(hookSrc, 'utf8');
4193
- const destPath = path.join(destCodexDir, 'hooks', hookFile);
4194
- await fs.writeFile(destPath, content);
4195
- if (hookFile.endsWith('.sh')) {
4196
- try { await fs.chmod(destPath, 0o750); } catch { /* Windows */ }
4221
+ const result = await manifestSafeCopy(src, dest, codexManifest);
4222
+ // src missing nothing copied and nothing at dest; only such a skip has no hash.
4223
+ if (result.action === 'skipped' && !result.hash) continue;
4224
+ if (exec && result.action !== 'unchanged') {
4225
+ try { await fs.chmod(dest, 0o750); } catch { /* Windows */ }
4226
+ }
4227
+ if (result.hash && result.action !== 'skipped') {
4228
+ codexManifestUpdates[dest] = { hash: result.hash, installedAt: new Date().toISOString() };
4197
4229
  }
4198
- copiedFiles.push(`.codex/hooks/${hookFile}`);
4230
+ copiedFiles.push(label);
4199
4231
  } catch { /* source not found */ }
4200
4232
  }
4201
4233
 
4234
+ await saveManifest(codexManifestPath, codexManifestUpdates).catch(() => { /* best effort */ });
4235
+
4202
4236
  if (copiedFiles.length > 0) {
4203
4237
  spinner.succeed(chalk.green(`Installed ${copiedFiles.length} Codex file${copiedFiles.length === 1 ? '' : 's'}!\n`));
4204
4238
  copiedFiles.forEach(file => {
@@ -4341,11 +4375,23 @@ async function configurePartyModeHook(targetDir, spinner, homeDirOverride) {
4341
4375
  const srcScript = path.join(__dirname, '..', '.claude', hooksSubdir, scriptName);
4342
4376
  const destScript = path.join(globalHooksDir, scriptName);
4343
4377
 
4344
- // Copy script to global hooks dir (create dir if needed)
4378
+ // Copy script to global hooks dir (create dir if needed). Non-Destructive
4379
+ // Configuration Rule: a user-modified bmad-party-speak script must survive an
4380
+ // update, so go through manifestSafeCopy (which .user.bak's and skips a file
4381
+ // the user changed) instead of an unconditional overwrite. The sibling
4382
+ // updateGlobalHooks path for this same file is already manifest-guarded.
4345
4383
  await fs.mkdir(globalHooksDir, { recursive: true });
4346
- await fs.copyFile(srcScript, destScript);
4347
- if (!isNativeWindows()) {
4348
- await fs.chmod(destScript, 0o750);
4384
+ const scriptManifestPath = getGlobalManifestPath(homeDir);
4385
+ const scriptManifest = await loadManifest(scriptManifestPath);
4386
+ const scriptCopy = await manifestSafeCopy(srcScript, destScript, scriptManifest);
4387
+ if (scriptCopy.action !== 'skipped') {
4388
+ if (!isNativeWindows() && scriptCopy.action !== 'unchanged') {
4389
+ await fs.chmod(destScript, 0o750);
4390
+ }
4391
+ if (scriptCopy.hash) {
4392
+ scriptManifest[destScript] = { hash: scriptCopy.hash, installedAt: new Date().toISOString() };
4393
+ await saveManifest(scriptManifestPath, scriptManifest).catch(() => { /* best effort */ });
4394
+ }
4349
4395
  }
4350
4396
 
4351
4397
  // Build the PostToolUse hook command
@@ -4480,7 +4526,8 @@ async function offerLibriTTSDownload(piperDownloadPath, options) {
4480
4526
  try {
4481
4527
  execScript(`${piperDownloadPath} --libritts --yes`, {
4482
4528
  stdio: 'inherit',
4483
- env: process.env
4529
+ env: process.env,
4530
+ allowedDirs: [path.dirname(piperDownloadPath)]
4484
4531
  });
4485
4532
  console.log(chalk.green('\n✅ LibriTTS voices downloaded! Browse with /agent-vibes:list\n'));
4486
4533
  } catch {
@@ -4531,7 +4578,8 @@ async function checkAndInstallPiper(targetDir, options) {
4531
4578
  if (fsSync.existsSync(piperDownloadPath)) {
4532
4579
  execScript(`${piperDownloadPath} --yes`, {
4533
4580
  stdio: options.silent ? 'pipe' : 'inherit',
4534
- env: process.env
4581
+ env: process.env,
4582
+ allowedDirs: [path.dirname(piperDownloadPath)]
4535
4583
  });
4536
4584
  console.log(chalk.green('\n✅ Voice models downloaded successfully!\n'));
4537
4585
  } else {
@@ -4576,7 +4624,8 @@ async function checkAndInstallPiper(targetDir, options) {
4576
4624
  try {
4577
4625
  execScript(`${piperInstallerPath} --non-interactive`, {
4578
4626
  stdio: options.silent ? 'pipe' : 'inherit',
4579
- env: process.env
4627
+ env: process.env,
4628
+ allowedDirs: [path.dirname(piperInstallerPath)]
4580
4629
  });
4581
4630
  console.log(chalk.green('\n✅ Piper TTS installed successfully!\n'));
4582
4631
  } catch (error) {
@@ -5393,8 +5442,13 @@ async function updateCommandFiles(targetDir, spinner) {
5393
5442
  * These hooks contain bug fixes (e.g. markdown stripping) that must propagate
5394
5443
  * on every `npx agentvibes update` regardless of target directory.
5395
5444
  */
5396
- const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'play-tts-piper.sh', 'audio-processor.sh', 'session-start-tts.sh', 'bmad-party-speak.sh'];
5397
- const CRITICAL_HOOKS_WINDOWS = ['play-tts.ps1', 'play-tts-piper.ps1', 'audio-processor.ps1', 'session-start-tts.ps1', 'bmad-speak.ps1', 'bmad-party-speak.ps1', 'tts-watcher.ps1'];
5445
+ // agentvibes-session-id.{sh,ps1} are listed because play-tts.{sh,ps1} SHELL OUT to
5446
+ // them to expand a {{session}} pretext. They were absent here, so on a global
5447
+ // install the global-update path never shipped them and the self-ID silently
5448
+ // degraded to an empty string forever — the callers fail soft by design, so the
5449
+ // omission produced no error, just a feature that never worked.
5450
+ const CRITICAL_HOOKS = ['stop-tts.sh', 'stop.sh', 'play-tts.sh', 'play-tts-piper.sh', 'audio-processor.sh', 'session-start-tts.sh', 'bmad-party-speak.sh', 'agentvibes-session-id.sh'];
5451
+ const CRITICAL_HOOKS_WINDOWS = ['play-tts.ps1', 'play-tts-piper.ps1', 'audio-processor.ps1', 'session-start-tts.ps1', 'bmad-speak.ps1', 'bmad-party-speak.ps1', 'tts-watcher.ps1', 'agentvibes-session-id.ps1'];
5398
5452
 
5399
5453
  /**
5400
5454
  * Update critical hooks in the global ~/.claude/hooks/ directory if it exists.
@@ -6785,8 +6839,11 @@ program
6785
6839
  .command('agentvibes-mcp-server')
6786
6840
  .description('Start AgentVibes MCP server')
6787
6841
  .action(async () => {
6788
- // Run the bash wrapper script
6789
- const mcpServerScript = path.join(__dirname, '..', 'bin', 'mcp-server');
6842
+ // Run the bash wrapper script. NOTE: 'bin/mcp-server' (no extension) has
6843
+ // never existed bin/ ships mcp-server.sh and mcp-server.js. Combined with
6844
+ // the catch below exiting silently, this subcommand failed with no output at
6845
+ // all for every user who tried it.
6846
+ const mcpServerScript = path.join(__dirname, '..', 'bin', 'mcp-server.sh');
6790
6847
 
6791
6848
  try {
6792
6849
  execScript(mcpServerScript, {
@@ -6794,6 +6851,9 @@ program
6794
6851
  env: process.env
6795
6852
  });
6796
6853
  } catch (error) {
6854
+ // Never exit silently — say why.
6855
+ console.error(chalk.red(`\n❌ Could not start the MCP server: ${error.message}`));
6856
+ console.error(chalk.gray(` Script: ${mcpServerScript}`));
6797
6857
  process.exit(error.status || 1);
6798
6858
  }
6799
6859
  });
@@ -7116,6 +7176,7 @@ export {
7116
7176
  // Manifest utilities (used by tests and external tooling)
7117
7177
  getProjectManifestPath, getGlobalManifestPath,
7118
7178
  loadManifest, saveManifest, computeFileHash, manifestSafeCopy, backupUserFile, removeManifestFiles,
7179
+ execScript,
7119
7180
  // Pure helper functions exported for testing
7120
7181
  readJsonConfigSafe, backupConfigFile,
7121
7182
  isPiperProvider, supportsEmoji, getPersonalityIcon,
@@ -1,47 +1,47 @@
1
- /**
2
- * LanguageService — single source of truth for the selected UI language.
3
- *
4
- * Persists the selection to ~/.claude/config/language.txt so it survives
5
- * process restarts. Notifies registered listeners on every change so the
6
- * TUI can re-render dynamic labels immediately.
7
- */
8
-
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import os from 'node:os';
12
- import { t, SUPPORTED_LANGUAGES } from '../i18n/strings.js';
13
-
14
- const LANG_FILE = path.join(os.homedir(), '.claude', 'config', 'language.txt');
15
- const VALID_LANGS = new Set(SUPPORTED_LANGUAGES.map(l => l.value));
16
-
17
- export class LanguageService {
18
- constructor() {
19
- this._lang = this._load();
20
- this._listeners = [];
21
- }
22
-
23
- _load() {
24
- try {
25
- const val = fs.readFileSync(LANG_FILE, 'utf8').trim();
26
- return VALID_LANGS.has(val) ? val : 'en';
27
- } catch {
28
- return 'en';
29
- }
30
- }
31
-
32
- getLang() { return this._lang; }
33
-
34
- setLang(lang) {
35
- if (!VALID_LANGS.has(lang)) return;
36
- this._lang = lang;
37
- try {
38
- fs.mkdirSync(path.dirname(LANG_FILE), { recursive: true });
39
- fs.writeFileSync(LANG_FILE, lang, { mode: 0o600 });
40
- } catch { /* non-fatal */ }
41
- this._listeners.forEach(fn => fn(lang));
42
- }
43
-
44
- onChange(fn) { this._listeners.push(fn); }
45
-
46
- t(key) { return t(this._lang, key); }
47
- }
1
+ /**
2
+ * LanguageService — single source of truth for the selected UI language.
3
+ *
4
+ * Persists the selection to ~/.claude/config/language.txt so it survives
5
+ * process restarts. Notifies registered listeners on every change so the
6
+ * TUI can re-render dynamic labels immediately.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { t, SUPPORTED_LANGUAGES } from '../i18n/strings.js';
13
+
14
+ const LANG_FILE = path.join(os.homedir(), '.claude', 'config', 'language.txt');
15
+ const VALID_LANGS = new Set(SUPPORTED_LANGUAGES.map(l => l.value));
16
+
17
+ export class LanguageService {
18
+ constructor() {
19
+ this._lang = this._load();
20
+ this._listeners = [];
21
+ }
22
+
23
+ _load() {
24
+ try {
25
+ const val = fs.readFileSync(LANG_FILE, 'utf8').trim();
26
+ return VALID_LANGS.has(val) ? val : 'en';
27
+ } catch {
28
+ return 'en';
29
+ }
30
+ }
31
+
32
+ getLang() { return this._lang; }
33
+
34
+ setLang(lang) {
35
+ if (!VALID_LANGS.has(lang)) return;
36
+ this._lang = lang;
37
+ try {
38
+ fs.mkdirSync(path.dirname(LANG_FILE), { recursive: true });
39
+ fs.writeFileSync(LANG_FILE, lang, { mode: 0o600 });
40
+ } catch { /* non-fatal */ }
41
+ this._listeners.forEach(fn => fn(lang));
42
+ }
43
+
44
+ onChange(fn) { this._listeners.push(fn); }
45
+
46
+ t(key) { return t(this._lang, key); }
47
+ }
@@ -964,7 +964,10 @@ export function saveLlmConfigSync(llmKey, config, targetDir) {
964
964
  const cfgKey = `llm:${llmKey}`;
965
965
  // Sanitize user-editable fields: strip pipe chars (config delimiter) and newlines
966
966
  // (newlines could inject extra rows into the pipe-delimited config file)
967
- const sanitize = (v) => (v || '').replace(/[\|\n\r\x00]/g, '');
967
+ // Strip the pipe delimiter and any control character (newlines/NUL could inject
968
+ // extra rows). Done via a char filter rather than control chars in a regex
969
+ // literal (Sonar S6324 flags regex control chars).
970
+ const sanitize = (v) => Array.from(String(v || '')).filter(ch => ch !== '|' && ch.charCodeAt(0) >= 0x20).join('');
968
971
  const cfgLine = `${cfgKey}|${sanitize(config.effects)}|${sanitize(config.bgTrack)}|${sanitize(config.bgVolume)}|${sanitize(config.voice)}|${sanitize(config.pretext)}|${sanitize(config.ttsEngine)}`;
969
972
  const resolvedTargetDir = targetDir || process.env.INIT_CWD || process.cwd();
970
973
  // When targetDir is explicitly passed, always write to the project dir — never follow
@@ -33,12 +33,16 @@ export { ELEVENLABS_VOICES, KOKORO_VOICE_IDS, kokoroGender };
33
33
  * Verified against a real Windows 11 install (System.Speech GetInstalledVoices).
34
34
  * The receiver remains authoritative — this is UI convenience, not inventory SSOT.
35
35
  */
36
+ // Only the classic SAPI5 "Desktop" voices are listed. The modern OneCore voices
37
+ // ("Microsoft David/Mark/Zira" WITHOUT "Desktop") are NOT selectable via
38
+ // System.Speech in a spawned process — the receiver's player is spawned, so
39
+ // SelectVoice throws "No matching voice is installed" and silently falls back to
40
+ // the default (David, male). That made e.g. "Microsoft Zira" play as a MALE voice.
41
+ // David Desktop + Zira Desktop are present on every Windows 10/11 and select
42
+ // reliably in any process/bitness, so they are the safe curated set.
36
43
  const WINDOWS_SAPI_VOICES = [
37
- { id: 'Microsoft David Desktop', name: 'David (Desktop)', gender: 'Male', lang: 'en-US' },
38
- { id: 'Microsoft Zira Desktop', name: 'Zira (Desktop)', gender: 'Female', lang: 'en-US' },
39
- { id: 'Microsoft David', name: 'David', gender: 'Male', lang: 'en-US' },
40
- { id: 'Microsoft Mark', name: 'Mark', gender: 'Male', lang: 'en-US' },
41
- { id: 'Microsoft Zira', name: 'Zira', gender: 'Female', lang: 'en-US' },
44
+ { id: 'Microsoft David Desktop', name: 'David', gender: 'Male', lang: 'en-US' },
45
+ { id: 'Microsoft Zira Desktop', name: 'Zira', gender: 'Female', lang: 'en-US' },
42
46
  ];
43
47
 
44
48
  const MACOS_VOICES = [