@tapflowio/protocol 0.17.0 → 0.19.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/dist/index.d.ts CHANGED
@@ -19,16 +19,45 @@ export interface DeviceReport {
19
19
  }
20
20
  /** A device as the relay lists it, after adding what only the relay knows. Named `DeviceSummary`
21
21
  * rather than `DeviceInfo` because both packages already used that name for different shapes:
22
- * the relay for this, the dashboard for the `session:deviceInfo` payload (`DeviceDetails`). */
22
+ * the relay for this, and `DeviceDetails` is the `session:deviceInfo` payload. */
23
23
  export interface DeviceSummary extends DeviceReport {
24
24
  sessionId: string;
25
25
  busy: boolean;
26
26
  }
27
- /** The `session:deviceInfo` payload — what the viewer shows in its info card. */
27
+ /** The `session:deviceInfo` payload.
28
+ *
29
+ * **Nothing reads it.** Both agents send it and the relay caches and replays it on join, but no consumer
30
+ * in this repo takes the value — the dashboard shows device name and OS from `agents:listed`
31
+ * (`DeviceSummary`) instead. This comment used to claim "what the viewer shows in its info card", which
32
+ * was the kind of false statement about a consumer that this package exists to remove; L6 found it while
33
+ * classifying every browser-inbound message. Kept on the wire because third-party agents send it. */
28
34
  export interface DeviceDetails {
29
35
  deviceName: string;
30
36
  osVersion: string;
31
37
  }
38
+ /** Closed role vocabulary shared by all platforms. An unmappable native role becomes `'other'` and the
39
+ * platform-native string is preserved in `rawRole`, so a consumer can still branch on it without the
40
+ * vocabulary having to grow for every platform. */
41
+ export type UIElementRole = 'button' | 'text' | 'input' | 'image' | 'checkbox' | 'switch' | 'slider' | 'list' | 'cell' | 'tab' | 'other';
42
+ /** Normalized to 0–1 in the same coordinate space the touch input path consumes, so a frame centre can
43
+ * be fed straight into a tap without conversion. */
44
+ export interface UIElementFrame {
45
+ x: number;
46
+ y: number;
47
+ width: number;
48
+ height: number;
49
+ }
50
+ /** One node of `ui:tree:response`. Lived in `agent-core` until L4a needed it to type that message —
51
+ * protocol is a leaf and cannot import agent-core, and the alternative was a copy. There was already
52
+ * one: `mcp-server` carried a hand-written mirror, which is the drift this package exists to remove. */
53
+ export interface UIElement {
54
+ role: UIElementRole;
55
+ label: string;
56
+ identifier?: string;
57
+ frame: UIElementFrame;
58
+ enabled: boolean;
59
+ rawRole?: string;
60
+ }
32
61
  /** `agents:listed` groups devices by agent machine. */
33
62
  export interface SessionInfo {
34
63
  agentName?: string;
@@ -48,17 +77,19 @@ export interface ChromeButton {
48
77
  anchor: string;
49
78
  /** true = button is above the device frame (e.g. home button) */
50
79
  onTop: boolean;
51
- /** button center at retracted/default position, in 2× composite px */
80
+ /** button center in the **expanded composite** space at 2× px (retracted/default) */
52
81
  normalOffset: {
53
82
  x: number;
54
83
  y: number;
55
84
  };
56
- /** button center at extended/hover position, in 2× composite px */
85
+ /** button center at the rollover (extended/hover) position, same space */
57
86
  rolloverOffset: {
58
87
  x: number;
59
88
  y: number;
60
89
  };
90
+ /** button width in 2× composite px */
61
91
  buttonW: number;
92
+ /** button height in 2× composite px */
62
93
  buttonH: number;
63
94
  /** HID usage page for SimulatorKit injection (0 = unknown) */
64
95
  usagePage: number;
@@ -68,6 +99,7 @@ export interface ChromeButton {
68
99
  buttonPng?: string;
69
100
  /** base64 PNG of the pressed state (imageDown asset) */
70
101
  pressedPng?: string;
102
+ /** position + size in the **expanded composite** space at 2× px */
71
103
  pressedRect?: ChromeRect;
72
104
  }
73
105
  export interface AndroidButton {
@@ -75,25 +107,59 @@ export interface AndroidButton {
75
107
  accessibilityTitle: string;
76
108
  keyCode: number;
77
109
  }
78
- /** iOS device chrome — the bezel artwork and hit regions the viewer composites around the screen. */
110
+ /** Payload on every `clipboard:error` from a read. `sentinelParked` answers the one question the
111
+ * viewer needs to decide its fallback: is a marker still sitting on the device clipboard?
112
+ *
113
+ * If it is, the agent's restore is about to overwrite whatever the device copies next, so
114
+ * pressing the plain chord as a fallback would hand the user a stale value — the exact bug the
115
+ * sentinel exists to prevent. If it is not, the chord is safe and is the only way the copy
116
+ * happens at all. Absent means "assume parked": an agent from before this field cannot tell us,
117
+ * and the silent-stale-paste failure is worse than a copy that did not happen.
118
+ *
119
+ * `unsupported` is narrower — this backend has no clipboard channel whatsoever — and drives the
120
+ * paste fallback and the wording of the toast. */
121
+ export interface ClipboardErrorPayload {
122
+ unsupported?: boolean;
123
+ sentinelParked?: boolean;
124
+ }
125
+ /**
126
+ * iOS device chrome — the bezel artwork and hit regions the viewer composites around the screen.
127
+ *
128
+ * **Two coordinate spaces exist here and the field names do not distinguish them.** The composite
129
+ * PDF is the device frame; the *expanded* composite is that canvas grown by the button margins, and
130
+ * it is the space the viewer lays out against. `compositeWidth`/`Height`, `screenRect` and every
131
+ * `ChromeButton` offset are in the **expanded** space; `padding` is the device's own padding inside
132
+ * the un-expanded one. Getting this wrong puts buttons at an offset that looks almost right.
133
+ *
134
+ * These descriptions come from the producer (`ios-agent`'s `DeviceChromeLoader`, which computes
135
+ * `expandedW = pdfSize.width + buttonMargins`). They were previously accurate only in that file —
136
+ * this declaration said `compositeWidth` was "full PDF width including devicePadding", a different
137
+ * quantity — so they moved here with the type rather than being lost with it.
138
+ */
79
139
  export interface ChromeData {
80
- /** full composite PDF at 2× — device frame visible, screen hole transparent */
140
+ /** composite with buttons baked in, at 2× — screen hole transparent */
81
141
  framePng: string;
142
+ /** composite minus devicePadding, at 2× px */
82
143
  bezelWidth: number;
83
144
  bezelHeight: number;
84
- /** full PDF width including devicePadding, at 2× px */
145
+ /** **expanded** canvas width — composite + button margins — at 2× px */
85
146
  compositeWidth: number;
147
+ /** **expanded** canvas height, at 2× px */
86
148
  compositeHeight: number;
149
+ /** devicePadding at 2× px, inside the un-expanded composite */
87
150
  padding: {
88
151
  left: number;
89
152
  right: number;
90
153
  top: number;
91
154
  bottom: number;
92
155
  };
156
+ /** screen position in the **expanded** composite space, at 2× px */
93
157
  screenRect: ChromeRect;
94
158
  /** screen corner radius in 2× px (0 if the device has no rounded corners) */
95
159
  screenCornerRadius: number;
160
+ /** screen width in iOS logical pixels (pt) */
96
161
  logicalWidth: number;
162
+ /** screen height in iOS logical pixels (pt) */
97
163
  logicalHeight: number;
98
164
  buttons: ChromeButton[];
99
165
  }
@@ -109,120 +175,625 @@ export interface AndroidChrome {
109
175
  * contract still states the shape, because the viewer parses it. A new platform adds its variant
110
176
  * here; relay and dashboard code stay unchanged, which is what the OCP rule asks for. */
111
177
  export type ChromePayload = ChromeData | AndroidChrome;
112
- export type RelayToAgent = {
178
+ export interface AgentRegistered {
113
179
  type: 'agent:registered';
114
180
  registeredSessions: Array<{
115
181
  deviceId: string;
116
182
  sessionId: string;
117
183
  }>;
118
- } | {
184
+ }
185
+ export interface StreamRequestIdr {
119
186
  type: 'stream:request-idr';
120
187
  sessionId: string;
121
- } | {
188
+ }
189
+ export interface DeviceShutdown {
122
190
  type: 'device:shutdown';
123
191
  sessionId: string;
192
+ requestId?: string;
124
193
  payload: {
125
194
  deviceId: string;
126
195
  };
127
- } | {
196
+ }
197
+ export interface AppInstallToAgent {
128
198
  type: 'app:install';
129
199
  sessionId: string;
200
+ requestId: string;
130
201
  payload: {
131
202
  filePath: string;
132
203
  bundleId: string | null;
133
204
  };
134
- } | {
205
+ }
206
+ export interface AppLaunchToAgent {
135
207
  type: 'app:launch';
136
208
  sessionId: string;
209
+ requestId: string;
137
210
  payload: {
138
211
  bundleId: string;
139
212
  };
140
- } | {
213
+ }
214
+ export interface ScreenshotRequest {
141
215
  type: 'screenshot:request';
142
216
  sessionId: string;
143
217
  requestId: string;
218
+ /**
219
+ * A **preference, not a requirement.** An agent may produce something else, and says what it
220
+ * produced in `ScreenshotDone.format`.
221
+ *
222
+ * That asymmetry is not slack, it is the platform contract: `DeviceAgent.screenshot()` takes no
223
+ * format argument at all, so no agent has ever been asked to honour this — iOS happens to
224
+ * (`simctl io … --type`), Android cannot (`screencap -p` produces PNG and takes no format), and a
225
+ * third-party platform registered through `AgentRegistry.register()` is free to produce whatever
226
+ * it can. Required here only because every in-repo sender supplies one; absence would have to mean
227
+ * "no preference", which is what `'png'` already means.
228
+ *
229
+ * A consumer that needs to know what it is holding reads the **bytes**, not this field and not the
230
+ * reply's echo of it — #508 was this field being read as an outcome.
231
+ */
144
232
  format: 'png' | 'jpeg';
145
- } | {
233
+ }
234
+ export interface UiTreeRequest {
146
235
  type: 'ui:tree:request';
147
236
  sessionId: string;
148
237
  requestId: string;
149
- };
238
+ }
239
+ export type RelayToAgent = AgentRegistered | StreamRequestIdr | DeviceShutdown | AppInstallToAgent | AppLaunchToAgent | ScreenshotRequest | UiTreeRequest;
240
+ export type SessionTerminatedReason = 'agent-disconnected';
150
241
  /**
151
- * Why a session stopped existing, server-side. A literal union rather than a string so that adding
152
- * a case is a compile-time event for every consumer that switches on it.
242
+ * Why a terminal input was not delivered — the machine-readable half of `input:error`.
153
243
  *
154
- * Named `session:terminated`, not `session:ended`: `session:end` is already a message the *browser*
155
- * sends to ask for shutdown, and the two would sit one letter apart in the same switch. This one
156
- * travels the other way and is not an acknowledgement of that request.
244
+ * The set is derived from **what a consumer has to do differently**, not from how many internal
245
+ * states an agent has. Those differ per platform (one HID helper on iOS; a scrcpy socket, an
246
+ * emulator gRPC channel and `adb shell input` on Android) and each agent maps its own states onto
247
+ * these. A closed set that is smaller than either agent's internals is the point.
248
+ *
249
+ * | reason | consumer should |
250
+ * |---|---|
251
+ * | `not-booted` | boot the device |
252
+ * | `channel-unavailable` | reconnect or rebind; do not blindly retry |
253
+ * | `channel-starting` | **retry shortly** — the channel exists and is coming up |
254
+ * | `dispatch-failed` | may retry once |
255
+ * | `unsupported` | never retry; this agent does not implement it |
256
+ * | `malformed` | fix the call; never retry |
257
+ * | `no-gesture` | **open a new gesture** — retrying this frame can never land |
258
+ *
259
+ * `channel-starting` is the one that had no name. On iOS the input helper needs a measured
260
+ * 186–247ms after spawn before an injected frame reaches the device, and `device:ready` can arrive
261
+ * inside that window — so a caller that taps as soon as a boot returns was being told the channel
262
+ * was gone when it was merely coming up.
263
+ *
264
+ * **A consumer that meets a reason it does not know must treat it as `channel-unavailable`** — the
265
+ * conservative reading. The field is optional precisely so an older agent can omit it, so absence
266
+ * means "unknown", never "fine".
267
+ *
268
+ * A string literal union rather than an enum, per this package's HOW NOT: it must erase under
269
+ * `import type` so it never lands in the dashboard's bundle.
157
270
  */
158
- export type SessionTerminatedReason = 'agent-disconnected';
159
- export type RelayToBrowser = {
271
+ export type InputErrorReason = 'not-booted' | 'channel-unavailable' | 'channel-starting' | 'dispatch-failed' | 'unsupported' | 'malformed'
272
+ /**
273
+ * A frame that only means something as part of a gesture arrived with no gesture behind it — the
274
+ * opening frame never landed, or the process serving it was replaced. Distinct from `malformed`
275
+ * because the advice differs: the message was well-formed and the channel may be perfectly
276
+ * healthy, but *this* frame can never be delivered, so the caller re-opens the gesture rather than
277
+ * giving up. Distinct from `channel-starting` for the same reason — waiting does not help.
278
+ */
279
+ | 'no-gesture'
280
+ /**
281
+ * The sender does not hold the session it addressed. The relay forwards a browser-role input after
282
+ * resolving the session and, until this existed, without checking that the socket asking is the one the
283
+ * session is bound to — so any authenticated client could drive a device another tester was looking at,
284
+ * and the agent's ack went to the session holder rather than to whoever asked.
285
+ *
286
+ * **Not folded into `channel-unavailable`**, on that set's own rule — a reason exists per thing a consumer
287
+ * must do differently. `channel-unavailable` means reconnect or re-join *this* session; this means the
288
+ * caller never held it, so the move is to join first, and it may find someone else holding it.
289
+ *
290
+ * And it is the **only** reason that guarantees nothing reached the device. Every other one leaves partial
291
+ * delivery open — `no-gesture` most sharply, which is why nothing retries on it (#491). Here the frame was
292
+ * refused at the relay's door, so a retry after joining is safe, and that makes this the first member
293
+ * whose advice can say so.
294
+ */
295
+ | 'not-session-owner';
296
+ /** Browser-inbound messages with **two** producers: an agent sends it and the relay also originates
297
+ * its own copy — replaying session state to a re-joining viewer, or failing fast when it cannot
298
+ * reach the agent at all.
299
+ *
300
+ * Declared once here and referenced by both directions rather than written into each, because two
301
+ * copies of one message drift. That is not hypothetical: the dashboard kept a hand-copy of this
302
+ * whole surface and four members had diverged from it, with nothing reporting the difference. */
303
+ export interface SessionChrome {
304
+ type: 'session:chrome';
305
+ sessionId: string;
306
+ payload: ChromePayload;
307
+ }
308
+ export interface SessionDeviceInfo {
309
+ type: 'session:deviceInfo';
310
+ sessionId: string;
311
+ payload: DeviceDetails;
312
+ }
313
+ export interface DeviceReady {
314
+ type: 'device:ready';
315
+ sessionId?: string;
316
+ /** Absent = this is not the answer to a `device:boot`. See 「Lifecycle correlation」 in AGENTS.md. */
317
+ requestId?: string;
318
+ payload: {
319
+ deviceId: string;
320
+ };
321
+ }
322
+ /**
323
+ * `sessionId` is what makes a failure findable. An MCP caller waits for the reply that carries its own
324
+ * sessionId, and without one it waits out the deadline instead (#445).
325
+ *
326
+ * **This is now a description of the wire, and for two releases it was a specification the relay did not
327
+ * meet.** Nothing validated inbound messages, so a client sending `{"type":"input:touch:end"}` with no
328
+ * sessionId reached `sessions.get(undefined)`, missed, and was answered through `msg.sessionId!` —
329
+ * `JSON.stringify` then dropped the key, shipping a frame whose required field this declaration claimed
330
+ * was there. `sessionId: ''` was the sharper half: it type-checks, and `mcp-server`'s tool schemas are
331
+ * bare `z.string()`, so an LLM could produce one.
332
+ *
333
+ * #444 closed it at the door. `@tapflowio/protocol/validate` parses an inbound frame against these
334
+ * declarations before the relay routes it, with `.min(1)` on this field so the empty string is refused
335
+ * too — and **`RelayServer.ts` now contains no `msg.sessionId!` at all**, where it once held eleven.
336
+ * There is no count left to keep current here, which is the point: the assertion is gone rather than
337
+ * enumerated, and a stale number in this paragraph is what taught that lesson.
338
+ *
339
+ * One member still declares `sessionId?` — `DeviceReady`, and its own note says why. That is a reasoned
340
+ * deferral about a correlator, not a gap in this one.
341
+ *
342
+ * Optional would have described the old wire accurately and still been the wrong contract: an MCP caller
343
+ * that receives an uncorrelatable `input:error` has nothing it can do with it and waits out the deadline
344
+ * either way. An earlier version of this note said the producer should "send `error` instead", pointing at
345
+ * `GenericError` below as an escape hatch for a failure with no session. **That escape is gone**:
346
+ * `GenericError` requires `sessionId` since L5d, and a request naming no session is refused at the door
347
+ * rather than answered, because a reply carrying no `requestId` cannot be attributed and costs the caller
348
+ * the same deadline silence would.
349
+ */
350
+ export interface SessionScoped {
351
+ sessionId: string;
352
+ }
353
+ export interface AppInstallError extends SessionScoped {
354
+ type: 'app:install-error';
355
+ message: string;
356
+ requestId: string;
357
+ }
358
+ export interface AppLaunchError extends SessionScoped {
359
+ type: 'app:launch-error';
360
+ message: string;
361
+ requestId: string;
362
+ }
363
+ export interface DeviceBootError extends SessionScoped {
364
+ type: 'device:boot-error';
365
+ message: string;
366
+ requestId?: string;
367
+ }
368
+ export interface OpenUrlError extends SessionScoped {
369
+ type: 'open-url:error';
370
+ message: string;
371
+ requestId: string;
372
+ }
373
+ export interface AppClearStateError extends SessionScoped {
374
+ type: 'app:clear-state-error';
375
+ message: string;
376
+ requestId: string;
377
+ }
378
+ /**
379
+ * **The guaranteed half is now the half that is a contract** (#491).
380
+ *
381
+ * `message` used to be required and `reason` optional, which is the inversion this message could least
382
+ * afford: `packages/protocol/AGENTS.md` states the split on purpose — `message` is free prose each agent
383
+ * owns, `reason` is the closed union consumers branch on — so the field a consumer was guaranteed to
384
+ * receive was the one it must not depend on, and the one it should depend on could be absent.
385
+ *
386
+ * That was the correct way to *ship* it (#490): an agent predating the field omitted it and nothing broke.
387
+ * It is not a correct end state, because every consumer carries an unknown-reason branch for as long as it
388
+ * holds, and "absence means unknown" has to be re-derived by each new one.
389
+ *
390
+ * **Measured before flipping it: all six in-repo producers already send a reason**, two of them with
391
+ * `satisfies InputErrorReason` on the literal — `IOSAgent.ts` (three sites), `AndroidAgent.ts` (two) and
392
+ * `RelayServer.refuseInput`. The prerequisite was the relay, which used to send prose alone (#492, closed).
393
+ * So this costs no in-repo change; what it buys is that an agent outside this repo cannot omit it, and that
394
+ * a consumer written tomorrow cannot be handed an unanswerable failure.
395
+ *
396
+ * **`message` is optional rather than removed.** It still carries parameterised detail a closed union
397
+ * cannot — `unknown key code: KeyFoo` — which is a debug and forward-compatibility field, and a debug field
398
+ * should not be required. Display copy belongs to the presentation layer: agent-authored English cannot be
399
+ * localised, and this product's audience is PO, PM, designers and QA. A structured `params` field is the
400
+ * honest way to keep `KeyFoo` once prose is demoted, and it is deliberately **not** added here — #485 is
401
+ * what will say whether a rendered UI misses the variable, and adding it now means guessing a schema from
402
+ * no requirement.
403
+ *
404
+ * `InputTypeError` keeps `reason?` and that asymmetry is deliberate, not an oversight: its agent-side
405
+ * producers answer with prose from a rejected `adb` or pasteboard write and have no reason to give. Only
406
+ * the relay sets one. The field's own doc there records it.
407
+ */
408
+ export interface InputError extends SessionScoped {
409
+ type: 'input:error';
410
+ /** Required — see `InputDone`. The relay produces this message too, so omission is a compile error there
411
+ * rather than something only a test could hold. */
412
+ requestId: string;
413
+ reason: InputErrorReason;
414
+ message?: string;
415
+ }
416
+ export interface ClipboardError extends SessionScoped {
417
+ type: 'clipboard:error';
418
+ message: string;
419
+ requestId: string;
420
+ payload?: ClipboardErrorPayload;
421
+ }
422
+ export type RelayOrAgentToBrowser = SessionChrome | SessionDeviceInfo | DeviceReady | AppInstallError | AppLaunchError | DeviceBootError | OpenUrlError | AppClearStateError | InputError | InputTypeError | ClipboardError;
423
+ export interface AgentsListed {
160
424
  type: 'agents:listed';
161
425
  sessions: SessionInfo[];
162
- } | {
163
- type: 'stream:registered';
164
- } | {
426
+ }
427
+ export interface SessionJoined {
165
428
  type: 'session:joined';
166
429
  sessionId: string;
167
430
  capabilities: string[];
168
- } | {
431
+ }
432
+ export interface SessionTerminated {
169
433
  type: 'session:terminated';
170
434
  sessionId: string;
171
435
  reason: SessionTerminatedReason;
172
- } | {
436
+ }
437
+ export interface SessionAgentAway {
173
438
  type: 'session:agent-away';
174
439
  sessionId: string;
175
- } | {
440
+ }
441
+ export interface SessionRebound {
176
442
  type: 'session:rebound';
177
443
  sessionId: string;
178
444
  capabilities: string[];
179
- } | {
180
- type: 'session:chrome';
181
- payload: ChromePayload;
182
- } | {
183
- type: 'session:deviceInfo';
184
- payload: DeviceDetails;
185
- } | {
186
- type: 'device:ready';
445
+ }
446
+ /**
447
+ * Why a `session:start` could not be honoured. The same split as `InputErrorReason`: `message` stays
448
+ * free prose the producer owns, and the machine field is closed.
449
+ *
450
+ * It exists because the dashboard was branching on the prose. Three wordings were sent and two were
451
+ * handled, so `Session busy` — the reply a second tester gets when someone else already holds the
452
+ * device — reached the viewer and did nothing at all, leaving the tab waiting on a `session:joined`
453
+ * that cannot arrive. Nothing reported it, because from the outside the type *was* handled.
454
+ *
455
+ * **Required, unlike `InputErrorReason`.** That one is permanently optional because its producer set is
456
+ * open by design — a third-party platform registers through `AgentRegistry.register()` and may predate
457
+ * the field. This one has a single producer: the relay, at three sites in `handleSessionStart`. So the
458
+ * compiler can insist, and it does — `sendTo` takes `RelayOutbound`.
459
+ */
460
+ export type SessionStartFailure =
461
+ /** No such session. Nothing else is ever coming for it. */
462
+ 'session-not-found'
463
+ /** The session exists and another browser socket holds it. It is alive; this viewer cannot have it. */
464
+ | 'session-busy'
465
+ /** The Mac is over its resource ceiling and refused to take another session. */
466
+ | 'agent-resources-exhausted';
467
+ /**
468
+ * The answer to a `session:start` the relay refused. **Not a general escape hatch**, which is what this
469
+ * comment used to claim — and the claim could not coexist with `SessionStartFailure`'s, one screen up, that
470
+ * the reason has a single producer inside `handleSessionStart`. Both were in HEAD at once for two months.
471
+ *
472
+ * L5c settled it by removing the general role rather than the specific one: a request that names no session
473
+ * is now **dropped at the relay's door** — by the inbound schema since #444 — because answering it would ship a frame whose own
474
+ * required `sessionId` `JSON.stringify` erases, and `error` has no `requestId` either — so a caller could not
475
+ * attribute the answer and would wait out the same deadline silence costs. With nothing left needing an
476
+ * unaddressed failure, every producer of this message answers one specific join.
477
+ *
478
+ * **So it extends `SessionScoped`**, and that is the honest form rather than a field bolted on: the base's
479
+ * own definition — a failure a *session* is waiting on — is now exactly what this is.
480
+ * `protocolMessageNames.test.mjs` enforces that membership, and its note saying `error` "cannot be a
481
+ * `SessionError`" described a nature that argument replaced. The base was called `SessionError` and carried
482
+ * `message` until #491 demoted prose on `input:error`; `message` now sits on each member that requires it,
483
+ * and what the base states is the one thing all nine share — the failure is addressed to a session.
484
+ *
485
+ * What the address buys: the join waiters in `mcp-server` and `flow-runner` matched
486
+ * `sessionId === undefined || sessionId === mine`, and with no such key the left half was **always true**, so
487
+ * any `error` resolved any pending join. Two concurrent `connect_device` calls and the first refusal woke the
488
+ * wrong one — reported as a failure the other session never had, while the one that did fail waited out its
489
+ * deadline. `dispatch` resolves only the first matching waiter, so that is one wrong answer and one timeout.
490
+ *
491
+ * The name stays `GenericError` despite the narrowed role. The derivation rule would give `Error`, which
492
+ * shadows the global, so the exception exists for the literal rather than the role — renaming to
493
+ * `SessionStartError` needs an exception entry just the same, and removing it needs a new wire literal.
494
+ */
495
+ export interface GenericError extends SessionScoped {
496
+ type: 'error';
497
+ message: string;
498
+ reason: SessionStartFailure;
499
+ }
500
+ /**
501
+ * A `device:shutdown` the relay could not deliver.
502
+ *
503
+ * **The pair's error member, added late and by a different producer than its `-done`.** Every other
504
+ * browser-originated command answers when the relay cannot dispatch it; this one resolved its session
505
+ * inline and dropped the frame in silence, so `mcp-server`'s `shutdownDevice` burned a 30s deadline and
506
+ * reported `Request timed out` with no cause (#542). There was no shape to answer *with*, which is why
507
+ * fixing it was a protocol change rather than a relay one.
508
+ *
509
+ * **`RelayToBrowser`, not `RelayOrAgentToBrowser`**, unlike `DeviceBootError` beside it. Both agents ack
510
+ * a shutdown they cannot perform by simply not sending `device:shutdown-done`; neither has a failure
511
+ * path that produces a message. Declaring it in the shared union would claim a producer that does not
512
+ * exist, and would add an `AgentToBrowser` member with no forwarding case — which is exactly what
513
+ * `browserInboundRouting.test.mjs`'s second assertion exists to report. If an agent later grows one,
514
+ * moving the member is one line and that check enforces the case at the same time.
515
+ *
516
+ * `requestId` is optional because **the request's is** — the relay originates `device:shutdown` from its
517
+ * own idle timer, so a reply cannot demand a field the request need not carry. Absent here means the
518
+ * same thing it means on `DeviceShutdownDone`: this frame answers no request.
519
+ */
520
+ export interface DeviceShutdownError extends SessionScoped {
521
+ type: 'device:shutdown-error';
522
+ message: string;
523
+ requestId?: string;
524
+ }
525
+ /** Messages the relay originates and no agent sends. */
526
+ export type RelayToBrowser = RelayOrAgentToBrowser | AgentsListed | SessionJoined | SessionTerminated | SessionAgentAway | SessionRebound | DeviceShutdownError | GenericError;
527
+ export interface DeviceBooting {
528
+ type: 'device:booting';
529
+ sessionId: string;
530
+ }
531
+ export interface DeviceShutdownDone {
532
+ type: 'device:shutdown-done';
533
+ /** Absent = this shutdown was not requested by a browser — the relay's idle timer ran. */
534
+ requestId?: string;
535
+ sessionId: string;
187
536
  payload: {
188
537
  deviceId: string;
189
538
  };
190
- } | {
191
- type: 'error';
192
- message: string;
193
- } | {
194
- type: 'app:install-error';
539
+ }
540
+ export interface AppInstallDone {
541
+ type: 'app:install-done';
195
542
  sessionId: string;
196
- message: string;
197
- } | {
198
- type: 'app:launch-error';
543
+ requestId: string;
544
+ }
545
+ export interface AppLaunchDone {
546
+ type: 'app:launch-done';
199
547
  sessionId: string;
200
- message: string;
201
- } | {
202
- type: 'device:boot-error';
548
+ requestId: string;
549
+ }
550
+ export interface AppClearStateDone {
551
+ type: 'app:clear-state-done';
203
552
  sessionId: string;
204
- message: string;
205
- } | {
206
- type: 'open-url:error';
553
+ requestId: string;
554
+ }
555
+ export interface OpenUrlDone {
556
+ type: 'open-url:done';
207
557
  sessionId: string;
208
- message: string;
209
- } | {
210
- type: 'app:clear-state-error';
558
+ requestId: string;
559
+ }
560
+ export interface InputDone {
561
+ type: 'input:done';
562
+ sessionId: string;
563
+ /**
564
+ * Required, and this is the pair where an absent correlator costs the most. The four already correlated
565
+ * arrive at the speed a person clicks a button; a swipe is dozens of frames, so a late ack landing in the
566
+ * next input's waiter is not a corner case — it is #499.
567
+ *
568
+ * There is no unsolicited producer to make this optional (the discriminator this repo uses): `ackInput`
569
+ * on both agents is the only sender, and it fires on terminal outcomes only. Contrast the lifecycle pair,
570
+ * where an Android stream dying mid-session produces a `device:boot-error` answering nothing.
571
+ */
572
+ requestId: string;
573
+ }
574
+ export interface InputTypeDone {
575
+ type: 'input:type-done';
211
576
  sessionId: string;
577
+ requestId: string;
578
+ }
579
+ export interface InputTypeError extends SessionScoped {
580
+ type: 'input:type-error';
212
581
  message: string;
213
- } | {
214
- type: 'input:error';
582
+ requestId: string;
583
+ /**
584
+ * Optional because the agents' own failures carry none — they answer with prose from a rejected `adb` or
585
+ * pasteboard write. The **relay** sets it, and without this field one of the five requests it can refuse
586
+ * had no machine-readable answer at all: `not-session-owner`'s whole value is the promise that nothing
587
+ * reached the device, and a consumer that can only read `message` cannot act on it. Branching on prose is
588
+ * the thing #492 legislated against.
589
+ */
590
+ reason?: InputErrorReason;
591
+ }
592
+ export interface KeyboardToggled {
593
+ type: 'keyboard:toggled';
215
594
  sessionId: string;
216
- message: string;
217
- } | {
218
- type: 'clipboard:error';
595
+ payload: {
596
+ visible: boolean;
597
+ };
598
+ }
599
+ export interface ClipboardData {
600
+ type: 'clipboard:data';
219
601
  sessionId: string;
220
602
  requestId: string;
221
- message: string;
222
- };
603
+ payload: {
604
+ text: string;
605
+ };
606
+ }
607
+ export interface ClipboardWriteDone {
608
+ type: 'clipboard:write-done';
609
+ sessionId: string;
610
+ requestId: string;
611
+ }
612
+ /** Messages an agent produces. The relay forwards them byte-for-byte (`JSON.stringify(raw)`, the frame
613
+ * exactly as it arrived, so a field a newer agent adds is not stripped by a relay that does not know it) rather
614
+ * than re-creating them, so it never constructs one — which is exactly why they were missing from
615
+ * this file until L3: nothing on the relay's own send path referenced them.
616
+ *
617
+ * The twelve declared below carry `sessionId` as required, on two independent grounds: both agents
618
+ * include it in every send literal, and the relay's forward gate resolves `sessions.get(msg.sessionId)`
619
+ * before forwarding, so a message with no sessionId never reaches a browser by this path.
620
+ *
621
+ * Nine of the ten inherited from `RelayOrAgentToBrowser` now carry it too. Three of them
622
+ * (`session:chrome`, `session:deviceInfo`, `device:ready`) were `sessionId?` for as long as the relay's
623
+ * replay omitted what both agents stamped. Two ways to tighten the *declaration* were weighed and
624
+ * rejected — declaring the three twice (drift, which is the finding this surface exists to remove), and
625
+ * mapping the union through `Omit<T,'sessionId'> & { sessionId: string }`.
626
+ *
627
+ * The recorded reason for rejecting the second one does not survive checking, and it is worth saying so
628
+ * rather than deleting it: it claimed the mapping breaks `useClipboardBridge`, which reads its replies
629
+ * through `Extract<>`. It does not — that hook takes the three replies as **named members**
630
+ * (`ClipboardData | ClipboardWriteDone | ClipboardError`) and says so in its own comment, and its only
631
+ * `Extract` is over `ClipboardRequest`, an outbound union this mapping would never touch.
632
+ *
633
+ * It does not change the outcome, because both alternatives are ways to make one declaration describe
634
+ * two producers that disagree, and the disagreement was the defect. The relay stamps the first two now.
635
+ * `device:ready` stays optional for a different reason — see the note above the declarations. */
636
+ export type AgentToBrowser = RelayOrAgentToBrowser | DeviceBooting | DeviceShutdownDone | AppInstallDone | AppLaunchDone | AppClearStateDone | OpenUrlDone | InputDone | InputTypeDone | KeyboardToggled | ClipboardData | ClipboardWriteDone;
637
+ /** Everything a browser socket can receive, whoever produced it. This is what a viewer's message
638
+ * handler should be typed with — the two unions above answer "who sends this", which matters to
639
+ * the relay and not to the consumer. */
640
+ export type BrowserInbound = RelayToBrowser | AgentToBrowser;
641
+ /** The agent's *stream* socket, not a browser. Its own direction because it has its own audience:
642
+ * the consumer is `agent-core`'s stream registration, and nothing in a browser reads it. */
643
+ export interface StreamRegistered {
644
+ type: 'stream:registered';
645
+ }
646
+ export type RelayToStream = StreamRegistered;
223
647
  /** Everything the relay originates. Messages it merely forwards keep their inbound type — they are
224
648
  * not re-created, so they are not checked against this union. */
225
- export type RelayOutbound = RelayToAgent | RelayToBrowser;
649
+ export type RelayOutbound = RelayToAgent | RelayToBrowser | RelayToStream;
650
+ /** The agent's opening message on its control socket, sent from inside `onopen`. */
651
+ export interface AgentRegister {
652
+ type: 'agent:register';
653
+ /** Open on purpose — **not** `'ios' | 'android'`. A third-party platform registers through
654
+ * `AgentRegistry.register()` (root AGENTS.md, OCP), and narrowing this would stop it producing a
655
+ * valid registration. `agent-core`'s own `Platform` is `string` for the same reason. */
656
+ platform: string;
657
+ /** What this agent implements, as plain strings on the wire. `agent-core`'s `AgentCapability` is
658
+ * assignable to it; the wire does not close the set. */
659
+ capabilities: string[];
660
+ /** Stable per-machine id (macOS IOPlatformUUID). **Optional because `getMachineId()` returns
661
+ * `undefined` off darwin**, and the relay falls back to `agentName` for dedup. */
662
+ agentId?: string;
663
+ agentName: string;
664
+ devices: DeviceReport[];
665
+ }
666
+ /** Periodic load report. The relay gates new sessions on it. */
667
+ export interface AgentResourceReport {
668
+ type: 'agent:resources';
669
+ resources: AgentResources;
670
+ }
671
+ export interface ScreenshotDone {
672
+ type: 'screenshot:done';
673
+ sessionId: string;
674
+ requestId: string;
675
+ /**
676
+ * What the agent produced — the relay turns this into the HTTP `Content-Type`.
677
+ *
678
+ * The **only** field on this pair that describes an outcome; the request's `format` is a
679
+ * preference (see `ScreenshotRequest`). Android echoed the request here while always producing
680
+ * PNG, so a JPEG request came back as PNG bytes labelled `image/jpeg` (#508).
681
+ *
682
+ * Still a claim rather than a guarantee: it is what the agent *says*, and an agent older than the
683
+ * fix says the wrong thing. The relay logs a mismatch against the bytes but does not overwrite
684
+ * this — a consumer whose behaviour depends on the real format sniffs the bytes itself.
685
+ */
686
+ format: 'png' | 'jpeg';
687
+ /** base64. */
688
+ data: string;
689
+ }
690
+ /** A screenshot that failed.
691
+ *
692
+ * **Does not extend `SessionScoped`**, unlike every other `*-error`, because it is not addressed to a
693
+ * session: the relay resolves the pending promise by `requestId` alone. The base is for a failure a
694
+ * *session* is waiting on, and drawing that line is what keeps it meaningful.
695
+ *
696
+ * `sessionId` is still **required**, because every producer has one. An earlier draft made it optional
697
+ * on the grounds that the agents pass through an optional id — true when written, and false by the end
698
+ * of the same change, which required it on both dispatchers. A field weaker than every producer
699
+ * describes a message nobody sends, and here it would also remove the one field a symmetric ownership
700
+ * check could read: the clipboard replies beside these verify `session.agentSocket === ws` before
701
+ * resolving, and these two do not. */
702
+ export interface ScreenshotError {
703
+ type: 'screenshot:error';
704
+ sessionId: string;
705
+ requestId: string;
706
+ message: string;
707
+ }
708
+ export interface UiTreeResponse {
709
+ type: 'ui:tree:response';
710
+ sessionId: string;
711
+ requestId: string;
712
+ elements: UIElement[];
713
+ }
714
+ /** Request-scoped, for the same reasons as `ScreenshotError`. */
715
+ export interface UiTreeError {
716
+ type: 'ui:tree:error';
717
+ sessionId: string;
718
+ requestId: string;
719
+ message: string;
720
+ }
721
+ export type AgentToRelay = AgentRegister | AgentResourceReport | ScreenshotDone | ScreenshotError | UiTreeResponse | UiTreeError;
722
+ /** Its own direction, mirroring `RelayToStream`. The relay assigns the role `'stream'` from this
723
+ * message rather than `'agent'` (`RelayServer.ts:554-557`), so putting it in `AgentToRelay` would make
724
+ * that union's name disagree with the runtime role — and would let a control socket claim to be a
725
+ * session's stream socket once L4b narrows inbound by role. */
726
+ export interface StreamRegister {
727
+ type: 'stream:register';
728
+ sessionId: string;
729
+ }
730
+ export type StreamToRelay = StreamRegister;
731
+ /** The three replies a clipboard request can get. Both agents build these through a local `respond`
732
+ * helper that merges the correlation ids, and that helper took `object` — so the replies were the last
733
+ * send path in the clipboard code that nothing checked. The consumer side names the same set
734
+ * (`useClipboardBridge`), so it lives here rather than in either. */
735
+ export type ClipboardReply = ClipboardData | ClipboardWriteDone | ClipboardError;
736
+ /** A `ClipboardReply` minus the ids the sender merges in. Distributive, so each member keeps its own
737
+ * payload — a plain `Omit` would collapse them into one shape and stop checking which goes with which. */
738
+ export type ClipboardReplyBody<T = ClipboardReply> = T extends unknown ? Omit<T, 'sessionId' | 'requestId'> : never;
739
+ /** The replies an `open-url` request can get. */
740
+ export type OpenUrlReply = OpenUrlDone | OpenUrlError;
741
+ /** An `OpenUrlReply` minus the ids the sender merges in — the `ClipboardReplyBody` shape.
742
+ *
743
+ * **What it buys, exactly.** A first draft of this comment claimed it made the echo obligation
744
+ * unstateable-otherwise and therefore removed the need for any check. Review measured that against 13
745
+ * attacks and it is false: `sendMsg` takes `AgentControlOutbound`, whose `OpenUrlDone`/`OpenUrlError`
746
+ * members declare `requestId: string`, so any site with any string in scope emits a fully-typed, fully
747
+ * wrong reply without going through the helper at all. The helper is convention, not a type boundary.
748
+ *
749
+ * What it does buy is one thing worth having: at a `respond({ … })` call, a **freshly minted id written
750
+ * as a literal** is an excess property. It does not survive a body *variable* — excess-property checking
751
+ * does not fire on those — which is why the agents spread `...body` **first** and put the ids last, so a
752
+ * variable that carries one cannot override the real one.
753
+ *
754
+ * Omission is caught, but by `requestId: string` being required on the reply interfaces rather than by
755
+ * this type. The rest is carried by each agent's echo tests, and each remaining correlation pair needs
756
+ * its own pair of them. A check can see that a field is present; it cannot see that the value came from
757
+ * the request, which is the actual property — that part of the original reasoning stands, and it is why
758
+ * the answer is tests at the sites rather than a cleverer checker. */
759
+ export type OpenUrlReplyBody<T = OpenUrlReply> = T extends unknown ? Omit<T, 'sessionId' | 'requestId'> : never;
760
+ /** The app-command pairs, same shape as `OpenUrlReplyBody` and for the same reason — see the note there
761
+ * for exactly what it buys and what it does not.
762
+ *
763
+ * There is deliberately **no request-direction counterpart.** One was designed and measured: a branded
764
+ * correlator is laundered by any cast to the brand, because a brand names a *kind* while provenance is a
765
+ * property of the *instance* and TypeScript has no value-dependent types; and a generic `Omit`-body
766
+ * helper does not compile without a cast of its own, which is worse than the literal it replaces since a
767
+ * literal at least gets its whole shape checked. The reply side is worth the type because there are
768
+ * ~20 literal sites; the request side has one per pair, on the line below the helper that builds it. So
769
+ * the relay's "carries the request's id" is held by tests, not by a type. */
770
+ export type AppInstallReply = AppInstallDone | AppInstallError;
771
+ export type AppInstallReplyBody<T = AppInstallReply> = T extends unknown ? Omit<T, 'sessionId' | 'requestId'> : never;
772
+ export type AppLaunchReply = AppLaunchDone | AppLaunchError;
773
+ export type AppLaunchReplyBody<T = AppLaunchReply> = T extends unknown ? Omit<T, 'sessionId' | 'requestId'> : never;
774
+ export type AppClearStateReply = AppClearStateDone | AppClearStateError;
775
+ export type AppClearStateReplyBody<T = AppClearStateReply> = T extends unknown ? Omit<T, 'sessionId' | 'requestId'> : never;
776
+ /** What an agent's **control** socket carries. This is what the agents' send helpers take.
777
+ *
778
+ * `StreamToRelay` is deliberately *not* in it. Splitting `stream:register` into its own direction was
779
+ * justified above by the hazard of a control socket claiming to be a session's stream socket — and a
780
+ * union that re-merged them would have handed that hazard straight back, since `case 'stream:register'`
781
+ * calls `setStreamSocket(session.id, ws)` with no role gate. The stream socket's one message is typed
782
+ * at its own send site in `agent-core/src/utils/stream.ts`. */
783
+ export type AgentControlOutbound = AgentToRelay | AgentToBrowser;
784
+ /**
785
+ * Every message this protocol declares, reached through the seven directions.
786
+ *
787
+ * Not a fourteenth union for its own sake: it is what lets a consumer assert that its own list of
788
+ * literals is *complete* rather than merely correct so far. `relay/src/types.ts` keeps such a list —
789
+ * hand-maintained, 62 entries, and missing `stream:request-idr` until this change, which is the exact
790
+ * drift this package was created to end (see AGENTS.md).
791
+ *
792
+ * The fact it restates is small and stable — **which directions exist** — not the 63 literals inside
793
+ * them. A message added to a direction flows in here for free; a message added to *no* direction does
794
+ * not, and nothing here can see that. That gap is real and is not this union's to close.
795
+ */
796
+ export type AnyWireMessage = BrowserToRelay | RelayToBrowser | AgentToRelay | AgentToBrowser | RelayToAgent | StreamToRelay | RelayToStream;
226
797
  /** Key input. The payload carries `code` — a `KeyboardEvent.code` name — and `modifiers` as a
227
798
  * **bitmap**, not a list and not `key`.
228
799
  *
@@ -234,6 +805,16 @@ export type RelayOutbound = RelayToAgent | RelayToBrowser;
234
805
  export interface InputKey {
235
806
  type: 'input:key';
236
807
  sessionId: string;
808
+ /**
809
+ * Required. A terminal input is the only kind that gets an ack, and #499 is what an unattributed one
810
+ * costs: an ack that arrives after its own deadline is consumed by the **next** input's waiter, which
811
+ * then reports the previous input's outcome — including reporting an unanswered input as landed.
812
+ *
813
+ * Opening and move frames deliberately do **not** carry this. They get no reply
814
+ * (`ios-agent/AGENTS.md`: "Opening frames stay silent: they carry no ack obligation"), so an id there
815
+ * would be minted for a waiter that does not exist. Nor does `input:rotate`, for the same reason.
816
+ */
817
+ requestId: string;
237
818
  payload: {
238
819
  code: string;
239
820
  modifiers?: number;
@@ -245,14 +826,15 @@ export interface Point {
245
826
  }
246
827
  /** The two clipboard requests a viewer can make. Kept as its own union because the bridge sends
247
828
  * them through one call that takes the type as an argument. */
248
- export type ClipboardRequest = {
829
+ export interface ClipboardRead {
249
830
  type: 'clipboard:read';
250
831
  sessionId: string;
251
832
  requestId: string;
252
833
  payload?: {
253
834
  press?: 'copy' | 'cut';
254
835
  };
255
- } | {
836
+ }
837
+ export interface ClipboardWrite {
256
838
  type: 'clipboard:write';
257
839
  sessionId: string;
258
840
  requestId: string;
@@ -260,100 +842,174 @@ export type ClipboardRequest = {
260
842
  text: string;
261
843
  pasteAfter?: boolean;
262
844
  };
263
- };
264
- export type BrowserToRelay = {
845
+ }
846
+ export type ClipboardRequest = ClipboardRead | ClipboardWrite;
847
+ export interface AgentsList {
265
848
  type: 'agents:list';
266
- } | {
849
+ }
850
+ export interface SessionStart {
267
851
  type: 'session:start';
268
852
  sessionId: string;
269
- } | {
853
+ }
854
+ export interface SessionEnd {
270
855
  type: 'session:end';
271
856
  sessionId: string;
272
- } | {
857
+ }
858
+ export interface SessionLeave {
273
859
  type: 'session:leave';
274
860
  sessionId: string;
275
- } | {
861
+ }
862
+ export interface DeviceBoot {
276
863
  type: 'device:boot';
277
864
  sessionId: string;
865
+ /**
866
+ * Required, unlike every reply in this pair. Absence has no legitimate meaning on the request side —
867
+ * nothing originates a boot but a browser, so there is no producer for an id-less one to belong to.
868
+ * This is what puts `device:boot` inside 「No fallback, and one policy at the door」: required is what
869
+ * `correlatedRequestsGated` derives its set from, and what makes the relay's door gate reachable.
870
+ *
871
+ * `device:shutdown` below stays optional for the opposite reason — the relay sends that one itself.
872
+ */
873
+ requestId: string;
278
874
  payload: {
279
875
  deviceId: string;
280
876
  resetMode?: 'app-only' | 'full-erase';
281
877
  acceptH264?: boolean;
282
878
  secureContext?: boolean;
283
879
  };
284
- } | {
285
- type: 'device:shutdown';
286
- sessionId: string;
287
- payload: {
288
- deviceId: string;
289
- };
290
- } | {
880
+ }
881
+ export interface AppInstallToRelay {
291
882
  type: 'app:install';
292
883
  sessionId: string;
884
+ requestId: string;
293
885
  buildId: number;
294
- } | {
886
+ }
887
+ export interface AppLaunchToRelay {
295
888
  type: 'app:launch';
296
889
  sessionId: string;
890
+ requestId: string;
297
891
  buildId: number;
298
- } | {
892
+ }
893
+ /** Wipe an installed app's data.
894
+ *
895
+ * `bundleId` is **required, twice over** — the field and the payload. It was `payload?: { bundleId?: string }`,
896
+ * which was looser than every producer *and* every consumer: `mcp-server` and `flow-runner` are its only
897
+ * senders (the dashboard resets through `device:boot` instead) and both supply a string, and both agents answer
898
+ * `app:clear-state-error` with `'bundleId missing'` when it is absent. A field weaker than every producer
899
+ * describes a message nobody sends.
900
+ *
901
+ * What required buys is a **compile error for a future sender**, no more. The agents keep that branch and
902
+ * should: `bundleId: ''` type-checks — the door checks the *shape*, and an empty string is a valid one
903
+ * everywhere but the two correlation fields, which carry `.min(1)`. So this is the same
904
+ * argument as `sessionId` below, not a stronger one.
905
+ *
906
+ * Unlike `app:install` / `app:launch`, which carry a `buildId` the relay resolves into a bundle id from the
907
+ * builds table, this message is passed through untouched. That is an **absence of relay-side resolution, not a
908
+ * design** — the machinery exists next door. It stays client-supplied because clearing a *different* app is a
909
+ * documented flow step (`clearState: com.other.app`), so the field has to be expressible either way; adding a
910
+ * relay-filled form would be a new feature, not a contract alignment. */
911
+ export interface AppClearState {
299
912
  type: 'app:clear-state';
300
913
  sessionId: string;
301
- payload?: {
302
- bundleId?: string;
914
+ requestId: string;
915
+ payload: {
916
+ bundleId: string;
303
917
  };
304
- } | {
918
+ }
919
+ /** First message of the correlation work (L5). `requestId` is **required on the request and on both
920
+ * replies**, which is the same shape the three pairs that already work use — `screenshot`, `ui:tree`
921
+ * and `clipboard` all declare it required on the reply too, including the four an *agent* produces.
922
+ *
923
+ * The alternative considered was optional-on-the-reply, so that an agent predating the field would not
924
+ * make the declaration false. It was rejected on measurement rather than taste:
925
+ *
926
+ * - Required yields **complete, precise** in-repo compile errors — seven, at exactly the seven
927
+ * production sites for this pair, nothing else. The write side is fully covered because L4a routed
928
+ * every agent send through a typed helper.
929
+ * - Optional needs a static check to replace the compiler, and that check **cannot exist.** Presence is
930
+ * checkable; the property is *provenance* — that the id is the request's. A check built and run
931
+ * against the clipboard family (100% correlated today) produced seven false positives, because
932
+ * `respond({ sessionId, requestId, ...body })` puts the `type` literal and the id in different object
933
+ * literals; and it passed when an echo was replaced with a freshly minted id.
934
+ * - Absence would carry **two** meanings that want opposite handling: "an old agent" and "not a reply at
935
+ * all". The relay's `device:ready` replay is a permanent in-repo producer of the second, and treating
936
+ * it as the first is the `{booted: true}` for a boot that never happened that #516 measured and
937
+ * refused to ship. Required makes absence mean "not a reply", unambiguously — which is what makes
938
+ * that message tightenable at all.
939
+ *
940
+ * There is deliberately **no fallback** to the old `sessionId` + type matching. The `fixed` version
941
+ * group in `.changeset/config.json` locks protocol, agent-core, both agents and the relay together, so
942
+ * the in-repo skew window is zero; a third-party agent predating this is a release-note matter. A
943
+ * permanent fallback would be a fifth correlation strategy in the layer whose goal is to have one. */
944
+ export interface OpenUrl {
305
945
  type: 'open-url';
306
946
  sessionId: string;
947
+ requestId: string;
307
948
  payload: {
308
949
  url: string;
309
950
  };
310
- } | {
951
+ }
952
+ export interface InputTouchStart {
311
953
  type: 'input:touch:start';
312
954
  sessionId: string;
313
955
  payload: Point;
314
- } | {
956
+ }
957
+ export interface InputTouchMove {
315
958
  type: 'input:touch:move';
316
959
  sessionId: string;
317
960
  payload: Point;
318
- } | {
961
+ }
962
+ export interface InputTouchEnd {
319
963
  type: 'input:touch:end';
320
964
  sessionId: string;
965
+ requestId: string;
321
966
  payload?: Point;
322
- } | {
967
+ }
968
+ export interface InputPinchStart {
323
969
  type: 'input:pinch:start';
324
970
  sessionId: string;
325
971
  payload: {
326
972
  f0: Point;
327
973
  f1: Point;
328
974
  };
329
- } | {
975
+ }
976
+ export interface InputPinchMove {
330
977
  type: 'input:pinch:move';
331
978
  sessionId: string;
332
979
  payload: {
333
980
  f0: Point;
334
981
  f1: Point;
335
982
  };
336
- } | {
983
+ }
984
+ export interface InputPinchEnd {
337
985
  type: 'input:pinch:end';
338
986
  sessionId: string;
339
- } | InputKey | {
987
+ requestId: string;
988
+ }
989
+ export interface InputType {
340
990
  type: 'input:type';
341
991
  sessionId: string;
992
+ requestId: string;
342
993
  payload: {
343
994
  text: string;
344
995
  };
345
- } | {
996
+ }
997
+ export interface InputButton {
346
998
  type: 'input:button';
347
999
  sessionId: string;
1000
+ requestId: string;
348
1001
  payload: {
349
1002
  name: string;
350
1003
  phase?: 'down' | 'up';
351
1004
  };
352
- } | {
1005
+ }
1006
+ export interface InputRotate {
353
1007
  type: 'input:rotate';
354
1008
  sessionId: string;
355
- } | {
1009
+ }
1010
+ export interface InputKeyboardToggle {
356
1011
  type: 'input:keyboard:toggle';
357
1012
  sessionId: string;
358
- } | ClipboardRequest;
1013
+ }
1014
+ export type BrowserToRelay = AgentsList | SessionStart | SessionEnd | SessionLeave | DeviceBoot | DeviceShutdown | AppInstallToRelay | AppLaunchToRelay | AppClearState | OpenUrl | InputTouchStart | InputTouchMove | InputTouchEnd | InputPinchStart | InputPinchMove | InputPinchEnd | InputKey | InputType | InputButton | InputRotate | InputKeyboardToggle | ClipboardRequest;
359
1015
  //# sourceMappingURL=index.d.ts.map