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