@termwright/protocol 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +213 -605
  2. package/dist/action-model-BP9Znu6L.d.ts +219 -0
  3. package/dist/action-model.d.ts +3 -0
  4. package/dist/action-model.js +15 -0
  5. package/dist/action-model.js.map +1 -0
  6. package/dist/capability-graph.d.ts +90 -0
  7. package/dist/capability-graph.js +43 -0
  8. package/dist/capability-graph.js.map +1 -0
  9. package/dist/chunk-B4VUTTUE.js +59 -0
  10. package/dist/chunk-B4VUTTUE.js.map +1 -0
  11. package/dist/chunk-CZK6NNP3.js +389 -0
  12. package/dist/chunk-CZK6NNP3.js.map +1 -0
  13. package/dist/chunk-ODOJRXL6.js +84 -0
  14. package/dist/chunk-ODOJRXL6.js.map +1 -0
  15. package/dist/chunk-PUXRCPGY.js +112 -0
  16. package/dist/chunk-PUXRCPGY.js.map +1 -0
  17. package/dist/chunk-VBLS6E6U.js +1109 -0
  18. package/dist/chunk-VBLS6E6U.js.map +1 -0
  19. package/dist/chunk-ZZIYHDJ4.js +202 -0
  20. package/dist/chunk-ZZIYHDJ4.js.map +1 -0
  21. package/dist/contract-CH9gmj2Y.d.ts +746 -0
  22. package/dist/contract.d.ts +2 -0
  23. package/dist/contract.js +21 -0
  24. package/dist/contract.js.map +1 -0
  25. package/dist/index.d.ts +82 -798
  26. package/dist/index.js +1087 -766
  27. package/dist/index.js.map +1 -1
  28. package/dist/run-events.d.ts +158 -0
  29. package/dist/run-events.js +27 -0
  30. package/dist/run-events.js.map +1 -0
  31. package/dist/run-journal.d.ts +55 -0
  32. package/dist/run-journal.js +10 -0
  33. package/dist/run-journal.js.map +1 -0
  34. package/dist/run-state.d.ts +38 -0
  35. package/dist/run-state.js +19 -0
  36. package/dist/run-state.js.map +1 -0
  37. package/dist/test-provider.d.ts +22 -0
  38. package/dist/test-provider.js +32 -0
  39. package/dist/test-provider.js.map +1 -0
  40. package/package.json +33 -5
package/README.md CHANGED
@@ -1,32 +1,12 @@
1
1
  # @termwright/protocol
2
2
 
3
- The termwright semantic wire protocol: message shapes, roles, limits, framing,
4
- the render-commit marker, and snapshot validation.
3
+ The language-neutral semantic wire contract used by Termwright probes and the
4
+ driver. This package defines message shapes, semantic trees, observations,
5
+ framing, limits, render markers, validation, probe metadata, and structured
6
+ application logs.
5
7
 
6
- This package is the normative source of truth for the protocol (see
7
- `CONTRACTS.md`). It depends on `zod` and Node builtins only never on React,
8
- Ink, MCP, PTY, or the driver — so adapters and drivers can both import it
9
- without dragging in each other's runtime.
10
-
11
- Everything here **fails closed**: untrusted input is rejected with a typed
12
- `ProtocolViolation` or a structured `{ ok: false, code, detail }` result, never
13
- partially accepted.
14
-
15
- ## Versioned geometry contract
16
-
17
- `termwright/1` remains a strict compatibility protocol: `bounds` and
18
- `occlusion` keep their historical meaning and are never silently upgraded.
19
- `termwright/2` requires `qualified-observations` and snapshot `v: 2`. Each node
20
- then reports independent `displayed`, `intendedRect` and `visibleRect`
21
- observations; the snapshot reports its coordinate space and an explicit
22
- pointer hit-grid observation. A known grid is accepted only after negotiating
23
- `pointer-hit-grid`. Its regions are canonical, non-overlapping row-major runs
24
- with positive width and `height: 1`, permitting unambiguous lookup and linear
25
- validation.
26
-
27
- The driver accepts either major and echoes it in `hello-ack`; snapshot majors
28
- must match the handshake. V2 is full-snapshot-only, so v1 delta semantics are
29
- never projected onto qualified facts.
8
+ It depends on Zod and Node built-ins only. Framework probes and the driver can
9
+ import it without pulling in React, Ink, MCP, PTY, or UI code.
30
10
 
31
11
  ## Install
32
12
 
@@ -34,618 +14,246 @@ never projected onto qualified facts.
34
14
  pnpm add @termwright/protocol
35
15
  ```
36
16
 
37
- ## Usage
17
+ ## Current protocol
18
+
19
+ The only supported protocol id is `termwright/2` (`PROTOCOL_VERSION === 2`).
20
+ Every semantic snapshot has `v: 2` and uses evidence-qualified observations.
21
+ The endpoint and token select the private semantic session; clients do not
22
+ choose a protocol at runtime.
23
+
24
+ Each semantic revision is published as a complete snapshot.
25
+
26
+ ## Package surface
27
+
28
+ | Module | Provides |
29
+ | ----------- | -------------------------------------------------------------------------------------------- |
30
+ | `env` | Endpoint/token names, `PROTOCOL_VERSION`, `PROTOCOL_ID` |
31
+ | `roles` | Closed semantic role and action vocabularies |
32
+ | `limits` | Default, absolute, and negotiated protocol limits |
33
+ | `tree` | `SemanticSnapshot`, `SemanticNode`, observations, rectangles, portable state, extended state |
34
+ | `node-keys` | Closed semantic-node key set shared by validators |
35
+ | `probe` | Probe IR, metadata, identity, capability, and provenance vocabularies |
36
+ | `logs` | Structured application-log records and validation |
37
+ | `messages` | Wire message types and both directional parsers |
38
+ | `framing` | Length-prefixed JSON framing and hostile-data projection |
39
+ | `marker` | Authenticated render-marker encoding and verification |
40
+ | `validate` | Full snapshot validation |
41
+ | `accesskit` | Pure conversion to AccessKit-compatible data |
42
+ | `errors` | Typed protocol violations |
43
+ | `run-state` | Closed run lifecycle, terminal verdicts, and transition validation |
44
+
45
+ `passed-with-skips` is a terminal run verdict distinct from both plain
46
+ `passed` and fully `skipped`. It preserves partial-skip evidence for hosts and
47
+ UIs; whether that verdict certifies is decided by the host's exact skip policy,
48
+ not by the protocol state alone.
49
+
50
+ ## Decode adapter traffic
38
51
 
39
52
  ```ts
40
- import {
41
- DEFAULT_LIMITS,
42
- createFrameDecoder,
43
- encodeFrame,
44
- encodeMarker,
45
- parseAdapterMessage,
46
- verifyMarkerPayload,
47
- } from '@termwright/protocol';
48
-
49
- // Driver side: decode length-prefixed frames off the socket.
53
+ import { DEFAULT_LIMITS, createFrameDecoder, parseAdapterMessage } from '@termwright/protocol';
54
+
50
55
  const decoder = createFrameDecoder(DEFAULT_LIMITS.maxFrameBytes);
51
56
 
52
57
  socket.on('data', (chunk: Uint8Array) => {
53
58
  for (const frame of decoder.push(chunk)) {
54
59
  const result = parseAdapterMessage(frame, DEFAULT_LIMITS);
55
60
  if (!result.ok) {
56
- // 'bad-version' | 'malformed' | 'limit-exceeded' — close the session.
57
- return closeWith(result.code, result.detail);
61
+ closeWith(result.code, result.detail);
62
+ return;
58
63
  }
64
+
59
65
  if (result.message.type === 'snapshot') {
60
- // Already validated and deep-frozen; safe to retain.
61
- publish(result.message.snapshot);
66
+ retain(result.message.snapshot); // validated and immutable
62
67
  }
63
68
  }
64
69
  });
70
+ ```
65
71
 
66
- // Adapter side: announce a committed render on stdout, then push the tree.
67
- process.stdout.write(encodeMarker(token, sessionId, revision));
68
- socket.write(encodeFrame({ type: 'revision-commit', revision }, DEFAULT_LIMITS.maxFrameBytes));
72
+ All decoded values pass through `projectDto`. Projection rejects getters,
73
+ proxies, symbol keys, exotic prototypes, reserved keys, sparse arrays, aliases,
74
+ cycles, non-finite numbers, and unpaired surrogates. It returns a deep-frozen
75
+ plain copy that shares no references with the input.
69
76
 
70
- // VT layer: verify an OSC payload before trusting it (null on any mismatch).
71
- const marker = verifyMarkerPayload(payload, token, sessionId);
72
- ```
77
+ ## Handshake
73
78
 
74
- ## Surface
75
-
76
- | Module | Provides |
77
- |---|---|
78
- | `env` | Env var names, `PROTOCOL_VERSION`, `PROTOCOL_ID` |
79
- | `roles` | Closed `SEMANTIC_ROLES` / `SEMANTIC_ACTIONS` sets |
80
- | `limits` | `DEFAULT_LIMITS`, `ABSOLUTE_LIMITS`, `ProtocolLimits` |
81
- | `tree` | `SemanticSnapshot`, `SemanticNode`, `Rect`, portable `SemanticState`, application `SemanticExtendedState` |
82
- | `node-keys` | closed semantic-node key set shared by cross-language validators |
83
- | `probe` | Probe IR, `ProbeInfo`, identity/capability/provenance vocabularies, bounds resolution and validation |
84
- | `logs` | bounded structured application-log records and validation |
85
- | `messages` | Message interfaces plus `parseAdapterMessage` / `parseDriverMessage` |
86
- | `framing` | `createFrameDecoder`, `encodeFrame`, `projectDto` |
87
- | `marker` | `encodeMarker`, `verifyMarkerPayload` |
88
- | `validate` | `validateSnapshot` |
89
- | `delta` | `TreeDelta`, `validateTreeDelta`, `applyTreeDelta` |
90
- | `accesskit` | `toAccessKitTreeUpdate`, `accessKitNodeId`, role table |
91
- | `errors` | `ProtocolViolation`, `ProtocolViolationCode` |
92
-
93
- `SemanticNode.state` is a closed, cross-framework vocabulary. Application
94
- facts that are meaningful but not portable belong under `SemanticNode.extended`
95
- as bounded JSON data; keeping the namespaces separate prevents a framework or
96
- annotation from silently inventing a portable state flag.
97
-
98
- ## Integrating the marker with a VT parser
99
-
100
- `encodeMarker` emits a private OSC sequence terminated by BEL:
79
+ The adapter sends `hello` first and exactly once:
101
80
 
102
- ```
103
- ESC ] 8487 ; twm;{rev};{mac} BEL
81
+ ```ts
82
+ {
83
+ type: 'hello',
84
+ protocol: 'termwright/2',
85
+ token,
86
+ adapter: {name: 'my-probe', version: '1.0.0'},
87
+ capabilities: ['tree', 'states', 'actions', 'render-revisions'],
88
+ probe: {
89
+ framework: 'my-framework',
90
+ probeVersion: '1.0.0',
91
+ identityKind: 'stable',
92
+ capabilities: ['visible-rect'],
93
+ },
94
+ }
104
95
  ```
105
96
 
106
- A VT parser hands an OSC handler everything after the number and its
107
- separator, which is exactly what `verifyMarkerPayload` takes verified against
108
- `@xterm/headless`:
97
+ `probe` is present for a framework probe and omitted for a hand-written
98
+ adapter. Adapter capabilities describe optional wire traffic or guarantees.
99
+ Probe metadata describes the framework facts that were actually audited.
109
100
 
110
- ```ts
111
- import { MARKER_OSC_CODE, verifyMarkerPayload } from '@termwright/protocol';
101
+ The driver replies with `hello-ack` containing the same protocol id, a session
102
+ id, active limits, snapshot subscription, marker configuration, and an optional
103
+ log budget.
112
104
 
113
- term.parser.registerOscHandler(MARKER_OSC_CODE, (data) => {
114
- const marker = verifyMarkerPayload(data, token, sessionId);
115
- if (marker !== null) commit(marker.revision);
116
- return true; // consumed: keeps the sequence out of the visible grid
117
- });
118
- ```
105
+ Unknown protocol ids are reported as `bad-version`. A malformed hello is never
106
+ partially accepted.
119
107
 
120
- A trailing BEL or ST is tolerated, because a caller scanning raw output with a
121
- regex keeps the terminator that a parser would have consumed.
122
-
123
- ### Why OSC 8487
124
-
125
- **Why OSC and not DCS.** ConPTY rewrites the stream it forwards. A passthrough
126
- probe run in CI across the three platforms showed it dropping DCS, APC and
127
- OSC 8, while passing private OSC with either terminator, and OSC 133. A DCS
128
- marker could not reach the driver on Windows at all.
129
-
130
- One encoding is used everywhere rather than negotiated per platform: two paths
131
- double the surface that has to stay correct, and the path used least is the one
132
- that rots unnoticed. BEL is emitted rather than ST because it is the terminator
133
- ConPTY was observed to forward most reliably.
134
-
135
- **Why this number.** OSC numbers have no registry, only convention, so 8487 is
136
- chosen to sit clear of everything in use — xterm's allocations (0–14, 46, 50,
137
- 52, 104, 110–119), OSC 8 hyperlinks, 9 and 1337 (iTerm2), 99 and 30001 (kitty),
138
- 133 (FinalTerm shell integration), 633 (VS Code), 697 (ConEmu), 777–779
139
- (urxvt/VTE). It is the ASCII codes of `T` and `W`, for termwright.
140
-
141
- The `twm;` tag after the number is kept as a self-identifying guard: if anything
142
- ever does claim 8487, a marker still says what it is rather than being mistaken
143
- for that feature's payload.
144
-
145
- The token is likewise **opaque**: whatever lands in `TERMWRIGHT_TOKEN` is what
146
- both sides pass to the HMAC as the key. Never decode it to bytes first. Use
147
- `generateToken()` so every client mints it the same way.
148
-
149
- ## Guarantees worth knowing
150
-
151
- - **Log record ordering.** `LogRecord.seq` is **strictly increasing** within a
152
- session. A gap upward means records were dropped at the source (rate limit,
153
- queue overflow) and is expected under load; a duplicate or a decrease means
154
- the sender is broken, and the receiver rejects that record with a diagnostic.
155
- Keeping those two distinguishable is the whole point of the counter. This is
156
- a rule between records, so `validateLogRecord` cannot check it — it validates
157
- one record's shape, and the driver, which is the only party that sees the
158
- whole session, enforces the ordering.
159
-
160
- - **Framing.** 4-byte big-endian length prefix + UTF-8 JSON. The declared
161
- length is checked against the ceiling *before* any body is read, so a
162
- four-byte header claiming 4 GB costs four bytes. Partial frames are buffered
163
- (never emitted); a violation poisons the decoder permanently rather than
164
- resynchronising on an attacker-chosen offset.
165
- - **Projection.** Every decoded value passes through `projectDto`, which walks
166
- the graph with `Object.getOwnPropertyDescriptor` and rejects accessors,
167
- proxies, symbol keys, exotic prototypes, reserved keys (`__proto__`), sparse
168
- arrays, aliases and cycles, non-finite numbers, and unpaired surrogates. A
169
- getter on hostile input is **detected without being invoked**. The result is
170
- a deep-frozen plain copy sharing no references with the input.
171
- - **Marker.** `\x1bPtwm;{revision};{mac}\x1b\\`, where the MAC is
172
- base64url(HMAC-SHA256(token, `${sessionId}:${revision}`)) truncated to 16
173
- bytes. Comparison is constant-time, revisions must be canonical decimal (`01`
174
- is not `1`), and the MAC binds session and revision so it cannot be replayed
175
- across either. `verifyMarkerPayload` is total: hostile input yields `null`.
176
- - **Validation.** `validateSnapshot` enforces the §8.2 invariants: unique ids,
177
- parents that exist, acyclic parent chains, depth/count/byte ceilings,
178
- UTF-8 byte bounds on strings, safe-integer rects that intersect the viewport
179
- unless `state.hidden`, a positive revision, and a closed role/action set.
180
- Unknown properties are rejected, not ignored. Checks run cheapest-first, so
181
- a snapshot over the byte ceiling is rejected before any per-node work.
182
-
183
- `bounds` is optional per node, and a snapshot carrying **no bounds at all** is
184
- valid. Class-B/C frameworks publish role+name nodes without trustworthy
185
- coordinates, and even a class-A adapter drops bounds wholesale when it cannot
186
- observe its own offset (Ink does this when the tree contains `<Static>`).
187
- Consumers must treat a bounds-free snapshot as a normal state, not a fault, and
188
- fall back to their non-geometric path.
189
-
190
- Two invariants are stricter than the prose spec strictly requires, and are
191
- called out here because adapters must satisfy them: every node without a
192
- `parentId` must appear in `rootIds`, and `labelledBy`/`describedBy` must
193
- reference nodes present in the same snapshot.
194
-
195
- ## Tree deltas
196
-
197
- With `subscribe: 'diffs'` an adapter sends `tree-delta` instead of a full
198
- snapshot after each commit. A delta is bound to an **exact** base revision:
108
+ ## Full snapshot publication
199
109
 
200
- ```ts
201
- import { applyTreeDelta, validateTreeDelta } from '@termwright/protocol';
110
+ After a framework completes a render, the producer publishes in this order:
111
+
112
+ 1. `snapshot` containing the complete semantic state for revision N;
113
+ 2. `revision-commit` for N;
114
+ 3. the authenticated OSC marker after the terminal bytes for N are flushed.
202
115
 
203
- const checked = validateTreeDelta(body, limits); // shape only
204
- if (!checked.ok) return closeWith('malformed', checked.detail);
116
+ If the driver subscribes to `revisions`, the producer sends the commit without
117
+ the tree. Otherwise every semantic revision carries a complete snapshot.
205
118
 
206
- const composed = applyTreeDelta(held, checked.delta, limits);
207
- if (!composed.ok) {
208
- // Never patch around a mismatch — ask for the whole tree instead.
209
- if (composed.code === 'revision') return requestFullTree();
210
- return closeWith('malformed', composed.detail);
119
+ A full snapshot includes the session id, revision, viewport, optional cursor,
120
+ roots, all nodes, coordinate-space observation, and hit-grid observation. Each
121
+ node contains required geometry observations:
122
+
123
+ ```ts
124
+ interface NodeGeometryObservations {
125
+ displayed: Observation<boolean>;
126
+ intendedRect: Observation<Rect>;
127
+ visibleRect: Observation<Rect>;
211
128
  }
212
129
  ```
213
130
 
214
- **Composition semantics** (normative — every adapter and client must agree):
215
-
216
- - `changed` upserts by id. A node already present is **replaced wholesale**,
217
- never field-merged: merging would need a third state meaning "unset this
218
- optional field", which the wire cannot express.
219
- - `removed` removes each id **together with its subtree**. Cascade is what
220
- keeps deltas small — dropping a dialog is one id, not one per descendant —
221
- and it is the only rule that cannot leave orphans behind.
222
- - `rootIds`, when present, replaces the root list. When absent the base roots
223
- carry over minus anything removed, so **introducing a new root requires
224
- sending `rootIds`**; otherwise the parentless node is missing from the root
225
- list and validation rejects it.
226
- - Removals are applied **before** upserts, so one delta can rescue a node out
227
- of a subtree it also removes.
228
- - **Retraction is wholesale replacement.** A recognizer that loses confidence
229
- in a fact sends the full node *without* that field; there is no separate
230
- "unset" operation, and none is needed, because a replacement node's silence
231
- about a field is already the signal. Partial node patches would buy back the
232
- bytes but reintroduce the third state ("leave this alone") that wholesale
233
- replacement exists to avoid, so they stay a future option contingent on
234
- measured `px` cost.
235
- - **A producer that dropped facts sends a full snapshot, not a delta.** Under
236
- backpressure a probe may sample, coalesce or discard; a delta built on top of
237
- facts it never saw describes a tree that never existed. `get-tree` resync is
238
- the same mechanism with a new trigger, and it is the producer's obligation:
239
- the receiver cannot detect the difference, because a delta missing a change
240
- is indistinguishable from a delta whose producer had nothing to say.
241
- - `cursor`, when present, replaces the cursor; absent means **unchanged**.
242
- Without it a diffs-only session could never move the cursor, which in a TUI
243
- moves on nearly every keystroke — the mode would be useless for exactly the
244
- interactive applications it exists to make cheap.
245
-
246
- A delta can set the cursor but **cannot clear it**, and those differ:
247
- `{ visible: false }` means there is a cursor and it is hidden, while an
248
- absent `cursor` on a snapshot means there is no cursor information at all.
249
- So a producer whose tree loses its cursor entirely **must send a full
250
- snapshot**, exactly as it must for a resize. Emitting a delta there leaves
251
- the receiver holding a cursor the application stopped reporting — stale
252
- state that looks live.
253
-
254
- **The validation split matters.** `validateTreeDelta` checks only what is
255
- knowable without the base: bounded sizes, well-formed nodes, unique ids, a
256
- revision that moves forward. Parent existence, acyclicity, depth and whether
257
- bounds intersect the viewport are properties of the *composed* tree — a delta
258
- carries no viewport at all — so `applyTreeDelta` checks them by running the
259
- result through `validateSnapshot`. A delta is never trusted to produce a valid
260
- tree, only to describe one.
261
-
262
- **Resynchronisation.** A base-revision mismatch, or a removal of a node the
263
- receiver does not hold, means the producer's view and ours have diverged. Both
264
- return a failure telling the caller to request a full snapshot via `get-tree`.
265
- A speculative patch would produce a tree that looks fine and is wrong, and
266
- every assertion downstream would inherit that error silently.
267
-
268
- A delta cannot change the viewport or the session id; those are inherited from
269
- the base snapshot, and changing them requires a full one.
270
-
271
- ## AccessKit export (bridge-ready)
272
-
273
- `toAccessKitTreeUpdate` converts a `SemanticSnapshot` into an AccessKit
274
- `TreeUpdate` in its serde JSON shape. It is a pure transformation — this
275
- package takes no dependency on AccessKit — so the output is data a bridge can
276
- hand to a real adapter.
131
+ ## Observations
277
132
 
278
- ```ts
279
- import { toAccessKitTreeUpdate } from '@termwright/protocol';
133
+ Physical facts use `Observation<T>` so missing evidence cannot become a false
134
+ boolean or guessed rectangle:
280
135
 
281
- const { update, cellBounds } = toAccessKitTreeUpdate(snapshot, {
282
- toolkitName: 'ink',
283
- toolkitVersion: '7.1.1',
284
- });
285
- ```
136
+ | Status | Meaning |
137
+ | ------------- | ---------------------------------------------------------------------------- |
138
+ | `known` | The value is present with structurally validated provenance |
139
+ | `absent` | Authoritative provenance proves the fact does not exist in this state |
140
+ | `unknown` | A revision pair, provider refresh or stale revision is temporarily unsettled |
141
+ | `unsupported` | The frozen session contract does not provide the capability |
286
142
 
287
- ### Why there is no native bridge in 1.0
288
-
289
- AccessKit's platform adapters attach a tree to a **native window**: an `NSView`
290
- on macOS, an `HWND` on Windows, a toplevel on AT-SPI. A terminal application
291
- has none of those. The emulator owns the window; the application under test is
292
- a child process writing bytes to a pseudo-terminal. There is nothing for an
293
- adapter to attach to, and no path for an assistive technology to route a
294
- request back to us.
295
-
296
- The geometry gap is the same problem from the other side. Our `bounds` are
297
- **terminal cells** — row 3, column 12 — while AccessKit's `Rect` is in pixels
298
- relative to the window origin. Converting needs the cell size and window
299
- position, which live in the emulator, not in the process being tested. Guessing
300
- a cell size would produce coordinates that look authoritative and point nowhere,
301
- which is worse than having none.
302
-
303
- So this is the half of the problem that can be solved correctly without a
304
- window. `bounds` is emitted **only** when the caller passes `cellSize`, which
305
- an embedder that owns the window (a GUI emulator embedding termwright) can do
306
- honestly. Otherwise cell rects are returned separately as `cellBounds`, because
307
- AccessKit's `Node` has no extension point for foreign coordinate systems and
308
- smuggling cells into a pixel field would silently corrupt every consumer.
309
-
310
- ### Mapping notes
311
-
312
- - **Focus is tree-level.** AccessKit puts `focus` on the `TreeUpdate`, not on a
313
- node, so the node carrying `state.focused` becomes the update's focus.
314
- - **Children are explicit.** Our tree is flat and joined by `parentId`;
315
- AccessKit nodes carry a `children` array, derived here in snapshot order.
316
- - **Ids are hashed.** AccessKit's `NodeId` is a `u64`, but JSON numbers are
317
- doubles, so `accessKitNodeId` takes SHA-256 of the id truncated to **53
318
- bits** — every id stays exactly representable, and a collision (about 1.4e-9
319
- at the 5 000-node ceiling) throws rather than merging two nodes.
320
- - **`select` is dropped.** AccessKit has no selection action; mapping it onto
321
- `click` would claim a behaviour the adapter never described. `toggle` does
322
- map to `click`, which is how AccessKit expresses toggling.
323
- - A multiline `textbox` becomes `multilineTextInput`.
324
-
325
- ### Schema provenance
326
-
327
- Verified against `accesskit` 0.24.1 (docs.rs, August 2026):
328
- `TreeUpdate { nodes, tree, tree_id, focus }`, `Tree { root, toolkit_name,
329
- toolkit_version }`, `NodeId(u64)`, `Rect { x0, y0, x1, y1 }`, `TreeId(Uuid)`
330
- with the nil UUID reserved for the root tree, and
331
- `#[serde(rename_all = "camelCase")]` on `Role`, `Action` and `Node`.
332
-
333
- One spelling could not be confirmed from the published docs: the serde
334
- representation of the `Toggled` enum. This export emits `"true" | "false" |
335
- "mixed"` for consistency with the crate's other public enums. Anyone building a
336
- real bridge should check that against the adapter they link, and it is a
337
- one-line change if it turns out to be `"True" | "False" | "Mixed"`.
338
-
339
- ## Adapter semantics conventions
340
-
341
- The vocabulary is already shared: roles, states and actions are closed sets the
342
- protocol enforces. The **conventions** were not. Where a name comes from, what
343
- falls back to what, whether an empty value is published — each adapter decided
344
- for itself, so two conformant adapters could describe the same UI differently
345
- and a test written against one would fail against another for no reason its
346
- author could see.
347
-
348
- This section is normative for every adapter in every language. An adapter that
349
- cannot follow a rule because its framework does not expose the data must say so
350
- in its own README under a `## Deviations` heading (rule 6) — silence is not an
351
- option, because a silent difference is exactly what costs a test author an
352
- afternoon.
353
-
354
- ### 1. Role — three levels, in order
355
-
356
- 1. explicit author annotation;
357
- 2. the framework's widget-type map;
358
- 3. `generic`.
359
-
360
- Stop at the first that produces a role in `SEMANTIC_ROLES`. An adapter may
361
- resolve level 2 from whatever its framework offers (a class map, an
362
- accessibility property, a convention prop), and may consult more than one
363
- source there, but it must not invent a fourth *precedence* level above the
364
- author's annotation: an explicit annotation always wins.
365
-
366
- ### 2. Name — ordered sources
367
-
368
- 1. explicit author annotation (including a deliberate empty string);
369
- 2. the widget's own label, title or placeholder property;
370
- 3. **for name-from-content roles only**: the concatenated text of descendants;
371
- 4. the widget's identifier.
372
-
373
- Step 3 is the one that has diverged most, so it is spelled out. The
374
- name-from-content roles are exactly:
375
-
376
- `button`, `listitem`, `menuitem`, `tab`, `checkbox`, `radio`, `cell`, `row`,
377
- `heading`
378
-
379
- **Containers are never named from their content.** A `region`, `dialog`,
380
- `list`, `table` or `application` with no label of its own has an empty
381
- name — it does not inherit the text of everything inside it. Naming containers
382
- from content is what makes `getByRole('region', { name: 'Approve' })` match the
383
- dialog *containing* the Approve button, so every ancestor of a label becomes a
384
- plausible match for it and locators stop being selective.
385
-
386
- Descendant text is collapsed on whitespace and bounded by
387
- `limits.maxStringBytes`.
388
-
389
- ### 3. testId — native identifier and annotation, both
390
-
391
- An adapter must accept **both**:
392
-
393
- - the framework's native identifier where one exists (a Textual DOM `id`, an
394
- OpenTUI `id`), and
395
- - an explicit author annotation, which wins over the native one.
396
-
397
- Framework-generated identifiers that are not author-chosen (OpenTUI's
398
- `renderable-<n>`) must be filtered out: a test id that changes when an unrelated
399
- widget is added is worse than none, because it fails only later and looks
400
- flaky rather than wrong.
401
-
402
- ### 4. States — mapped, never guessed
403
-
404
- `disabled`, `focused`, `selected`, `checked`, `expanded`, `modal`, `hidden`,
405
- `readonly` are published **only** when read from a native framework flag or
406
- supplied by the author. An adapter must not infer a state from appearance,
407
- position or role.
408
-
409
- Omitting a state means "this framework does not report it", which a test can
410
- handle. Guessing means the tree asserts something the application never said,
411
- and a passing test then proves nothing.
412
-
413
- An adapter that drops hidden nodes from the tree entirely (rather than
414
- publishing them with `hidden: true`) must say so under `## Deviations`; both are
415
- defensible, but they are not the same tree.
416
-
417
- ### 5. `value` versus `name`
418
-
419
- `value` carries what the widget *contains*; `name` carries what it is *called*.
420
- Publish `value` whenever the widget has one, **including the empty string** — an
421
- empty textbox has `value: ''`, not an absent value.
422
-
423
- The distinction is load-bearing: `''` means "the field is empty" and absent
424
- means "this is not a value-bearing widget". Collapsing them makes
425
- `toHaveValue('')` unassertable, and a wire format that drops empty strings
426
- (Go's `omitempty` and friends) silently converts the first into the second.
427
-
428
- **Which roles derive a value.** Automatic derivation is gated to
429
- `textbox` and `progressbar`. An explicit author annotation bypasses the gate on
430
- any role — the author knows something the widget map does not — but an adapter
431
- must not go looking for a `.value` property on roles outside the set.
432
-
433
- `scrollbar` is deliberately excluded: its position is `state.scrollOffset` and
434
- `state.scrollExtent`, which are numbers with defined meaning, whereas a
435
- stringified scroll position in `value` would be a second encoding of the same
436
- fact that no matcher knows how to read.
437
-
438
- **A boolean is never a value.** A widget whose `.value` is `true`/`false` is
439
- reporting a *state*, not contents: it maps to `state.checked`, and `value` stays
440
- absent. This is a real divergence found while converging two adapters, not a
441
- hypothetical — publishing `value: "true"` makes a checkbox look like a textbox
442
- containing the word "true" to every role-blind matcher.
443
-
444
- ### 6. Deviations must be declared
445
-
446
- Per-adapter differences are permitted **only** where the framework does not
447
- expose the data, and each one must be listed in that adapter's README under a
448
- `## Deviations` heading, saying what the rule is, what the adapter does
449
- instead, and why the framework forces it.
450
-
451
- An undeclared deviation is a bug, not a difference.
452
-
453
- Rules 1–5 bind whatever publishes a semantic tree, so a package that publishes
454
- none — the Rust crate is the protocol plus a logs bridge — has nothing to
455
- declare and needs no such heading. The requirement follows the adapter, not the
456
- package.
457
-
458
- Entry formatting is deliberately unconstrained: adapters use prose, bullets and
459
- a table, and conformance parses all three. The rule governs adapters, not
460
- markdown, and making authors rewrite prose to suit a parser would be the tail
461
- wagging the dog.
462
-
463
- ### Bounds are visible geometry, and occlusion is a separate fact
464
-
465
- The IR keeps `intendedRect` (where an object asked to draw) and `visibleRect`
466
- (what survived the clip) apart, because they are different facts. A
467
- `SemanticNode` publishes **one** rectangle, so a normalizer collapses them, and
468
- the collapse is guaranteed rather than incidental:
469
-
470
- `bounds` is always the best known **visible** geometry — the framework's own
471
- clip intersection where it computes one, `intendedRect ∩ clip` where a clip is
472
- known, and the intended rectangle only as a last resort. A consumer never has
473
- to ask which of the two it is holding.
474
-
475
- Publishing both rectangles was the alternative and was rejected: it moves "which
476
- of these did you mean" onto every consumer of the tree instead of answering it
477
- once. `resolveNodeBounds` implements the rule, so the five client
478
- implementations share one collapse rather than five.
479
-
480
- What `bounds` cannot say is whether something was painted on top of it. Legacy
481
- v1 `occlusion: 'known'` says only that the probe reported paint order — it does
482
- not name the topmost input recipient. **Absent means `'unknown'`**.
483
-
484
- A consumer performing pointer actions must require a qualified hit test naming
485
- the recipient, rather than click and hope. The input lands somewhere real, and
486
- if it lands on another widget the result is attributed to the intended target
487
- — a silent false green, which is worse than a refusal. Paint order alone never
488
- lifts that requirement.
489
-
490
- ### Scrolled away is not the same as never displayed
491
-
492
- `state.offscreen` says the node exists in the layout but every one of its cells
493
- falls outside the visible area — it is scrolled out, and scrolling can bring it
494
- back. It is named for the claim a test author makes, not for the mechanism:
495
- clipping is *how* it happens, off screen is *what it means*.
496
-
497
- Three states that used to collapse into two:
498
-
499
- | Situation | `bounds` | `state` |
500
- |---|---|---|
501
- | Visible | the visible rectangle | — |
502
- | Scrolled out of view | zero-area rectangle at its anchor | `hidden: true, offscreen: true` |
503
- | Not displayed | zero-area rectangle, or absent if unknown | `hidden: true` |
504
- | Producer does not know the geometry | absent | — |
505
-
506
- The last row is why the field exists. Without it an adapter had to choose
507
- between "no geometry" and "scrolled away", so `bounds: undefined` carried both
508
- meanings and a consumer reading a tree generically could not tell them apart.
509
- `offscreen` gives the scrolled case its own word and returns absent bounds to
510
- its single meaning.
511
-
512
- `offscreen: true` implies `hidden: true` and validation refuses the pair
513
- without it — every cell outside the visible area and the node still visible
514
- cannot both be true. **Absent means "not claiming"**, not "on screen": a
515
- producer that cannot observe clipping omits the field, which is why this is a
516
- positive assertion rather than another tri-state.
517
-
518
- Textual is the worked example and the reason this is expressible at all: it
519
- computes `clip ∩ region`, so its probe reports a zero-area rectangle for a
520
- scrolled-out widget and no rectangle at all for `display=False`. Normalizers
521
- get there from `resolveNodeBounds`, whose `clippedAway` maps to exactly this
522
- pair.
523
-
524
- ### Merge precedence
525
-
526
- Facts about a node arrive from several sources at once, and the tree publishes
527
- one answer. The order is:
528
-
529
- **annotation > recognizer > framework mapping > render inference > heuristic**
530
-
531
- with one exception that matters more than the order itself: **physical facts
532
- are never casually overridden by an annotation.** Bounds, focus, visibility and
533
- cells describe what the terminal actually did. An author may name a widget, give
534
- it a role or a test id — those are claims about meaning. An author may not
535
- declare where something is on screen, because a test that trusts an annotated
536
- rectangle over a measured one stops testing the application and starts testing
537
- the annotation.
538
-
539
- Each node records where its facts came from in `p`, with per-field exceptions in
540
- `px`, drawn from a closed set: `annotation`, `recognizer`, `framework`,
541
- `correlation`, `heuristic`. One source per node covers the overwhelming
542
- majority; the exception map means a mixed node pays only for the fields that
543
- actually differ.
544
-
545
- Provenance is not decoration. A fact with a weak source is not the same as an
546
- absent fact, and neither is the same as a fact known to be false — three states
547
- that collapse into one the moment a tree stops saying where its facts came
548
- from.
549
-
550
- ### Where the current differences live
551
-
552
- This section carried a snapshot of per-adapter gaps when the rules were first
553
- written down. Every entry in it has since been fixed or declared, so the
554
- snapshot is deleted rather than left to rot: a stale list in a normative
555
- document is worse than no list, because it is read as current. That applies to
556
- counts and claims here too — this paragraph deliberately names no totals.
557
-
558
- The live source of truth is the compatibility registry plus each
559
- tree-publishing probe/client's limitations. `@termwright/ink` and
560
- `@termwright/opentui` contribute optional author intent but do not publish a
561
- tree themselves. Keeping one generated registry prevents two copies of a fact
562
- from drifting.
143
+ `intendedRect` and `visibleRect` are different facts. A producer must not copy
144
+ the intended rectangle into the visible field when clipping is unavailable.
145
+ Permanent unobservability is `unsupported`, never `unknown`; committed
146
+ guaranteed observations may only settle as `known` or `absent`.
563
147
 
564
- ## Protocol evolution
148
+ The snapshot-level coordinate space qualifies every known rectangle. Geometry
149
+ in framework-local cells is inspectable but cannot be used as terminal-input
150
+ coordinates.
565
151
 
566
- The protocol grows without a version bump only in ways an already published
567
- client can survive. Anything else is a breaking change.
568
-
569
- ### Direction decides strictness
570
-
571
- The two directions are read differently, and the difference is about **who is
572
- speaking**, not about the message:
573
-
574
- | Direction | Reader | Unknown fields |
575
- |---|---|---|
576
- | adapter → driver (`parseAdapterMessage`) | strict | rejected as `malformed` |
577
- | driver → adapter (`parseDriverMessage`) | tolerant | ignored, and passed through |
578
-
579
- Adapter traffic crosses the hostile-input boundary: it comes from a process
580
- under test that may be broken or malicious, so an unknown field is a signal,
581
- not an extension. The driver is the trusted party and behaviour there is
582
- governed by negotiated capabilities, so a newer driver may add an optional
583
- field without invalidating every adapter already published.
584
-
585
- Tolerant does not mean lax. Known fields stay strictly type-checked, closed
586
- sets stay closed, and unknown fields are *carried through* rather than
587
- stripped, so a reader that does understand them still can.
588
-
589
- **Additive — readers must tolerate these:**
590
-
591
- - **New fields on any driver → adapter message**, including nested objects
592
- (`marker`, `logs`). `hello-ack.logs` is the worked example: **absent means
593
- the feature is off**, so an older driver that never sends it keeps working,
594
- and an adapter must not use a feature it was not explicitly granted.
595
- - **New keys in `limits`.** Lenient in *both* directions — capacity is
596
- negotiated, so a driver learning a new ceiling must not invalidate adapters
597
- in the wild.
598
- - **New capability strings.** The driver filters the adapter's advertised
599
- capabilities down to the ones it knows, so an adapter may advertise a
600
- capability a given driver has never heard of.
601
- - **A new closed-set value that is gated behind a capability.**
602
- `subscribe: 'diffs'` is the worked example. Growing a closed set is normally
603
- breaking, and it still would be here — except the driver only ever selects
604
- `diffs` for an adapter that announced `tree-diffs` first. An adapter that has
605
- never heard of the value cannot be sent it, so the gate, not the set, is what
606
- makes this safe. Extending a closed set **without** such a gate stays
607
- breaking.
608
-
609
- **Breaking — needs a coordinated release:**
610
-
611
- - A new or renamed **required** field on any message.
612
- - A new member of a **closed set** a reader must accept: message `type`,
613
- `error.code`, roles, actions, log levels, `subscribe`. These stay strict in
614
- both directions, precisely so unknown values fail loudly instead of
615
- acquiring behaviour by accident.
616
- - Any new field on an **adapter → driver** message. That direction is strict,
617
- so adding one breaks every driver that has not been updated.
618
- - Changing the meaning, units or clock of an existing field.
619
- - **Changing an encoding.** The render marker moved from a private DCS
620
- sequence to `OSC 8487 … BEL` because ConPTY drops DCS, so the old encoding
621
- could not work on Windows at all. Every producer and every receiver had to
622
- change together; `MARKER_DCS_PREFIX`/`MARKER_DCS_FINAL` were replaced by
623
- `MARKER_OSC_CODE`/`MARKER_OSC_PREFIX` with **no aliases**, because an alias
624
- would have left two encodings alive and the second one untested. This was
625
- done pre-publication, as a single generation of producers — the only point at
626
- which a change of this shape is cheap. Tightening
627
- `LogRecord.seq` from non-decreasing to strictly increasing is an example:
628
- nothing about the shape changed, but a sender that repeated a number was
629
- previously conforming and now is not.
630
-
631
- The asymmetry is deliberate: *capacity* is negotiated and therefore extensible,
632
- while *vocabulary* is closed and therefore fixed. When in doubt, ask whether a
633
- reader that ignores the new thing still behaves correctly. If yes it is
634
- additive; if it would silently do the wrong thing, it is breaking.
635
-
636
- Cross-language clients (`clients/`) assert against generated vectors in
637
- `clients/test-vectors/`. An additive change still requires regenerating those
638
- vectors, because they pin exact constants.
639
-
640
- ## Development
152
+ ## Pointer ownership
641
153
 
642
- ```sh
643
- pnpm --filter @termwright/protocol build # tsup, ESM + d.ts
644
- pnpm --filter @termwright/protocol typecheck # tsc --noEmit
645
- pnpm --filter @termwright/protocol test # vitest
646
- pnpm --filter @termwright/protocol test:hostile # same suites, 128 MB heap cap
154
+ A known `hitGrid` contains canonical, non-overlapping, row-major runs. Every
155
+ run has positive width, `height: 1`, and a `recipientId` referring to a node in
156
+ the same snapshot.
157
+
158
+ Only the framework's fresh-pointer routing result can establish ownership.
159
+ Paint order, z-index, overlap, or a visible rectangle do not prove which widget
160
+ will receive input. A framework that cannot provide an exact map publishes an
161
+ `unknown` or `unsupported` hit-grid observation.
162
+
163
+ ## Semantic conventions
164
+
165
+ The role vocabulary is closed and ARIA-aligned. An explicit application
166
+ annotation takes precedence over a framework widget mapping; an unrecognised
167
+ widget uses `generic` and must include its native `frameworkType`.
168
+
169
+ Names and values remain separate. `value: ''` is a known empty value; omitting
170
+ `value` means the node does not expose one. Application-specific JSON belongs
171
+ under `extended`, not in the portable state namespace.
172
+
173
+ `p` records a node's primary provenance and `px` records exceptions. The
174
+ provenance vocabulary is `annotation`, `recognizer`, `framework`,
175
+ `correlation`, or `heuristic`.
176
+
177
+ Annotations may supply semantic intent such as role, name, relationships,
178
+ actions, stable identity, and domain state. They must not override measured
179
+ focus, rendered text, geometry, clipping, or pointer routing.
180
+
181
+ An unrecognised framework node sets `opaqueChildren: true` when the probe
182
+ cannot prove that its child enumeration is complete. This is a typed,
183
+ framework-provenance degradation boundary; it must not be hidden in
184
+ application-specific `extended` data.
185
+
186
+ ## Snapshot validation
187
+
188
+ `validateSnapshot` checks:
189
+
190
+ - literal snapshot version `2`;
191
+ - encoded byte size before per-node work;
192
+ - positive revision and valid viewport/cursor coordinates;
193
+ - bounded node count, tree depth, strings, relationships, and extended JSON;
194
+ - unique ids, existing parents, acyclic ancestry, and complete `rootIds`;
195
+ - relationships targeting nodes in the same snapshot;
196
+ - required, well-formed observations;
197
+ - safe-integer rectangles and canonical hit-grid runs;
198
+ - closed role, action, state, observation, and provenance sets;
199
+ - rejection of unknown properties.
200
+
201
+ Validation returns `{ok: true, snapshot}` or a structured failure with a stable
202
+ code and detail. It does not retain a partially valid tree.
203
+
204
+ ## Framing
205
+
206
+ Messages use a four-byte big-endian length followed by UTF-8 JSON. The receiver
207
+ checks the declared size before reading the body. Partial frames are buffered
208
+ and never emitted. A framing violation permanently poisons that decoder.
209
+
210
+ `DEFAULT_LIMITS` defines normal ceilings. A session may tighten them through
211
+ `hello-ack`; it cannot widen `ABSOLUTE_LIMITS`.
212
+
213
+ ## Render marker
214
+
215
+ `encodeMarker` emits:
216
+
217
+ ```text
218
+ ESC ] 8487 ; twm;{revision};{mac} BEL
647
219
  ```
648
220
 
649
- `test:hostile` pins the worker's old-space to 128 MB via `execArgv`, so
650
- resource-exhaustion cases fail closed instead of passing by virtue of a large
651
- default heap.
221
+ The MAC is base64url(HMAC-SHA256(token,
222
+ `${sessionId}:${revision}`)), truncated to 16 bytes. Comparison is
223
+ constant-time and revisions use canonical decimal text.
224
+
225
+ Register OSC code `8487` with the VT parser and pass the payload after the OSC
226
+ number and separator to `verifyMarkerPayload`. A trailing BEL or ST is
227
+ tolerated because raw-stream scanners may retain the terminator.
228
+
229
+ ## Structured logs
230
+
231
+ An adapter announcing `logs` receives a source-side rate budget in
232
+ `hello-ack`. Without that budget it sends no records. `LogRecord.seq` increases
233
+ strictly within a session; an upward gap reports records dropped at the source,
234
+ while duplicates or decreases are protocol errors.
235
+
236
+ ## Directional strictness
237
+
238
+ Adapter-to-driver traffic is strict: unknown fields are rejected. It crosses
239
+ an untrusted process boundary, so an unexpected field is a protocol error.
240
+
241
+ Driver-to-adapter traffic tolerates unknown additive fields while still
242
+ validating known fields and closed sets. This lets a newer driver add optional
243
+ metadata without breaking an already published client.
244
+
245
+ ## Cross-language conformance
246
+
247
+ `clients/test-vectors/` contains reference-generated frame bytes, hostile
248
+ cases, marker sequences, observation cases, and valid and invalid v2
249
+ snapshots. The generator validates each expectation before writing it.
250
+
251
+ Framework integrations should also run `@termwright/conformance` against a
252
+ real subprocess. A claimed observation is accepted only when the framework
253
+ fixture demonstrates the same fact independently.
254
+
255
+ ## Protocol evolution
256
+
257
+ Additive changes are allowed only when an existing v2 reader can survive them.
258
+ Changing a required field, closed-set member without a gate, encoding, unit, or
259
+ observable meaning requires a coordinated protocol-major change.