diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
index 341b8abc3..9fac47c89 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
@@ -84,6 +84,90 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
VerticalAlignment="Center"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
index 0a6f4234d..231fc2f23 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
@@ -36,6 +36,7 @@ public sealed partial class ConfigPage : Page
private ConfigEditorSnapshot _serverSnapshot = ConfigEditorSnapshot.Empty;
private ConfigEditorSnapshot _editSnapshot = ConfigEditorSnapshot.Empty;
private IOperatorGatewayClient? _permissionClient;
+ private IOperatorGatewayClient? _configClient;
private string _selectedPath = "";
private string _searchText = "";
@@ -47,6 +48,7 @@ public sealed partial class ConfigPage : Page
private bool _jsonPreviewCollapsedByUser;
private bool _refreshConfigAfterReconnect;
private bool _refreshConfigWhenGatewayAvailable;
+ private bool _syncingMaintenanceControls = true;
private ContentDialog? _reconnectDialog;
private TextBlock? _reconnectDialogMessage;
private GridLength _jsonPreviewExpandedWidth = new(360);
@@ -64,6 +66,7 @@ public sealed partial class ConfigPage : Page
public ConfigPage()
{
InitializeComponent();
+ _syncingMaintenanceControls = false;
NavigationCacheMode = NavigationCacheMode.Required;
Unloaded += (_, _) =>
{
@@ -109,7 +112,9 @@ public void Initialize()
_appState = CurrentApp.AppState!;
_appState.PropertyChanged += OnAppStateChanged;
- SubscribePermissionClient(CurrentApp.GatewayClient);
+ var currentClient = CurrentApp.GatewayClient;
+ ResetForGatewayClientChange(currentClient);
+ SubscribePermissionClient(currentClient);
Logger.Info("[ConfigPage] Initialize");
if (CompleteReconnectIfReady())
return;
@@ -147,6 +152,7 @@ public void UpdateConfig(JsonElement config)
_serverSnapshot = ConfigEditorModel.CaptureSnapshot(configSnapshot);
if (_pendingChanges.Count == 0)
_editSnapshot = _serverSnapshot;
+ SyncMaintenanceControlsFromDraft();
if (configSnapshot.TryGetProperty("path", out var pathEl) &&
pathEl.ValueKind == JsonValueKind.String)
@@ -214,7 +220,10 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
if (_appState!.ConfigSchema.HasValue) UpdateConfigSchema(_appState.ConfigSchema.Value);
break;
case nameof(AppState.Status):
- SubscribePermissionClient(CurrentApp.GatewayClient);
+ {
+ var currentClient = CurrentApp.GatewayClient;
+ ResetForGatewayClientChange(currentClient);
+ SubscribePermissionClient(currentClient);
UpdateConnectionBanner();
UpdatePermissionBanner();
var configPermissionState = GetConfigPermissionState();
@@ -234,6 +243,7 @@ configPermissionState is (ConfigPermissionState.ReadOnly or ConfigPermissionStat
}
UpdateMetaAndButtons();
break;
+ }
}
}
@@ -472,6 +482,14 @@ private void ApplyEditorChanges(string sectionPath, SchemaConfigChangedEventArgs
_validationErrors[fullPath] = error;
}
+ if (sectionPath.StartsWith("session.maintenance", StringComparison.Ordinal) ||
+ args.Changes.Keys.Any(path =>
+ (string.IsNullOrEmpty(sectionPath) ? path : $"{sectionPath}.{path}")
+ .StartsWith("session.maintenance", StringComparison.Ordinal)))
+ {
+ SyncMaintenanceControlsFromDraft();
+ }
+
RenderTree();
UpdateSelectedJsonPreviewForCurrentSelection();
UpdateMetaAndButtons();
@@ -665,6 +683,7 @@ private void OnDiscardChanges(object sender, RoutedEventArgs e)
_pendingChanges.Clear();
_validationErrors.Clear();
_editSnapshot = ConfigEditorSnapshot.Empty;
+ SyncMaintenanceControlsFromDraft();
ShowStatus(
L("ConfigPage_StatusChangesDiscardedTitle"),
L("ConfigPage_StatusChangesDiscardedMessage"),
@@ -679,6 +698,12 @@ private void OnResetSection(object sender, RoutedEventArgs e)
{
RemoveSectionEntries(_selectedPath, _pendingChanges);
RemoveSectionEntries(_selectedPath, _validationErrors);
+ if (string.IsNullOrEmpty(_selectedPath) ||
+ _selectedPath.StartsWith("session.maintenance", StringComparison.Ordinal) ||
+ "session.maintenance".StartsWith(_selectedPath + ".", StringComparison.Ordinal))
+ {
+ SyncMaintenanceControlsFromDraft();
+ }
var sectionLabel = string.IsNullOrEmpty(_selectedPath) ? L("ConfigPage_FullConfig") : _selectedPath;
ShowStatus(
L("ConfigPage_StatusSectionResetTitle"),
@@ -894,12 +919,183 @@ private void UpdateMetaAndButtons()
SaveStatusIcon.Glyph = BuildSaveStatusGlyph(permissionState, dirtyCount, invalidCount);
AccessSummaryText.Text = BuildAccessSummaryText(permissionState, dirtyCount, invalidCount);
SetDetailEditingEnabled(!editingLocked);
+ SetMaintenanceEditingEnabled(!editingLocked && _serverSnapshot.HasRoot);
ResetSectionButton.IsEnabled = !editingLocked;
SaveButton.IsEnabled = !_saving && !_loading && canWriteConfig && dirtyCount > 0 && invalidCount == 0;
DiscardButton.IsEnabled = !editingLocked && dirtyCount > 0;
}
+ private void SyncMaintenanceControlsFromDraft()
+ {
+ if (!_serverSnapshot.HasRoot || MaintenanceModeComboBox is null)
+ return;
+
+ var root = _pendingChanges.Count == 0
+ ? _serverSnapshot.Root
+ : ConfigEditorModel.ApplyChanges(_serverSnapshot.Root, _pendingChanges);
+ var settings = SessionMaintenanceConfigModel.Read(root);
+ _syncingMaintenanceControls = true;
+ try
+ {
+ MaintenanceModeComboBox.SelectedIndex = settings.Mode == "warn" ? 1 : 0;
+ PruneAfterTextBox.Text = settings.PruneAfter;
+ MaxEntriesNumberBox.Value = settings.MaxEntries;
+ KeepResetArchivesToggle.IsOn = settings.KeepResetArchives;
+ ResetArchiveRetentionTextBox.Text = settings.ResetArchiveRetention;
+ LimitDiskUsageToggle.IsOn = settings.LimitDiskUsage;
+ MaxDiskBytesTextBox.Text = settings.MaxDiskBytes;
+ HighWaterBytesTextBox.Text = settings.HighWaterBytes == "0" ? "" : settings.HighWaterBytes;
+ }
+ finally
+ {
+ _syncingMaintenanceControls = false;
+ }
+ SetMaintenanceEditingEnabled(!IsConfigEditingLocked(GetConfigPermissionState()));
+ }
+
+ private void ResetForGatewayClientChange(IOperatorGatewayClient? client)
+ {
+ if (ReferenceEquals(_configClient, client))
+ return;
+
+ _configClient = client;
+ _pendingChanges.Clear();
+ _validationErrors.Clear();
+ _serverSnapshot = ConfigEditorSnapshot.Empty;
+ _editSnapshot = ConfigEditorSnapshot.Empty;
+ _lastConfig = null;
+ _lastSchema = null;
+ _initialized = false;
+ _syncingMaintenanceControls = true;
+ try
+ {
+ var defaults = SessionMaintenanceSettings.Defaults;
+ MaintenanceModeComboBox.SelectedIndex = 0;
+ PruneAfterTextBox.Text = defaults.PruneAfter;
+ MaxEntriesNumberBox.Value = defaults.MaxEntries;
+ KeepResetArchivesToggle.IsOn = defaults.KeepResetArchives;
+ ResetArchiveRetentionTextBox.Text = defaults.ResetArchiveRetention;
+ LimitDiskUsageToggle.IsOn = defaults.LimitDiskUsage;
+ MaxDiskBytesTextBox.Text = defaults.MaxDiskBytes;
+ HighWaterBytesTextBox.Text = defaults.HighWaterBytes;
+ }
+ finally
+ {
+ _syncingMaintenanceControls = false;
+ }
+ }
+
+ private SessionMaintenanceSettings CaptureMaintenanceSettings()
+ {
+ var mode = (MaintenanceModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "enforce";
+ var maxEntriesValue = MaxEntriesNumberBox.Value;
+ var maxEntries = double.IsFinite(maxEntriesValue) &&
+ maxEntriesValue >= 1 &&
+ maxEntriesValue <= int.MaxValue &&
+ maxEntriesValue == Math.Truncate(maxEntriesValue)
+ ? Convert.ToInt64(maxEntriesValue)
+ : 0;
+ return new SessionMaintenanceSettings(
+ mode,
+ PruneAfterTextBox.Text,
+ maxEntries,
+ KeepResetArchivesToggle.IsOn,
+ ResetArchiveRetentionTextBox.Text,
+ LimitDiskUsageToggle.IsOn,
+ MaxDiskBytesTextBox.Text,
+ HighWaterBytesTextBox.Text);
+ }
+
+ private void OnMaintenanceValueChanged(object sender, RoutedEventArgs e)
+ {
+ if (!_syncingMaintenanceControls)
+ ApplyMaintenanceDraft();
+ }
+
+ private void OnMaintenanceNumberChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
+ {
+ if (!_syncingMaintenanceControls)
+ ApplyMaintenanceDraft();
+ }
+
+ private void OnMaintenanceOptionToggled(object sender, RoutedEventArgs e)
+ {
+ if (_syncingMaintenanceControls)
+ return;
+ ApplyMaintenanceDraft();
+ SetMaintenanceEditingEnabled(!IsConfigEditingLocked(GetConfigPermissionState()));
+ }
+
+ private void ApplyMaintenanceDraft()
+ {
+ if (IsConfigEditingLocked(GetConfigPermissionState()) || !_serverSnapshot.HasRoot)
+ return;
+ if (_pendingChanges.Count == 0)
+ _editSnapshot = _serverSnapshot;
+
+ const string section = "session.maintenance";
+ RemoveSectionEntries(section, _pendingChanges);
+ RemoveSectionEntries(section, _validationErrors);
+
+ var settings = CaptureMaintenanceSettings();
+ _pendingChanges[$"{section}.mode"] = settings.Mode;
+ _pendingChanges[$"{section}.pruneAfter"] = settings.PruneAfter.Trim();
+ _pendingChanges[$"{section}.maxEntries"] = settings.MaxEntries;
+ _pendingChanges[$"{section}.resetArchiveRetention"] = settings.KeepResetArchives
+ ? false
+ : settings.ResetArchiveRetention.Trim();
+ _pendingChanges[$"{section}.maxDiskBytes"] = settings.LimitDiskUsage
+ ? settings.MaxDiskBytes.Trim()
+ : false;
+ _pendingChanges[$"{section}.highWaterBytes"] = string.IsNullOrWhiteSpace(settings.HighWaterBytes)
+ ? 0L
+ : settings.HighWaterBytes.Trim();
+
+ foreach (var (field, _) in SessionMaintenanceConfigModel.Validate(settings))
+ _validationErrors[$"{section}.{MaintenanceFieldPath(field)}"] = MaintenanceValidationMessage(field);
+
+ RenderTree();
+ UpdateSelectedJsonPreviewForCurrentSelection();
+ UpdateMetaAndButtons();
+ }
+
+ private void SetMaintenanceEditingEnabled(bool isEnabled)
+ {
+ if (MaintenanceModeComboBox is null)
+ return;
+ MaintenanceModeComboBox.IsEnabled = isEnabled;
+ PruneAfterTextBox.IsEnabled = isEnabled;
+ MaxEntriesNumberBox.IsEnabled = isEnabled;
+ KeepResetArchivesToggle.IsEnabled = isEnabled;
+ ResetArchiveRetentionTextBox.IsEnabled = isEnabled && !KeepResetArchivesToggle.IsOn;
+ LimitDiskUsageToggle.IsEnabled = isEnabled;
+ MaxDiskBytesTextBox.IsEnabled = isEnabled && LimitDiskUsageToggle.IsOn;
+ HighWaterBytesTextBox.IsEnabled = isEnabled && LimitDiskUsageToggle.IsOn;
+ }
+
+ private static string MaintenanceFieldPath(string field) => field switch
+ {
+ "Mode" => "mode",
+ "Prune after" => "pruneAfter",
+ "Maximum entries" => "maxEntries",
+ "Reset archive retention" => "resetArchiveRetention",
+ "Maximum disk usage" => "maxDiskBytes",
+ "High-water target" => "highWaterBytes",
+ _ => field,
+ };
+
+ private static string MaintenanceValidationMessage(string field) => field switch
+ {
+ "Mode" => L("ConfigPage_MaintenanceModeError"),
+ "Prune after" => L("ConfigPage_MaintenancePruneAfterError"),
+ "Maximum entries" => L("ConfigPage_MaintenanceMaxEntriesError"),
+ "Reset archive retention" => L("ConfigPage_MaintenanceArchiveRetentionError"),
+ "Maximum disk usage" => L("ConfigPage_MaintenanceMaxDiskError"),
+ "High-water target" => L("ConfigPage_MaintenanceHighWaterError"),
+ _ => field,
+ };
+
private bool IsConfigEditingLocked(ConfigPermissionState permissionState) =>
_saving ||
_refreshConfigAfterReconnect ||
diff --git a/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs b/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs
new file mode 100644
index 000000000..99356b47d
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/SessionMaintenanceConfigModel.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace OpenClawTray.Services;
+
+internal sealed record SessionMaintenanceSettings(
+ string Mode,
+ string PruneAfter,
+ long MaxEntries,
+ bool KeepResetArchives,
+ string ResetArchiveRetention,
+ bool LimitDiskUsage,
+ string MaxDiskBytes,
+ string HighWaterBytes)
+{
+ public static SessionMaintenanceSettings Defaults { get; } = new(
+ "enforce",
+ "30d",
+ 500,
+ true,
+ "",
+ true,
+ "10gb",
+ "");
+}
+
+internal static partial class SessionMaintenanceConfigModel
+{
+ private static readonly Regex DurationPattern = CreateDurationPattern();
+ private static readonly Regex CompositeDurationTokenPattern = CreateCompositeDurationTokenPattern();
+ private static readonly Regex ByteSizePattern = CreateByteSizePattern();
+
+ public static SessionMaintenanceSettings Read(JsonElement configResponse)
+ {
+ var snapshot = ConfigEditorModel.CaptureSnapshot(configResponse);
+ if (!snapshot.HasRoot ||
+ !snapshot.Root.TryGetProperty("session", out var session) ||
+ session.ValueKind != JsonValueKind.Object ||
+ !session.TryGetProperty("maintenance", out var maintenance) ||
+ maintenance.ValueKind != JsonValueKind.Object)
+ {
+ return SessionMaintenanceSettings.Defaults;
+ }
+
+ var defaults = SessionMaintenanceSettings.Defaults;
+ var mode = ReadString(maintenance, "mode") ?? defaults.Mode;
+ var pruneAfter = ReadScalar(maintenance, "pruneAfter") ?? defaults.PruneAfter;
+ var maxEntries = ReadInt64(maintenance, "maxEntries") ?? defaults.MaxEntries;
+
+ var keepResetArchives = true;
+ var resetArchiveRetention = "";
+ if (maintenance.TryGetProperty("resetArchiveRetention", out var resetValue) &&
+ resetValue.ValueKind is not (JsonValueKind.False or JsonValueKind.Null))
+ {
+ keepResetArchives = false;
+ resetArchiveRetention = ScalarText(resetValue);
+ }
+
+ var limitDiskUsage = true;
+ var maxDiskBytes = defaults.MaxDiskBytes;
+ if (maintenance.TryGetProperty("maxDiskBytes", out var maxDiskValue))
+ {
+ if (maxDiskValue.ValueKind == JsonValueKind.False)
+ {
+ limitDiskUsage = false;
+ maxDiskBytes = "";
+ }
+ else if (maxDiskValue.ValueKind is not JsonValueKind.Null)
+ {
+ maxDiskBytes = ScalarText(maxDiskValue);
+ }
+ }
+
+ var highWaterBytes = ReadScalar(maintenance, "highWaterBytes") ?? "";
+ return new SessionMaintenanceSettings(
+ mode,
+ pruneAfter,
+ maxEntries,
+ keepResetArchives,
+ resetArchiveRetention,
+ limitDiskUsage,
+ maxDiskBytes,
+ highWaterBytes);
+ }
+
+ public static IReadOnlyDictionary Validate(SessionMaintenanceSettings settings)
+ {
+ var errors = new Dictionary(StringComparer.Ordinal);
+ if (settings.Mode is not ("enforce" or "warn"))
+ errors["Mode"] = "Choose Enforce or Warn only.";
+ if (!IsPositiveDuration(settings.PruneAfter))
+ errors["Prune after"] = "Use a positive duration such as 30d, 12h, or 500ms.";
+ if (settings.MaxEntries < 1)
+ errors["Maximum entries"] = "Enter an integer of at least 1.";
+ if (!settings.KeepResetArchives && !IsPositiveDuration(settings.ResetArchiveRetention))
+ errors["Reset archive retention"] = "Use a positive duration such as 14d.";
+ if (settings.LimitDiskUsage && !IsByteSize(settings.MaxDiskBytes, allowZero: false))
+ errors["Maximum disk usage"] = "Use a positive size such as 10gb or 500mb.";
+ if (!string.IsNullOrWhiteSpace(settings.HighWaterBytes) &&
+ !IsByteSize(settings.HighWaterBytes, allowZero: true))
+ {
+ errors["High-water target"] = "Use a non-negative size such as 8gb or leave it blank for 80%.";
+ }
+ return errors;
+ }
+
+ private static string? ReadString(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private static string? ReadScalar(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value) &&
+ value.ValueKind is JsonValueKind.String or JsonValueKind.Number
+ ? ScalarText(value)
+ : null;
+
+ private static long? ReadInt64(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value) && value.TryGetInt64(out var result)
+ ? result
+ : null;
+
+ private static string ScalarText(JsonElement value) => value.ValueKind == JsonValueKind.String
+ ? value.GetString() ?? ""
+ : value.GetRawText();
+
+ private static bool IsPositiveDuration(string value)
+ {
+ var trimmed = value.Trim();
+ var match = DurationPattern.Match(trimmed);
+ if (match.Success)
+ {
+ return decimal.TryParse(match.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount) &&
+ amount > 0;
+ }
+
+ decimal total = 0;
+ var consumed = 0;
+ foreach (Match token in CompositeDurationTokenPattern.Matches(trimmed))
+ {
+ if (token.Index != consumed ||
+ !decimal.TryParse(token.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount))
+ {
+ return false;
+ }
+ total += amount;
+ consumed += token.Length;
+ }
+ return consumed == trimmed.Length && consumed > 0 && total > 0;
+ }
+
+ private static bool IsByteSize(string value, bool allowZero)
+ {
+ var match = ByteSizePattern.Match(value.Trim());
+ return match.Success &&
+ decimal.TryParse(match.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount) &&
+ (allowZero ? amount >= 0 : amount > 0);
+ }
+
+ [GeneratedRegex(@"^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h|d)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex CreateDurationPattern();
+
+ [GeneratedRegex(@"([0-9]+(?:\.[0-9]+)?)(ms|s|m|h|d)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex CreateCompositeDurationTokenPattern();
+
+ [GeneratedRegex(@"^([0-9]+(?:\.[0-9]+)?)(b|k|kb|m|mb|g|gb|t|tb)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex CreateByteSizePattern();
+}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 02c292072..372799bf0 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -4734,6 +4734,28 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo
64 MiB
++ Session maintenance
+ Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below.
+ Mode
+ Enforce cleanup
+ Warn only
+ Prune inactive sessions after
+ 30d
+ Maximum session entries
+ Reset archive retention
+ 14d
+ Maximum disk usage
+ 10gb
+ Cleanup target
+ 80% of maximum (default)
+ Disable age-based archive expiry
+ Limit session disk usage
++ Mode: choose Enforce or Warn only.
+ Prune after: use a positive duration such as 30d, 12h, or 1h30m.
+ Maximum entries: enter a whole number of at least 1.
+ Reset archive retention: use a positive duration such as 14d.
+ Maximum disk usage: use a positive size such as 10gb or 500mb.
+ Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%.
Sessions
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 60e556866..cb250660e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -4687,6 +4687,28 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo
64 MiB
++ Session maintenance
+ Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below.
+ Mode
+ Enforce cleanup
+ Warn only
+ Prune inactive sessions after
+ 30d
+ Maximum session entries
+ Reset archive retention
+ 14d
+ Maximum disk usage
+ 10gb
+ Cleanup target
+ 80% of maximum (default)
+ Disable age-based archive expiry
+ Limit session disk usage
++ Mode: choose Enforce or Warn only.
+ Prune after: use a positive duration such as 30d, 12h, or 1h30m.
+ Maximum entries: enter a whole number of at least 1.
+ Reset archive retention: use a positive duration such as 14d.
+ Maximum disk usage: use a positive size such as 10gb or 500mb.
+ Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%.
Mes sessions
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index cbb9ea5f2..02ccd7109 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -4688,6 +4688,28 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik
64 MiB
++ Session maintenance
+ Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below.
+ Mode
+ Enforce cleanup
+ Warn only
+ Prune inactive sessions after
+ 30d
+ Maximum session entries
+ Reset archive retention
+ 14d
+ Maximum disk usage
+ 10gb
+ Cleanup target
+ 80% of maximum (default)
+ Disable age-based archive expiry
+ Limit session disk usage
++ Mode: choose Enforce or Warn only.
+ Prune after: use a positive duration such as 30d, 12h, or 1h30m.
+ Maximum entries: enter a whole number of at least 1.
+ Reset archive retention: use a positive duration such as 14d.
+ Maximum disk usage: use a positive size such as 10gb or 500mb.
+ Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%.
Sessies
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index bf824cb7a..a7b010373 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -4687,6 +4687,28 @@
64 MiB
++ Session maintenance
+ Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below.
+ Mode
+ Enforce cleanup
+ Warn only
+ Prune inactive sessions after
+ 30d
+ Maximum session entries
+ Reset archive retention
+ 14d
+ Maximum disk usage
+ 10gb
+ Cleanup target
+ 80% of maximum (default)
+ Disable age-based archive expiry
+ Limit session disk usage
++ Mode: choose Enforce or Warn only.
+ Prune after: use a positive duration such as 30d, 12h, or 1h30m.
+ Maximum entries: enter a whole number of at least 1.
+ Reset archive retention: use a positive duration such as 14d.
+ Maximum disk usage: use a positive size such as 10gb or 500mb.
+ Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%.
会话
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 58acfbc95..e6b4225e3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -4687,6 +4687,28 @@
64 MiB
++ Session maintenance
+ Control automatic session retention and disk cleanup on this gateway. Changes use the Save changes button below.
+ Mode
+ Enforce cleanup
+ Warn only
+ Prune inactive sessions after
+ 30d
+ Maximum session entries
+ Reset archive retention
+ 14d
+ Maximum disk usage
+ 10gb
+ Cleanup target
+ 80% of maximum (default)
+ Disable age-based archive expiry
+ Limit session disk usage
++ Mode: choose Enforce or Warn only.
+ Prune after: use a positive duration such as 30d, 12h, or 1h30m.
+ Maximum entries: enter a whole number of at least 1.
+ Reset archive retention: use a positive duration such as 14d.
+ Maximum disk usage: use a positive size such as 10gb or 500mb.
+ Cleanup target: use a non-negative size such as 8gb or leave it blank for 80%.
工作階段
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index 0cdec5a5f..cc22bea93 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -204,6 +204,30 @@ public class LocalizationValidationTests
"ConfigPage_ReconnectDialogBody",
"ConfigPage_ReconnectDialogTitle",
"ConfigPage_ReconnectDialogWaiting",
+ // Gateway session-maintenance controls are seeded English-only across
+ // all locales until the next translation pass.
+ "ConfigPage_Maintenance.Header",
+ "ConfigPage_MaintenanceDescription.Text",
+ "ConfigPage_MaintenanceMode.Header",
+ "ConfigPage_MaintenanceEnforce.Content",
+ "ConfigPage_MaintenanceWarn.Content",
+ "ConfigPage_MaintenancePruneAfter.Header",
+ "ConfigPage_MaintenancePruneAfter.PlaceholderText",
+ "ConfigPage_MaintenanceMaxEntries.Header",
+ "ConfigPage_MaintenanceArchiveRetention.Header",
+ "ConfigPage_MaintenanceArchiveRetention.PlaceholderText",
+ "ConfigPage_MaintenanceMaxDisk.Header",
+ "ConfigPage_MaintenanceMaxDisk.PlaceholderText",
+ "ConfigPage_MaintenanceHighWater.Header",
+ "ConfigPage_MaintenanceHighWater.PlaceholderText",
+ "ConfigPage_MaintenanceDisableArchiveExpiry.Header",
+ "ConfigPage_MaintenanceLimitDisk.Header",
+ "ConfigPage_MaintenanceModeError",
+ "ConfigPage_MaintenancePruneAfterError",
+ "ConfigPage_MaintenanceMaxEntriesError",
+ "ConfigPage_MaintenanceArchiveRetentionError",
+ "ConfigPage_MaintenanceMaxDiskError",
+ "ConfigPage_MaintenanceHighWaterError",
"CronPage_AmericaChicago.Content",
"CronPage_AmericaDenver.Content",
"CronPage_AmericaLosAngeles.Content",
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index ac1e0df6e..43c3857dc 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -65,6 +65,7 @@
+
diff --git a/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs b/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs
new file mode 100644
index 000000000..ded745a75
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/SessionMaintenanceConfigModelTests.cs
@@ -0,0 +1,67 @@
+using System.Text.Json;
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests;
+
+public sealed class SessionMaintenanceConfigModelTests
+{
+ [Fact]
+ public void Read_UsesGatewayValuesAndDefaultsForOmittedFields()
+ {
+ using var document = JsonDocument.Parse("""
+ {
+ "baseHash": "abc",
+ "parsed": {
+ "session": {
+ "maintenance": {
+ "mode": "warn",
+ "maxEntries": 125,
+ "resetArchiveRetention": "14d",
+ "maxDiskBytes": false
+ }
+ }
+ }
+ }
+ """);
+
+ var settings = SessionMaintenanceConfigModel.Read(document.RootElement);
+
+ Assert.Equal("warn", settings.Mode);
+ Assert.Equal("30d", settings.PruneAfter);
+ Assert.Equal(125, settings.MaxEntries);
+ Assert.False(settings.KeepResetArchives);
+ Assert.Equal("14d", settings.ResetArchiveRetention);
+ Assert.False(settings.LimitDiskUsage);
+ Assert.Equal("", settings.HighWaterBytes);
+ }
+
+ [Fact]
+ public void Validate_RejectsInvalidDurationsCountsAndSizes()
+ {
+ var settings = new SessionMaintenanceSettings(
+ "other", "never", 0, false, "0d", true, "lots", "-1gb");
+
+ var errors = SessionMaintenanceConfigModel.Validate(settings);
+
+ Assert.Equal(6, errors.Count);
+ Assert.Contains("Mode", errors.Keys);
+ Assert.Contains("Prune after", errors.Keys);
+ Assert.Contains("Maximum entries", errors.Keys);
+ Assert.Contains("Reset archive retention", errors.Keys);
+ Assert.Contains("Maximum disk usage", errors.Keys);
+ Assert.Contains("High-water target", errors.Keys);
+ }
+
+ [Fact]
+ public void Validate_MatchesGatewayDurationAndByteGrammar()
+ {
+ var valid = new SessionMaintenanceSettings(
+ "enforce", "1h30m", 500, false, "2m500ms", true, "10g", "512mb");
+ var invalidSpacing = valid with { PruneAfter = "30 d", MaxDiskBytes = "10 gb" };
+
+ Assert.Empty(SessionMaintenanceConfigModel.Validate(valid));
+ var errors = SessionMaintenanceConfigModel.Validate(invalidSpacing);
+ Assert.Contains("Prune after", errors.Keys);
+ Assert.Contains("Maximum disk usage", errors.Keys);
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs b/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs
new file mode 100644
index 000000000..d0b49a64a
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/SessionMaintenanceUiContractTests.cs
@@ -0,0 +1,54 @@
+namespace OpenClaw.Tray.Tests;
+
+public sealed class SessionMaintenanceUiContractTests
+{
+ [Fact]
+ public void ConfigPage_ExposesSessionMaintenanceEditor()
+ {
+ var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "ConfigPage.xaml");
+
+ Assert.Contains("ConfigPageSessionMaintenance", xaml, StringComparison.Ordinal);
+ Assert.Contains("MaintenanceModeComboBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("PruneAfterTextBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("MaxEntriesNumberBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("ResetArchiveRetentionTextBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("MaxDiskBytesTextBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("HighWaterBytesTextBox", xaml, StringComparison.Ordinal);
+ Assert.Contains("Changes use the Save changes button below", xaml, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ConfigPage_StagesMaintenanceInExistingOptimisticConfigPatch()
+ {
+ var source = Read("src", "OpenClaw.Tray.WinUI", "Pages", "ConfigPage.xaml.cs");
+
+ Assert.Contains("ConfigEditorModel.CaptureSnapshot", source, StringComparison.Ordinal);
+ Assert.Contains("ApplyMaintenanceDraft", source, StringComparison.Ordinal);
+ Assert.Contains("_pendingChanges[$\"{section}.maxEntries\"]", source, StringComparison.Ordinal);
+ Assert.Contains("PatchConfigDetailedAsync(updated, saveBase.BaseHash)", source, StringComparison.Ordinal);
+ Assert.Contains("LooksLikeStaleBaseHash", source, StringComparison.Ordinal);
+ Assert.Contains("OperatorScopeHelper.CanWriteConfig", source, StringComparison.Ordinal);
+ }
+
+ private static string Read(params string[] parts)
+ {
+ var path = Path.Combine(new[] { RepoRoot() }.Concat(parts).ToArray());
+ return File.ReadAllText(path);
+ }
+
+ private static string RepoRoot()
+ {
+ var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT");
+ if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env))
+ return env;
+
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null &&
+ (!File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) ||
+ !Directory.Exists(Path.Combine(directory.FullName, "src"))))
+ {
+ directory = directory.Parent;
+ }
+ return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root not found.");
+ }
+}