How Ego‑Lite's Reference System Provides Stable Element References Across Script Runs
Ego‑Lite maintains stable element references through a three‑component architecture: RefMap stores numeric IDs mapped to backendNodeId, browserRefMap with ensureRefMapForRef lazily refreshes snapshots when needed, and resolveElement* helpers perform two‑step resolution with automatic fallback to role/name lookup.
The ego‑lite browser automation framework solves a critical problem for autonomous agents: how to identify the same DOM element after navigation, DOM mutations, or entirely separate script executions. Unlike brittle CSS selectors that break on markup changes, ego‑lite's stable element reference system assigns numeric identifiers (@1, @2, …) that persist across script runs through snapshot regeneration and intelligent resolution fallbacks.
Core Components of the Reference System
Three tightly‑coupled modules work together to provide reference stability.
RefMap: The Numeric-to-Element Registry
Located in [package/ego-browser/src/ref-map.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts), RefMap maintains the mapping between numeric reference IDs and element metadata. Each entry stores not just the Chrome DevTools Protocol (CDP) backendNodeId, but also the element's role, name, nth position, and frame information.
// ref-map.ts – storing a ref with full context
this.map.set(refId, {
backendNodeId,
role,
name,
nth,
selector: undefined,
frameId,
});
The parseRef utility handles multiple input formats, stripping @ or ref= prefixes to extract the numeric ID:
// ref-map.ts – flexible ref parsing
export function parseRef(input) {
const trimmed = String(input || "").trim();
const candidate = trimmed.startsWith("@") ? trimmed.slice(1) :
trimmed.startsWith("ref=") ? trimmed.slice(4) :
trimmed;
return candidate && /^\d+$/.test(candidate) ? candidate : null;
}
Global State and Lazy Snapshot Refresh
[package/ego-browser/src/ref-state.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts) exports browserRefMap, a singleton holding the current snapshot's refs. The critical function ensureRefMapForRef implements lazy regeneration: when a script uses a reference but the map is empty (post‑navigation, for instance), it automatically triggers a fresh snapshot.
// ref-state.ts – lazy snapshot with re-entrancy guard
export async function ensureRefMapForRef(selectorOrRef) {
if (ensuring) return; // prevent concurrent snapshots
if (typeof selectorOrRef !== "string") return;
if (!parseRef(selectorOrRef)) return; // not a ref, skip
if (browserRefMap.map.size > 0) return; // already populated
if (!snapshotImpl) return; // no callback registered
ensuring = true;
try {
await snapshotImpl(); // rebuild the map
} finally {
ensuring = false;
}
}
The ensuring flag prevents duplicate snapshot attempts during concurrent operations.
Two-Step Element Resolution
[package/ego-browser/src/element-resolver.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts) contains resolveElementCenter and resolveElementObjectId, which turn a reference into an actionable CDP node. The resolution strategy prioritizes speed, then stability:
- Fast path: Use the stored
backendNodeIddirectly viaDOM.getBoxModelorDOM.resolveNode - Fallback path: If the node is stale (removed by DOM changes), search by role and name using
findBackendNodeIdByRoleName
// element-resolver.ts – two-step ref resolution
const refId = parseRef(selectorOrRef);
if (refId) {
const entry = refMap.get(refId);
if (!entry) throw new ElementResolutionError(`Unknown ref: ${refId}`, "transient");
// Try stored backendNodeId first (fastest)
if (entry.backendNodeId !== undefined) { … }
// Fallback to role/name lookup (stable across DOM mutations)
const backendNodeId = await findBackendNodeIdByRoleName(entry.role, entry.name, entry.nth);
}
How References Survive Across Script Runs
The system achieves cross‑script stability through three mechanisms.
Snapshot Retention Between Scripts
When a script finishes, ego‑lite retains the current RefMap. The next script execution reuses browserRefMap unless it's empty, in which case ensureRefMapForRef triggers regeneration. This means @7 in script A typically refers to the same logical element in script B.
Consistent ID Assignment Within Task-Spaces
The runtime re‑captures the page before each new script round. While numeric IDs are rebuilt on every snapshot, the traversal order (top‑to‑bottom through the accessibility tree) remains deterministic. Elements with stable roles, names, and DOM positions receive consistent reference numbers across snapshots.
Automatic Recovery from Navigation
Page loads clear the RefMap (since backendNodeIds are invalidated). However, agents don't need explicit waits: the first reference usage after navigation automatically invokes snapshotImpl() via ensureRefMapForRef, repopulating the map with fresh entries for the new page.
Practical Usage Examples
Creating and Reusing a Stable Reference
// 1️⃣ Initialize and capture snapshot
await egobrowser.observe(); // populates RefMap with @1, @2, @3...
// 2️⃣ Interact using numeric reference
const buttonRef = await egobrowser.click("@12"); // returns "@12" for chaining
// 3️⃣ Reuse in subsequent calls – no re-query needed
await egobrowser.moveMouse("@12");
await egobrowser.type("@12", "search query");
Post‑Navigation Recovery
// After page navigation, refs are cleared but auto‑regenerate
await egobrowser.click("@5"); // Map empty → ensureRefMapForRef triggers snapshot
// New @5 points to element with same role/name/nth
Manual Snapshot Control
// Force regeneration after known DOM mutations
await egobrowser.snapshot(); // Explicit refresh, keeps workflow predictable
Implementation Files and Responsibilities
| File | Responsibility |
|---|---|
[ref-map.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts) |
RefMap class, parseRef() utility, entry data structure |
[ref-state.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts) |
Global browserRefMap, ensureRefMapForRef(), snapshot callback registration |
[element-resolver.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts) |
resolveElementCenter(), resolveElementObjectId(), fallback search logic |
[driver/observe.ts](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/observe.ts) |
Snapshot capture, accessibility tree traversal, RefMap population |
Summary
RefMapimplements the numeric-to-element registry with role/name/frame metadata for resilient identificationbrowserRefMap+ensureRefMapForRefprovide lazy, guarded snapshot regeneration that automatically recovers from navigation and clearsresolveElement*functions execute two‑step resolution:backendNodeIdfast path with automatic role/name fallback- References persist across script runs through snapshot retention and deterministic ID assignment within task-spaces
- The system requires no explicit synchronization from agents—empty maps trigger automatic refresh on first reference use
Frequently Asked Questions
What happens if a referenced element is deleted from the DOM?
The resolveElement* helpers detect stale backendNodeIds and automatically fall back to findBackendNodeIdByRoleName, which searches the current accessibility tree by role, name, and nth position. If the element has been replaced by another with matching attributes, the reference resolves to the new instance; if no match exists, an ElementResolutionError is thrown with code "transient".
Do reference numbers stay identical forever?
No—numeric IDs are reassigned on every snapshot. However, within a task-space, elements with stable roles, names, and DOM positions typically receive consistent numbers because the accessibility tree traversal order is deterministic. For cross‑script stability, agents should rely on the reference mechanism itself (the runtime refreshes automatically) rather than hard‑coding specific numbers.
How does ego‑lite handle references across iframe boundaries?
The RefMap.addWithFrame method stores frameId alongside each entry. During resolution, the system routes CDP commands to the correct frame context, enabling stable references for elements inside iframes without requiring explicit frame switching in agent scripts.
Is there a performance cost to the lazy snapshot refresh?
ensureRefMapForRef executes only when browserRefMap.map.size === 0, guarded by the ensuring flag to prevent duplicate work. The snapshot itself involves one CDP Accessibility.getFullAXTree call and tree traversal. In practice, this overhead is negligible compared to the network latency of browser automation, and it eliminates the need for manual waitForSelector calls.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →