agentvibes 5.11.2 → 5.12.0-alpha.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 (97) hide show
  1. package/.agentvibes/config.json +1 -15
  2. package/.claude/activation-instructions +54 -54
  3. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  4. package/.claude/commands/agent-vibes-rdp.md +24 -24
  5. package/.claude/config/audio-effects.cfg +5 -5
  6. package/.claude/config/background-music-enabled.txt +1 -1
  7. package/.claude/docs/TERMUX_SETUP.md +408 -408
  8. package/.claude/github-star-reminder.txt +1 -1
  9. package/.claude/hooks/audio-processor.sh +1 -1
  10. package/.claude/hooks/background-music-manager.sh +2 -1
  11. package/.claude/hooks/bmad-speak-enhanced.sh +37 -22
  12. package/.claude/hooks/bmad-speak.sh +26 -8
  13. package/.claude/hooks/play-tts-agentvibes-receiver-for-voiceless-connections.sh +1 -1
  14. package/.claude/hooks/play-tts-kokoro.sh +9 -0
  15. package/.claude/hooks/play-tts-piper.sh +13 -1
  16. package/.claude/hooks/play-tts-ssh-remote.sh +78 -5
  17. package/.claude/hooks/play-tts-termux-ssh.sh +1 -1
  18. package/.claude/hooks/play-tts.sh +85 -4
  19. package/.claude/hooks-windows/background-music-manager.ps1 +2 -1
  20. package/.claude/hooks-windows/play-tts-kokoro.ps1 +14 -0
  21. package/.claude/hooks-windows/play-tts.ps1 +163 -30
  22. package/.claude/hooks-windows/voice-manager-windows.ps1 +101 -1
  23. package/.clawdbot/README.md +105 -105
  24. package/LICENSE +190 -190
  25. package/RELEASE_NOTES.md +29 -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/mcp-server.sh +6 -19
  30. package/bin/resolve-utterance.js +137 -0
  31. package/bin/test-bmad-pr +78 -78
  32. package/mcp-server/QUICK_START.md +203 -203
  33. package/mcp-server/README.md +345 -345
  34. package/mcp-server/examples/claude_desktop_config.json +11 -11
  35. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  36. package/mcp-server/examples/custom_instructions.md +169 -169
  37. package/mcp-server/pyproject.toml +52 -52
  38. package/mcp-server/requirements.txt +2 -2
  39. package/mcp-server/server.py +280 -99
  40. package/mcp-server/test_mcp_correctness.py +486 -0
  41. package/mcp-server/test_server.py +395 -395
  42. package/mcp-server/test_windows_script_parity.py +341 -336
  43. package/package.json +3 -2
  44. package/setup-windows.ps1 +867 -815
  45. package/src/cli/list-personalities.js +110 -110
  46. package/src/cli/list-voices.js +114 -114
  47. package/src/commands/bmad-voices.js +394 -394
  48. package/src/console/app.js +13 -2
  49. package/src/console/brand-colors.js +13 -13
  50. package/src/console/constants/personalities.js +44 -44
  51. package/src/console/tabs/agents-tab.js +82 -9
  52. package/src/console/tabs/help-tab.js +314 -314
  53. package/src/console/tabs/music-tab.js +7 -1
  54. package/src/console/tabs/readme-tab.js +272 -272
  55. package/src/console/tabs/settings-tab.js +29 -1
  56. package/src/console/tabs/setup-tab.js +4 -4
  57. package/src/console/widgets/destroy-list.js +25 -25
  58. package/src/console/widgets/notice.js +55 -55
  59. package/src/console/widgets/track-picker.js +3 -3
  60. package/src/i18n/de.js +0 -1
  61. package/src/i18n/en.js +0 -1
  62. package/src/i18n/es.js +0 -1
  63. package/src/i18n/fr.js +0 -1
  64. package/src/i18n/hi.js +0 -1
  65. package/src/i18n/ja.js +0 -1
  66. package/src/i18n/ko.js +0 -1
  67. package/src/i18n/pt.js +0 -1
  68. package/src/i18n/zh-CN.js +0 -1
  69. package/src/installer/language-screen.js +31 -31
  70. package/src/installer/music-file-input.js +304 -304
  71. package/src/installer.js +205 -15
  72. package/src/services/config-service.js +264 -264
  73. package/src/services/language-service.js +47 -47
  74. package/src/services/llm-provider-service.js +7 -7
  75. package/src/services/utterance-loader.js +280 -0
  76. package/src/services/utterance-resolver.js +526 -0
  77. package/src/utils/audio-format-validator.js +277 -277
  78. package/src/utils/dependency-checker.js +469 -469
  79. package/src/utils/file-ownership-verifier.js +358 -358
  80. package/src/utils/music-file-validator.js +285 -285
  81. package/src/utils/preview-list-prompt.js +144 -144
  82. package/src/utils/secure-music-storage.js +412 -412
  83. package/templates/agentvibes-receiver.sh +74 -12
  84. package/voice-assignments.json +8245 -0
  85. package/.agentvibes/install-manifest.json +0 -342
  86. package/.claude/config/background-music-position.txt +0 -27
  87. package/.claude/config/background-music-volume.txt +0 -1
  88. package/.claude/config/background-music.cfg +0 -1
  89. package/.claude/config/background-music.txt +0 -1
  90. package/.claude/config/language.txt +0 -1
  91. package/.claude/config/personality.txt +0 -1
  92. package/.claude/config/tts-speech-rate.txt +0 -1
  93. package/.claude/config/tts-verbosity.txt +0 -1
  94. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  95. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
  96. package/.claude/piper-voices-dir.txt +0 -1
  97. package/.mcp.json +0 -22
@@ -46,12 +46,44 @@ import os
46
46
  import platform
47
47
  import re as _re
48
48
  import subprocess
49
+ from dataclasses import dataclass
49
50
  from pathlib import Path
50
51
  from typing import Optional
51
52
 
52
53
  from mcp.server import Server
53
- from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
54
+ from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource, CallToolResult
54
55
  import mcp.server.stdio
56
+
57
+
58
+ # Default per-script timeout for _run_script(). Keeps a hung/interactive
59
+ # manager script (e.g. a `read -p` prompt reached by mistake) from wedging
60
+ # the MCP server forever and eating the stdio JSON-RPC stream.
61
+ DEFAULT_SCRIPT_TIMEOUT = 30.0
62
+
63
+
64
+ @dataclass
65
+ class ScriptResult:
66
+ """Structured result of running a hook script.
67
+
68
+ Callers MUST branch on `ok`/`returncode`, never on emoji/text sniffing —
69
+ Windows manager scripts print plain text (no ✅/✓/🎭), so any
70
+ `"<emoji>" in stdout` check silently reports failure on Windows even when
71
+ the script succeeded.
72
+ """
73
+ returncode: int
74
+ stdout: str
75
+ stderr: str
76
+
77
+ @property
78
+ def ok(self) -> bool:
79
+ return self.returncode == 0
80
+
81
+ @property
82
+ def error_detail(self) -> str:
83
+ """Best-effort human-readable error text for failure messages."""
84
+ return self.stderr or self.stdout or f"exit code {self.returncode}"
85
+
86
+
55
87
  class AgentVibesServer:
56
88
  """MCP Server for AgentVibes TTS functionality"""
57
89
 
@@ -87,6 +119,14 @@ class AgentVibesServer:
87
119
  # Store AgentVibes root directory for environment variable
88
120
  self.agentvibes_root = self.claude_dir.parent
89
121
 
122
+ # Serializes the "mutate global personality/language -> speak -> restore"
123
+ # critical section in text_to_speech() so concurrent MCP tool calls
124
+ # cannot interleave and corrupt persistent state (residual risk: this
125
+ # only protects against concurrent calls *within this process* — a
126
+ # second MCP server process or a slash-command CLI invocation writing
127
+ # the same file at the same time is not covered; see story 8.2 notes).
128
+ self._override_lock = asyncio.Lock()
129
+
90
130
  def _find_claude_dir(self) -> Path:
91
131
  """Find the .claude directory relative to this script"""
92
132
  # Get the AgentVibes root directory (parent of mcp-server)
@@ -278,14 +318,33 @@ class AgentVibesServer:
278
318
  Returns:
279
319
  Success message with audio file path
280
320
  """
281
- # Store original settings to restore later
321
+ # Store original settings to restore later. Mutating the personality/
322
+ # language files is inherently racy across processes; the lock below
323
+ # only protects against concurrent tool calls within *this* server
324
+ # instance (see the residual-risk note on self._override_lock).
282
325
  original_personality = None
326
+ personality_file_existed = True
283
327
  original_language = None
328
+ needs_override = bool(personality or language)
329
+
330
+ if needs_override:
331
+ await self._override_lock.acquire()
284
332
 
285
333
  try:
286
334
  # Temporarily set personality if specified
335
+ personality_path = self._get_config_dir() / "tts-personality.txt"
287
336
  if personality:
288
- original_personality = await self._get_personality()
337
+ # Read the ORIGINAL from the SAME file the manager writes to
338
+ # (the config dir). _get_personality() reads a different set of
339
+ # dirs (package dir, then global ~/.claude) and, from inside a
340
+ # host project, returns the wrong value — restoring that would
341
+ # overwrite the project's real personality. Non-Destructive Rule.
342
+ personality_file_existed = personality_path.exists()
343
+ if personality_file_existed:
344
+ try:
345
+ original_personality = personality_path.read_text(encoding="utf-8").strip()
346
+ except OSError:
347
+ original_personality = None # can't read → don't clobber on restore
289
348
  await self._run_script(
290
349
  self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
291
350
  )
@@ -325,8 +384,16 @@ class AgentVibesServer:
325
384
 
326
385
  env = self._build_script_env()
327
386
 
387
+ # Declare voice provenance so the resolver treats an MCP-requested
388
+ # voice as a genuine explicit pick (user-explicit), never demoting it
389
+ # to a per-LLM/default row the way it would an LLM echo (F-1). Only
390
+ # set when the caller actually asked for a specific voice.
391
+ if voice:
392
+ env["AGENTVIBES_VOICE_SOURCE"] = "user-explicit"
393
+
328
394
  result = await asyncio.create_subprocess_exec(
329
395
  *args,
396
+ stdin=asyncio.subprocess.DEVNULL,
330
397
  stdout=asyncio.subprocess.PIPE,
331
398
  stderr=asyncio.subprocess.PIPE,
332
399
  env=env,
@@ -377,15 +444,48 @@ class AgentVibesServer:
377
444
  await result.wait()
378
445
 
379
446
  finally:
380
- # Restore original settings
381
- if original_personality:
382
- await self._run_script(
383
- self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
384
- )
385
- if original_language:
386
- await self._run_script(
447
+ # Restore original personality. personality-manager.sh has no
448
+ # delete-on-default behavior (unlike language-manager.sh), so if
449
+ # no personality file existed before this call, restore by
450
+ # deleting the file rather than writing "normal" into it — the
451
+ # non-destructive-config rule means a temporary per-call override
452
+ # must not leave a permanent file behind that wasn't there before.
453
+ if personality:
454
+ if personality_file_existed:
455
+ if original_personality is not None:
456
+ restore = await self._run_script(
457
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", original_personality]
458
+ )
459
+ if not restore.ok:
460
+ import sys
461
+ print(
462
+ f"Warning: failed to restore personality "
463
+ f"'{original_personality}': {restore.error_detail}",
464
+ file=sys.stderr,
465
+ )
466
+ else:
467
+ # No personality file existed before this call — restore by
468
+ # deleting (same config dir we captured existence from).
469
+ try:
470
+ personality_path.unlink()
471
+ except OSError:
472
+ pass
473
+ if original_language is not None:
474
+ # "english"/"reset" makes language-manager.sh *delete* the
475
+ # language file rather than write it, so this naturally
476
+ # restores "no override was ever set" correctly too.
477
+ restore_lang = await self._run_script(
387
478
  self.LANGUAGE_MANAGER_SCRIPT, ["set", original_language]
388
479
  )
480
+ if not restore_lang.ok:
481
+ import sys
482
+ print(
483
+ f"Warning: failed to restore language "
484
+ f"'{original_language}': {restore_lang.error_detail}",
485
+ file=sys.stderr,
486
+ )
487
+ if needs_override:
488
+ self._override_lock.release()
389
489
 
390
490
  async def list_voices(self) -> str:
391
491
  """
@@ -400,8 +500,8 @@ class AgentVibesServer:
400
500
 
401
501
  # voice-manager.sh list-simple is now provider-aware
402
502
  result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["list-simple"])
403
- if result:
404
- voices = result.strip().split("\n")
503
+ if result.ok and result.stdout:
504
+ voices = result.stdout.strip().split("\n")
405
505
  voices = [v for v in voices if v] # Filter empty strings
406
506
 
407
507
  if not voices:
@@ -460,7 +560,7 @@ class AgentVibesServer:
460
560
  output += f"\n💡 Switch to {alternative_provider}? Use: set_provider(provider=\"{alternative_provider.lower()}\")\n"
461
561
 
462
562
  return output
463
- return "❌ Failed to list voices"
563
+ return f"❌ Failed to list voices: {result.error_detail}"
464
564
 
465
565
  async def set_voice(self, voice_name: str) -> str:
466
566
  """
@@ -512,11 +612,14 @@ class AgentVibesServer:
512
612
  result = await self._run_script(
513
613
  self.VOICE_MANAGER_SCRIPT, ["switch", resolved_name, "--silent"]
514
614
  )
515
- if result and "✅" in result:
615
+ if result.ok:
516
616
  if original_name.lower() != resolved_name.lower():
517
617
  return f"✅ Voice switched to: {original_name} ({resolved_name})"
518
618
  return f"✅ Voice switched to: {voice_name}"
519
- return f"❌ Failed to switch voice — could not resolve '{voice_name}'. Try 'list_voices' to see available names."
619
+ return (
620
+ f"❌ Failed to switch voice — could not resolve '{voice_name}'. "
621
+ f"Try 'list_voices' to see available names. ({result.error_detail})"
622
+ )
520
623
 
521
624
  async def list_personalities(self) -> str:
522
625
  """
@@ -526,7 +629,9 @@ class AgentVibesServer:
526
629
  Formatted list of personalities with descriptions
527
630
  """
528
631
  result = await self._run_script(self.PERSONALITY_MANAGER_SCRIPT, ["list"])
529
- return result if result else "❌ Failed to list personalities"
632
+ if result.ok:
633
+ return result.stdout
634
+ return f"❌ Failed to list personalities: {result.error_detail}"
530
635
 
531
636
  async def set_personality(self, personality: str) -> str:
532
637
  """
@@ -538,12 +643,18 @@ class AgentVibesServer:
538
643
  Returns:
539
644
  Success or error message
540
645
  """
541
- result = await self._run_script(
542
- self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
543
- )
544
- if result and "🎭" in result:
545
- return result
546
- return f"❌ Failed to set personality: {result}"
646
+ # Serialize against text_to_speech's temporary override/restore so a
647
+ # deliberate set here can't be silently reverted by an in-flight call's
648
+ # restore step (they mutate the same tts-personality.txt).
649
+ async with self._override_lock:
650
+ result = await self._run_script(
651
+ self.PERSONALITY_MANAGER_SCRIPT, ["set", personality]
652
+ )
653
+ if result.ok:
654
+ # Windows (.ps1) scripts print plain text with no 🎭 marker; add
655
+ # our own so the tool's output stays consistent across platforms.
656
+ return result.stdout if "🎭" in result.stdout else f"🎭 {result.stdout}"
657
+ return f"❌ Failed to set personality: {result.error_detail}"
547
658
 
548
659
  async def get_config(self) -> str:
549
660
  """
@@ -591,10 +702,14 @@ class AgentVibesServer:
591
702
  Returns:
592
703
  Success or error message
593
704
  """
594
- result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
595
- if result and "✓" in result:
596
- return result
597
- return f"❌ Failed to set language: {result}"
705
+ # Serialize against text_to_speech's temporary override/restore (both
706
+ # mutate the same language config), so a deliberate set here isn't
707
+ # silently reverted by an in-flight call's restore step.
708
+ async with self._override_lock:
709
+ result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["set", language])
710
+ if result.ok:
711
+ return result.stdout if "✓" in result.stdout else f"✓ {result.stdout}"
712
+ return f"❌ Failed to set language: {result.error_detail}"
598
713
 
599
714
  async def replay_audio(self, n: int = 1) -> str:
600
715
  """
@@ -607,9 +722,9 @@ class AgentVibesServer:
607
722
  Success or error message
608
723
  """
609
724
  result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["replay", str(n)])
610
- if result and "🔊" in result:
611
- return result
612
- return f"❌ Failed to replay audio: {result}"
725
+ if result.ok:
726
+ return result.stdout if "🔊" in result.stdout else f"🔊 {result.stdout}"
727
+ return f"❌ Failed to replay audio: {result.error_detail}"
613
728
 
614
729
  async def set_provider(self, provider: str) -> str:
615
730
  """
@@ -630,7 +745,7 @@ class AgentVibesServer:
630
745
  return f"❌ Invalid provider: {provider}. Choose from: {', '.join(valid_providers)}"
631
746
 
632
747
  result = await self._run_script("provider-manager.sh", ["switch", provider])
633
- if result and ("✓" in result or "[OK]" in result):
748
+ if result.ok:
634
749
  # Automatically speak confirmation in the new provider's voice
635
750
  provider_names = {
636
751
  "macos": "macOS",
@@ -650,15 +765,15 @@ class AgentVibesServer:
650
765
  timeout=5.0
651
766
  )
652
767
  # Return the provider switch result plus TTS confirmation
653
- return f"{result}\n🔊 Spoken confirmation: {confirmation_text}"
768
+ return f"{result.stdout}\n🔊 Spoken confirmation: {confirmation_text}"
654
769
  except asyncio.TimeoutError:
655
770
  # Timeout - provider may need setup (e.g., Piper not installed)
656
- return f"{result}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
771
+ return f"{result.stdout}\n⚠️ Provider switched (TTS confirmation timed out - provider may need setup)"
657
772
  except Exception as e:
658
773
  # If TTS fails, still return success for the provider switch
659
- return f"{result}\n⚠️ Provider switched but TTS confirmation failed: {e}"
774
+ return f"{result.stdout}\n⚠️ Provider switched but TTS confirmation failed: {e}"
660
775
 
661
- return f"❌ Failed to switch provider: {result}"
776
+ return f"❌ Failed to switch provider: {result.error_detail}"
662
777
 
663
778
  async def set_learn_mode(self, enabled: bool) -> str:
664
779
  """
@@ -674,9 +789,9 @@ class AgentVibesServer:
674
789
  """
675
790
  action = "enable" if enabled else "disable"
676
791
  result = await self._run_script("learn-manager.sh", [action])
677
- if result and "✓" in result:
678
- return result
679
- return f"❌ Failed to set learn mode: {result}"
792
+ if result.ok:
793
+ return result.stdout if "✓" in result.stdout else f"✓ {result.stdout}"
794
+ return f"❌ Failed to set learn mode: {result.error_detail}"
680
795
 
681
796
  async def set_speed(self, speed: str, target: bool = False) -> str:
682
797
  """
@@ -697,7 +812,7 @@ class AgentVibesServer:
697
812
 
698
813
  args = ["target", speed] if target else [speed]
699
814
  result = await self._run_script("speed-manager.sh", args)
700
- if result and "✓" in result:
815
+ if result.ok:
701
816
  # Simple test messages to demonstrate the new speed
702
817
  test_messages = [
703
818
  "Testing speed change",
@@ -713,12 +828,12 @@ class AgentVibesServer:
713
828
  try:
714
829
  # Speak the test message to demonstrate the new speed
715
830
  await self.text_to_speech(test_message)
716
- return f"{result}\n🔊 Testing new speed: \"{test_message}\""
831
+ return f"{result.stdout}\n🔊 Testing new speed: \"{test_message}\""
717
832
  except Exception as e:
718
833
  # If TTS fails, still return success for the speed change
719
- return f"{result}\n⚠️ Speed changed but demo failed: {e}"
834
+ return f"{result.stdout}\n⚠️ Speed changed but demo failed: {e}"
720
835
 
721
- return f"❌ Failed to set speed: {result}"
836
+ return f"❌ Failed to set speed: {result.error_detail}"
722
837
 
723
838
  async def get_speed(self) -> str:
724
839
  """
@@ -728,7 +843,7 @@ class AgentVibesServer:
728
843
  Current speed settings for main and target voices
729
844
  """
730
845
  result = await self._run_script("speed-manager.sh", ["get"])
731
- return result if result else "❌ Failed to get speed settings"
846
+ return result.stdout if result.ok else f"❌ Failed to get speed settings: {result.error_detail}"
732
847
 
733
848
  async def download_extra_voices(self, auto_yes: bool = False) -> str:
734
849
  """
@@ -742,11 +857,23 @@ class AgentVibesServer:
742
857
  Returns:
743
858
  Success message with download summary
744
859
  """
745
- args = ["--yes"] if auto_yes else []
746
- result = await self._run_script("download-extra-voices.sh", args)
747
- if result and ("✅" in result or "Successfully downloaded" in result or "already downloaded" in result):
748
- return result
749
- return f"❌ Failed to download extra voices: {result}"
860
+ if not auto_yes:
861
+ # download-extra-voices.sh hits `read -p "...? [Y/n]: "` when no
862
+ # --yes flag is given. Since stdin is always DEVNULL (see
863
+ # _run_script), that read would return EOF/empty rather than
864
+ # hang but reaching it at all is still the wrong behavior for
865
+ # an MCP tool: an LLM caller can't answer an interactive prompt.
866
+ # Fail fast with a clear, actionable error instead of ever
867
+ # spawning the script.
868
+ return (
869
+ "⚠️ Confirmation required: call download_extra_voices(auto_yes=True) "
870
+ "to download the extra voices. This tool cannot answer an interactive "
871
+ "Y/n prompt, so it refuses to start the download without explicit consent."
872
+ )
873
+ result = await self._run_script("download-extra-voices.sh", ["--yes"], timeout=180.0)
874
+ if result.ok:
875
+ return result.stdout
876
+ return f"❌ Failed to download extra voices: {result.error_detail}"
750
877
 
751
878
  async def get_verbosity(self) -> str:
752
879
  """
@@ -756,8 +883,8 @@ class AgentVibesServer:
756
883
  Current verbosity level with description
757
884
  """
758
885
  result = await self._run_script("verbosity-manager.sh", ["get"])
759
- if result:
760
- level = result.strip()
886
+ if result.ok:
887
+ level = result.stdout.strip()
761
888
  descriptions = {
762
889
  "low": "LOW - Acknowledgments + Completions only (minimal)",
763
890
  "medium": "MEDIUM - + Major decisions and findings (balanced)",
@@ -765,7 +892,7 @@ class AgentVibesServer:
765
892
  }
766
893
  desc = descriptions.get(level, level)
767
894
  return f"🎙️ Current Verbosity: {desc}\n\n💡 Change with: set_verbosity(level=\"low|medium|high\")"
768
- return "❌ Failed to get verbosity level"
895
+ return f"❌ Failed to get verbosity level: {result.error_detail}"
769
896
 
770
897
  async def set_verbosity(self, level: str) -> str:
771
898
  """
@@ -778,9 +905,10 @@ class AgentVibesServer:
778
905
  Success or error message
779
906
  """
780
907
  result = await self._run_script("verbosity-manager.sh", ["set", level])
781
- if result and "✅" in result:
782
- return f"{result}\n\n⚠️ Restart Claude Code for changes to take effect"
783
- return f" Failed to set verbosity: {result}"
908
+ if result.ok:
909
+ body = result.stdout if "✅" in result.stdout else f"✅ {result.stdout}"
910
+ return f"{body}\n\n⚠️ Restart Claude Code for changes to take effect"
911
+ return f"❌ Failed to set verbosity: {result.error_detail}"
784
912
 
785
913
  def _get_mute_files(self) -> list:
786
914
  """Get all mute file paths for current platform"""
@@ -866,7 +994,7 @@ class AgentVibesServer:
866
994
  Formatted list of all pre-packaged background music files
867
995
  """
868
996
  result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
869
- return result if result else "❌ Failed to list background music"
997
+ return result.stdout if result.ok else f"❌ Failed to list background music: {result.error_detail}"
870
998
 
871
999
  async def set_background_music(self, track_name: str, agent_name: Optional[str] = None) -> str:
872
1000
  """
@@ -883,12 +1011,12 @@ class AgentVibesServer:
883
1011
 
884
1012
  # Get list of available tracks for fuzzy matching
885
1013
  list_result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["list"])
886
- if not list_result or "❌" in list_result:
887
- return "❌ Failed to list background music tracks"
1014
+ if not list_result.ok:
1015
+ return f"❌ Failed to list background music tracks: {list_result.error_detail}"
888
1016
 
889
1017
  # Parse track names
890
1018
  tracks = []
891
- for line in list_result.split("\n"):
1019
+ for line in list_result.stdout.split("\n"):
892
1020
  match = re.match(r'\s*\d+\.\s+(.+)', line.strip())
893
1021
  if match:
894
1022
  tracks.append(match.group(1).strip())
@@ -926,11 +1054,11 @@ class AgentVibesServer:
926
1054
  # Set as default
927
1055
  result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["set-default", matched_track])
928
1056
 
929
- if result and ("✅" in result or "[OK]" in result):
1057
+ if result.ok:
930
1058
  if matched_track.lower() != track_name.lower():
931
- return f"{result}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
932
- return result
933
- return f"❌ Failed to set background music: {result}"
1059
+ return f"{result.stdout}\n\n🔍 Matched '{track_name}' to '{matched_track}'"
1060
+ return result.stdout
1061
+ return f"❌ Failed to set background music: {result.error_detail}"
934
1062
 
935
1063
  async def enable_background_music(self, enabled: bool) -> str:
936
1064
  """
@@ -958,7 +1086,9 @@ class AgentVibesServer:
958
1086
  cfg_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
959
1087
  except Exception:
960
1088
  pass # best-effort sync
961
- return result if result else f"❌ Failed to {'enable' if enabled else 'disable'} background music"
1089
+ if result.ok:
1090
+ return result.stdout
1091
+ return f"❌ Failed to {'enable' if enabled else 'disable'} background music: {result.error_detail}"
962
1092
 
963
1093
  async def set_background_music_volume(self, volume: float) -> str:
964
1094
  """
@@ -971,7 +1101,7 @@ class AgentVibesServer:
971
1101
  Success or error message
972
1102
  """
973
1103
  result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["volume", str(volume)])
974
- return result if result else "❌ Failed to set background music volume"
1104
+ return result.stdout if result.ok else f"❌ Failed to set background music volume: {result.error_detail}"
975
1105
 
976
1106
  async def get_background_music_status(self) -> str:
977
1107
  """
@@ -981,7 +1111,7 @@ class AgentVibesServer:
981
1111
  Status information
982
1112
  """
983
1113
  result = await self._run_script(self.BACKGROUND_MUSIC_MANAGER_SCRIPT, ["status"])
984
- return result if result else "❌ Failed to get background music status"
1114
+ return result.stdout if result.ok else f"❌ Failed to get background music status: {result.error_detail}"
985
1115
 
986
1116
  async def set_reverb(self, level: str, agent: str = "default", apply_all: bool = False) -> str:
987
1117
  """
@@ -999,7 +1129,9 @@ class AgentVibesServer:
999
1129
  if apply_all:
1000
1130
  args.append("--all")
1001
1131
  result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, args)
1002
- return result if result else f"✅ Set reverb to {level}"
1132
+ if result.ok:
1133
+ return result.stdout if result.stdout else f"✅ Set reverb to {level}"
1134
+ return f"❌ Failed to set reverb: {result.error_detail}"
1003
1135
 
1004
1136
  async def get_reverb(self, agent: str = "default") -> str:
1005
1137
  """
@@ -1012,9 +1144,9 @@ class AgentVibesServer:
1012
1144
  Current reverb level
1013
1145
  """
1014
1146
  result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["get-reverb", agent])
1015
- if result:
1016
- return f"Current reverb level for {agent}: {result.strip()}"
1017
- return f"❌ Failed to get reverb for {agent}"
1147
+ if result.ok:
1148
+ return f"Current reverb level for {agent}: {result.stdout.strip()}"
1149
+ return f"❌ Failed to get reverb for {agent}: {result.error_detail}"
1018
1150
 
1019
1151
  async def list_audio_effects(self) -> str:
1020
1152
  """
@@ -1024,7 +1156,7 @@ class AgentVibesServer:
1024
1156
  Effects configuration
1025
1157
  """
1026
1158
  result = await self._run_script(self.EFFECTS_MANAGER_SCRIPT, ["list"])
1027
- return result if result else "❌ Failed to list audio effects"
1159
+ return result.stdout if result.ok else f"❌ Failed to list audio effects: {result.error_detail}"
1028
1160
 
1029
1161
  async def clean_audio_cache(self) -> str:
1030
1162
  """
@@ -1038,7 +1170,7 @@ class AgentVibesServer:
1038
1170
  Cleanup results with file count and space freed
1039
1171
  """
1040
1172
  result = await self._run_script("clean-audio-cache.sh", [])
1041
- return result if result else "❌ Failed to clean audio cache"
1173
+ return result.stdout if result.ok else f"❌ Failed to clean audio cache: {result.error_detail}"
1042
1174
 
1043
1175
  # ── Hermes config helpers ────────────────────────────────────────────────
1044
1176
 
@@ -1189,14 +1321,28 @@ class AgentVibesServer:
1189
1321
 
1190
1322
  return env
1191
1323
 
1192
- async def _run_script(self, script_name: str, args: list[str]) -> str:
1193
- """Run a script and return output (bash on Unix, PowerShell on Windows)"""
1324
+ async def _run_script(
1325
+ self,
1326
+ script_name: str,
1327
+ args: list[str],
1328
+ timeout: float = DEFAULT_SCRIPT_TIMEOUT,
1329
+ ) -> ScriptResult:
1330
+ """Run a script and return its (returncode, stdout, stderr) as a ScriptResult.
1331
+
1332
+ Callers MUST branch on `.ok`/`.returncode` — never on text/emoji
1333
+ content — because Windows manager scripts (.ps1) print plain text
1334
+ where the Unix (.sh) scripts print an emoji marker.
1335
+
1336
+ `stdin` is always DEVNULL and a timeout is always enforced so a
1337
+ script that reaches an interactive prompt (e.g. `read -p`) cannot
1338
+ inherit the MCP stdio JSON-RPC stream or hang the server forever.
1339
+ """
1194
1340
  # Auto-resolve .sh → .ps1 on Windows (class constants handle special cases)
1195
1341
  if self.is_windows and script_name.endswith('.sh'):
1196
1342
  script_name = script_name[:-3] + '.ps1'
1197
1343
  script_path = self.hooks_dir / script_name
1198
1344
  if not script_path.exists():
1199
- return f"Script not found: {script_path}"
1345
+ return ScriptResult(127, "", f"Script not found: {script_path}")
1200
1346
 
1201
1347
  # Build command — PowerShell on Windows, bash on Unix
1202
1348
  if self.is_windows:
@@ -1209,34 +1355,42 @@ class AgentVibesServer:
1209
1355
 
1210
1356
  env = self._build_script_env()
1211
1357
 
1358
+ proc = None
1212
1359
  try:
1213
- result = await asyncio.create_subprocess_exec(
1360
+ proc = await asyncio.create_subprocess_exec(
1214
1361
  *cmd,
1362
+ stdin=asyncio.subprocess.DEVNULL,
1215
1363
  stdout=asyncio.subprocess.PIPE,
1216
1364
  stderr=asyncio.subprocess.PIPE,
1217
1365
  env=env,
1218
1366
  )
1219
1367
  try:
1220
- stdout, stderr = await result.communicate()
1221
- if result.returncode == 0:
1222
- return stdout.decode().strip()
1223
- else:
1224
- error_msg = stderr.decode().strip()
1225
- if not error_msg: # If stderr is empty, include stdout for debugging
1226
- error_msg = f"Return code {result.returncode}. Stdout: {stdout.decode().strip()}"
1227
- return error_msg
1228
- finally:
1229
- # Ensure process cleanup
1230
- if result.returncode is None:
1231
- result.kill()
1232
- await result.wait()
1368
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
1369
+ except asyncio.TimeoutError:
1370
+ proc.kill()
1371
+ await proc.wait()
1372
+ return ScriptResult(
1373
+ -1, "",
1374
+ f"Script '{script_name}' timed out after {timeout:.0f}s "
1375
+ "(it may have reached an interactive prompt)"
1376
+ )
1377
+ return ScriptResult(
1378
+ proc.returncode if proc.returncode is not None else -1,
1379
+ stdout.decode(errors="replace").strip(),
1380
+ stderr.decode(errors="replace").strip(),
1381
+ )
1233
1382
  except Exception as e:
1234
- return f"Error running script: {e}"
1383
+ return ScriptResult(-2, "", f"Error running script: {e}")
1384
+ finally:
1385
+ # Ensure process cleanup
1386
+ if proc is not None and proc.returncode is None:
1387
+ proc.kill()
1388
+ await proc.wait()
1235
1389
 
1236
1390
  async def _get_current_voice(self) -> str:
1237
1391
  """Get the currently active voice"""
1238
1392
  result = await self._run_script(self.VOICE_MANAGER_SCRIPT, ["get"])
1239
- return result.strip() if result else "Unknown"
1393
+ return result.stdout.strip() if result.ok and result.stdout else "Unknown"
1240
1394
 
1241
1395
  async def _get_personality(self) -> str:
1242
1396
  """Get the current personality setting"""
@@ -1257,7 +1411,7 @@ class AgentVibesServer:
1257
1411
  async def _get_language(self) -> str:
1258
1412
  """Get the current language setting"""
1259
1413
  result = await self._run_script(self.LANGUAGE_MANAGER_SCRIPT, ["code"])
1260
- return result.strip() if result else "english"
1414
+ return result.stdout.strip() if result.ok and result.stdout else "english"
1261
1415
 
1262
1416
  async def _get_provider(self) -> str:
1263
1417
  """Get the active TTS provider"""
@@ -1477,13 +1631,24 @@ Examples:
1477
1631
  ),
1478
1632
  Tool(
1479
1633
  name="download_extra_voices",
1480
- description="Download extra high-quality custom Piper voices from HuggingFace. Includes: Kristin (US female), Jenny (UK female with Irish accent), and Tracy/16Speakers (multi-speaker). Perfect for adding variety to your TTS voices.",
1634
+ description=(
1635
+ "Download extra high-quality custom Piper voices from HuggingFace. "
1636
+ "Includes: Kristin (US female), Jenny (UK female with Irish accent), "
1637
+ "and Tracy/16Speakers (multi-speaker). Perfect for adding variety to "
1638
+ "your TTS voices. This tool never proceeds without explicit consent: "
1639
+ "call it with auto_yes=True to actually start the download, or it "
1640
+ "returns a 'confirmation required' message and does nothing."
1641
+ ),
1481
1642
  inputSchema={
1482
1643
  "type": "object",
1483
1644
  "properties": {
1484
1645
  "auto_yes": {
1485
1646
  "type": "boolean",
1486
- "description": "Skip confirmation prompt and download automatically (default: False)",
1647
+ "description": (
1648
+ "Must be True to download. False (the default) returns a "
1649
+ "confirmation-required message without downloading anything — "
1650
+ "this tool cannot answer an interactive Y/n prompt."
1651
+ ),
1487
1652
  "default": False
1488
1653
  }
1489
1654
  },
@@ -1574,7 +1739,7 @@ Fuzzy matching examples:
1574
1739
  ),
1575
1740
  Tool(
1576
1741
  name="enable_background_music",
1577
- description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 30%).",
1742
+ description="Enable or disable background music globally. When enabled, TTS audio will be mixed with background music at configured volume (default 20%).",
1578
1743
  inputSchema={
1579
1744
  "type": "object",
1580
1745
  "properties": {
@@ -1594,7 +1759,7 @@ Fuzzy matching examples:
1594
1759
  "properties": {
1595
1760
  "volume": {
1596
1761
  "type": "number",
1597
- "description": "Volume level (0.0 = silent, 0.30 = default, 1.0 = full volume)",
1762
+ "description": "Volume level (0.0 = silent, 0.20 = default, 1.0 = full volume)",
1598
1763
  "minimum": 0.0,
1599
1764
  "maximum": 1.0,
1600
1765
  }
@@ -1701,8 +1866,15 @@ Examples:
1701
1866
 
1702
1867
 
1703
1868
  @app.call_tool()
1704
- async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1705
- """Handle tool calls"""
1869
+ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolResult:
1870
+ """Handle tool calls.
1871
+
1872
+ Every AgentVibesServer method below returns a string that starts with
1873
+ "❌" on failure (a marker produced by our own code from the script's
1874
+ *exit code*, not sniffed from the child script's stdout — see H1/#1 in
1875
+ story 8.2). We use that marker here, once, to set MCP's `isError` so
1876
+ clients can distinguish failure without parsing text.
1877
+ """
1706
1878
  try:
1707
1879
  if name == "text_to_speech":
1708
1880
  result = await agent_vibes.text_to_speech(
@@ -1785,12 +1957,21 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
1785
1957
  voice=arguments.get("voice"),
1786
1958
  )
1787
1959
  else:
1788
- result = f"Unknown tool: {name}"
1960
+ return CallToolResult(
1961
+ content=[TextContent(type="text", text=f"Unknown tool: {name}")],
1962
+ isError=True,
1963
+ )
1789
1964
 
1790
- return [TextContent(type="text", text=result)]
1965
+ content = [TextContent(type="text", text=result)]
1966
+ if isinstance(result, str) and result.startswith("❌"):
1967
+ return CallToolResult(content=content, isError=True)
1968
+ return content
1791
1969
 
1792
1970
  except Exception as e:
1793
- return [TextContent(type="text", text=f"Error: {str(e)}")]
1971
+ return CallToolResult(
1972
+ content=[TextContent(type="text", text=f"Error: {str(e)}")],
1973
+ isError=True,
1974
+ )
1794
1975
 
1795
1976
 
1796
1977
  async def main():