@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 +1005 -92
- package/dist/index.d.ts.map +1 -1
- package/dist/validate/assert.d.ts +68 -0
- package/dist/validate/assert.d.ts.map +1 -0
- package/dist/validate/assert.js +2 -0
- package/dist/validate/assert.js.map +1 -0
- package/dist/validate/index.d.ts +586 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +380 -0
- package/dist/validate/index.js.map +1 -0
- package/package.json +16 -4
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
152
|
-
*
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
} | {
|
|
194
|
-
type: 'app:install-error';
|
|
781
|
+
}
|
|
782
|
+
export interface AppInstallDone {
|
|
783
|
+
type: 'app:install-done';
|
|
195
784
|
sessionId: string;
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
785
|
+
requestId: string;
|
|
786
|
+
}
|
|
787
|
+
export interface AppLaunchDone {
|
|
788
|
+
type: 'app:launch-done';
|
|
199
789
|
sessionId: string;
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
790
|
+
requestId: string;
|
|
791
|
+
}
|
|
792
|
+
export interface AppClearStateDone {
|
|
793
|
+
type: 'app:clear-state-done';
|
|
203
794
|
sessionId: string;
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
795
|
+
requestId: string;
|
|
796
|
+
}
|
|
797
|
+
export interface OpenUrlDone {
|
|
798
|
+
type: 'open-url:done';
|
|
207
799
|
sessionId: string;
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
800
|
+
requestId: string;
|
|
801
|
+
}
|
|
802
|
+
export interface InputDone {
|
|
803
|
+
type: 'input:done';
|
|
211
804
|
sessionId: string;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|