# v0.13.0 — Unified page snapshot (`read_page`)

The biggest architectural change since multi-session. The agent's "how I read pages" pipeline is rebuilt around a single pull tool.

## What changed

Pie 0.12 and earlier shipped two parallel ways to read a tab:

- An **automatic per-iteration push** of the interactive-element index list, injected into every ReAct observation.
- An **explicit `get_tab_content` tool** the model could call to pull the page's visible text.

Two code paths, two formats, two budget regimes — and two long-standing gaps: Shadow DOM was a black box, and the model couldn't distinguish "I haven't read this page yet" from "the page changed under me". 0.13 collapses both into one tool, `read_page`, and adds a per-frame version token so stale element indices fail loudly instead of silently.

## `read_page` tool

```
read_page({ tabId })
```

Returns, in a single observation, everything the model needs to both understand and operate the page:

- A `<frame_map>` enumerating every frame in the tab with its current `version`, plus a `cross_origin="true"` flag where applicable and `unreachable="true" reason="..."` for sandboxed / extension-child / about-blank frames.
- Optional `<scrollable_regions>` hints for any element where `scrollHeight > clientHeight × 1.2` (lists, feeds, grids) so the model knows when there's more content to scroll for.
- Per-frame `<untrusted_page_content frame_id="N" frame_version="V">` blocks containing a stripped HTML view of that frame. Every visible interactive element is stamped with `data-pie-idx="N"` — that's the index `click` / `type` / `select` consume.

The HTML is sanitized through a strict tag + attribute whitelist (no `class`, no `style`, no `data-*` other than the two `data-pie-*`), with iframe `src` removed and unsafe `javascript:` / `data:` URLs stripped. Wrapper-tag injection attempts (`</untrusted_page_content>` in page text) are entity-encoded so they can't escape the prompt boundary. Total budget 50 KB, top-frame-first; over-budget frames carry `unread="budget"` so the model knows what it didn't see.

## Shadow DOM traversal

`read_page` walks **open shadow roots**, including nested ones. YouTube, Salesforce Lightning, MUI Joy, Ionic, and similar Web Components-heavy apps are no longer black boxes to the agent. Closed shadow roots remain opaque (DOM spec doesn't allow traversal).

A known trade-off: the `MutationObserver` that maintains the version counter is installed on `documentElement` only and does not pierce into shadow roots, so mutations *inside* a shadow tree won't bump the frame version. In practice this rarely matters — most user-visible state changes route through light-DOM updates — but it's documented for completeness.

## Stale-detection contract

`click`, `type`, and `select` now require an `expectedFrameVersion: number` argument. The model gets this value from the `frame_version` attribute on the per-frame block of the most recent `read_page`. On every write:

- Registry miss → `frameGone: Frame not in registry. Call read_page first.`
- Observer marked dead (heartbeat lost) → `frameStale: Observer dead. Re-call read_page to refresh.`
- Version mismatch → `frameVersionMismatch: expected X, current Y. Re-call read_page; indices may have shifted.`

Each error code is actionable: the model knows exactly what to do next. Versions bump on any DOM mutation observed in the top frame (debounced ~150 ms), and the bridge tells the SW immediately so the next write can fail-fast.

## Operational changes

- **Push removed.** `buildObservationMessage` now emits only `Current URL` + `Page title`. The system prompt's `READ_PAGE_GUIDANCE` explicitly instructs the model to call `read_page` before interacting.
- **First-turn nudge.** When the conversation starts and a pinned tab exists, the iteration-0 observation is suffixed with a hint pointing at `read_page({tabId: N})`. This isn't strictly necessary — the system prompt covers it — but it reinforces the contract at the natural decision point.
- **`focus_tab` semantics.** "The new tab becomes the focus on the NEXT iteration; call `read_page` on it next turn before writing."

## Breaking changes

- **`get_tab_content` removed.** Use `read_page`. The new tool returns structured HTML instead of stripped text — the model can ignore the markup if it wants pure text, or follow the `data-pie-idx` anchors when it needs to act.
- **`click` / `type` / `select` now require `expectedFrameVersion`.** Hand-authored skills that synthesize tool calls must include this argument; otherwise the JSON schema rejects them.
- **`ToolHandlerContext.snapshot` field removed.** No built-in tool consumed it after Phase 3; if a custom integration relied on it, switch to calling `read_page` from the model side.
- **Older `data-chrome-ai-agent-idx` attribute retired.** `click` / `type` / `select` now look up `data-pie-idx`. Any external automation that injected `data-chrome-ai-agent-idx` stamps no longer works — `read_page` is the single source of stamps.

## Out of scope (deliberate)

This release does not include:

- Origin allow/deny list or sensitive-page detection guardrails (separate spec).
- A `scroll_and_read` virtual-scroll auto-explorer (separate spec).
- Closed shadow root traversal (impossible at the browser level).
- Canvas-rendered text extraction (Google Docs / Figma / Excalidraw — same limitation as before, the canvas hides text from the DOM).

## 中文摘要

合并自动快照推送 + `get_tab_content` 为单一 `read_page` 工具。LLM 主动调它拿到统一 HTML：含 `data-pie-idx` 操作锚点、Shadow DOM 内元素、滚动提示、每 frame 版本号。写类工具（click / type / select）新增 `expectedFrameVersion` required，页面在 read 和 write 之间变化时返回明确错误，LLM 自动 re-read。

主要破坏性改动：
- `get_tab_content` 删除，改用 `read_page`
- click / type / select 必须带 `expectedFrameVersion`
- 元素索引属性从 `data-chrome-ai-agent-idx` 改为 `data-pie-idx`
- `ToolHandlerContext.snapshot` 字段移除
