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).
Status Update — 2026-07-10: marker-first renderer installation
The original version marker prevented the renderer from executing the walker bundle more than once per document, but the server still sent that bundle with every snapshot, find, read, probe, and live similar-ref walk. The injection protocol now has two paths:
- The normal path sends a compact body that verifies the bundle content marker
and the expected
__stagewrightWalkor__stagewrightProbeglobal, then invokes that global. A miss returns a privatenullsentinel. - Only a miss sends the full bundle and installs it before invoking the same global. This covers first use, renderer reload/navigation, a bundle upgrade, a missing global despite a stale marker, and each separately selected window.
The sentinel is consumed inside the server helper and never changes a tool's public response. Snapshot schema v1, ref reconciliation, DOM retagging, error mapping, and Injector's renderer-evaluation boundary are unchanged. The marker is memoized alongside the cached bundle on the server, while its validity is always checked against the actual renderer global, not session-local state.