ADR-005: Snapshot schema v1 + renderer-injected tool layer
- Status: Accepted
- Date: 2026-05-28
- Deciders: johnny4young
- Note: Public ADR. Committed artifacts may cite
ADR-005; this file is the canonical design record.
Context
Agents need a structured, low-token view of the renderer to decide what to do —
roles, names, state, bounding boxes, and stable handles (refs) they can act on.
The pure snapshot module (walker, accname, fingerprint, diff, reconcile, find)
shipped earlier as framework-agnostic functions over a Document. What was
deferred was the tool layer: running that walker inside the Electron renderer
and threading per-session state so an agent can call snapshot({ since: 'last' }).
Decision
Schema (v1) —
Snapshotis{ schemaVersion, entries[], meta }. EachSnapshotEntrycarriesref(number | null), role, name, state envelope, bbox, fingerprint,interactive,recently_changed. Refs are matched across snapshots by fingerprint (reconcileRefs) for stability. Everything is plain JSON (noMap/Set/Date) so it round-trips to the agent.Renderer injection by bundle, not hand-serialisation — the walker plus its helpers are bundled (esbuild, IIFE) into
dist/snapshot/injected-walker.js. Theelectron_snapshot/electron_findtools read that artifact and inject it viasession.evaluate('renderer', …), which installsglobalThis.__stagewrightWalk. Serialising the walker withFunction.prototype.toStringwas rejected: it drops the function's imported dependencies. The bundle keeps the walker the single source of truth.Ref resolution by
data-sw-reftagging — during the renderer walk, each interactive element that receives arefis taggeddata-sw-ref="<ref>"(via the walker'srefAttributeoption). A later interaction tool resolvesref: Nto the[data-sw-ref="N"]selector for real input. The tag is a renderer-only DOM mutation; it is re-applied each walk and gone after a reload.Stateful
since:'last'— a per-sessionSnapshotStoreholds the last FULL (unfiltered) snapshot.since:'last'returns the delta (diffSnapshotsrecently_changed); a detected reload (detectRendererReload) forces a full snapshot and setsrenderer_reloaded.interactiveOnly/maxEntriesfilter only the RETURNED snapshot, never the stored baseline (so diffs stay accurate).
Alternatives considered
| Alternative | Why rejected |
|---|---|
Hand-serialise the walker via Function.prototype.toString |
Drops imported helpers; fragile and duplicative. |
Ship refs with no DOM resolution |
Decorative — interaction could not act on a ref. |
A separate snapshot_diff tool |
ADR-007 P7: diff is a parameter (since:'last'), not a second tool. |
| Store the filtered snapshot as the diff baseline | Produces spurious added/removed entries when filters differ between calls. |
Consequences
- The interaction and read tool families consume
refs via thedata-sw-refselector and the snapshot store. - The build gains an esbuild renderer-bundle step;
esbuildis a devDependency. - The injected bundle runs in the page main world; eval-CSP considerations for renderer code are deferred to the eval-tools slice.
Status Update — 2026-06-10
snapshot({ since: 'last' }) now defaults to a COMPACT diff encoding at the
tool layer; the wire Snapshot shape and schemaVersion: 1 are unchanged.
- The full encoding carried complete
prev/currentries per change, which blew MCP-client token caps on busy dialogs. The compact encoding keepsaddedentries complete (new UI), shrinksremovedto identity (fingerprint/ref/role/name), and shrinkschangedto identity plus ONLY the changed fields' previous/current values.diffFormat: 'full'restores the original shape. - A
budgetTokensargument adds server-side truncation that drops lowest-value entries first (non-interactive removed → changed → added, then interactive in the same order); the real delta counts in_metaare preserved andtruncated_entriesreports the omission. - The closed-shadow-root opt-in gained a timing-independent registration
array: apps push roots onto
window.__stagewright_closedShadowRootsatattachShadowtime (merged and deduplicated with the original__stagewright_inspectShadowcallback; detached hosts are skipped).