diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md
index 37dd1c27c..63629b340 100644
--- a/docs/CONNECTION_ARCHITECTURE.md
+++ b/docs/CONNECTION_ARCHITECTURE.md
@@ -118,7 +118,10 @@ Idle → Connecting → Connected
| Connected | Error/Rejected | Degraded |
| Connected | PairingRequired | PairingRequired |
| Connected | Connecting | Connecting |
-| Connected | Disabled/Off | Connected |
+| Connected | Idle while Node mode is intended | Degraded |
+| Connected | Disabled/Off | Ready |
+
+`GatewayConnectionSnapshot.NodeConnectionIntended` records the Node mode intent used by the manager's state machine. If Node mode is enabled but node startup is skipped, blocked, or missing a node credential, the manager publishes a blocked node snapshot (`NodeState=Error`, `NodeError=...`) instead of leaving the node idle and letting tray surfaces report a healthy connection.
## Gateway registry and persistence
diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md
index 187f0262e..a93ea0037 100644
--- a/docs/MCP_MODE.md
+++ b/docs/MCP_MODE.md
@@ -111,6 +111,8 @@ Settings UI exposes both toggles in the Advanced section, with the live MCP endp
A legacy `McpOnlyMode` field is migrated automatically on load and never re-written.
+MCP startup is reported from the actual listener state. `NodeService.McpStartupError` is populated when capability registration or the HTTP listener fails, and MCP-only startup is not treated as successful unless the loopback MCP server is running. Tray, Permissions, and Command Center surfaces show local MCP-only separately from gateway connectivity so a working local MCP listener is never presented as a gateway connection.
+
## Why this matters
### Testing
diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs
index 7613e6122..a4b709b70 100644
--- a/src/OpenClaw.Connection/ConnectionStateMachine.cs
+++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs
@@ -123,9 +123,16 @@ public void SetNodeEnabled(bool enabled)
{
_nodeEnabled = enabled;
if (!enabled)
+ {
_nodeState = RoleConnectionState.Disabled;
+ _nodeError = null;
+ _nodeCredentialSource = null;
+ }
else if (_nodeState == RoleConnectionState.Disabled)
+ {
_nodeState = RoleConnectionState.Idle;
+ _nodeError = null;
+ }
RebuildSnapshot();
}
@@ -199,6 +206,15 @@ internal void SetNodeCredentialSource(string? source)
RebuildSnapshot();
}
+ internal void BlockNodeStart(string detail)
+ {
+ _nodeEnabled = true;
+ _nodeState = RoleConnectionState.Error;
+ _nodeError = detail;
+ _nodeCredentialSource = null;
+ RebuildSnapshot();
+ }
+
/// Update the operator pairing request ID in the snapshot.
internal void SetOperatorPairingRequestId(string? requestId)
{
@@ -304,6 +320,7 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail)
case ConnectionTrigger.NodePairingRequired:
_nodeState = RoleConnectionState.PairingRequired;
+ _nodeError = null;
break;
case ConnectionTrigger.NodePaired:
@@ -340,6 +357,7 @@ private void RebuildSnapshot()
// Clear requestId when no longer in PairingRequired to prevent stale reads
OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired
? Current.OperatorPairingRequestId : null,
+ NodeConnectionIntended = _nodeEnabled,
NodeState = _nodeState,
NodeError = _nodeError,
NodeCredentialSource = _nodeCredentialSource,
diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs
index a4792c1fc..b68179a36 100644
--- a/src/OpenClaw.Connection/GatewayConnectionManager.cs
+++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs
@@ -51,6 +51,17 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager
private bool _activeConnectUsedBootstrapToken;
private bool _postBootstrapOperatorReconnectScheduled;
+ private const string MissingNodeCredentialMessage =
+ "No node credential available. Re-pair this PC or add a shared/bootstrap gateway token.";
+ private const string MissingNodeConnectorMessage =
+ "Node mode is enabled, but no node connector is configured.";
+ private const string MissingActiveGatewayForNodeMessage =
+ "Node mode is enabled, but there is no active gateway context for node startup.";
+ private const string MissingGatewayRecordForNodeMessage =
+ "Node mode is enabled, but the active gateway record could not be found.";
+ private const string NodeTunnelStartFailedMessage =
+ "Node mode is enabled, but the SSH tunnel for node startup could not be started.";
+
public event EventHandler? StateChanged;
public event EventHandler? DiagnosticEvent;
public event EventHandler? OperatorClientChanged;
@@ -119,7 +130,6 @@ public async Task ConnectAsync(string? gatewayId = null)
public async Task ConnectNodeOnlyAsync(string? gatewayId = null)
{
ThrowIfDisposed();
- var prevState = _stateMachine.Current.OverallState;
long? preparedGeneration = null;
await _transitionSemaphore.WaitAsync();
@@ -137,7 +147,7 @@ public async Task ConnectNodeOnlyAsync(string? gatewayId = null)
var startedGeneration = await StartNodeConnectionAsync(preparedGeneration.Value);
if (startedGeneration.HasValue)
- EmitStateChanged(prevState);
+ EmitStateChanged();
}
/// Core connect logic. Caller must hold .
@@ -202,16 +212,16 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
_activeGatewayRecordId = record.Id;
_activeSshTunnel = record.SshTunnel;
_gatewayNeedsV2Signature = record.IsLocal || record.RequiresV2Signature;
+ SyncNodeIntentFromSettings();
if (credential == null)
{
_logger.Warn("[ConnMgr] No credential available for gateway");
- var prev = _stateMachine.Current.OverallState;
// Must go through Connecting → Error since AuthenticationFailed requires Connecting state
_stateMachine.TryTransition(ConnectionTrigger.ConnectRequested);
_stateMachine.SetOperatorCredentialSource(null);
_stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available");
- EmitStateChanged(prev);
+ EmitStateChanged();
return;
}
@@ -220,7 +230,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
_stateMachine.TryTransition(ConnectionTrigger.ConnectRequested);
_stateMachine.SetOperatorCredentialSource(credential.Source);
_diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState);
- EmitStateChanged(prevState);
+ EmitStateChanged();
// Create client via factory — use a diagnostic-tee logger so client handshake
// logs appear in the Connection Status window timeline.
@@ -235,9 +245,8 @@ tunnel.SshPort is < 1 or > 65535 ||
{
_logger.Warn("[ConnMgr] SSH tunnel config is incomplete");
_diagnostics.Record("tunnel", "SSH tunnel config is incomplete");
- var p = _stateMachine.Current.OverallState;
_stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "SSH tunnel config is incomplete");
- EmitStateChanged(p);
+ EmitStateChanged();
return;
}
try
@@ -249,9 +258,8 @@ tunnel.SshPort is < 1 or > 65535 ||
{
_logger.Error($"[ConnMgr] SSH tunnel start failed: {ex.Message}");
_diagnostics.Record("tunnel", "SSH tunnel start failed", ex.Message);
- var p = _stateMachine.Current.OverallState;
_stateMachine.TryTransition(ConnectionTrigger.WebSocketError, $"SSH tunnel failed: {ex.Message}");
- EmitStateChanged(p);
+ EmitStateChanged();
return;
}
}
@@ -356,14 +364,6 @@ tunnel.SshPort is < 1 or > 65535 ||
if (!Directory.Exists(perGatewayIdentityDir))
Directory.CreateDirectory(perGatewayIdentityDir);
- var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir);
- if (nodeCredential == null)
- {
- _logger.Warn("[ConnMgr] No node credential available for node-only connect");
- _diagnostics.Record("node", "No node credential available for node-only connect");
- return null;
- }
-
// Same-gateway node reapproval reconnects keep the operator alive so it can
// request the post-handshake node.list; all other paths reset lifecycle/tunnel state.
var preservesOperatorConnection =
@@ -396,15 +396,31 @@ tunnel.SshPort is < 1 or > 65535 ||
GatewayUrl = record.Url,
GatewayName = record.FriendlyName
};
+ _stateMachine.SetNodeEnabled(true);
+ _stateMachine.StartNodeConnecting();
+ _stateMachine.SetNodeCredentialSource(null);
+
+ var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir);
+ if (nodeCredential == null)
+ {
+ _logger.Warn("[ConnMgr] No node credential available for node-only connect");
+ _diagnostics.Record("node", "No node credential available for node-only connect");
+ _stateMachine.BlockNodeStart(MissingNodeCredentialMessage);
+ EmitStateChanged();
+ return null;
+ }
_diagnostics.RecordCredentialResolution(nodeCredential);
_stateMachine.SetOperatorCredentialSource(operatorCredentialSource);
- _stateMachine.SetNodeCredentialSource(nodeCredential.Source);
_diagnostics.Record("node", $"Starting node-only connection to {record.Url}",
$"Credential source: {nodeCredential.Source}");
if (!preservesOperatorConnection && !await TryStartTunnelForNodeOnlyAsync(record))
+ {
+ _stateMachine.BlockNodeStart(NodeTunnelStartFailedMessage);
+ EmitStateChanged();
return null;
+ }
return Interlocked.Read(ref _generation) == gen ? gen : null;
}
@@ -469,9 +485,10 @@ private async Task DisconnectCoreAsync()
var prev = _stateMachine.Current.OverallState;
await DisposeActiveClientAsync();
+ SyncNodeIntentFromSettings();
_stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested);
_diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
- EmitStateChanged(prev);
+ EmitStateChanged();
}
public async Task ReconnectAsync()
@@ -703,7 +720,6 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon
{
if (Interlocked.Read(ref _generation) != gen) return;
- var prev = _stateMachine.Current.OverallState;
switch (status)
{
case ConnectionStatus.Connected:
@@ -725,7 +741,7 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon
_diagnostics.RecordWebSocketEvent("WebSocket connecting");
break;
}
- EmitStateChanged(prev);
+ EmitStateChanged();
}
finally
{
@@ -743,10 +759,9 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen)
if (TryScheduleOperatorTokenRecovery(message, gen))
return;
- var prev = _stateMachine.Current.OverallState;
_diagnostics.Record("error", "Authentication failed", message);
_stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message);
- EmitStateChanged(prev);
+ EmitStateChanged();
}
finally
{
@@ -785,6 +800,10 @@ private static bool IsOperatorDeviceTokenMismatch(string message) =>
private async Task HandleHandshakeSucceededAsync(long gen)
{
+ bool shouldStartNodeConnection = false;
+ bool missingGatewayRecordForNode = false;
+ bool missingActiveGatewayForNode = false;
+ bool missingNodeConnector = false;
await _transitionSemaphore.WaitAsync();
try
{
@@ -793,7 +812,7 @@ private async Task HandleHandshakeSucceededAsync(long gen)
var prev = _stateMachine.Current.OverallState;
_diagnostics.Record("state", "Handshake succeeded (hello-ok)");
_stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded);
- _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
+ var nodeModeIntended = SyncNodeIntentFromSettings();
if (_operatorTokenRecoveryAttemptedGatewayId == _activeGatewayRecordId)
_operatorTokenRecoveryAttemptedGatewayId = null;
@@ -803,7 +822,43 @@ private async Task HandleHandshakeSucceededAsync(long gen)
_stateMachine.SetOperatorDeviceId(client.OperatorDeviceId);
}
- EmitStateChanged(prev);
+ missingActiveGatewayForNode =
+ nodeModeIntended &&
+ (_activeGatewayRecordId == null || _activeIdentityPath == null);
+ missingGatewayRecordForNode =
+ nodeModeIntended &&
+ !missingActiveGatewayForNode &&
+ _activeGatewayRecordId != null &&
+ _registry.GetById(_activeGatewayRecordId) == null;
+ shouldStartNodeConnection =
+ !missingActiveGatewayForNode &&
+ !missingGatewayRecordForNode &&
+ ShouldStartNodeConnection();
+ missingNodeConnector = shouldStartNodeConnection && _nodeConnector == null;
+ if (missingActiveGatewayForNode)
+ {
+ _stateMachine.BlockNodeStart(MissingActiveGatewayForNodeMessage);
+ }
+ else if (missingGatewayRecordForNode)
+ {
+ _stateMachine.BlockNodeStart(MissingGatewayRecordForNodeMessage);
+ }
+ else if (missingNodeConnector)
+ {
+ _stateMachine.BlockNodeStart(MissingNodeConnectorMessage);
+ }
+ else if (shouldStartNodeConnection)
+ {
+ _stateMachine.SetNodeEnabled(true);
+ if (_nodeConnector != null)
+ {
+ _stateMachine.StartNodeConnecting();
+ _stateMachine.SetNodeCredentialSource(null);
+ }
+ }
+
+ _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
+ EmitStateChanged();
// Stamp LastConnected so auto-reconnect on next startup can use this gateway.
// Uses the atomic Update helper to avoid overwriting concurrent registry changes.
@@ -826,10 +881,18 @@ private async Task HandleHandshakeSucceededAsync(long gen)
_transitionSemaphore.Release();
}
- // Start node connection outside the semaphore to avoid deadlocks
- if (_nodeConnector != null && ShouldStartNodeConnection())
+ // Start node connection outside the semaphore to avoid deadlocks.
+ // If Node mode is intended but no connector exists, publish the blocker
+ // through the same manager snapshot instead of leaving node Idle/healthy.
+ if (missingActiveGatewayForNode || missingGatewayRecordForNode || missingNodeConnector)
{
- await StartNodeConnectionAsync(gen);
+ return;
+ }
+
+ if (shouldStartNodeConnection)
+ {
+ if (_nodeConnector != null)
+ await StartNodeConnectionAsync(gen);
}
}
@@ -972,7 +1035,7 @@ private async Task HandlePairingRequiredAsync(string? requestId, long gen)
// Store requestId in snapshot so setup flows can use it for explicit approval
_stateMachine.SetOperatorPairingRequestId(requestId);
_diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
- EmitStateChanged(prev);
+ EmitStateChanged();
}
finally
{
@@ -1095,6 +1158,18 @@ private bool ShouldStartNodeConnection()
return _isNodeEnabled?.Invoke() ?? false;
}
+ private bool SyncNodeIntentFromSettings()
+ {
+ var enabled = _isNodeEnabled?.Invoke() ?? false;
+ if (_stateMachine.Current.NodeConnectionIntended != enabled ||
+ (!enabled && _stateMachine.Current.NodeState != RoleConnectionState.Disabled))
+ {
+ _stateMachine.SetNodeEnabled(enabled);
+ }
+
+ return enabled;
+ }
+
private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) =>
!_disposed &&
Interlocked.Read(ref _generation) == lifecycleGeneration &&
@@ -1104,9 +1179,11 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration)
long expectedLifecycleGeneration,
long? expectedNodeGeneration = null)
{
- CancellationTokenSource nodeOperationCts;
- CancellationToken nodeOperationToken;
- long nodeGeneration;
+ CancellationTokenSource? nodeOperationCts = null;
+ CancellationToken nodeOperationToken = CancellationToken.None;
+ long nodeGeneration = 0;
+ string? preStartBlocker = null;
+ CancellationToken preStartBlockerToken = CancellationToken.None;
await _nodeStartSemaphore.WaitAsync();
try
@@ -1132,26 +1209,31 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration)
"Previous node disconnect"))
{
_diagnostics.Record("node", "Previous node disconnect timed out");
- return null;
+ preStartBlocker = "Previous node disconnect timed out";
+ preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None;
}
}
catch (Exception ex)
{
_logger.Error($"[ConnMgr] Previous node disconnect failed: {ex.Message}");
_diagnostics.Record("node", "Previous node disconnect failed", ex.Message);
- return null;
+ preStartBlocker = $"Previous node disconnect failed: {ex.Message}";
+ preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None;
}
}
- lock (_nodeOperationLock)
+ if (preStartBlocker == null)
{
- if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration))
- return null;
+ lock (_nodeOperationLock)
+ {
+ if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration))
+ return null;
- nodeOperationCts = new CancellationTokenSource();
- nodeOperationToken = nodeOperationCts.Token;
- nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration);
- _nodeOperationCts = nodeOperationCts;
+ nodeOperationCts = new CancellationTokenSource();
+ nodeOperationToken = nodeOperationCts.Token;
+ nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration);
+ _nodeOperationCts = nodeOperationCts;
+ }
}
}
finally
@@ -1159,9 +1241,19 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration)
_nodeStartSemaphore.Release();
}
+ if (preStartBlocker != null)
+ {
+ await BlockNodeStartAsync(
+ preStartBlocker,
+ preStartBlockerToken,
+ expectedLifecycleGeneration,
+ expectedNodeGeneration);
+ return null;
+ }
+
try
{
- return await StartNodeConnectionCoreAsync(nodeGeneration, nodeOperationToken)
+ return await StartNodeConnectionCoreAsync(expectedLifecycleGeneration, nodeGeneration, nodeOperationToken)
? nodeGeneration
: null;
}
@@ -1176,7 +1268,7 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration)
if (ReferenceEquals(_nodeOperationCts, nodeOperationCts))
_nodeOperationCts = null;
}
- nodeOperationCts.Dispose();
+ nodeOperationCts!.Dispose();
}
}
@@ -1188,7 +1280,50 @@ private bool IsExpectedNodeStartCurrent(
(!expectedNodeGeneration.HasValue ||
Interlocked.Read(ref _nodeConnectionGeneration) == expectedNodeGeneration.Value);
+ private async Task BlockNodeStartAsync(
+ string detail,
+ CancellationToken cancellationToken,
+ long? expectedLifecycleGeneration = null,
+ long? expectedNodeGeneration = null)
+ {
+ if (expectedLifecycleGeneration.HasValue &&
+ Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value)
+ {
+ return;
+ }
+
+ if (expectedNodeGeneration.HasValue &&
+ Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value)
+ {
+ return;
+ }
+
+ await _transitionSemaphore.WaitAsync(cancellationToken);
+ try
+ {
+ if (expectedLifecycleGeneration.HasValue &&
+ Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value)
+ {
+ return;
+ }
+
+ if (expectedNodeGeneration.HasValue &&
+ Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value)
+ {
+ return;
+ }
+
+ _stateMachine.BlockNodeStart(detail);
+ EmitStateChanged();
+ }
+ finally
+ {
+ _transitionSemaphore.Release();
+ }
+ }
+
private async Task StartNodeConnectionCoreAsync(
+ long expectedLifecycleGeneration,
long nodeGeneration,
CancellationToken cancellationToken)
{
@@ -1198,30 +1333,63 @@ private async Task StartNodeConnectionCoreAsync(
return false;
}
- if (_nodeConnector == null || _activeGatewayRecordId == null || _activeIdentityPath == null) return false;
+ if (_nodeConnector == null)
+ {
+ await BlockNodeStartAsync(MissingNodeConnectorMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration);
+ return false;
+ }
+
+ if (_activeGatewayRecordId == null || _activeIdentityPath == null)
+ {
+ await BlockNodeStartAsync(MissingActiveGatewayForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration);
+ return false;
+ }
var record = _registry.GetById(_activeGatewayRecordId);
if (record == null)
{
_logger.Warn("[ConnMgr] Cannot start node — gateway record not found");
+ await BlockNodeStartAsync(MissingGatewayRecordForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration);
return false;
}
+ // Mark node as enabled in the state machine so UI reflects node state
+ // before credential resolution can fail. Otherwise node mode could look
+ // healthy even though the intended node never started.
+ await _transitionSemaphore.WaitAsync(cancellationToken);
+ try
+ {
+ if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration))
+ return false;
+
+ var before = _stateMachine.Current;
+ _stateMachine.SetNodeEnabled(true);
+ _stateMachine.StartNodeConnecting();
+ _stateMachine.SetNodeCredentialSource(null);
+ if (_stateMachine.Current != before)
+ EmitStateChanged();
+ }
+ finally
+ {
+ _transitionSemaphore.Release();
+ }
+
// Use root identity path — clients always read/write from root, not per-gateway
var nodeCredential = _credentialResolver.ResolveNode(record, _activeIdentityPath!);
if (nodeCredential == null)
{
_logger.Warn("[ConnMgr] No node credential available — skipping node connection");
_diagnostics.Record("node", "No node credential available");
+ await BlockNodeStartAsync(MissingNodeCredentialMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration);
return false;
}
- // Mark node as enabled in the state machine so UI reflects node state
- // State machine is not thread-safe — acquire semaphore for mutation
await _transitionSemaphore.WaitAsync(cancellationToken);
try
{
- _stateMachine.SetNodeEnabled(true);
+ if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration))
+ return false;
+
_stateMachine.SetNodeCredentialSource(nodeCredential.Source);
}
finally
@@ -1262,6 +1430,12 @@ await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, _activeIdentit
_logger.Error($"[ConnMgr] Node connect failed: {ex.Message}");
_diagnostics.Record("node", "Node connect failed", ex.Message);
+ await BlockNodeStartAsync(
+ $"Node connect failed: {ex.Message}",
+ cancellationToken,
+ expectedLifecycleGeneration,
+ nodeGeneration);
+ return false;
}
return !cancellationToken.IsCancellationRequested &&
@@ -1297,7 +1471,6 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status)
await _transitionSemaphore.WaitAsync();
try
{
- var prev = _stateMachine.Current.OverallState;
switch (status)
{
case ConnectionStatus.Connected:
@@ -1336,7 +1509,7 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status)
}
TryClearBootstrapTokenAfterDurablePairing();
- EmitStateChanged(prev);
+ EmitStateChanged();
}
finally
{
@@ -1371,7 +1544,6 @@ private async Task OnNodePairingStatusChangedAsync(
if (!IsCurrentNodeAttempt(lifecycleGeneration, nodeGeneration))
return;
- var prev = _stateMachine.Current.OverallState;
switch (e.Status)
{
case PairingStatus.Paired:
@@ -1404,7 +1576,7 @@ private async Task OnNodePairingStatusChangedAsync(
}
TryClearBootstrapTokenAfterDurablePairing();
- EmitStateChanged(prev);
+ EmitStateChanged();
}
finally
{
@@ -1686,7 +1858,7 @@ private static string BuildDeviceAutoApprovalFailureDetail(IReadOnlyList
// ─── Helpers ───
- private void EmitStateChanged(OverallConnectionState previousOverall)
+ private void EmitStateChanged()
{
var snapshot = _stateMachine.Current;
// Always fire when any part of the snapshot changed — not just OverallState.
diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs
index 28fd05879..b06cec57d 100644
--- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs
+++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs
@@ -22,6 +22,7 @@ public sealed record GatewayConnectionSnapshot
public string? OperatorPairingRequestId { get; init; }
// ─── Node ───
+ public bool NodeConnectionIntended { get; init; }
public RoleConnectionState NodeState { get; init; }
public string? NodeError { get; init; }
public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; }
@@ -67,10 +68,26 @@ public static OverallConnectionState DeriveOverall(
if (op == RoleConnectionState.Connecting)
return OverallConnectionState.Connecting;
+ if (op != RoleConnectionState.Connected && nodeEnabled)
+ {
+ if (node == RoleConnectionState.PairingRequired)
+ return OverallConnectionState.PairingRequired;
+
+ if (node == RoleConnectionState.Connecting)
+ return OverallConnectionState.Connecting;
+
+ if (node == RoleConnectionState.Connected)
+ return OverallConnectionState.Connected;
+
+ if (node is RoleConnectionState.Error or RoleConnectionState.PairingRejected or RoleConnectionState.RateLimited)
+ return OverallConnectionState.Error;
+ }
+
// From here, operator is Connected.
if (op == RoleConnectionState.Connected && nodeEnabled &&
(node == RoleConnectionState.Error ||
+ node == RoleConnectionState.Idle ||
node == RoleConnectionState.PairingRejected ||
node == RoleConnectionState.RateLimited))
return OverallConnectionState.Degraded;
diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs
index ed4f45926..0746523c3 100644
--- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs
+++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs
@@ -144,7 +144,34 @@ private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request)
return Error("Missing required arg: value");
if (SettingsSetHandler == null)
return Error("Settings handler not registered");
- return Success(SettingsSetHandler(name, value));
+
+ var result = SettingsSetHandler(name, value);
+ if (TryGetErrorPayload(result, out var error))
+ return Error(error);
+
+ return Success(result);
+ }
+
+ private static bool TryGetErrorPayload(object? result, out string error)
+ {
+ error = "";
+ if (result == null)
+ return false;
+
+ var property = result.GetType().GetProperty(
+ "error",
+ System.Reflection.BindingFlags.Instance |
+ System.Reflection.BindingFlags.Public |
+ System.Reflection.BindingFlags.IgnoreCase);
+
+ if (property?.GetValue(result) is not string message ||
+ string.IsNullOrWhiteSpace(message))
+ {
+ return false;
+ }
+
+ error = message;
+ return true;
}
private NodeInvokeResponse HandleMenu()
diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
index ab9bf4b72..12f9eae31 100644
--- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
+++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
@@ -255,7 +255,7 @@ private object HandleToolsList()
["app.navigate"] =
"Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.",
["app.status"] =
- "Get current connection status, node state, and gateway info. Returns { connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }.",
+ "Get current connection status, manager-owned overall/operator/node state, and gateway info. Returns { connectionStatus, overallState, operatorState, nodeState, nodeConnected, nodePaired, nodePendingApproval, nodeError, gatewayVersion, sessionCount, nodeCount }.",
["app.sessions"] =
"List active sessions with optional agent filter. Args: agentId (string, optional). Returns array of { Key, Status, Model, AgeText, tokens }.",
["app.agents"] =
@@ -267,9 +267,9 @@ private object HandleToolsList()
["app.settings.get"] =
"Read a local app setting by name. Args: name (string, required). Returns the setting value.",
["app.settings.set"] =
- "Set a local app setting (name and value). Args: name (string, required), value (string, required). Returns { name, value }.",
+ "Set a local app setting (name and value), persist it, and apply the same reconnect/reload behavior as saving settings in the app UI. Args: name (string, required), value (string, required). Returns { name, value }; runtime apply failures surface as tool errors.",
["app.menu"] =
- "Get tray menu state (status, session count, node count). Returns array of menu items.",
+ "Get tray menu state (status including overallState/nodeState/nodeError, session count, node count). Returns array of menu items.",
["app.search"] =
"Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.",
["app.dashboard.url"] =
diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs
index d2cc98ce3..ff15d7ed2 100644
--- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs
+++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs
@@ -47,17 +47,25 @@ private void WireAppCapabilityHandlers()
return await tcs.Task;
};
- app.StatusHandler = () => new
+ app.StatusHandler = () =>
{
- connectionStatus = _appState!.Status.ToString(),
- nodeConnected = _nodeService?.IsConnected ?? false,
- nodePaired = _nodeService?.IsPaired ?? false,
- nodePendingApproval = _nodeService?.IsPendingApproval ?? false,
- gatewayVersion = _appState!.GatewaySelf?.ServerVersion,
- sessionCount = _appState!.Sessions?.Length ?? 0,
- nodeCount = _appState!.Nodes?.Length ?? 0,
- operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(),
- operatorDeviceId = _connectionManager?.CurrentSnapshot.OperatorDeviceId,
+ var snapshot = _connectionManager?.CurrentSnapshot;
+ return new
+ {
+ connectionStatus = _appState!.Status.ToString(),
+ overallState = snapshot?.OverallState.ToString(),
+ operatorState = snapshot?.OperatorState.ToString(),
+ nodeState = snapshot?.NodeState.ToString(),
+ nodeConnected = snapshot?.NodeState == RoleConnectionState.Connected,
+ nodePaired = snapshot?.NodePairingStatus == PairingStatus.Paired,
+ nodePendingApproval = snapshot?.NodeState == RoleConnectionState.PairingRequired,
+ nodeError = snapshot?.NodeError,
+ gatewayVersion = _appState!.GatewaySelf?.ServerVersion,
+ sessionCount = _appState!.Sessions?.Length ?? 0,
+ nodeCount = _appState!.Nodes?.Length ?? 0,
+ operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(),
+ operatorDeviceId = snapshot?.OperatorDeviceId,
+ };
};
app.SessionsHandler = async (agentId) =>
@@ -149,6 +157,15 @@ private void WireAppCapabilityHandlers()
var converted = Convert.ChangeType(value, prop.PropertyType);
prop.SetValue(_settings, converted);
_settings.Save();
+ OnSettingsSaved(this, EventArgs.Empty);
+ var runtimeError = McpRuntimeStatePolicy.GetSettingsSetError(
+ name,
+ converted,
+ _nodeService?.IsMcpRunning == true,
+ _nodeService?.McpStartupError);
+ if (!string.IsNullOrWhiteSpace(runtimeError))
+ return new { error = runtimeError };
+
return new { name, value = prop.GetValue(_settings) };
}
catch (Exception ex)
@@ -160,9 +177,17 @@ private void WireAppCapabilityHandlers()
app.MenuHandler = () =>
{
+ var snapshot = _connectionManager?.CurrentSnapshot;
var items = new List