From 231017055a935d788dd5c4045cbf0fa3b1d5734f Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Sat, 11 Apr 2026 09:44:59 -0400 Subject: [PATCH 1/4] fix(VoiceServer): cross-platform audio playback in playAudio() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playAudio() hardcoded /usr/bin/afplay, which is macOS-only. On Linux, every TTS notification fails with ENOENT and the voice server appears to work but produces no audio (the failure is swallowed by the fire-and-forget curl pattern used at the call sites). Extract player resolution into getAudioPlayer(): - darwin → afplay (unchanged) - linux + ffplay → ffplay -nodisp -autoexit -volume 0..100 - linux + mpg123 → mpg123 -f 0..32768 (PCM scale) - neither → throw with an actionable install hint ffplay is preferred because ffmpeg is widely preinstalled; mpg123 is the lightweight fallback. Both route through PulseAudio, so this works on native Linux and on Windows via WSL2 + WSLg out of the box. Verified on Ubuntu 24.04 / WSL2 (Windows 11): TTS audio plays through WSLg PulseAudio to Windows speakers with no additional configuration. Addresses the audio-playback half of #855. Complementary to #1030, which covers the desktop-notification half (osascript → notify-send) without overlap. --- Releases/v4.0.3/.claude/VoiceServer/server.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Releases/v4.0.3/.claude/VoiceServer/server.ts b/Releases/v4.0.3/.claude/VoiceServer/server.ts index 9f5dec95c..751a6a382 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}`)); } }); }); From 4f4d9b53cdb7ff2ef42627b41997639e4d2a46df Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Wed, 15 Apr 2026 14:52:12 -0400 Subject: [PATCH 2/4] fix(VoiceServer): cross-platform desktop notifications, log path, and port check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the getAudioPlayer() pattern from #1061 to cover three more macOS- only assumptions that fail silently or visibly on Linux and WSL2: 1. sendNotification() hardcoded /usr/bin/osascript with an AppleScript `display notification` call. On Linux this is ENOENT; on WSL2 it is also ENOENT and the user loses every desktop banner. 2. The hardcoded ~/Library/Logs/pai-voice-server.log path appears in six different scripts (install/uninstall/start/stop/status and the menubar BitBar indicator). On Linux it writes into a non-standard location inside $HOME that XDG-aware tools never discover. 3. status.sh and stop.sh/uninstall.sh gate all port-check logic on `lsof`, which is not installed by default on many Linux and container images. When lsof is absent the "is port 8888 in use?" check silently returns no, even when pai-voice is actively listening. Changes: - server.ts: add three helpers next to getAudioPlayer(). * isWSL() — single source of truth for WSL1/WSL2 detection via /proc/version, short-circuits on non-Linux. * getLogPath() — darwin unchanged; linux/wsl uses ${XDG_DATA_HOME:-$HOME/.local/share}/pai/logs/… * getNotificationCmd() — mirrors getAudioPlayer() shape. darwin → /usr/bin/osascript (literal AppleScript preserved byte-identically so macOS behavior is unchanged). wsl2 → wsl-notify-send if present, else powershell.exe with BurntToast (if the module is importable) or a bare [Windows.UI.Notifications.ToastNotificationManager] one-liner as the final fallback. linux → /usr/bin/notify-send. Call site inside sendNotification() routes through the new helper. The darwin branch produces argv identical to the pre-refactor literal, so macOS is an obvious-by-inspection no-op. - lib/platform.sh (new): shared shell helpers sourced by every script. * pai_is_wsl — matches isWSL() in TS (single detector). * pai_log_path — matches getLogPath() in TS. * pai_port_pids PORT — cascades lsof → ss → netstat, printing one PID per line; returns 1 if nothing listens. POSIX-leaning bash, side-effect free on source, no mkdir, no exit. - install.sh, uninstall.sh, status.sh, start.sh, stop.sh, menubar/pai-voice.5s.sh: source lib/platform.sh, replace literal LOG_PATH assignments with "$(pai_log_path)", and swap lsof-only port checks for pai_port_pids so the scripts work when lsof is unavailable. Darwin launchctl logic is untouched; only the log-path string and the port-check call site change on the macOS flow. Verified on Ubuntu 24.04 / WSL2: - bun bundles server.ts cleanly. - bash -n passes on every modified script. - pai_is_wsl returns 0 inside WSL2 and stays false on pure Linux (no /proc/version microsoft match). - pai_log_path resolves to /home/$USER/.local/share/pai/logs/pai-voice-server.log. - pai_port_pids 8888 returns the live PID via lsof, ss (lsof masked), and netstat (lsof + ss masked) — all three branches confirmed against a running pai-voice.service. - getNotificationCmd() on WSL2 selects the powershell.exe branch when wsl-notify-send is absent; powershell.exe returns exit 0 and fires a toast via BurntToast/WinRT. Darwin paths are preserved byte-identically and were not exercised on hardware (author runs PAI on WSL2 only). Please review the darwin branches carefully — they are intentionally line-for-line equal to the pre-refactor literals. Stacked on top of #1061 (cross-platform audio playback). Should be merged after #1061, or rebased onto main if #1061 lands first. --- .../v4.0.3/.claude/VoiceServer/install.sh | 4 +- .../.claude/VoiceServer/lib/platform.sh | 130 ++++++++++++++++++ .../VoiceServer/menubar/pai-voice.5s.sh | 21 ++- Releases/v4.0.3/.claude/VoiceServer/server.ts | 110 ++++++++++++++- Releases/v4.0.3/.claude/VoiceServer/start.sh | 7 +- Releases/v4.0.3/.claude/VoiceServer/status.sh | 22 ++- Releases/v4.0.3/.claude/VoiceServer/stop.sh | 13 +- .../v4.0.3/.claude/VoiceServer/uninstall.sh | 15 +- 8 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 Releases/v4.0.3/.claude/VoiceServer/lib/platform.sh diff --git a/Releases/v4.0.3/.claude/VoiceServer/install.sh b/Releases/v4.0.3/.claude/VoiceServer/install.sh index 15b6c83e0..f6a57623a 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/install.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/install.sh @@ -14,9 +14,11 @@ 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" +LOG_PATH="$(pai_log_path)" ENV_FILE="$HOME/.env" echo -e "${BLUE}=====================================================${NC}" 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/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 751a6a382..7d45dff93 100644 --- a/Releases/v4.0.3/.claude/VoiceServer/server.ts +++ b/Releases/v4.0.3/.claude/VoiceServer/server.ts @@ -447,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 // ========================================================================== @@ -545,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..3acb690ac 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/start.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/start.sh @@ -2,9 +2,12 @@ # 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 )" # Colors RED='\033[0;31m' @@ -42,7 +45,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..f5e3865b6 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/status.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/status.sh @@ -2,9 +2,13 @@ # 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" +LOG_PATH="$(pai_log_path)" ENV_FILE="$HOME/.env" # Colors @@ -45,13 +49,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 diff --git a/Releases/v4.0.3/.claude/VoiceServer/stop.sh b/Releases/v4.0.3/.claude/VoiceServer/stop.sh index d5048582c..4588b45f6 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/stop.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/stop.sh @@ -2,6 +2,10 @@ # 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" @@ -28,9 +32,12 @@ else echo -e "${YELLOW}! Voice server is not running${NC}" 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..1e4df7055 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh @@ -2,9 +2,13 @@ # 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" +LOG_PATH="$(pai_log_path)" # Colors RED='\033[0;31m' @@ -51,10 +55,13 @@ else echo -e "${YELLOW} LaunchAgent file not found${NC}" 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 From 80e4aa04cf7493441348bdd26b2b4484a0920944 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Wed, 15 Apr 2026 17:34:54 -0400 Subject: [PATCH 3/4] fix(docs+menubar): platform-aware fallback messages and macOS-only guard Four small cross-platform cosmetic fixes that close out the PAI audit: - VoiceServer/install.sh and status.sh told Linux/WSL users the voice server "will use macOS 'say'" as a fallback when no ElevenLabs API key was configured. On non-Darwin hosts `say` does not exist and the server has no built-in TTS fallback. Both scripts now branch on pai_is_darwin (from lib/platform.sh, added in the cross-platform helpers PR) and print an honest message on Linux/WSL. The Darwin strings are preserved byte-identical. - VoiceServer/menubar/install-menubar.sh now has an early platform guard. SwiftBar and BitBar are macOS-only; on Linux/WSL the script prints an explanatory message pointing at getNotificationCmd() in server.ts and exits 0 so CI and cross-platform bootstrap flows can invoke it unconditionally. The guard is a single `uname -s` check; on Darwin the script continues through to the unchanged SwiftBar/BitBar detection logic byte-identically. - Packs/Utilities/INSTALL.md's troubleshooting section for the AudioEditor sub-skill only listed `brew install ffmpeg`. Added collapsible
blocks with macOS and Linux/WSL install commands. Other entries (wrangler/npm, bun/curl, fabric) are already cross-platform and were left alone. Stacked on top of the cross-platform helpers PR (adds lib/platform.sh with pai_is_darwin) and the cross-platform audio PR. Rebase onto main after those land. Scope note: Packs/Research/INSTALL.md also has two `brew install fabric` references but the plan scoped PR 4 to Utilities/INSTALL.md specifically. Left for a follow-up. --- Packs/Utilities/INSTALL.md | 15 ++++++++++++++- Releases/v4.0.3/.claude/VoiceServer/install.sh | 13 +++++++++++-- .../VoiceServer/menubar/install-menubar.sh | 18 ++++++++++++++++++ Releases/v4.0.3/.claude/VoiceServer/status.sh | 16 ++++++++++++++-- 4 files changed, 57 insertions(+), 5 deletions(-) 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 f6a57623a..20dc95532 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/install.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/install.sh @@ -53,6 +53,15 @@ 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 @@ -60,12 +69,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 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/status.sh b/Releases/v4.0.3/.claude/VoiceServer/status.sh index f5e3865b6..e041c9520 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/status.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/status.sh @@ -71,6 +71,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 @@ -80,10 +84,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 From 9c7d1c366f688f5ddf9f7aa819f134ae0d8e92cd Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Wed, 15 Apr 2026 17:47:41 -0400 Subject: [PATCH 4/4] fix(VoiceServer): service-manager detect-and-branch for launchd+systemd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceServer's install/start/stop/status/uninstall scripts previously assumed macOS/launchctl exclusively. Linux and WSL2 users had no supported path to run the voice server as a supervised service. This adds a systemd --user branch to each script, selected at runtime via the pai_is_darwin helper from lib/platform.sh. The Darwin launchctl flow is preserved byte-identical. ### install.sh - New systemd_unit_* configuration constants alongside the existing PLIST_PATH. - "Existing installation" check branches on pai_is_darwin. On Linux/WSL it probes systemctl --user list-unit-files and the on- disk unit file, prompts the user with the same y/n reinstall UX as the macOS path, and on decline exits 0 without touching the live unit. - Linux/WSL prechecks that systemctl is present and that systemctl --user list-units --no-pager is reachable. On WSL2 it prints the /etc/wsl.conf [boot] systemd=true hint if the user session is unavailable. - New unit generator writes ~/.config/systemd/user/pai-voice.service templated on the reference unit that ships with PAI on WSL2: [Unit] Description / After=default.target [Service] Type=simple WorkingDirectory=${SCRIPT_DIR} ExecStart=${BUN_BIN} run server.ts Restart=on-failure RestartSec=3 StandardOutput/StandardError=append:${LOG_PATH} Environment=HOME/PATH [Install] WantedBy=default.target LOG_PATH comes from pai_log_path (XDG on Linux, Library/Logs on macOS). BUN_BIN is resolved with command -v bun at install time. HOME and PATH are set explicitly so the child process can find the user's ~/.env and runtime helpers like mpg123. The unit passes systemd-analyze verify with no warnings. - daemon-reload + enable --now starts and persists the service. Failure prints the systemctl/journalctl commands to diagnose. - Post-install summary branches by service manager ("launchd" vs "systemd --user") and the stale "macOS Say (fallback)" voice string is now Darwin-only, matching the honest message PR #1075 introduced elsewhere. ### start.sh - Darwin path preserved byte-identical (LaunchAgent existence check, launchctl list, launchctl load, START_RC capture). - Linux/WSL branch checks $SYSTEMD_UNIT_PATH for existence, systemctl --user is-active --quiet for the already-running fast path, and systemctl --user start otherwise. The "already running" hint on Linux points at `systemctl --user restart` instead of the macOS-only ./restart.sh. ### stop.sh - Darwin path preserved byte-identical. - Linux/WSL branch: systemctl --user is-active --quiet → stop → ok. The existing pai_port_pids-based port-8888 cleanup at the tail of the script stays common to both platforms (unchanged from #1072). ### status.sh - Service Status block branches on pai_is_darwin. Linux/WSL reads systemctl --user is-active + MainPID, falling back to list-unit-files for the installed-but-inactive state, and prints "not installed" if neither. - The Voice Configuration block (Darwin "Using macOS 'say'" vs Linux "No TTS fallback") from PR #1075 is untouched. ### uninstall.sh - Confirmation banner branches so Linux/WSL says "Remove the systemd --user unit" instead of "Remove the LaunchAgent". - Stop-and-remove block branches on pai_is_darwin. Linux/WSL path stops the unit, disables it, removes the file, and daemon-reloads. - The optional log-file cleanup and post-uninstall notes are platform-agnostic and unchanged. ### Reference unit Templated on the working pai-voice.service unit that ships with PAI on WSL2 (Description, After, Type, Restart, StandardOutput/Error format, WantedBy). Differences from the reference: - WorkingDirectory uses ${SCRIPT_DIR} instead of %h/.claude/VoiceServer so fork checkouts at any path work correctly. - ExecStart uses $(command -v bun) instead of %h/.bun/bin/bun so non-default bun install locations work. - Log path uses pai_log_path (XDG on Linux) instead of %h/.claude/VoiceServer/voice-server.log so logs land in the XDG-compliant location introduced by #1072. - Explicit Environment=HOME and Environment=PATH so the service can locate ~/.env and runtime helpers (mpg123, ffplay, etc.) regardless of how the systemd --user session was launched. ### Idempotency On a machine with an already-active pai-voice.service unit, re- running install.sh prompts for reinstall (y/n). Declining exits cleanly without touching the live unit file or the running process (verified on the author's machine: PID and unit mtime unchanged across a full install.sh run with 'n' answer). Accepting will stop, disable, rewrite, daemon-reload, enable, and start, matching the exact UX of the macOS reinstall path. ### Verification On Ubuntu 24.04 / WSL2 with systemd --user: - bash -n passes on all five modified scripts. - systemd-analyze verify on the generated unit: clean exit. - Generated plist content (Darwin branch) is byte-identical to the pre-refactor heredoc — confirmed by running the unaltered heredoc body with identical stubs and diffing. The Darwin branch only gains an enclosing `if pai_is_darwin; then ... fi` wrapper; the heredoc body lines are at their original columns so the plist written to disk is byte-identical. - install.sh with an existing unit file + 'n' answer: hits the "Installation cancelled" path, exit 0, live unit mtime and the running service PID both unchanged. - start.sh against a live running unit: hits "already running", exit 0, live PID unchanged. - start.sh with a missing unit (fake HOME): hits "Service not installed", exit 1, no systemctl invocation. - status.sh against a live running unit: reports "OK Service is active (PID: ...)" with the real MainPID. - status.sh with a bogus unit name: hits "Service is not installed". - stop.sh sliced with a bogus unit name: hits "not running" branch, no side effects. - uninstall.sh with 'n' answer: prints Linux-specific confirmation banner, "Uninstall cancelled", live state untouched. Darwin path not exercised on hardware (author has no Mac). The launchctl/plist code paths are wrapped in `if pai_is_darwin; then` with the pre-refactor content preserved verbatim, and the generated plist is confirmed byte-identical. Darwin reviewers please spot- check the wrapped launchctl flow for any regressions. Stacked on #1061, #1072, #1075. Rebase onto main after those land. --- .../v4.0.3/.claude/VoiceServer/install.sh | 156 +++++++++++++++--- Releases/v4.0.3/.claude/VoiceServer/start.sh | 54 ++++-- Releases/v4.0.3/.claude/VoiceServer/status.sh | 32 +++- Releases/v4.0.3/.claude/VoiceServer/stop.sh | 36 ++-- .../v4.0.3/.claude/VoiceServer/uninstall.sh | 55 ++++-- 5 files changed, 261 insertions(+), 72 deletions(-) diff --git a/Releases/v4.0.3/.claude/VoiceServer/install.sh b/Releases/v4.0.3/.claude/VoiceServer/install.sh index 20dc95532..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 @@ -18,6 +21,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" . "$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" +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" @@ -37,17 +43,59 @@ 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 @@ -86,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 @@ -134,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 @@ -172,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/start.sh b/Releases/v4.0.3/.claude/VoiceServer/start.sh index 3acb690ac..14766d430 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/start.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/start.sh @@ -8,6 +8,8 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" +SYSTEMD_UNIT_NAME="pai-voice.service" +SYSTEMD_UNIT_PATH="$HOME/.config/systemd/user/$SYSTEMD_UNIT_NAME" # Colors RED='\033[0;31m' @@ -17,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 + # 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 + + # 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 diff --git a/Releases/v4.0.3/.claude/VoiceServer/status.sh b/Releases/v4.0.3/.claude/VoiceServer/status.sh index e041c9520..ae60b1657 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/status.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/status.sh @@ -8,6 +8,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" +SYSTEMD_UNIT_NAME="pai-voice.service" LOG_PATH="$(pai_log_path)" ENV_FILE="$HOME/.env" @@ -23,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 diff --git a/Releases/v4.0.3/.claude/VoiceServer/stop.sh b/Releases/v4.0.3/.claude/VoiceServer/stop.sh index 4588b45f6..72f04868d 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/stop.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/stop.sh @@ -8,6 +8,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 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' @@ -17,19 +18,34 @@ 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 - - if [ $? -eq 0 ]; then - echo -e "${GREEN}OK Voice server stopped successfully${NC}" +# 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}" + 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 — uses pai_port_pids which diff --git a/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh b/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh index 1e4df7055..3087e3569 100755 --- a/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh +++ b/Releases/v4.0.3/.claude/VoiceServer/uninstall.sh @@ -8,6 +8,8 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SERVICE_NAME="com.pai.voice-server" PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist" +SYSTEMD_UNIT_NAME="pai-voice.service" +SYSTEMD_UNIT_PATH="$HOME/.config/systemd/user/$SYSTEMD_UNIT_NAME" LOG_PATH="$(pai_log_path)" # Colors @@ -25,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 @@ -37,22 +43,43 @@ 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 on port 8888 — pai_port_pids cascades