diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
new file mode 100644
index 00000000..9d6d5279
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
@@ -0,0 +1,178 @@
+using Opc.Ua;
+using Opc.Ua.Client;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
+
+///
+/// Live one-level-per-call browse session over a remote OPC UA server. Created by
+/// OpcUaClientBrowser on picker open and owned by the AdminUI's
+/// BrowseSessionRegistry; the registry's TTL reaper disposes idle sessions.
+///
+internal sealed class OpcUaClientBrowseSession : IBrowseSession
+{
+ private readonly ISession _session;
+ private readonly NamespaceMap _nsMap;
+ private readonly NodeId _rootNodeId;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private bool _disposed;
+
+ ///
+ /// Construct a browse session bound to an already-connected .
+ ///
+ /// The OPC UA client session to browse against.
+ /// Namespace snapshot taken at connect time; used to render outbound
+ /// NodeIds in the server-stable nsu=… form.
+ /// The node under which browses one level.
+ internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
+ {
+ _session = session;
+ _nsMap = nsMap;
+ _rootNodeId = rootNodeId;
+ }
+
+ /// Opaque token identifying this session in the AdminUI registry.
+ public Guid Token { get; } = Guid.NewGuid();
+
+ /// Wall-clock time of the most recent successful browse call; the reaper uses
+ /// this for idle eviction.
+ public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
+
+ /// Browse one level under the configured root node.
+ /// Cancellation token.
+ public Task> RootAsync(CancellationToken cancellationToken)
+ => BrowseOneLevelAsync(_rootNodeId, cancellationToken);
+
+ /// Browse one level under the node identified by ,
+ /// which must be a stable reference produced by
+ /// (or a plain ns=N;… form).
+ /// Stable reference string for the parent node.
+ /// Cancellation token.
+ /// Thrown when cannot be
+ /// resolved against the live session's namespace table.
+ public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken)
+ {
+ if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
+ throw new ArgumentException(
+ $"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
+ return BrowseOneLevelAsync(resolved, cancellationToken);
+ }
+
+ /// The OPC UA picker treats variables as terminal leaves and does not surface
+ /// a per-attribute side-panel, so this always returns empty.
+ /// Ignored.
+ /// Ignored.
+ public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
+ => Task.FromResult>(Array.Empty());
+
+ /// Issue a single-level Browse (plus continuation-point follow-ups) under the
+ /// given parent node. is not thread-safe, so all calls
+ /// serialize through .
+ private async Task> BrowseOneLevelAsync(NodeId parent, CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var descriptions = new BrowseDescriptionCollection
+ {
+ new()
+ {
+ NodeId = parent,
+ BrowseDirection = BrowseDirection.Forward,
+ ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
+ IncludeSubtypes = true,
+ NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
+ ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
+ | BrowseResultMask.NodeClass),
+ },
+ };
+
+ var resp = await _session.BrowseAsync(
+ requestHeader: null,
+ view: null,
+ requestedMaxReferencesPerNode: 0,
+ nodesToBrowse: descriptions,
+ ct: ct).ConfigureAwait(false);
+
+ if (resp.Results.Count == 0)
+ {
+ LastUsedUtc = DateTime.UtcNow;
+ return Array.Empty();
+ }
+
+ var result = resp.Results[0];
+ var refs = result.References;
+
+ // Follow browse continuation points so folders larger than the server's per-call
+ // cap aren't silently truncated (same pattern as runtime
+ // Driver.OpcUaClient-003).
+ var cp = result.ContinuationPoint;
+ while (cp is { Length: > 0 })
+ {
+ var next = await _session.BrowseNextAsync(
+ requestHeader: null,
+ releaseContinuationPoints: false,
+ continuationPoints: [cp],
+ ct: ct).ConfigureAwait(false);
+
+ if (next.Results.Count == 0) break;
+ var nextResult = next.Results[0];
+ if (nextResult.References is { Count: > 0 })
+ refs.AddRange(nextResult.References);
+ cp = nextResult.ContinuationPoint;
+ }
+
+ LastUsedUtc = DateTime.UtcNow;
+
+ var nodes = new List(refs.Count);
+ foreach (var rf in refs)
+ nodes.Add(ToBrowseNode(rf));
+ return nodes;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ /// Project a single into the driver-agnostic
+ /// shape, encoding the outbound NodeId in the stable
+ /// nsu=… form so the picker survives a remote namespace-table reorder.
+ private BrowseNode ToBrowseNode(ReferenceDescription rf)
+ {
+ var childId = ExpandedNodeId.ToNodeId(rf.NodeId, _session.NamespaceUris);
+ var isObject = rf.NodeClass == NodeClass.Object;
+ var displayName = rf.DisplayName?.Text
+ ?? rf.BrowseName?.Name
+ ?? childId.ToString()
+ ?? "(unnamed)";
+ return new BrowseNode(
+ NodeId: _nsMap.ToStableReference(childId),
+ DisplayName: displayName,
+ Kind: isObject ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
+ HasChildrenHint: isObject);
+ }
+
+ /// Idempotent best-effort dispose: closes the underlying session if it's a
+ /// concrete , disposes it, and disposes the gate. Close errors are
+ /// swallowed because the registry reaper may be racing a remote disconnect.
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ if (_session is Session s)
+ {
+ try { await s.CloseAsync().ConfigureAwait(false); }
+ catch { /* best-effort */ }
+ }
+
+ try { _session.Dispose(); }
+ catch { /* best-effort */ }
+
+ try { _gate.Dispose(); }
+ catch { /* best-effort */ }
+ }
+}