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