@termwright/protocol 0.2.0 → 0.3.0
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.
- package/README.md +213 -605
- package/dist/action-model-BP9Znu6L.d.ts +219 -0
- package/dist/action-model.d.ts +3 -0
- package/dist/action-model.js +15 -0
- package/dist/action-model.js.map +1 -0
- package/dist/capability-graph.d.ts +90 -0
- package/dist/capability-graph.js +43 -0
- package/dist/capability-graph.js.map +1 -0
- package/dist/chunk-B4VUTTUE.js +59 -0
- package/dist/chunk-B4VUTTUE.js.map +1 -0
- package/dist/chunk-CZK6NNP3.js +389 -0
- package/dist/chunk-CZK6NNP3.js.map +1 -0
- package/dist/chunk-ODOJRXL6.js +84 -0
- package/dist/chunk-ODOJRXL6.js.map +1 -0
- package/dist/chunk-PUXRCPGY.js +112 -0
- package/dist/chunk-PUXRCPGY.js.map +1 -0
- package/dist/chunk-VBLS6E6U.js +1109 -0
- package/dist/chunk-VBLS6E6U.js.map +1 -0
- package/dist/chunk-ZZIYHDJ4.js +202 -0
- package/dist/chunk-ZZIYHDJ4.js.map +1 -0
- package/dist/contract-CH9gmj2Y.d.ts +746 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +21 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +82 -798
- package/dist/index.js +1087 -766
- package/dist/index.js.map +1 -1
- package/dist/run-events.d.ts +158 -0
- package/dist/run-events.js +27 -0
- package/dist/run-events.js.map +1 -0
- package/dist/run-journal.d.ts +55 -0
- package/dist/run-journal.js +10 -0
- package/dist/run-journal.js.map +1 -0
- package/dist/run-state.d.ts +38 -0
- package/dist/run-state.js +19 -0
- package/dist/run-state.js.map +1 -0
- package/dist/test-provider.d.ts +22 -0
- package/dist/test-provider.js +32 -0
- package/dist/test-provider.js.map +1 -0
- package/package.json +33 -5
package/README.md
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
1
|
# @termwright/protocol
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
return
|
|
61
|
+
closeWith(result.code, result.detail);
|
|
62
|
+
return;
|
|
58
63
|
}
|
|
64
|
+
|
|
59
65
|
if (result.message.type === 'snapshot') {
|
|
60
|
-
//
|
|
61
|
-
publish(result.message.snapshot);
|
|
66
|
+
retain(result.message.snapshot); // validated and immutable
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
});
|
|
70
|
+
```
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
const marker = verifyMarkerPayload(payload, token, sessionId);
|
|
72
|
-
```
|
|
77
|
+
## Handshake
|
|
73
78
|
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
133
|
+
Physical facts use `Observation<T>` so missing evidence cannot become a false
|
|
134
|
+
boolean or guessed rectangle:
|
|
280
135
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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.
|