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