diff --git a/Packs/Utilities/INSTALL.md b/Packs/Utilities/INSTALL.md index 2435542cf..3ca0bbfd8 100644 --- a/Packs/Utilities/INSTALL.md +++ b/Packs/Utilities/INSTALL.md @@ -373,7 +373,20 @@ Check that the sub-skill's SKILL.md exists in its directory. The routing table i ### Sub-skill workflow fails with missing dependency Individual sub-skills may require external tools: -- **AudioEditor**: Install ffmpeg (`brew install ffmpeg`) +- **AudioEditor**: Install ffmpeg +
macOS + + ```bash + brew install ffmpeg + ``` +
+
Linux / WSL + + ```bash + sudo apt-get install -y ffmpeg # Debian/Ubuntu/WSL + sudo dnf install -y ffmpeg # Fedora + ``` +
- **Cloudflare**: Install wrangler (`npm install -g wrangler`) - **Fabric**: Install fabric (see fabric docs) - **CreateCLI, Evals, PAIUpgrade, Parser**: Install bun (`curl -fsSL https://bun.sh/install | bash`) diff --git a/Releases/v4.0.3/.claude/VoiceServer/install.sh b/Releases/v4.0.3/.claude/VoiceServer/install.sh index 15b6c83e0..8778c8525 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/install.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/install.sh @@ -1,7 +1,10 @@ #!/bin/bash # Voice Server Installation Script -# This script installs the voice server as a macOS service +# Installs the voice server as a system service: +# - macOS → launchctl LaunchAgent (unchanged, byte-identical) +# - Linux → systemd --user unit +# - WSL2 → systemd --user unit (requires systemd-on-WSL enabled) set -e @@ -14,9 +17,14 @@ NC='\033[0m' # No Color # Configuration SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# shellcheck source=lib/platform.sh +. "$SCRIPT_DIR/lib/platform.sh" SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" -LOG_PATH="$HOME/Library/Logs/pai-voice-server.log" +SYSTEMD_UNIT_NAME="pai-voice.service" +SYSTEMD_UNIT_DIR="$HOME/.config/systemd/user" +SYSTEMD_UNIT_PATH="$SYSTEMD_UNIT_DIR/$SYSTEMD_UNIT_NAME" +LOG_PATH="$(pai_log_path)" ENV_FILE="$HOME/.env" echo -e "${BLUE}=====================================================${NC}" @@ -35,22 +43,73 @@ fi echo -e "${GREEN}OK Bun is installed${NC}" # Check for existing installation -if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then - echo -e "${YELLOW}! Voice server is already installed${NC}" - read -p "Do you want to reinstall? (y/n): " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo -e "${YELLOW}> Stopping existing service...${NC}" - launchctl unload "$PLIST_PATH" 2>/dev/null || true - echo -e "${GREEN}OK Existing service stopped${NC}" - else - echo "Installation cancelled" - exit 0 +# Darwin path preserved byte-identical. On Linux/WSL we probe the +# systemd --user unit; user is prompted the same way as on macOS. +if pai_is_darwin; then + if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then + echo -e "${YELLOW}! Voice server is already installed${NC}" + read -p "Do you want to reinstall? (y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}> Stopping existing service...${NC}" + launchctl unload "$PLIST_PATH" 2>/dev/null || true + echo -e "${GREEN}OK Existing service stopped${NC}" + else + echo "Installation cancelled" + exit 0 + fi + fi +else + # Require systemd --user to be reachable. On bare Linux this is + # the default; on WSL2 it requires systemd-on-WSL to be enabled + # (/etc/wsl.conf: [boot] systemd=true). Fail loudly if not. + if ! command -v systemctl >/dev/null 2>&1; then + echo -e "${RED}X systemctl not found${NC}" + echo " The Linux voice server installer requires systemd." + echo " Install or enable systemd and re-run this script." + exit 1 + fi + if ! systemctl --user list-units --no-pager >/dev/null 2>&1; then + echo -e "${RED}X systemd --user session is not reachable${NC}" + if pai_is_wsl; then + echo " On WSL2, enable systemd by adding the following to" + echo " /etc/wsl.conf and running 'wsl --shutdown' from Windows:" + echo " [boot]" + echo " systemd=true" + else + echo " Ensure systemd --user is available for your session" + echo " (user@${UID}.service should be running)." + fi + exit 1 + fi + if [ -f "$SYSTEMD_UNIT_PATH" ] \ + || systemctl --user list-unit-files "$SYSTEMD_UNIT_NAME" 2>/dev/null | grep -q "$SYSTEMD_UNIT_NAME"; then + echo -e "${YELLOW}! Voice server is already installed${NC}" + read -p "Do you want to reinstall? (y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}> Stopping existing service...${NC}" + systemctl --user stop "$SYSTEMD_UNIT_NAME" 2>/dev/null || true + systemctl --user disable "$SYSTEMD_UNIT_NAME" 2>/dev/null || true + echo -e "${GREEN}OK Existing service stopped${NC}" + else + echo "Installation cancelled" + exit 0 + fi fi fi # Check for ElevenLabs configuration echo -e "${YELLOW}> Checking ElevenLabs configuration...${NC}" +# Platform-aware fallback message — the historical string mentioned +# macOS 'say' regardless of host OS, which is misleading on Linux/WSL +# where no built-in TTS fallback exists. The Darwin message is kept +# byte-identical so the macOS flow reads the same as before. +if pai_is_darwin; then + FALLBACK_NOTE="Voice server will use macOS 'say' command as fallback" +else + FALLBACK_NOTE="Voice server will have no TTS fallback without an ElevenLabs API key" +fi if [ -f "$ENV_FILE" ] && grep -q "ELEVENLABS_API_KEY=" "$ENV_FILE"; then API_KEY=$(grep "ELEVENLABS_API_KEY=" "$ENV_FILE" | cut -d'=' -f2) if [ "$API_KEY" != "your_api_key_here" ] && [ -n "$API_KEY" ]; then @@ -58,12 +117,12 @@ if [ -f "$ENV_FILE" ] && grep -q "ELEVENLABS_API_KEY=" "$ENV_FILE"; then ELEVENLABS_CONFIGURED=true else echo -e "${YELLOW}! ElevenLabs API key not configured${NC}" - echo " Voice server will use macOS 'say' command as fallback" + echo " $FALLBACK_NOTE" ELEVENLABS_CONFIGURED=false fi else echo -e "${YELLOW}! No ElevenLabs configuration found${NC}" - echo " Voice server will use macOS 'say' command as fallback" + echo " $FALLBACK_NOTE" ELEVENLABS_CONFIGURED=false fi @@ -75,11 +134,17 @@ if [ "$ELEVENLABS_CONFIGURED" = false ]; then echo fi -# Create LaunchAgent plist -echo -e "${YELLOW}> Creating LaunchAgent configuration...${NC}" -mkdir -p "$HOME/Library/LaunchAgents" +# Create and load the service unit (platform branch) +# Darwin path preserved byte-identical: same plist content, same +# launchctl load invocation. Linux/WSL writes a systemd --user unit +# at ~/.config/systemd/user/pai-voice.service, templated after the +# reference unit that ships with PAI on WSL2. +if pai_is_darwin; then + # Create LaunchAgent plist + echo -e "${YELLOW}> Creating LaunchAgent configuration...${NC}" + mkdir -p "$HOME/Library/LaunchAgents" -cat > "$PLIST_PATH" << EOF + cat > "$PLIST_PATH" << EOF @@ -123,15 +188,59 @@ cat > "$PLIST_PATH" << EOF EOF -echo -e "${GREEN}OK LaunchAgent configuration created${NC}" + echo -e "${GREEN}OK LaunchAgent configuration created${NC}" -# Load the LaunchAgent -echo -e "${YELLOW}> Starting voice server service...${NC}" -launchctl load "$PLIST_PATH" 2>/dev/null || { - echo -e "${RED}X Failed to load LaunchAgent${NC}" - echo " Try manually: launchctl load $PLIST_PATH" - exit 1 -} + # Load the LaunchAgent + echo -e "${YELLOW}> Starting voice server service...${NC}" + launchctl load "$PLIST_PATH" 2>/dev/null || { + echo -e "${RED}X Failed to load LaunchAgent${NC}" + echo " Try manually: launchctl load $PLIST_PATH" + exit 1 + } +else + # Create systemd --user unit + echo -e "${YELLOW}> Creating systemd user unit...${NC}" + mkdir -p "$SYSTEMD_UNIT_DIR" + mkdir -p "$(dirname "$LOG_PATH")" + + BUN_BIN="$(command -v bun)" + if [ -z "$BUN_BIN" ]; then + echo -e "${RED}X Could not locate bun on PATH${NC}" + exit 1 + fi + + cat > "$SYSTEMD_UNIT_PATH" << EOF +[Unit] +Description=PAI Voice Server (ElevenLabs TTS) +After=default.target + +[Service] +Type=simple +WorkingDirectory=${SCRIPT_DIR} +ExecStart=${BUN_BIN} run server.ts +Restart=on-failure +RestartSec=3 +StandardOutput=append:${LOG_PATH} +StandardError=append:${LOG_PATH} +Environment=HOME=${HOME} +Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.bun/bin + +[Install] +WantedBy=default.target +EOF + + echo -e "${GREEN}OK systemd unit written to $SYSTEMD_UNIT_PATH${NC}" + + # Load and start the service + echo -e "${YELLOW}> Starting voice server service...${NC}" + systemctl --user daemon-reload + systemctl --user enable --now "$SYSTEMD_UNIT_NAME" || { + echo -e "${RED}X Failed to start systemd unit${NC}" + echo " Try manually: systemctl --user status $SYSTEMD_UNIT_NAME" + echo " Logs: journalctl --user -u $SYSTEMD_UNIT_NAME -n 50" + exit 1 + } +fi # Wait for server to start sleep 2 @@ -161,15 +270,21 @@ echo -e "${GREEN} Installation Complete!${NC}" echo -e "${GREEN}=====================================================${NC}" echo echo -e "${BLUE}Service Information:${NC}" -echo " - Service: $SERVICE_NAME" +if pai_is_darwin; then + echo " - Service: $SERVICE_NAME (launchd)" +else + echo " - Service: $SYSTEMD_UNIT_NAME (systemd --user)" +fi echo " - Status: Running" echo " - Port: 8888" echo " - Logs: $LOG_PATH" if [ "$ELEVENLABS_CONFIGURED" = true ]; then echo " - Voice: ElevenLabs AI" -else +elif pai_is_darwin; then echo " - Voice: macOS Say (fallback)" +else + echo " - Voice: none (configure ElevenLabs API key in ~/.env)" fi echo diff --git a/Releases/v4.0.3/.claude/VoiceServer/lib/platform.sh b/Releases/v4.0.3/.claude/VoiceServer/lib/platform.sh new file mode 100644 index 000000000..b79b98c21 --- /dev/null +++ b/Releases/v4.0.3/.claude/VoiceServer/lib/platform.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# PAI VoiceServer — shared platform detection and path helpers. +# +# Designed to be sourced by sibling scripts (install.sh, start.sh, stop.sh, +# status.sh, uninstall.sh, menubar/pai-voice.5s.sh). POSIX-leaning bash: no +# associative arrays, no process substitution in function bodies, works under +# `bash` on macOS and Linux/WSL2. +# +# Exports three helpers: +# pai_is_wsl — returns 0 iff running inside WSL (any version) +# pai_log_path — prints the platform-appropriate log file path +# pai_port_pids PORT — prints PIDs listening on PORT (lsof > ss > netstat) +# +# Platform matrix: +# macOS (darwin): log path = $HOME/Library/Logs/pai-voice-server.log +# (byte-identical to pre-refactor literal) +# Linux + WSL2: log path = ${XDG_DATA_HOME:-$HOME/.local/share}/pai/logs/pai-voice-server.log +# +# This file is intentionally side-effect free on source — no echo, no exit, +# no directory creation. Callers decide when to mkdir the log directory. + +# -------------------------------------------------------------------------- +# OS detection +# -------------------------------------------------------------------------- + +pai_uname_s() { + uname -s 2>/dev/null || echo "Unknown" +} + +pai_is_darwin() { + [ "$(pai_uname_s)" = "Darwin" ] +} + +pai_is_linux() { + [ "$(pai_uname_s)" = "Linux" ] +} + +# pai_is_wsl — true iff /proc/version mentions Microsoft (WSL1 or WSL2). +# Case-insensitive match catches both "Microsoft" (WSL1) and "microsoft" (WSL2). +pai_is_wsl() { + if ! pai_is_linux; then + return 1 + fi + if [ ! -r /proc/version ]; then + return 1 + fi + if grep -qi 'microsoft' /proc/version; then + return 0 + fi + return 1 +} + +# -------------------------------------------------------------------------- +# Paths +# -------------------------------------------------------------------------- + +# pai_log_path — prints the absolute log file path for pai-voice-server. +# On macOS preserves the historical ~/Library/Logs location (byte-identical +# to the pre-refactor literal) so the Darwin flow stays untouched. +# On Linux/WSL uses XDG_DATA_HOME with a sane fallback. +pai_log_path() { + if pai_is_darwin; then + printf '%s\n' "$HOME/Library/Logs/pai-voice-server.log" + return 0 + fi + # Linux or WSL — XDG Base Directory spec. + local base="${XDG_DATA_HOME:-$HOME/.local/share}" + printf '%s\n' "$base/pai/logs/pai-voice-server.log" +} + +# -------------------------------------------------------------------------- +# Port helpers +# -------------------------------------------------------------------------- + +# pai_port_pids PORT — prints one PID per line that is listening on PORT. +# Tries lsof first (historic behavior on macOS), then ss (iproute2, ubiquitous +# on modern Linux/WSL), then netstat as a last resort. Prints nothing and +# returns 1 if none are available or nothing is listening. +pai_port_pids() { + local port="$1" + if [ -z "$port" ]; then + return 2 + fi + + if command -v lsof >/dev/null 2>&1; then + local pids + pids=$(lsof -ti ":${port}" 2>/dev/null) + if [ -n "$pids" ]; then + printf '%s\n' "$pids" + return 0 + fi + fi + + if command -v ss >/dev/null 2>&1; then + # ss -H -ltnp sport = :PORT — machine-readable, one line per socket. + # Extract pid=NNN from users:(("name",pid=NNN,fd=N)). + local out + out=$(ss -H -ltnp "sport = :${port}" 2>/dev/null \ + | grep -oE 'pid=[0-9]+' \ + | cut -d= -f2 \ + | sort -u) + if [ -n "$out" ]; then + printf '%s\n' "$out" + return 0 + fi + fi + + if command -v netstat >/dev/null 2>&1; then + # netstat -ltnp, extract "PID/program" from the last column. + local out + out=$(netstat -ltnp 2>/dev/null \ + | awk -v p=":${port}" '$4 ~ p"$" {print $7}' \ + | cut -d/ -f1 \ + | grep -E '^[0-9]+$' \ + | sort -u) + if [ -n "$out" ]; then + printf '%s\n' "$out" + return 0 + fi + fi + + return 1 +} + +# pai_port_in_use PORT — 0 iff something is listening on PORT. +pai_port_in_use() { + local pids + pids=$(pai_port_pids "$1") || return 1 + [ -n "$pids" ] +} diff --git a/Releases/v4.0.3/.claude/VoiceServer/menubar/install-menubar.sh b/Releases/v4.0.3/.claude/VoiceServer/menubar/install-menubar.sh index c0107ed7b..07abf37df 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/menubar/install-menubar.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/menubar/install-menubar.sh @@ -19,6 +19,24 @@ echo -e "${BLUE} PAI Voice Menu Bar Installation${NC}" echo -e "${BLUE}=====================================================${NC}" echo +# ─── Platform guard ────────────────────────────────────── +# SwiftBar and BitBar are macOS-only. On Linux and WSL there is no +# equivalent persistent tray plugin host, and the plan scope is to +# deliver notifications via notify-send / wsl-notify-send from the +# voice server itself (see getNotificationCmd() in server.ts). Exit +# cleanly with exit 0 so CI pipelines and cross-platform bootstrap +# scripts can invoke this unconditionally without failing. +if [ "$(uname -s)" != "Darwin" ]; then + echo -e "${YELLOW}! SwiftBar menu bar indicator is macOS-only.${NC}" + echo + echo " On Linux and WSL, PAI delivers voice-server notifications via" + echo " notify-send (Linux) or wsl-notify-send / BurntToast (WSL)." + echo " See VoiceServer/server.ts getNotificationCmd() for details." + echo + echo " Skipping menu bar install." + exit 0 +fi + # Check if SwiftBar is installed if [ -d "/Applications/SwiftBar.app" ]; then echo -e "${GREEN}OK SwiftBar is installed${NC}" diff --git a/Releases/v4.0.3/.claude/VoiceServer/menubar/pai-voice.5s.sh b/Releases/v4.0.3/.claude/VoiceServer/menubar/pai-voice.5s.sh index d799df589..d3d896f9e 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/menubar/pai-voice.5s.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/menubar/pai-voice.5s.sh @@ -7,6 +7,25 @@ PAI_DIR="${PAI_DIR:-$HOME/.claude}" VOICE_SERVER_DIR="$PAI_DIR/VoiceServer" +# Source the shared platform helpers if available. BitBar/SwiftBar runs this +# from an arbitrary cwd so we resolve the library relative to the script. +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +if [ -r "$SCRIPT_DIR/../lib/platform.sh" ]; then + # shellcheck source=../lib/platform.sh + . "$SCRIPT_DIR/../lib/platform.sh" +elif [ -r "$VOICE_SERVER_DIR/lib/platform.sh" ]; then + # shellcheck source=/dev/null + . "$VOICE_SERVER_DIR/lib/platform.sh" +fi + +# Resolve log path once. Fall back to the historical macOS literal if the +# helper isn't available (e.g., running from an older checkout). +if command -v pai_log_path >/dev/null 2>&1; then + LOG_PATH="$(pai_log_path)" +else + LOG_PATH="$HOME/Library/Logs/pai-voice-server.log" +fi + # Check if server is running if curl -s -f http://localhost:8888/health > /dev/null 2>&1; then # Server is running - show green indicator with size @@ -41,7 +60,7 @@ fi echo "---" echo "Check Status | bash='$VOICE_SERVER_DIR/status.sh' terminal=true" -echo "View Logs | bash='tail -f ~/Library/Logs/pai-voice-server.log' terminal=true" +echo "View Logs | bash='tail -f $LOG_PATH' terminal=true" echo "---" echo "Test Voice | bash='curl -X POST http://localhost:8888/notify -H \"Content-Type: application/json\" -d \"{\\\"message\\\":\\\"Testing voice server\\\"}\"' terminal=false" echo "---" diff --git a/Releases/v4.0.3/.claude/VoiceServer/server.ts b/Releases/v4.0.3/.claude/VoiceServer/server.ts index 9f5dec95c..7d45dff93 100644 --- a/Releases/v4.0.3/.claude/VoiceServer/server.ts +++ b/Releases/v4.0.3/.claude/VoiceServer/server.ts @@ -369,14 +369,47 @@ async function generateSpeech( return await response.arrayBuffer(); } -// Play audio using afplay (macOS) +// Resolve the audio player command + arg builder for the current platform. +// macOS uses afplay; Linux tries ffplay (ffmpeg) then falls back to mpg123. +function getAudioPlayer(volume: number, tempFile: string): { command: string; args: string[] } | null { + if (process.platform === 'darwin') { + return { command: '/usr/bin/afplay', args: ['-v', volume.toString(), tempFile] }; + } + + const clamped = Math.max(0, Math.min(1, volume)); + + if (existsSync('/usr/bin/ffplay')) { + return { + command: '/usr/bin/ffplay', + args: ['-nodisp', '-autoexit', '-loglevel', 'quiet', '-volume', Math.round(clamped * 100).toString(), tempFile], + }; + } + + if (existsSync('/usr/bin/mpg123')) { + // mpg123 -f scales raw PCM by integer 0..32768 + return { + command: '/usr/bin/mpg123', + args: ['-q', '-f', Math.round(clamped * 32768).toString(), tempFile], + }; + } + + return null; +} + +// Play audio: macOS uses afplay, Linux uses ffplay or mpg123 (whichever is installed). async function playAudio(audioBuffer: ArrayBuffer, volume: number = FALLBACK_VOLUME): Promise { const tempFile = `/tmp/voice-${Date.now()}.mp3`; await Bun.write(tempFile, audioBuffer); + const player = getAudioPlayer(volume, tempFile); + if (!player) { + spawn('/bin/rm', [tempFile]); + throw new Error('No supported audio player found. Install ffmpeg (ffplay) or mpg123.'); + } + return new Promise((resolve, reject) => { - const proc = spawn('/usr/bin/afplay', ['-v', volume.toString(), tempFile]); + const proc = spawn(player.command, player.args); proc.on('error', (error) => { console.error('Error playing audio:', error); @@ -388,7 +421,7 @@ async function playAudio(audioBuffer: ArrayBuffer, volume: number = FALLBACK_VOL if (code === 0) { resolve(); } else { - reject(new Error(`afplay exited with code ${code}`)); + reject(new Error(`${player.command} exited with code ${code}`)); } }); }); @@ -414,6 +447,103 @@ function spawnSafe(command: string, args: string[]): Promise { }); } +// ========================================================================== +// Cross-platform helpers +// ========================================================================== + +// isWSL — single source of truth for WSL1/WSL2 detection. Shell scripts use +// the matching `pai_is_wsl` helper in lib/platform.sh. Do NOT duplicate this +// /proc/version read elsewhere in the TS codebase. +function isWSL(): boolean { + if (process.platform !== 'linux') return false; + try { + if (!existsSync('/proc/version')) return false; + const content = readFileSync('/proc/version', 'utf-8'); + return /microsoft/i.test(content); + } catch { + return false; + } +} + +// getLogPath — absolute path to the pai-voice-server log file, respecting +// the platform's conventional location. On macOS this is byte-identical to +// the historical ~/Library/Logs literal so the Darwin flow is untouched. +// server.ts currently only emits console.log; this helper exists for +// symmetry with the shell-side pai_log_path and for future consumers +// (status endpoint, metrics, etc.) that may need to report the log location. +function getLogPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Logs', 'pai-voice-server.log'); + } + const xdgData = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); + return join(xdgData, 'pai', 'logs', 'pai-voice-server.log'); +} + +// Resolve the desktop-notification command + arg builder for the current +// platform. Mirrors the getAudioPlayer() shape introduced for playAudio(). +// +// Darwin: osascript `display notification "MSG" with title "TITLE" sound +// name ""` — byte-identical to the pre-refactor literal at the call +// site so macOS behavior is preserved. +// WSL2: prefer wsl-notify-send when on PATH; else invoke powershell.exe +// with BurntToast (New-BurntToastNotification) if the module is +// importable; else fall back to a bare +// [Windows.UI.Notifications.ToastNotificationManager] one-liner that +// needs no module. +// Linux: notify-send "title" "message". +function getNotificationCmd( + title: string, + message: string, +): { command: string; args: string[] } { + if (process.platform === 'darwin') { + // escapeForAppleScript is applied by the caller before reaching here. + const script = `display notification "${message}" with title "${title}" sound name ""`; + return { command: '/usr/bin/osascript', args: ['-e', script] }; + } + + if (isWSL()) { + // Prefer the zero-friction native-ish path: wsl-notify-send if installed. + if (existsSync('/usr/bin/wsl-notify-send') || existsSync('/usr/local/bin/wsl-notify-send')) { + const bin = existsSync('/usr/bin/wsl-notify-send') + ? '/usr/bin/wsl-notify-send' + : '/usr/local/bin/wsl-notify-send'; + return { command: bin, args: [`--category=${title}`, message] }; + } + + // Fall back to powershell.exe via WSL interop. Escape embedded single + // quotes for PowerShell by doubling them. + const psEscape = (s: string) => s.replace(/'/g, "''"); + const pTitle = psEscape(title); + const pMessage = psEscape(message); + + // Prefer BurntToast when available (widely installed), otherwise fall + // back to the bare WinRT ToastNotificationManager one-liner. The `try` + // block exits 0 on either path; stderr from Import-Module is swallowed. + const script = + `$ErrorActionPreference='SilentlyContinue';` + + `if (Get-Module -ListAvailable -Name BurntToast) {` + + ` Import-Module BurntToast;` + + ` New-BurntToastNotification -Text '${pTitle}','${pMessage}'` + + `} else {` + + ` [Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null;` + + ` $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);` + + ` $texts = $template.GetElementsByTagName('text');` + + ` $texts.Item(0).AppendChild($template.CreateTextNode('${pTitle}')) | Out-Null;` + + ` $texts.Item(1).AppendChild($template.CreateTextNode('${pMessage}')) | Out-Null;` + + ` $toast = [Windows.UI.Notifications.ToastNotification]::new($template);` + + ` [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('PAI').Show($toast)` + + `}`; + + return { + command: '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', script], + }; + } + + // Plain Linux desktop. + return { command: '/usr/bin/notify-send', args: [title, message] }; +} + // ========================================================================== // Core: Send notification with 3-tier voice settings resolution // ========================================================================== @@ -512,13 +642,20 @@ async function sendNotification( } } - // Display macOS notification (can be disabled via settings.json: notifications.desktop.enabled: false) + // Display desktop notification (can be disabled via settings.json: notifications.desktop.enabled: false) + // Darwin: osascript display notification (byte-identical to pre-refactor path). + // Linux: notify-send. + // WSL2: wsl-notify-send or powershell.exe BurntToast / ToastNotificationManager. if (voiceConfig.desktopNotifications) { try { + // escapeForAppleScript double-escapes backslashes and quotes — safe for + // the Darwin osascript branch. On non-Darwin branches the escaped form + // is harmless since notify-send/PowerShell receive the strings as + // separate argv entries. const escapedTitle = escapeForAppleScript(safeTitle); const escapedMessage = escapeForAppleScript(safeMessage); - const script = `display notification "${escapedMessage}" with title "${escapedTitle}" sound name ""`; - await spawnSafe('/usr/bin/osascript', ['-e', script]); + const notifyCmd = getNotificationCmd(escapedTitle, escapedMessage); + await spawnSafe(notifyCmd.command, notifyCmd.args); } catch (error) { console.error("Notification display error:", error); } diff --git a/Releases/v4.0.3/.claude/VoiceServer/start.sh b/Releases/v4.0.3/.claude/VoiceServer/start.sh index 3e1459ccd..14766d430 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/start.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/start.sh @@ -2,9 +2,14 @@ # Start the Voice Server +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# shellcheck source=lib/platform.sh +. "$SCRIPT_DIR/lib/platform.sh" + SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +SYSTEMD_UNIT_NAME="pai-voice.service" +SYSTEMD_UNIT_PATH="$HOME/.config/systemd/user/$SYSTEMD_UNIT_NAME" # Colors RED='\033[0;31m' @@ -14,24 +19,46 @@ NC='\033[0m' echo -e "${YELLOW}> Starting Voice Server...${NC}" -# Check if LaunchAgent exists -if [ ! -f "$PLIST_PATH" ]; then - echo -e "${RED}X Service not installed${NC}" - echo " Run ./install.sh first to install the service" - exit 1 -fi +# Darwin path preserved byte-identical. Linux/WSL uses systemd --user. +if pai_is_darwin; then + # Check if LaunchAgent exists + if [ ! -f "$PLIST_PATH" ]; then + echo -e "${RED}X Service not installed${NC}" + echo " Run ./install.sh first to install the service" + exit 1 + fi -# Check if already running -if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then - echo -e "${YELLOW}! Voice server is already running${NC}" - echo " To restart, use: ./restart.sh" - exit 0 -fi + # Check if already running + if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then + echo -e "${YELLOW}! Voice server is already running${NC}" + echo " To restart, use: ./restart.sh" + exit 0 + fi + + # Load the service + launchctl load "$PLIST_PATH" 2>/dev/null + START_RC=$? +else + # Check if systemd unit exists + if [ ! -f "$SYSTEMD_UNIT_PATH" ]; then + echo -e "${RED}X Service not installed${NC}" + echo " Run ./install.sh first to install the service" + exit 1 + fi -# Load the service -launchctl load "$PLIST_PATH" 2>/dev/null + # Check if already running + if systemctl --user is-active --quiet "$SYSTEMD_UNIT_NAME"; then + echo -e "${YELLOW}! Voice server is already running${NC}" + echo " To restart, use: systemctl --user restart $SYSTEMD_UNIT_NAME" + exit 0 + fi + + # Start the service + systemctl --user start "$SYSTEMD_UNIT_NAME" + START_RC=$? +fi -if [ $? -eq 0 ]; then +if [ $START_RC -eq 0 ]; then # Wait for server to start sleep 2 @@ -42,7 +69,7 @@ if [ $? -eq 0 ]; then echo " Test: curl -X POST http://localhost:8888/notify -H 'Content-Type: application/json' -d '{\"message\":\"Test\"}'" else echo -e "${YELLOW}! Server started but not responding yet${NC}" - echo " Check logs: tail -f ~/Library/Logs/pai-voice-server.log" + echo " Check logs: tail -f $(pai_log_path)" fi else echo -e "${RED}X Failed to start voice server${NC}" diff --git a/Releases/v4.0.3/.claude/VoiceServer/status.sh b/Releases/v4.0.3/.claude/VoiceServer/status.sh index 3e98d3d0c..ae60b1657 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/status.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/status.sh @@ -2,9 +2,14 @@ # Check status of Voice Server +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# shellcheck source=lib/platform.sh +. "$SCRIPT_DIR/lib/platform.sh" + SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" -LOG_PATH="$HOME/Library/Logs/pai-voice-server.log" +SYSTEMD_UNIT_NAME="pai-voice.service" +LOG_PATH="$(pai_log_path)" ENV_FILE="$HOME/.env" # Colors @@ -19,17 +24,34 @@ echo -e "${BLUE} PAI Voice Server Status${NC}" echo -e "${BLUE}=====================================================${NC}" echo -# Check LaunchAgent +# Check service manager — platform branch. +# Darwin path preserved byte-identical. Linux/WSL reads systemd --user +# state via `systemctl is-active` and `is-enabled`. echo -e "${BLUE}Service Status:${NC}" -if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then - PID=$(launchctl list | grep "$SERVICE_NAME" | awk '{print $1}') - if [ "$PID" != "-" ]; then - echo -e " ${GREEN}OK Service is loaded (PID: $PID)${NC}" +if pai_is_darwin; then + if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then + PID=$(launchctl list | grep "$SERVICE_NAME" | awk '{print $1}') + if [ "$PID" != "-" ]; then + echo -e " ${GREEN}OK Service is loaded (PID: $PID)${NC}" + else + echo -e " ${YELLOW}! Service is loaded but not running${NC}" + fi else - echo -e " ${YELLOW}! Service is loaded but not running${NC}" + echo -e " ${RED}X Service is not loaded${NC}" fi else - echo -e " ${RED}X Service is not loaded${NC}" + if systemctl --user is-active --quiet "$SYSTEMD_UNIT_NAME" 2>/dev/null; then + SYSTEMD_PID=$(systemctl --user show "$SYSTEMD_UNIT_NAME" -p MainPID 2>/dev/null | cut -d= -f2) + if [ -n "$SYSTEMD_PID" ] && [ "$SYSTEMD_PID" != "0" ]; then + echo -e " ${GREEN}OK Service is active (PID: $SYSTEMD_PID)${NC}" + else + echo -e " ${GREEN}OK Service is active${NC}" + fi + elif systemctl --user list-unit-files "$SYSTEMD_UNIT_NAME" 2>/dev/null | grep -q "$SYSTEMD_UNIT_NAME"; then + echo -e " ${YELLOW}! Service is installed but not running${NC}" + else + echo -e " ${RED}X Service is not installed${NC}" + fi fi # Check if server is responding @@ -45,13 +67,21 @@ else echo -e " ${RED}X Server is not responding${NC}" fi -# Check port binding +# Check port binding — pai_port_pids cascades lsof > ss > netstat. echo echo -e "${BLUE}Port Status:${NC}" -if lsof -i :8888 > /dev/null 2>&1; then - PROCESS=$(lsof -i :8888 | grep LISTEN | head -1) +PORT_PIDS=$(pai_port_pids 8888 || true) +if [ -n "$PORT_PIDS" ]; then echo -e " ${GREEN}OK Port 8888 is in use${NC}" - echo "$PROCESS" | awk '{print " Process: " $1 " (PID: " $2 ")"}' + for pid in $PORT_PIDS; do + PNAME="" + if [ -r "/proc/$pid/comm" ]; then + PNAME=$(cat "/proc/$pid/comm" 2>/dev/null || true) + elif command -v ps >/dev/null 2>&1; then + PNAME=$(ps -p "$pid" -o comm= 2>/dev/null || true) + fi + echo " Process: ${PNAME:-unknown} (PID: $pid)" + done else echo -e " ${YELLOW}! Port 8888 is not in use${NC}" fi @@ -59,6 +89,10 @@ fi # Check ElevenLabs configuration echo echo -e "${BLUE}Voice Configuration:${NC}" +# Platform-aware fallback messages — Darwin strings preserved +# byte-identical. On Linux/WSL the server has no built-in TTS +# fallback when ElevenLabs is not configured, and the status line +# now says so instead of falsely claiming macOS 'say'. if [ -f "$ENV_FILE" ] && grep -q "ELEVENLABS_API_KEY=" "$ENV_FILE"; then API_KEY=$(grep "ELEVENLABS_API_KEY=" "$ENV_FILE" | cut -d'=' -f2) if [ "$API_KEY" != "your_api_key_here" ] && [ -n "$API_KEY" ]; then @@ -68,10 +102,18 @@ if [ -f "$ENV_FILE" ] && grep -q "ELEVENLABS_API_KEY=" "$ENV_FILE"; then echo " Voice ID: $VOICE_ID" fi else - echo -e " ${YELLOW}! Using macOS 'say' (no API key)${NC}" + if pai_is_darwin; then + echo -e " ${YELLOW}! Using macOS 'say' (no API key)${NC}" + else + echo -e " ${YELLOW}! No TTS fallback (no API key)${NC}" + fi fi else - echo -e " ${YELLOW}! Using macOS 'say' (no configuration)${NC}" + if pai_is_darwin; then + echo -e " ${YELLOW}! Using macOS 'say' (no configuration)${NC}" + else + echo -e " ${YELLOW}! No TTS fallback (no configuration)${NC}" + fi fi # Check logs diff --git a/Releases/v4.0.3/.claude/VoiceServer/stop.sh b/Releases/v4.0.3/.claude/VoiceServer/stop.sh index d5048582c..72f04868d 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/stop.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/stop.sh @@ -2,8 +2,13 @@ # Stop the Voice Server +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# shellcheck source=lib/platform.sh +. "$SCRIPT_DIR/lib/platform.sh" + SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" +SYSTEMD_UNIT_NAME="pai-voice.service" # Colors RED='\033[0;31m' @@ -13,24 +18,42 @@ NC='\033[0m' echo -e "${YELLOW}> Stopping Voice Server...${NC}" -# Check if service is loaded -if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then - # Unload the service - launchctl unload "$PLIST_PATH" 2>/dev/null +# Darwin path preserved byte-identical. Linux/WSL uses systemd --user. +if pai_is_darwin; then + # Check if service is loaded + if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then + # Unload the service + launchctl unload "$PLIST_PATH" 2>/dev/null - if [ $? -eq 0 ]; then - echo -e "${GREEN}OK Voice server stopped successfully${NC}" + if [ $? -eq 0 ]; then + echo -e "${GREEN}OK Voice server stopped successfully${NC}" + else + echo -e "${RED}X Failed to stop voice server${NC}" + exit 1 + fi else - echo -e "${RED}X Failed to stop voice server${NC}" - exit 1 + echo -e "${YELLOW}! Voice server is not running${NC}" fi else - echo -e "${YELLOW}! Voice server is not running${NC}" + # Check if systemd unit is active + if systemctl --user is-active --quiet "$SYSTEMD_UNIT_NAME" 2>/dev/null; then + if systemctl --user stop "$SYSTEMD_UNIT_NAME"; then + echo -e "${GREEN}OK Voice server stopped successfully${NC}" + else + echo -e "${RED}X Failed to stop voice server${NC}" + exit 1 + fi + else + echo -e "${YELLOW}! Voice server is not running${NC}" + fi fi -# Kill any remaining processes on port 8888 -if lsof -i :8888 > /dev/null 2>&1; then +# Kill any remaining processes on port 8888 — uses pai_port_pids which +# cascades lsof > ss > netstat for cross-platform coverage. +PORT_PIDS=$(pai_port_pids 8888 || true) +if [ -n "$PORT_PIDS" ]; then echo -e "${YELLOW}> Cleaning up port 8888...${NC}" - lsof -ti :8888 | xargs kill -9 2>/dev/null + # shellcheck disable=SC2086 + kill -9 $PORT_PIDS 2>/dev/null || true echo -e "${GREEN}OK Port 8888 cleared${NC}" fi diff --git a/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh b/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh index 1f4cc8dcb..3087e3569 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh @@ -2,9 +2,15 @@ # Uninstall Voice Server +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +# shellcheck source=lib/platform.sh +. "$SCRIPT_DIR/lib/platform.sh" + SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" -LOG_PATH="$HOME/Library/Logs/pai-voice-server.log" +SYSTEMD_UNIT_NAME="pai-voice.service" +SYSTEMD_UNIT_PATH="$HOME/.config/systemd/user/$SYSTEMD_UNIT_NAME" +LOG_PATH="$(pai_log_path)" # Colors RED='\033[0;31m' @@ -21,7 +27,11 @@ echo # Confirm uninstall echo -e "${YELLOW}This will:${NC}" echo " - Stop the voice server" -echo " - Remove the LaunchAgent" +if pai_is_darwin; then + echo " - Remove the LaunchAgent" +else + echo " - Remove the systemd --user unit" +fi echo " - Keep your server files and configuration" echo read -p "Are you sure you want to uninstall? (y/n): " -n 1 -r @@ -33,28 +43,52 @@ if [[ ! $REPLY =~ ^[Yy]$ ]]; then exit 0 fi -# Stop the service if running +# Stop the service if running — platform branch. +# Darwin path preserved byte-identical. echo -e "${YELLOW}> Stopping voice server...${NC}" -if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then - launchctl unload "$PLIST_PATH" 2>/dev/null - echo -e "${GREEN}OK Voice server stopped${NC}" -else - echo -e "${YELLOW} Service was not running${NC}" -fi +if pai_is_darwin; then + if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then + launchctl unload "$PLIST_PATH" 2>/dev/null + echo -e "${GREEN}OK Voice server stopped${NC}" + else + echo -e "${YELLOW} Service was not running${NC}" + fi -# Remove LaunchAgent plist -echo -e "${YELLOW}> Removing LaunchAgent...${NC}" -if [ -f "$PLIST_PATH" ]; then - rm "$PLIST_PATH" - echo -e "${GREEN}OK LaunchAgent removed${NC}" + # Remove LaunchAgent plist + echo -e "${YELLOW}> Removing LaunchAgent...${NC}" + if [ -f "$PLIST_PATH" ]; then + rm "$PLIST_PATH" + echo -e "${GREEN}OK LaunchAgent removed${NC}" + else + echo -e "${YELLOW} LaunchAgent file not found${NC}" + fi else - echo -e "${YELLOW} LaunchAgent file not found${NC}" + if systemctl --user is-active --quiet "$SYSTEMD_UNIT_NAME" 2>/dev/null; then + systemctl --user stop "$SYSTEMD_UNIT_NAME" 2>/dev/null || true + echo -e "${GREEN}OK Voice server stopped${NC}" + else + echo -e "${YELLOW} Service was not running${NC}" + fi + + # Remove systemd unit + echo -e "${YELLOW}> Removing systemd unit...${NC}" + systemctl --user disable "$SYSTEMD_UNIT_NAME" 2>/dev/null || true + if [ -f "$SYSTEMD_UNIT_PATH" ]; then + rm "$SYSTEMD_UNIT_PATH" + systemctl --user daemon-reload 2>/dev/null || true + echo -e "${GREEN}OK systemd unit removed${NC}" + else + echo -e "${YELLOW} systemd unit file not found${NC}" + fi fi -# Kill any remaining processes -if lsof -i :8888 > /dev/null 2>&1; then +# Kill any remaining processes on port 8888 — pai_port_pids cascades +# lsof > ss > netstat for cross-platform coverage. +PORT_PIDS=$(pai_port_pids 8888 || true) +if [ -n "$PORT_PIDS" ]; then echo -e "${YELLOW}> Cleaning up port 8888...${NC}" - lsof -ti :8888 | xargs kill -9 2>/dev/null + # shellcheck disable=SC2086 + kill -9 $PORT_PIDS 2>/dev/null || true echo -e "${GREEN}OK Port 8888 cleared${NC}" fi