diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
index e98a7f05c..61e77de3d 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
@@ -16,6 +16,76 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" Margin="0,0,0,8"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(App)Microsoft.UI.Xaml.Application.Current!;
private VoiceService? _voiceService;
+ private AssistantBridgeService? _assistantBridgeService;
+ private CancellationTokenSource? _assistantRequestCts;
+ private int _assistantOperationVersion;
private bool _suppressEvents = true; // suppress until Initialize/LoadSettings runs
// Per-asset CTS so a Piper download doesn't cancel an in-flight Whisper
// download (and vice versa). Each download type owns its own token.
@@ -34,9 +37,12 @@ public VoiceSettingsPage()
{
UpdateModelStatus();
UpdatePiperVoiceState();
+ _ = RefreshAssistantAsync();
};
Unloaded += async (_, _) =>
{
+ CancelAssistantRequest();
+
if (App.Current is App app)
app.SpeakerMuteChanged -= OnAppSpeakerMuteChanged;
@@ -52,6 +58,7 @@ public VoiceSettingsPage()
public void Initialize(VoiceService? voiceService)
{
_voiceService = voiceService;
+ _assistantBridgeService ??= new AssistantBridgeService(new AppLogger());
if (App.Current is App app)
{
app.SpeakerMuteChanged -= OnAppSpeakerMuteChanged;
@@ -64,6 +71,248 @@ public void Initialize(VoiceService? voiceService)
PiperPreviewLabel.Text = L("VoiceSettingsPage_PiperPreviewButtonContent");
PreviewVoiceLabel.Text = L("VoiceSettingsPage_PreviewVoiceButtonContent");
LoadSettings();
+ _ = RefreshAssistantAsync();
+ }
+
+ private void OnAssistantRefreshClick(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ RefreshAssistantAsync,
+ new OpenClawTray.AppLogger(),
+ nameof(OnAssistantRefreshClick));
+
+ private void OnAssistantStartClick(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ OnAssistantStartClickAsync,
+ new OpenClawTray.AppLogger(),
+ nameof(OnAssistantStartClick));
+
+ private async Task OnAssistantStartClickAsync()
+ {
+ var bridge = EnsureAssistantBridge();
+ var busyVersion = BeginAssistantOperation();
+ AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStarting");
+ AssistantDetailText.Text = L("VoiceSettingsPage_AssistantStartingDetail");
+ try
+ {
+ var result = await bridge.StartListenServiceAsync(NewAssistantRequestToken());
+ if (!result.Success)
+ {
+ AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStartFailed");
+ AssistantDetailText.Text = result.ErrorMessage;
+ return;
+ }
+
+ await RefreshAssistantAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ finally
+ {
+ EndAssistantOperation(busyVersion);
+ }
+ }
+
+ private void OnAssistantStopClick(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ OnAssistantStopClickAsync,
+ new OpenClawTray.AppLogger(),
+ nameof(OnAssistantStopClick));
+
+ private async Task OnAssistantStopClickAsync()
+ {
+ var bridge = EnsureAssistantBridge();
+ var busyVersion = BeginAssistantOperation();
+ AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStopping");
+ AssistantDetailText.Text = "";
+ try
+ {
+ var result = await bridge.StopListenServiceAsync(NewAssistantRequestToken());
+ if (!result.Success)
+ {
+ AssistantStatusText.Text = L("VoiceSettingsPage_AssistantStopFailed");
+ AssistantDetailText.Text = result.ErrorMessage;
+ return;
+ }
+
+ await RefreshAssistantAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ finally
+ {
+ EndAssistantOperation(busyVersion);
+ }
+ }
+
+ private async Task RefreshAssistantAsync()
+ {
+ var bridge = EnsureAssistantBridge();
+ var busyVersion = BeginAssistantOperation();
+ try
+ {
+ var snapshot = await bridge.GetStatusAsync(NewAssistantRequestToken());
+ RenderAssistantSnapshot(snapshot);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ finally
+ {
+ EndAssistantOperation(busyVersion);
+ }
+ }
+
+ private AssistantBridgeService EnsureAssistantBridge() =>
+ _assistantBridgeService ??= new AssistantBridgeService(new AppLogger());
+
+ private CancellationToken NewAssistantRequestToken()
+ {
+ CancelAssistantRequest();
+ _assistantRequestCts = new CancellationTokenSource();
+ return _assistantRequestCts.Token;
+ }
+
+ private void CancelAssistantRequest()
+ {
+ try { _assistantRequestCts?.Cancel(); }
+ catch (Exception ex) { Logger.Debug($"VoiceSettingsPage: assistant request cancel failed: {ex.Message}"); }
+ _assistantRequestCts?.Dispose();
+ _assistantRequestCts = null;
+ }
+
+ private void SetAssistantBusy(bool busy)
+ {
+ AssistantRefreshButton.IsEnabled = !busy;
+ AssistantStartButton.IsEnabled = !busy;
+ AssistantStopButton.IsEnabled = !busy;
+ }
+
+ private int BeginAssistantOperation()
+ {
+ var version = Interlocked.Increment(ref _assistantOperationVersion);
+ SetAssistantBusy(true);
+ return version;
+ }
+
+ private void EndAssistantOperation(int version)
+ {
+ if (Volatile.Read(ref _assistantOperationVersion) == version)
+ SetAssistantBusy(false);
+ }
+
+ private void RenderAssistantSnapshot(AssistantBridgeSnapshot snapshot)
+ {
+ if (!snapshot.IsAvailable)
+ {
+ AssistantStatusText.Text = L("VoiceSettingsPage_AssistantBridgeUnavailable");
+ AssistantDetailText.Text = snapshot.ErrorMessage;
+ AssistantLastRefreshText.Text = "";
+ AssistantTurnsPanel.Children.Clear();
+ AssistantTurnsPanel.Children.Add(new TextBlock
+ {
+ Text = L("VoiceSettingsPage_AssistantNoTurnsLoaded"),
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
+ TextWrapping = TextWrapping.Wrap
+ });
+ return;
+ }
+
+ var listen = snapshot.ListenService;
+ var unknown = L("VoiceSettingsPage_AssistantUnknown");
+ var status = string.IsNullOrWhiteSpace(listen.Status) ? unknown : listen.Status;
+ AssistantStatusText.Text = listen.IsRunning
+ ? Lf("VoiceSettingsPage_AssistantListeningFormat", listen.Pid?.ToString(CultureInfo.CurrentCulture) ?? unknown)
+ : listen.IsStopped
+ ? L("VoiceSettingsPage_AssistantStopped")
+ : Lf("VoiceSettingsPage_AssistantStatusFormat", status);
+
+ var details = new[]
+ {
+ string.IsNullOrWhiteSpace(listen.Transcriber) ? "" : Lf("VoiceSettingsPage_AssistantTranscriberFormat", listen.Transcriber),
+ string.IsNullOrWhiteSpace(snapshot.PreferredInputDevice) ? "" : Lf("VoiceSettingsPage_AssistantMicFormat", snapshot.PreferredInputDevice),
+ string.IsNullOrWhiteSpace(snapshot.PreferredOutputDevice) ? "" : Lf("VoiceSettingsPage_AssistantSpeakerFormat", snapshot.PreferredOutputDevice),
+ Lf("VoiceSettingsPage_AssistantCloudRoutingFormat", listen.AllowCloud ? L("VoiceSettingsPage_AssistantOn") : L("VoiceSettingsPage_AssistantOff")),
+ Lf("VoiceSettingsPage_AssistantSpeechOutputFormat", listen.SpeakAloud ? L("VoiceSettingsPage_AssistantOn") : L("VoiceSettingsPage_AssistantOff"))
+ }.Where(s => !string.IsNullOrWhiteSpace(s));
+ AssistantDetailText.Text = string.Join(" | ", details);
+ AssistantLastRefreshText.Text = string.IsNullOrWhiteSpace(snapshot.GeneratedAt)
+ ? ""
+ : Lf("VoiceSettingsPage_AssistantLastBridgeUpdateFormat", snapshot.GeneratedAt);
+
+ AssistantTurnsPanel.Children.Clear();
+ if (snapshot.RecentTurns.Count == 0)
+ {
+ AssistantTurnsPanel.Children.Add(new TextBlock
+ {
+ Text = L("VoiceSettingsPage_AssistantNoConversationTurns"),
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
+ TextWrapping = TextWrapping.Wrap
+ });
+ return;
+ }
+
+ foreach (var turn in snapshot.RecentTurns.Take(5))
+ AssistantTurnsPanel.Children.Add(CreateAssistantTurnView(turn));
+ }
+
+ private static Border CreateAssistantTurnView(AssistantTurnSnapshot turn)
+ {
+ var panel = new StackPanel { Spacing = 6 };
+ var source = string.IsNullOrWhiteSpace(turn.Source) ? L("VoiceSettingsPage_AssistantSourceDefault") : turn.Source;
+ var stage = string.IsNullOrWhiteSpace(turn.Stage) ? L("VoiceSettingsPage_AssistantUnknown") : turn.Stage;
+ var model = string.IsNullOrWhiteSpace(turn.Provider)
+ ? turn.ModelProfile
+ : $"{turn.Provider} {turn.ModelProfile}".Trim();
+ var metadata = turn.TotalMs is int ms
+ ? Lf("VoiceSettingsPage_AssistantTurnMetadataWithLatencyFormat", source, stage, ms.ToString(CultureInfo.CurrentCulture))
+ : Lf("VoiceSettingsPage_AssistantTurnMetadataFormat", source, stage);
+ panel.Children.Add(new TextBlock
+ {
+ Text = metadata,
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
+ TextWrapping = TextWrapping.Wrap
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = Lf("VoiceSettingsPage_AssistantUserTurnFormat", TrimForDisplay(turn.InputText)),
+ TextWrapping = TextWrapping.Wrap
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = Lf("VoiceSettingsPage_AssistantResponseTurnFormat", TrimForDisplay(turn.ResponseText)),
+ TextWrapping = TextWrapping.Wrap
+ });
+ if (!string.IsNullOrWhiteSpace(model))
+ {
+ panel.Children.Add(new TextBlock
+ {
+ Text = model,
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
+ TextWrapping = TextWrapping.Wrap
+ });
+ }
+
+ return new Border
+ {
+ Child = panel,
+ Background = (Brush)Application.Current.Resources["CardBackgroundFillColorSecondaryBrush"],
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(12)
+ };
+ }
+
+ private static string TrimForDisplay(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return L("VoiceSettingsPage_AssistantEmptyValue");
+ value = value.Trim();
+ return value.Length <= 220 ? value : value[..217] + "...";
}
private void OnAppSpeakerMuteChanged(bool muted)
diff --git a/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs b/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs
new file mode 100644
index 000000000..3f6572a1f
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/AssistantBridgeService.cs
@@ -0,0 +1,449 @@
+using OpenClaw.Shared;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace OpenClawTray.Services;
+
+internal sealed class AssistantBridgeService
+{
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
+ private readonly IOpenClawLogger _logger;
+ private readonly string _userId;
+ private readonly OpenClawCliLauncher? _launcher;
+ private readonly TimeSpan _timeout;
+
+ public AssistantBridgeService(IOpenClawLogger logger, string userId = "owner")
+ : this(logger, userId, ResolveOpenClawCli)
+ {
+ }
+
+ internal AssistantBridgeService(
+ IOpenClawLogger logger,
+ string userId,
+ Func launcherResolver,
+ TimeSpan? commandTimeout = null)
+ {
+ _logger = logger;
+ _userId = string.IsNullOrWhiteSpace(userId) ? "owner" : userId;
+ _launcher = launcherResolver();
+ _timeout = commandTimeout ?? DefaultTimeout;
+
+ var overrideRoot = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT");
+ if (!string.IsNullOrWhiteSpace(overrideRoot))
+ {
+ if (TryNormalizeBackendRoot(overrideRoot, out var normalizedOverrideRoot))
+ {
+ if (_launcher != null &&
+ string.Equals(_launcher.WorkingDirectory, normalizedOverrideRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Info($"Assistant bridge using OPENCLAW_BACKEND_ROOT launcher at '{_launcher.WorkingDirectory}'.");
+ }
+ }
+ else
+ {
+ _logger.Warn("Assistant bridge ignored OPENCLAW_BACKEND_ROOT because it is not a fully qualified local checkout under a trusted parent.");
+ }
+ }
+ }
+
+ public async Task GetStatusAsync(CancellationToken cancellationToken = default)
+ {
+ var result = await RunOpenClawAsync(["dashboard", "bridge", "status", "--user", _userId, "--json"], cancellationToken);
+ if (!result.Success)
+ return AssistantBridgeSnapshot.Unavailable(result.ErrorMessage);
+
+ try
+ {
+ return ParseStatus(result.Stdout);
+ }
+ catch (JsonException ex)
+ {
+ _logger.Warn($"Assistant bridge status JSON could not be parsed: {ex.Message}");
+ return AssistantBridgeSnapshot.Unavailable("OpenClaw returned status JSON the Companion could not read.");
+ }
+ }
+
+ public Task StartListenServiceAsync(CancellationToken cancellationToken = default) =>
+ RunCommandAsync(
+ ["assistant", "listen-service", "start", "--user", _userId, "--store-turn", "--json"],
+ cancellationToken);
+
+ public Task StopListenServiceAsync(CancellationToken cancellationToken = default) =>
+ RunCommandAsync(
+ ["assistant", "listen-service", "stop", "--user", _userId, "--json"],
+ cancellationToken);
+
+ private async Task RunCommandAsync(IReadOnlyList args, CancellationToken cancellationToken)
+ {
+ var result = await RunOpenClawAsync(args, cancellationToken);
+ return new AssistantCommandResult(result.Success, result.ErrorMessage);
+ }
+
+ private async Task RunOpenClawAsync(
+ IReadOnlyList args,
+ CancellationToken cancellationToken)
+ {
+ var launcher = _launcher;
+ if (launcher == null)
+ return AssistantProcessResult.Failed(BuildBackendNotFoundMessage());
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = launcher.ExecutablePath,
+ WorkingDirectory = launcher.WorkingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8
+ };
+
+ foreach (var arg in launcher.PrefixArgs)
+ psi.ArgumentList.Add(arg);
+ foreach (var arg in args)
+ psi.ArgumentList.Add(arg);
+
+ try
+ {
+ using var process = new Process { StartInfo = psi };
+ if (!process.Start())
+ return AssistantProcessResult.Failed("Could not start the OpenClaw backend command.");
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(_timeout);
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
+ var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
+ var timedOut = false;
+ try
+ {
+ await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ timedOut = true;
+ TryKillProcessTree(process);
+ }
+ catch (OperationCanceledException)
+ {
+ TryKillProcessTree(process);
+ throw;
+ }
+
+ var stdout = await ReadProcessStreamAsync(stdoutTask).ConfigureAwait(false);
+ var stderr = await ReadProcessStreamAsync(stderrTask).ConfigureAwait(false);
+ if (timedOut)
+ return AssistantProcessResult.Failed("OpenClaw backend command timed out.");
+
+ if (process.ExitCode != 0)
+ {
+ var detail = string.IsNullOrWhiteSpace(stderr) ? "OpenClaw backend command failed." : stderr.Trim();
+ return AssistantProcessResult.Failed(detail);
+ }
+
+ return new AssistantProcessResult(true, stdout, "");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warn($"Assistant bridge command failed to start: {ex.Message}");
+ return AssistantProcessResult.Failed("OpenClaw backend command could not be started.");
+ }
+ }
+
+ private static async Task ReadProcessStreamAsync(Task streamTask)
+ {
+ try
+ {
+ return await streamTask.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return "";
+ }
+ catch (IOException)
+ {
+ return "";
+ }
+ catch (ObjectDisposedException)
+ {
+ return "";
+ }
+ }
+
+ private static void TryKillProcessTree(Process process)
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ catch (Win32Exception)
+ {
+ }
+ catch (NotSupportedException)
+ {
+ }
+ }
+
+ internal static AssistantBridgeSnapshot ParseStatus(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var generatedAt = ReadString(root, "generated_at");
+ var assistant = ReadObject(root, "assistant");
+ var voice = ReadObject(root, "voice");
+
+ var listen = AssistantListenServiceSnapshot.Empty;
+ if (assistant.HasValue && assistant.Value.TryGetProperty("listen_service", out var listenElement))
+ {
+ listen = new AssistantListenServiceSnapshot(
+ ReadString(listenElement, "status"),
+ ReadBool(listenElement, "configured"),
+ ReadNullableInt(listenElement, "pid"),
+ ReadBool(listenElement, "stop_requested"),
+ ReadBool(listenElement, "allow_cloud"),
+ ReadBool(listenElement, "speak_aloud"),
+ ReadString(listenElement, "transcriber"),
+ ReadString(listenElement, "input_mode"),
+ ReadString(listenElement, "log_file"));
+ }
+
+ var turns = new List();
+ if (assistant.HasValue && assistant.Value.TryGetProperty("recent_turns", out var recentTurns) &&
+ recentTurns.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var turn in recentTurns.EnumerateArray())
+ {
+ turns.Add(new AssistantTurnSnapshot(
+ ReadString(turn, "created_at"),
+ ReadString(turn, "source"),
+ ReadString(turn, "input_text"),
+ ReadString(turn, "display_response_text"),
+ ReadString(turn, "provider"),
+ ReadString(turn, "model_profile"),
+ ReadString(turn, "stage"),
+ ReadNullableInt(turn, "total_ms")));
+ }
+ }
+
+ return new AssistantBridgeSnapshot(
+ true,
+ "",
+ generatedAt,
+ ReadString(root, "user_id"),
+ ReadString(voice, "preferred_input_device"),
+ ReadString(voice, "preferred_output_device"),
+ listen,
+ turns);
+ }
+
+ internal static OpenClawCliLauncher? ResolveOpenClawCli()
+ {
+ foreach (var root in CandidateBackendRoots())
+ {
+ if (!TryNormalizeBackendRoot(root, out var normalizedRoot))
+ continue;
+
+ var openclawExe = Path.Combine(normalizedRoot, ".venv", "Scripts", "openclaw.exe");
+ if (File.Exists(openclawExe))
+ return new OpenClawCliLauncher(openclawExe, normalizedRoot, []);
+
+ var pythonExe = Path.Combine(normalizedRoot, ".venv", "Scripts", "python.exe");
+ if (File.Exists(pythonExe))
+ return new OpenClawCliLauncher(pythonExe, normalizedRoot, ["-m", "openclaw.cli"]);
+ }
+
+ return null;
+ }
+
+ internal static string BuildBackendNotFoundMessage()
+ {
+ var searchedRoots = CandidateBackendRoots()
+ .Select(root => TryNormalizeBackendRoot(root, out var normalizedRoot) ? normalizedRoot : root)
+ .Where(root => !string.IsNullOrWhiteSpace(root))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ return "OpenClaw backend checkout was not found. Set OPENCLAW_BACKEND_ROOT to a fully qualified local checkout under D:\\Projects, %USERPROFILE%\\Projects, or %USERPROFILE%\\source\\repos. Searched: " +
+ string.Join("; ", searchedRoots) +
+ ".";
+ }
+
+ internal static IReadOnlyList CandidateBackendRoots()
+ {
+ var candidates = new List();
+ var overrideRoot = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT");
+ if (!string.IsNullOrWhiteSpace(overrideRoot))
+ candidates.Add(overrideRoot);
+
+ candidates.Add(@"D:\Projects\OpenClaw");
+ candidates.Add(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Projects",
+ "OpenClaw"));
+ candidates.Add(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "source",
+ "repos",
+ "OpenClaw"));
+
+ return candidates;
+ }
+
+ internal static bool TryNormalizeBackendRoot(string root, out string normalizedRoot)
+ {
+ normalizedRoot = "";
+ if (string.IsNullOrWhiteSpace(root))
+ return false;
+
+ var expandedRoot = Environment.ExpandEnvironmentVariables(root.Trim());
+ if (string.IsNullOrWhiteSpace(expandedRoot) || !Path.IsPathFullyQualified(expandedRoot))
+ return false;
+
+ try
+ {
+ normalizedRoot = Path.GetFullPath(expandedRoot);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ catch (PathTooLongException)
+ {
+ return false;
+ }
+
+ if (normalizedRoot.StartsWith(@"\\", StringComparison.Ordinal))
+ return false;
+
+ var candidateRoot = normalizedRoot;
+ return TrustedBackendParentRoots()
+ .Any(parent => IsSameOrUnderPath(candidateRoot, parent));
+ }
+
+ private static IEnumerable TrustedBackendParentRoots()
+ {
+ yield return @"D:\Projects";
+
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (string.IsNullOrWhiteSpace(userProfile))
+ yield break;
+
+ yield return Path.Combine(userProfile, "Projects");
+ yield return Path.Combine(userProfile, "source", "repos");
+ }
+
+ private static bool IsSameOrUnderPath(string path, string parent)
+ {
+ var normalizedParent = Path.GetFullPath(parent)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var normalizedPath = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ return string.Equals(normalizedPath, normalizedParent, StringComparison.OrdinalIgnoreCase) ||
+ normalizedPath.StartsWith(normalizedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static JsonElement? ReadObject(JsonElement? parent, string name)
+ {
+ if (parent is not { } element || element.ValueKind != JsonValueKind.Object)
+ return null;
+ return element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Object
+ ? value
+ : null;
+ }
+
+ private static string ReadString(JsonElement? parent, string name)
+ {
+ if (parent is not { } element || element.ValueKind != JsonValueKind.Object)
+ return "";
+ return element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString() ?? ""
+ : "";
+ }
+
+ private static bool ReadBool(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True;
+
+ private static int? ReadNullableInt(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out var value))
+ return null;
+ return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var parsed)
+ ? parsed
+ : null;
+ }
+
+ private sealed record AssistantProcessResult(bool Success, string Stdout, string ErrorMessage)
+ {
+ public static AssistantProcessResult Failed(string errorMessage) => new(false, "", errorMessage);
+ }
+}
+
+internal sealed record OpenClawCliLauncher(
+ string ExecutablePath,
+ string WorkingDirectory,
+ IReadOnlyList PrefixArgs);
+
+internal sealed record AssistantCommandResult(bool Success, string ErrorMessage);
+
+internal sealed record AssistantBridgeSnapshot(
+ bool IsAvailable,
+ string ErrorMessage,
+ string GeneratedAt,
+ string UserId,
+ string PreferredInputDevice,
+ string PreferredOutputDevice,
+ AssistantListenServiceSnapshot ListenService,
+ IReadOnlyList RecentTurns)
+{
+ public static AssistantBridgeSnapshot Unavailable(string errorMessage) =>
+ new(false, errorMessage, "", "", "", "", AssistantListenServiceSnapshot.Empty, []);
+}
+
+internal sealed record AssistantListenServiceSnapshot(
+ string Status,
+ bool Configured,
+ int? Pid,
+ bool StopRequested,
+ bool AllowCloud,
+ bool SpeakAloud,
+ string Transcriber,
+ string InputMode,
+ string LogFile)
+{
+ public static AssistantListenServiceSnapshot Empty { get; } =
+ new("", false, null, false, false, false, "", "", "");
+
+ public bool IsRunning => string.Equals(Status, "running", StringComparison.OrdinalIgnoreCase);
+
+ public bool IsStopped =>
+ string.Equals(Status, "stopped", StringComparison.OrdinalIgnoreCase) ||
+ (StopRequested && string.Equals(Status, "stop-requested", StringComparison.OrdinalIgnoreCase));
+}
+
+internal sealed record AssistantTurnSnapshot(
+ string CreatedAt,
+ string Source,
+ string InputText,
+ string ResponseText,
+ string Provider,
+ string ModelProfile,
+ string Stage,
+ int? TotalMs);
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 947a1ee93..00d5db710 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2637,6 +2637,105 @@ On your gateway host (Mac/Linux), run:
Configure speech-to-text and voice interaction settings. All speech processing runs locally on your device.
+
+ Assistant
+
+
+ Always-listening assistant service and latest conversation turns.
+
+
+ Refresh assistant status
+
+
+ Start
+
+
+ Stop
+
+
+ Checking assistant status...
+
+
+ Recent turns
+
+
+ Starting assistant...
+
+
+ Starting always-listening mode with local assistant routing and turn storage.
+
+
+ Assistant did not start
+
+
+ Stopping assistant...
+
+
+ Assistant did not stop
+
+
+ Assistant bridge unavailable
+
+
+ No assistant turns loaded.
+
+
+ unknown
+
+
+ Assistant listening (PID {0})
+
+
+ Assistant stopped
+
+
+ Assistant {0}
+
+
+ Transcriber: {0}
+
+
+ Mic: {0}
+
+
+ Speaker: {0}
+
+
+ Cloud routing: {0}
+
+
+ Speech output: {0}
+
+
+ on
+
+
+ off
+
+
+ Last bridge update: {0}
+
+
+ No conversation turns yet.
+
+
+ assistant
+
+
+ {0} | {1}
+
+
+ {0} | {1} | {2} ms
+
+
+ You: {0}
+
+
+ OpenClaw: {0}
+
+
+ (empty)
+
Speech-to-Text
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 144bb96b8..0988cbb46 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2589,6 +2589,105 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Configurez la reconnaissance vocale et les paramètres d'interaction vocale. Tout le traitement vocal s'exécute localement sur votre appareil.
+
+ Assistant vocal
+
+
+ Service d'assistant toujours à l'écoute et derniers tours de conversation.
+
+
+ Actualiser l'état de l'assistant
+
+
+ Démarrer
+
+
+ Arrêter
+
+
+ Vérification de l'état de l'assistant...
+
+
+ Tours récents
+
+
+ Démarrage de l'assistant...
+
+
+ Démarrage du mode toujours à l'écoute avec routage local de l'assistant et stockage des tours.
+
+
+ L'assistant n'a pas démarré
+
+
+ Arrêt de l'assistant...
+
+
+ L'assistant ne s'est pas arrêté
+
+
+ Pont assistant indisponible
+
+
+ Aucun tour d'assistant chargé.
+
+
+ inconnu
+
+
+ Assistant à l'écoute (PID {0})
+
+
+ Assistant arrêté
+
+
+ État de l'assistant : {0}
+
+
+ Transcripteur : {0}
+
+
+ Micro : {0}
+
+
+ Haut-parleur : {0}
+
+
+ Routage cloud : {0}
+
+
+ Sortie vocale : {0}
+
+
+ activé
+
+
+ désactivé
+
+
+ Dernière mise à jour du pont : {0}
+
+
+ Aucun tour de conversation pour le moment.
+
+
+ l'assistant
+
+
+ {0} / {1}
+
+
+ {0} | {1} | {2} ms de latence
+
+
+ Vous : {0}
+
+
+ OpenClaw : {0}
+
+
+ (vide)
+
Reconnaissance vocale
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index c298fbfb1..b6f0c2695 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2590,6 +2590,105 @@ Voer op uw gateway-host (Mac/Linux) uit:
Configureer spraak-naar-tekst en spraakinteractie-instellingen. Alle spraakverwerking draait lokaal op uw apparaat.
+
+ Assistent
+
+
+ Altijd luisterende assistentservice en nieuwste gespreksbeurten.
+
+
+ Assistentstatus vernieuwen
+
+
+ Starten
+
+
+ Stoppen
+
+
+ Assistentstatus controleren...
+
+
+ Recente beurten
+
+
+ Assistent starten...
+
+
+ Altijd-luisterende modus starten met lokale assistentroutering en opslag van beurten.
+
+
+ Assistent is niet gestart
+
+
+ Assistent stoppen...
+
+
+ Assistent is niet gestopt
+
+
+ Assistentbrug niet beschikbaar
+
+
+ Geen assistentbeurten geladen.
+
+
+ onbekend
+
+
+ Assistent luistert (PID {0})
+
+
+ Assistent gestopt
+
+
+ Assistent {0}
+
+
+ Transcriptie: {0}
+
+
+ Microfoon: {0}
+
+
+ Luidspreker: {0}
+
+
+ Cloudroutering: {0}
+
+
+ Spraakuitvoer: {0}
+
+
+ aan
+
+
+ uit
+
+
+ Laatste brugupdate: {0}
+
+
+ Nog geen gespreksbeurten.
+
+
+ assistent
+
+
+ {0} / {1}
+
+
+ {0} | {1} | {2} ms vertraging
+
+
+ Jij: {0}
+
+
+ OpenClaw-antwoord: {0}
+
+
+ (leeg)
+
Spraak-naar-tekst
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 2a66056b2..0e66266a3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2589,6 +2589,105 @@
配置语音转文字和语音交互设置。所有语音处理均在本机本地运行。
+
+ 助手
+
+
+ 始终监听的助手服务和最新对话轮次。
+
+
+ 刷新助手状态
+
+
+ 启动
+
+
+ 停止
+
+
+ 正在检查助手状态...
+
+
+ 最近轮次
+
+
+ 正在启动助手...
+
+
+ 正在启动始终监听模式,并使用本地助手路由和轮次存储。
+
+
+ 助手未启动
+
+
+ 正在停止助手...
+
+
+ 助手未停止
+
+
+ 助手桥不可用
+
+
+ 未加载助手轮次。
+
+
+ 未知
+
+
+ 助手正在监听 (PID {0})
+
+
+ 助手已停止
+
+
+ 助手 {0}
+
+
+ 转录器:{0}
+
+
+ 麦克风:{0}
+
+
+ 扬声器:{0}
+
+
+ 云路由:{0}
+
+
+ 语音输出:{0}
+
+
+ 开启
+
+
+ 关闭
+
+
+ 上次桥更新:{0}
+
+
+ 还没有对话轮次。
+
+
+ 助手
+
+
+ {0} / {1}
+
+
+ {0} | {1} | {2} 毫秒
+
+
+ 你:{0}
+
+
+ OpenClaw:{0}
+
+
+ (空)
+
语音转文字
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 29fadcc44..5e3a02349 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2589,6 +2589,105 @@
設定語音轉文字和語音互動設定。所有語音處理均在本機本地執行。
+
+ 助理
+
+
+ 永遠聆聽的助理服務和最新對話輪次。
+
+
+ 重新整理助理狀態
+
+
+ 啟動
+
+
+ 停止
+
+
+ 正在檢查助理狀態...
+
+
+ 最近輪次
+
+
+ 正在啟動助理...
+
+
+ 正在啟動永遠聆聽模式,並使用本機助理路由和輪次儲存。
+
+
+ 助理未啟動
+
+
+ 正在停止助理...
+
+
+ 助理未停止
+
+
+ 助理橋接器無法使用
+
+
+ 未載入助理輪次。
+
+
+ 未知
+
+
+ 助理正在聆聽 (PID {0})
+
+
+ 助理已停止
+
+
+ 助理 {0}
+
+
+ 轉錄器:{0}
+
+
+ 麥克風:{0}
+
+
+ 喇叭:{0}
+
+
+ 雲端路由:{0}
+
+
+ 語音輸出:{0}
+
+
+ 開啟
+
+
+ 關閉
+
+
+ 上次橋接器更新:{0}
+
+
+ 尚無對話輪次。
+
+
+ 助理
+
+
+ {0} / {1}
+
+
+ {0} | {1} | {2} 毫秒
+
+
+ 你:{0}
+
+
+ OpenClaw:{0}
+
+
+ (空)
+
語音轉文字
diff --git a/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs
new file mode 100644
index 000000000..7291729f4
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/AssistantBridgeServiceTests.cs
@@ -0,0 +1,294 @@
+using OpenClaw.Shared;
+using OpenClawTray.Services;
+using System.Diagnostics;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class AssistantBridgeServiceTests
+{
+ [Fact]
+ public void ParseStatus_ReadsListenServiceAndRecentTurns()
+ {
+ const string json = """
+ {
+ "generated_at": "2026-06-21T19:40:30+00:00",
+ "user_id": "owner",
+ "assistant": {
+ "listen_service": {
+ "allow_cloud": true,
+ "configured": true,
+ "input_mode": "always-listening",
+ "log_file": "C:\\Users\\RipauvGohil\\AppData\\Local\\OpenClaw\\runtime\\assistant-listen-owner.log",
+ "pid": 20656,
+ "speak_aloud": true,
+ "status": "running",
+ "transcriber": "local-whisper"
+ },
+ "recent_turns": [
+ {
+ "created_at": "2026-06-21T19:26:59+00:00",
+ "source": "local-whisper",
+ "input_text": "OpenClaw, say only live voice smoke ok.",
+ "display_response_text": "live voice smoke ok",
+ "provider": "local-openai-compatible",
+ "model_profile": "local-private",
+ "stage": "answered",
+ "total_ms": 23502
+ }
+ ]
+ },
+ "voice": {
+ "preferred_input_device": "Microphone (Yeti Nano)",
+ "preferred_output_device": "LG TV SSCR2 (NVIDIA High Definition Audio)"
+ }
+ }
+ """;
+
+ var snapshot = AssistantBridgeService.ParseStatus(json);
+
+ Assert.True(snapshot.IsAvailable);
+ Assert.Equal("owner", snapshot.UserId);
+ Assert.Equal("2026-06-21T19:40:30+00:00", snapshot.GeneratedAt);
+ Assert.Equal("Microphone (Yeti Nano)", snapshot.PreferredInputDevice);
+ Assert.Equal("LG TV SSCR2 (NVIDIA High Definition Audio)", snapshot.PreferredOutputDevice);
+ Assert.True(snapshot.ListenService.IsRunning);
+ Assert.True(snapshot.ListenService.AllowCloud);
+ Assert.True(snapshot.ListenService.SpeakAloud);
+ Assert.Equal(20656, snapshot.ListenService.Pid);
+ Assert.Equal("local-whisper", snapshot.ListenService.Transcriber);
+ Assert.Single(snapshot.RecentTurns);
+ Assert.Equal("OpenClaw, say only live voice smoke ok.", snapshot.RecentTurns[0].InputText);
+ Assert.Equal("live voice smoke ok", snapshot.RecentTurns[0].ResponseText);
+ Assert.Equal(23502, snapshot.RecentTurns[0].TotalMs);
+ }
+
+ [Fact]
+ public void ResolveOpenClawCli_PrefersBackendRootEnvironmentOverride()
+ {
+ var oldValue = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT");
+ var root = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Projects",
+ "OpenClaw.Tray.Tests",
+ Guid.NewGuid().ToString("N"));
+ try
+ {
+ var scripts = Path.Combine(root, ".venv", "Scripts");
+ Directory.CreateDirectory(scripts);
+ var exe = Path.Combine(scripts, "openclaw.exe");
+ File.WriteAllText(exe, "");
+
+ Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", root);
+
+ var launcher = AssistantBridgeService.ResolveOpenClawCli();
+
+ Assert.NotNull(launcher);
+ Assert.Equal(exe, launcher!.ExecutablePath);
+ Assert.Equal(root, launcher.WorkingDirectory);
+ Assert.Empty(launcher.PrefixArgs);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", oldValue);
+ try { Directory.Delete(root, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void ResolveOpenClawCli_IgnoresBackendRootOutsideTrustedParents()
+ {
+ var oldValue = Environment.GetEnvironmentVariable("OPENCLAW_BACKEND_ROOT");
+ var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ var scripts = Path.Combine(root, ".venv", "Scripts");
+ Directory.CreateDirectory(scripts);
+ var exe = Path.Combine(scripts, "openclaw.exe");
+ File.WriteAllText(exe, "");
+
+ Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", root);
+
+ var launcher = AssistantBridgeService.ResolveOpenClawCli();
+
+ Assert.True(
+ launcher == null || !string.Equals(launcher.ExecutablePath, exe, StringComparison.OrdinalIgnoreCase),
+ "OPENCLAW_BACKEND_ROOT should not execute arbitrary binaries outside trusted checkout parents.");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("OPENCLAW_BACKEND_ROOT", oldValue);
+ try { Directory.Delete(root, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void TryNormalizeBackendRoot_RejectsRelativePaths()
+ {
+ Assert.False(AssistantBridgeService.TryNormalizeBackendRoot("OpenClaw", out _));
+ }
+
+ [Fact]
+ public void TryNormalizeBackendRoot_AcceptsTrustedCheckoutParents()
+ {
+ var root = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Projects",
+ "OpenClaw");
+
+ Assert.True(AssistantBridgeService.TryNormalizeBackendRoot(root, out var normalizedRoot));
+ Assert.Equal(Path.GetFullPath(root), normalizedRoot);
+ }
+
+ [Fact]
+ public void BuildBackendNotFoundMessage_ListsSearchedLocations()
+ {
+ var message = AssistantBridgeService.BuildBackendNotFoundMessage();
+
+ Assert.Contains("Searched:", message);
+ Assert.Contains(@"D:\Projects\OpenClaw", message);
+ Assert.Contains("OPENCLAW_BACKEND_ROOT", message);
+ }
+
+ [Fact]
+ public async Task StartListenServiceAsync_DefaultsToLocalAssistantRouting()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N"));
+ var argsPath = Path.Combine(root, "args.txt");
+ Directory.CreateDirectory(root);
+ try
+ {
+ var scriptPath = Path.Combine(root, "capture-args.ps1");
+ File.WriteAllText(
+ scriptPath,
+ $"Set-Content -LiteralPath {PsQuote(argsPath)} -Value ($args -join [Environment]::NewLine)");
+ var launcher = PowerShellScriptLauncher(scriptPath, root);
+ var service = new AssistantBridgeService(
+ NullLogger.Instance,
+ "owner",
+ () => launcher);
+
+ var result = await service.StartListenServiceAsync();
+
+ Assert.True(result.Success, result.ErrorMessage);
+ var args = File.ReadAllLines(argsPath);
+ Assert.Contains("assistant", args);
+ Assert.Contains("listen-service", args);
+ Assert.Contains("start", args);
+ Assert.Contains("--store-turn", args);
+ Assert.Contains("--json", args);
+ Assert.DoesNotContain("--allow-cloud", args);
+ }
+ finally
+ {
+ try { Directory.Delete(root, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public async Task StartListenServiceAsync_KillsTimedOutBackendCommand()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N"));
+ var pidPath = Path.Combine(root, "pid.txt");
+ Directory.CreateDirectory(root);
+ int? pid = null;
+ try
+ {
+ var scriptPath = Path.Combine(root, "sleep.ps1");
+ File.WriteAllText(
+ scriptPath,
+ $"Set-Content -LiteralPath {PsQuote(pidPath)} -Value $PID; Start-Sleep -Seconds 30");
+ var launcher = PowerShellScriptLauncher(scriptPath, root);
+ var service = new AssistantBridgeService(
+ NullLogger.Instance,
+ "owner",
+ () => launcher,
+ TimeSpan.FromSeconds(2));
+
+ var result = await service.StartListenServiceAsync();
+
+ Assert.False(result.Success);
+ Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+ pid = await ReadPidAsync(pidPath);
+ Assert.True(
+ await WaitForProcessExitAsync(pid.Value),
+ $"Expected timed-out backend process {pid.Value} to be killed.");
+ }
+ finally
+ {
+ if (pid is int runningPid)
+ TryKillProcess(runningPid);
+ try { Directory.Delete(root, recursive: true); } catch { }
+ }
+ }
+
+ private static OpenClawCliLauncher PowerShellScriptLauncher(string scriptPath, string workingDirectory)
+ {
+ var exe = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.System),
+ "WindowsPowerShell",
+ "v1.0",
+ "powershell.exe");
+ if (!File.Exists(exe))
+ exe = "powershell.exe";
+
+ return new OpenClawCliLauncher(
+ exe,
+ workingDirectory,
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath]);
+ }
+
+ private static string PsQuote(string value) => "'" + value.Replace("'", "''") + "'";
+
+ private static async Task ReadPidAsync(string pidPath)
+ {
+ for (var i = 0; i < 50; i++)
+ {
+ if (File.Exists(pidPath) &&
+ int.TryParse((await File.ReadAllTextAsync(pidPath)).Trim(), out var pid))
+ {
+ return pid;
+ }
+
+ await Task.Delay(100);
+ }
+
+ Assert.Fail("Timed-out backend command did not write its process id.");
+ return 0;
+ }
+
+ private static async Task WaitForProcessExitAsync(int pid)
+ {
+ for (var i = 0; i < 50; i++)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ if (process.HasExited)
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ return true;
+ }
+
+ await Task.Delay(100);
+ }
+
+ return false;
+ }
+
+ private static void TryKillProcess(int pid)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ process.Kill(entireProcessTree: true);
+ }
+ catch (ArgumentException)
+ {
+ }
+ catch (InvalidOperationException)
+ {
+ }
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 85bdecb61..ac9645a8c 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -42,6 +42,7 @@
+