How Snapshot References (`@N`) Work Across Multiple Heredoc Execution Rounds in Ego-Lite
Snapshot references (@N) persist across heredoc execution rounds because Ego-Lite's browser runtime rebuilds the global refMap on every page.snapshot() call, enabling automatic lazy re-resolution when stale references are encountered.
In Ego-Lite, the citrolabs/ego-lite repository provides a browser automation harness that uses the Chrome DevTools Protocol (CDP) to interact with web pages. A snapshot reference (@N) is a compact, agent-friendly way to refer to DOM elements by their backendNodeId—a numeric identifier assigned by Chrome. Understanding how these references survive across multiple heredoc execution rounds requires examining how the runtime manages state in refMap and when snapshots are triggered.
What Snapshot References (@N) Actually Represent
A snapshot reference is not a persistent DOM selector. Instead, it is a transient mapping to a CDP backendNodeId.
When page.snapshot() or page.snapshotRaw() is invoked, the runtime:
- Captures the page's semantic structure as human-readable content
- Extracts a
refsarray containing metadata for visible elements - Rebuilds the global
refMapinsrc/ref-state.tsfrom scratch
This design means @N references are only valid relative to the most recent snapshot. The runtime ensures freshness through lazy re-snapshotting.
How refMap Is Rebuilt Each Round
The core mechanism lives in src/browser-runtime.ts. The function browserSnapshotRefsToRefMap handles the population logic:
// Conceptual flow from src/browser-runtime.ts
refMap.clear();
for (const ref of snapshot.refs) {
refMap.set(ref.backendNodeId, ref);
}
Because refMap is cleared and repopulated on every snapshot, any heredoc round that calls page.snapshot() automatically refreshes all available references. The map is stored in a runtime singleton defined in src/state.ts, making it accessible across execution contexts.
Automatic Resolution and Lazy Re-Snapshotting
The src/element-resolver.ts module handles the actual lookup when an agent uses @N syntax:
- Parse the locator to extract the numeric id (e.g.,
@12→12) - Query
refMap.get(refId)for the correspondingRefEntry - Trigger re-snapshot if the map is empty or the id is missing
- Resolve the element or throw
ElementResolutionErrorfor invalid ids
This lazy approach means agents do not need explicit snapshot calls between rounds. The resolver automatically ensures the map is current.
// Round 1: Explicit snapshot to establish references
const snap = await page.snapshot();
console.log(snap); // Contains "@12", "@34", etc.
// Round 2: Reuse reference without manual snapshot
await page.click('@12'); // Resolver checks refMap, re-snapshots if stale
Handling Navigation and DOM Changes
Navigation or significant DOM mutations invalidate the refMap because backendNodeIds are page-specific. However, the resolver's lazy re-snapshotting handles this transparently:
await page.goto('https://example.com'); // Navigation clears refMap
await page.click('@12'); // Auto-triggers fresh snapshot
If the element with backendNodeId 12 no longer exists, the re-snapshot will populate refMap with current ids, and the resolver will throw ElementResolutionError for the stale reference.
State Management Across File Boundaries
The persistence mechanism spans several source files with distinct responsibilities:
| File | Key Function | Role in @N Persistence |
|---|---|---|
src/ref-state.ts |
Holds snapshotImpl hook and mutable refMap |
Defines the global state container |
src/browser-runtime.ts |
browserSnapshotRefsToRefMap |
Clears and repopulates refMap from snapshot data |
src/element-resolver.ts |
@N locator resolution |
Triggers re-snapshot when refMap lookup fails |
src/helpers.ts |
snapshot() and snapshotRaw() |
Public API for explicit snapshot calls |
src/format.ts |
@N syntax formatting |
Defines how references appear in output |
Complete Working Example
// Multi-round heredoc pattern with automatic reference persistence
// Round 1: Establish baseline snapshot
const initial = await page.snapshot();
// Output: "Search button [@12]\nSubmit form [@23]"
// Round 2: Interact using established references
await page.click('@12'); // Clicks search button
// Round 3: Navigate, then reuse reference pattern
await page.goto('https://example.com/results');
// Despite navigation, @N syntax still works via auto-re-snapshot
await page.click('@5'); // Resolver fetches fresh map automatically
// Round 4: Handle potential staleness explicitly
try {
await page.click('@999'); // Invalid reference
} catch (err) {
// ElementResolutionError: reference not in current snapshot
}
Summary
- Snapshot references (
@N) map to CDPbackendNodeIdvalues, not stable DOM selectors refMapinsrc/ref-state.tsis cleared and rebuilt on everypage.snapshot()call viabrowserSnapshotRefsToRefMapinsrc/browser-runtime.ts- Lazy re-snapshotting in
src/element-resolver.tsensures references resolve correctly across heredoc rounds without explicit snapshot calls - Navigation and DOM changes trigger automatic refresh on the next
@Nusage, with clear error semantics for permanently invalid references
Frequently Asked Questions
What happens if I use @N without calling page.snapshot() first?
The resolver in src/element-resolver.ts automatically triggers page.snapshot() when refMap is empty or the requested id is missing. Your code will still execute, incurring a one-time latency penalty for the implicit snapshot.
Are @N references stable across page navigations?
No. BackendNodeIds are assigned per-page by the Chrome DevTools Protocol. After navigation, previous @N values become invalid. However, the resolver detects stale maps and fetches fresh references automatically, throwing ElementResolutionError only if the specific id does not exist in the new page state.
Can multiple heredoc scripts share the same refMap?
Yes. refMap is stored in a runtime singleton (src/state.ts) shared across all execution contexts within the same browser session. Any script's snapshot call updates the map for all subsequent reference resolutions.
How do I debug which @N values are currently available?
Call page.snapshotRaw() from src/helpers.ts to inspect the raw refs array containing all current backendNodeIds and their associated metadata:
const raw = await page.snapshotRaw();
console.log(raw.refs.map(r => ({ id: r.backendNodeId, role: r.role, name: r.name })));
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 →