wave-code 1.3.0 → 1.5.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.
Files changed (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -0,0 +1,690 @@
1
+ /**
2
+ * RelayClient — the machine's outbound connection to the remote-control relay.
3
+ *
4
+ * The phone never connects to this machine: it cannot (no public address, no
5
+ * port forwarding). Instead this machine dials out to the relay and subscribes
6
+ * to its own channel:
7
+ *
8
+ * wss://<relay>/v1/hosts/ws/{hostId}/subscribe
9
+ *
10
+ * The channel is keyed by **this machine**, not by session — a session-level
11
+ * channel cannot work here, because the phone has to learn the session list
12
+ * from this machine in the first place, and a session-level channel would leave
13
+ * the list screen with nowhere to ask (see `codechat#40` §1 for the three
14
+ * reasons). One connection therefore carries every session: `sessionId` is
15
+ * already a field of every JSON-RPC envelope the bridge emits
16
+ * (`agentBridge.emit(method, params, sessionId)`), so the relay keeps forwarding
17
+ * bytes without ever parsing what they mean.
18
+ *
19
+ * Everything about the stream is deliberately dumb: one JSON object per
20
+ * WebSocket message, the same envelope the local transports use, plus the
21
+ * **control frames the relay itself owns**. Those are told apart structurally,
22
+ * not by a whitelist of names: a control frame is a JSON object with a
23
+ * top-level `type` and no `jsonrpc`, and the relay never forwards one — which
24
+ * is exactly why the machine has to speak them (`auth`, `ping`, `host_state`)
25
+ * and parse them (`auth_ok`, `auth_error`, `peer_disconnected`,
26
+ * `resync_required`, `pong`). Everything else is forwarded byte for byte,
27
+ * unparsed, in both directions.
28
+ *
29
+ * A control frame that arrives malformed is dropped, never fed to the local
30
+ * JSON-RPC layer: the relay is a shared component and a future control frame
31
+ * we do not recognise must not take the phone offline or reach the agent.
32
+ */
33
+
34
+ /** One notification or request travelling in either direction. */
35
+ export interface RelayEnvelope {
36
+ method: string;
37
+ params?: unknown;
38
+ sessionId?: string;
39
+ id?: number | string;
40
+ }
41
+
42
+ /**
43
+ * Who this connection is. The relay has exactly two credentials and tells them
44
+ * apart by this field alone:
45
+ *
46
+ * - `host` — the machine being controlled, holding the long-lived token the
47
+ * relay minted at registration (`rch_…`).
48
+ * - `sso` — a phone, holding the user's login JWT.
49
+ *
50
+ * The two cannot be inferred from the caller: the phone and the machine belong
51
+ * to the **same person**, so there is no identity to distinguish them by. And
52
+ * the machine's link has to outlive an hour-long JWT — it is reconnected for
53
+ * hours — so it needs the revocable `rch_` token rather than a login token.
54
+ */
55
+ export interface RelayCredential {
56
+ type: "host" | "sso";
57
+ /** Never logged. */
58
+ token: string;
59
+ }
60
+
61
+ /** The control frames the relay sends us. Not forwarded, not JSON-RPC. */
62
+ export type RelayControlFrame =
63
+ | { type: "auth_ok"; role?: string; hostId?: string }
64
+ | { type: "auth_error"; error: string }
65
+ | { type: "peer_disconnected"; hostId?: string }
66
+ | { type: "resync_required"; hostId?: string }
67
+ | { type: "ping" }
68
+ | { type: "pong" };
69
+
70
+ function asString(value: unknown): string | undefined {
71
+ return typeof value === "string" ? value : undefined;
72
+ }
73
+
74
+ /**
75
+ * The slice of the WebSocket client this transport needs. Node ≥22 ships a
76
+ * global `WebSocket`, so the CLI needs no `ws` dependency; tests inject a
77
+ * scripted double instead of standing up a server.
78
+ */
79
+ export interface RelaySocket {
80
+ send(data: string): void;
81
+ close(code?: number, reason?: string): void;
82
+ readonly bufferedAmount?: number;
83
+ onopen: (() => void) | null;
84
+ onmessage: ((event: { data: unknown }) => void) | null;
85
+ onclose: ((event: { code?: number; reason?: string }) => void) | null;
86
+ onerror: ((event: unknown) => void) | null;
87
+ }
88
+
89
+ export type RelaySocketFactory = (url: string) => RelaySocket;
90
+
91
+ export const defaultRelaySocketFactory: RelaySocketFactory = (url) => {
92
+ const Ctor = (
93
+ globalThis as unknown as { WebSocket?: new (url: string) => RelaySocket }
94
+ ).WebSocket;
95
+ if (!Ctor) {
96
+ throw new Error(
97
+ "WebSocket is not available in this runtime (Node >= 22 required)",
98
+ );
99
+ }
100
+ return new Ctor(url);
101
+ };
102
+
103
+ /** Close codes the relay uses to tell us how to react. */
104
+ export const RELAY_CLOSE_UNAVAILABLE = 4001; // transient — the host is not ready yet
105
+ export const RELAY_CLOSE_UNAUTHORIZED = 4003; // permanent — do not come back
106
+ // (the one exception is a *stale stored identity*, which the credential's owner
107
+ // can replace by registering again: see `RELAY_STALE_IDENTITY_ERRORS`)
108
+ export const RELAY_CLOSE_BACKPRESSURE = 4008; // we gave up on a stuck socket
109
+ /**
110
+ * The `4001` reason the relay sends when another process on this machine dialled
111
+ * in and took the channel over (it keeps the original host running the same
112
+ * close code, so only the reason separates "I was replaced" from "this machine
113
+ * is not online" — and those two want opposite answers: step down, versus retry).
114
+ */
115
+ export const RELAY_CLOSE_REPLACED = "replaced";
116
+
117
+ /**
118
+ * The `auth_error` reasons that mean the **identity this machine stored** no
119
+ * longer exists on the relay: the row was revoked, the token aged out, or the
120
+ * local file holds an id/token pair that never belonged together. All of them
121
+ * are fixed the same way — register again, and the relay hands back the same row
122
+ * (the id is derived from the machine, so registration is idempotent) — which is
123
+ * exactly why they are separated from the rest of `4003`. The others
124
+ * (`organization_mismatch`, `unauthorized`, and anything a relay adds later) are
125
+ * about *this account, this organization*: registering again cannot change any
126
+ * of them, and treating them as stale would only hide the real reason behind a
127
+ * pointless round trip.
128
+ */
129
+ export const RELAY_STALE_IDENTITY_ERRORS: readonly string[] = [
130
+ "invalid_host_token",
131
+ "unknown_host",
132
+ ];
133
+
134
+ export function isStaleIdentityError(
135
+ error: string | undefined,
136
+ ): error is string {
137
+ return error !== undefined && RELAY_STALE_IDENTITY_ERRORS.includes(error);
138
+ }
139
+
140
+ /** Retry cadence. Flat 2s: the relay is a well-known host, not a flaky peer —
141
+ * exponential backoff would only delay a laptop waking from sleep. */
142
+ export const RELAY_RETRY_DELAY_MS = 2_000;
143
+ /** Attempts for an ordinary drop (relay restart, network blip). */
144
+ export const RELAY_MAX_ATTEMPTS = 5;
145
+ /** Separate, smaller budget for `4001`: the host announced itself but had no
146
+ * session ready. Retrying a real outage five times buys nothing. */
147
+ export const RELAY_MAX_UNAVAILABLE_ATTEMPTS = 3;
148
+ /** Idle keepalive. Browsers cannot send WebSocket control pings, and the relay
149
+ * faces the phone anyway, so this is an application-level frame. */
150
+ export const RELAY_PING_INTERVAL_MS = 30_000;
151
+ /** Above this much unsent data the phone is not keeping up. Closing (rather
152
+ * than queueing) bounds this process's memory; the phone reconnects and catches
153
+ * up through `getMessages({ since })`, which is the same path a cold start
154
+ * takes. Matches the relay's own single-frame ceiling. */
155
+ export const RELAY_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
156
+
157
+ export type RelayState = "idle" | "connecting" | "open" | "closed";
158
+
159
+ export interface RelayClientOptions {
160
+ /** Full subscribe URL, including the host id path segment. */
161
+ url: string;
162
+ /** Who we are on that channel — read from the auth service / state file, never logged. */
163
+ credential: RelayCredential;
164
+ /**
165
+ * This CLI's release version, reported so the relay can enforce the minimum
166
+ * the served page was built against (the page itself is always the newest
167
+ * one; the skew is handled by refusing old clients, not by keeping old
168
+ * pages — see the version note in `docs/specs/ui/remote-control.md`).
169
+ *
170
+ * It sits at the **top level** of the auth frame, not inside `params`: that
171
+ * is where the relay reads it, and a version it cannot parse is silently
172
+ * treated as "too old to judge", i.e. the floor would quietly stop applying.
173
+ */
174
+ version: string;
175
+ createSocket?: RelaySocketFactory;
176
+ /**
177
+ * The socket is up and the auth frame is on the wire. Not "authenticated" —
178
+ * that is `auth_ok` (`onAuthenticated`).
179
+ */
180
+ onOpen?: () => void;
181
+ /** `auth_ok`: the relay accepted us and will fan frames in both directions. */
182
+ onAuthenticated?: (info: { role?: string; hostId?: string }) => void;
183
+ /**
184
+ * `auth_error`. The relay closes with `4003` right after, so this only adds
185
+ * the *reason* — the close code cannot say why.
186
+ */
187
+ onAuthError?: (info: { error: string }) => void;
188
+ /** The other end of this channel is gone (the phone, or the machine). */
189
+ onPeerDisconnected?: () => void;
190
+ /**
191
+ * The relay dropped frames because our peer stopped draining, and asked for
192
+ * a resync: whatever we were sending incrementally is no longer complete.
193
+ */
194
+ onResyncRequired?: () => void;
195
+ /** A frame from the relay (phone request or notification). */
196
+ onFrame?: (frame: RelayEnvelope) => void;
197
+ /**
198
+ * `4003` whose reason says the stored identity is stale (see
199
+ * `RELAY_STALE_IDENTITY_ERRORS`). Reported **instead of** `onFatal` because it
200
+ * is not the end of the road: replacing the credential means an HTTP
201
+ * registration, which only the credential's owner can do — this class knows
202
+ * nothing about that. Nothing is retried from here either way.
203
+ */
204
+ onStaleIdentity?: (info: { error: string }) => void;
205
+ /** The connection is gone for good — `4003`, or the retry budget ran out. */
206
+ onFatal?: (reason: string) => void;
207
+ }
208
+
209
+ export class RelayClient {
210
+ private state: RelayState = "idle";
211
+ private socket: RelaySocket | undefined;
212
+ private retryTimer: ReturnType<typeof setTimeout> | undefined;
213
+ private pingTimer: ReturnType<typeof setInterval> | undefined;
214
+ private attempts = 0;
215
+ private unavailableAttempts = 0;
216
+ private stopped = false;
217
+ /** Why the relay refused us, kept so the `4003` that follows can say more. */
218
+ private authError: { error: string } | undefined;
219
+ /**
220
+ * `auth_ok` has arrived. Until it does the relay parses *every* frame as the
221
+ * auth frame, and one it cannot parse closes the connection for good
222
+ * (`4003`, never retried) — so nothing but the auth frame may go out first.
223
+ */
224
+ private authed = false;
225
+ /** Frames held back until `auth_ok`, replayed in order once it lands. */
226
+ private outbox: string[] = [];
227
+ private outboxBytes = 0;
228
+
229
+ constructor(private readonly options: RelayClientOptions) {}
230
+
231
+ getState(): RelayState {
232
+ return this.state;
233
+ }
234
+
235
+ start(): void {
236
+ if (this.stopped || this.state === "connecting" || this.state === "open") {
237
+ return;
238
+ }
239
+ this.connect();
240
+ }
241
+
242
+ /** Tear down for good — no retry, no pending timers. */
243
+ stop(): void {
244
+ this.stopped = true;
245
+ this.clearTimers();
246
+ this.clearOutbox();
247
+ const socket = this.socket;
248
+ this.socket = undefined;
249
+ this.state = "closed";
250
+ // Close only after detaching handlers: a deliberate stop must not be
251
+ // mistaken for a dropped link and schedule a reconnect.
252
+ if (socket) {
253
+ socket.onopen = null;
254
+ socket.onmessage = null;
255
+ socket.onclose = null;
256
+ socket.onerror = null;
257
+ try {
258
+ socket.close(1000, "client stop");
259
+ } catch {
260
+ // Already gone.
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Send one envelope. Returns false when there is nowhere to put it (the link
267
+ * is down) — the caller may drop the frame; the phone re-syncs from a
268
+ * snapshot on reconnect, so a gap is recoverable, a crash is not. A frame
269
+ * handed over before the handshake completes is parked rather than dropped:
270
+ * it still goes out, in order, the moment the relay accepts us.
271
+ */
272
+ send(frame: RelayEnvelope): boolean {
273
+ return this.write(JSON.stringify(frame));
274
+ }
275
+
276
+ /**
277
+ * Send one control frame. Same channel, different judge: control frames are
278
+ * the only ones the relay interprets rather than forwards, so they are plain
279
+ * objects (`{type}`) rather than JSON-RPC envelopes (`{method}`).
280
+ */
281
+ sendControl(frame: Record<string, unknown>): boolean {
282
+ return this.write(JSON.stringify(frame));
283
+ }
284
+
285
+ /**
286
+ * The handshake owns the first frame. On an unauthenticated connection the
287
+ * relay parses *every* frame it receives as `auth` (it has no way to tell a
288
+ * straggler apart from the handshake), and a frame that does not parse is a
289
+ * permanent `4003` — so going out early is the difference between "the phone
290
+ * is reconnecting" and "this machine never comes back".
291
+ */
292
+ private write(payload: string): boolean {
293
+ const socket = this.socket;
294
+ if (!socket || this.state !== "open") return false;
295
+ if (!this.authed) return this.park(payload);
296
+ return this.writeNow(payload);
297
+ }
298
+
299
+ /**
300
+ * Straight to the wire: the auth frame, and the replay after `auth_ok`. Kept
301
+ * separate from `write` so the one frame that is allowed before the handshake
302
+ * completes has to say so at the call site.
303
+ */
304
+ private writeNow(payload: string): boolean {
305
+ const socket = this.socket;
306
+ if (!socket || this.state !== "open") return false;
307
+
308
+ const buffered = socket.bufferedAmount ?? 0;
309
+ if (buffered > RELAY_MAX_BUFFERED_BYTES) {
310
+ this.dropForBackpressure();
311
+ return false;
312
+ }
313
+
314
+ try {
315
+ socket.send(payload);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Hold one frame until `auth_ok`. The window is a single relay round trip
324
+ * (the relay verifies the token against the database before answering), so
325
+ * this stays tiny — but it is capped anyway, at the same ceiling `writeNow`
326
+ * uses: a queue that can grow without bound is exactly what the backpressure
327
+ * judgement exists to prevent, and the remedy is the same (drop the link,
328
+ * let the phone resync).
329
+ */
330
+ private park(payload: string): boolean {
331
+ const bytes = Buffer.byteLength(payload, "utf8");
332
+ if (this.outboxBytes + bytes > RELAY_MAX_BUFFERED_BYTES) {
333
+ this.dropForBackpressure();
334
+ return false;
335
+ }
336
+ this.outbox.push(payload);
337
+ this.outboxBytes += bytes;
338
+ return true;
339
+ }
340
+
341
+ /** Replay what was parked, in order. Called the moment `auth_ok` lands. */
342
+ private flushOutbox(): void {
343
+ const queued = this.outbox;
344
+ this.clearOutbox();
345
+ for (const payload of queued) {
346
+ // The gate is behind us, so these are ordinary sends again — the
347
+ // backpressure judgement still applies to every one of them.
348
+ if (!this.writeNow(payload)) return;
349
+ }
350
+ }
351
+
352
+ private clearOutbox(): void {
353
+ this.outbox = [];
354
+ this.outboxBytes = 0;
355
+ }
356
+
357
+ // ── Connection lifecycle ──────────────────────────────────────
358
+
359
+ private connect(): void {
360
+ this.state = "connecting";
361
+ // Every socket starts unauthenticated, however the last one ended.
362
+ this.authed = false;
363
+ let socket: RelaySocket;
364
+ try {
365
+ socket = (this.options.createSocket ?? defaultRelaySocketFactory)(
366
+ this.options.url,
367
+ );
368
+ } catch (err) {
369
+ this.state = "closed";
370
+ this.options.onFatal?.((err as Error).message);
371
+ return;
372
+ }
373
+ this.socket = socket;
374
+
375
+ socket.onopen = () => {
376
+ if (this.socket !== socket) return;
377
+ this.state = "open";
378
+ this.attempts = 0;
379
+ this.unavailableAttempts = 0;
380
+ // The relay authenticates the runner before it will fan anything out, and
381
+ // it reads the version from the top level of this same frame. Ten seconds
382
+ // without it and the relay closes with `auth_timeout` (4003). This is the
383
+ // one frame that may travel before `auth_ok`, so it goes to the wire
384
+ // directly instead of through the gate in `write`.
385
+ this.writeNow(
386
+ JSON.stringify({
387
+ type: "auth",
388
+ credential: {
389
+ type: this.options.credential.type,
390
+ token: this.options.credential.token,
391
+ },
392
+ version: this.options.version,
393
+ }),
394
+ );
395
+ this.startPing();
396
+ this.options.onOpen?.();
397
+ };
398
+
399
+ socket.onmessage = (event) => {
400
+ if (this.socket !== socket) return;
401
+ // Control frames are judged first and never reach the JSON-RPC layer:
402
+ // they carry no `method`, so the envelope parser would drop them
403
+ // silently — which is how "the version floor is too low" and "the token
404
+ // was revoked" used to look exactly alike from here.
405
+ const control = parseRelayControlFrame(event.data);
406
+ if (control) {
407
+ this.handleControlFrame(control);
408
+ return;
409
+ }
410
+ const frame = parseRelayFrame(event.data);
411
+ if (frame) this.options.onFrame?.(frame);
412
+ };
413
+
414
+ socket.onerror = () => {
415
+ // The close event that follows carries the code; nothing to do here.
416
+ };
417
+
418
+ socket.onclose = (event) => {
419
+ if (this.socket !== socket) return;
420
+ this.socket = undefined;
421
+ this.stopPing();
422
+ this.handleClose(event?.code, event?.reason);
423
+ };
424
+ }
425
+
426
+ private handleClose(code: number | undefined, reason?: string): void {
427
+ if (this.stopped) {
428
+ this.state = "closed";
429
+ return;
430
+ }
431
+
432
+ if (code === RELAY_CLOSE_UNAVAILABLE && reason === RELAY_CLOSE_REPLACED) {
433
+ // Another process on this machine dialled in and the relay handed the
434
+ // channel over to it. Retrying would take the channel back, and then the
435
+ // other side would retry too: the two would replace each other every two
436
+ // seconds, forever — every successful connect resets the retry budget, so
437
+ // neither ever exceeds it. So this side steps down for good; dialling
438
+ // again from here is an explicit user action and takes the channel back.
439
+ this.stopped = true;
440
+ this.state = "closed";
441
+ this.options.onFatal?.(
442
+ "another wave process on this machine took over remote control",
443
+ );
444
+ return;
445
+ }
446
+
447
+ if (code === RELAY_CLOSE_UNAUTHORIZED) {
448
+ this.stopped = true;
449
+ this.state = "closed";
450
+ const error = this.authError?.error;
451
+ if (isStaleIdentityError(error)) {
452
+ // No reconnect from here: the fix is a registration round trip, not a
453
+ // retry (dialling again with the same dead token can only repeat this).
454
+ this.options.onStaleIdentity?.({ error });
455
+ return;
456
+ }
457
+ this.options.onFatal?.(
458
+ this.authError
459
+ ? `relay rejected the credential (4003): ${this.authError.error}`
460
+ : "relay rejected the credential (4003)",
461
+ );
462
+ return;
463
+ }
464
+
465
+ if (code === RELAY_CLOSE_UNAVAILABLE) {
466
+ this.unavailableAttempts += 1;
467
+ if (this.unavailableAttempts > RELAY_MAX_UNAVAILABLE_ATTEMPTS) {
468
+ this.stopped = true;
469
+ this.state = "closed";
470
+ this.options.onFatal?.("relay reported the host unavailable (4001)");
471
+ return;
472
+ }
473
+ } else {
474
+ this.attempts += 1;
475
+ if (this.attempts > RELAY_MAX_ATTEMPTS) {
476
+ this.stopped = true;
477
+ this.state = "closed";
478
+ this.options.onFatal?.(
479
+ `relay connection lost${code === undefined ? "" : ` (${code})`}`,
480
+ );
481
+ return;
482
+ }
483
+ }
484
+
485
+ this.state = "closed";
486
+ this.retryTimer = setTimeout(() => {
487
+ this.retryTimer = undefined;
488
+ if (!this.stopped) this.connect();
489
+ }, RELAY_RETRY_DELAY_MS);
490
+ this.retryTimer.unref?.();
491
+ }
492
+
493
+ /**
494
+ * The phone stopped draining. Queueing would grow this process without bound,
495
+ * so the link is dropped instead: the phone reconnects and re-reads the tail
496
+ * through `getMessages({ since })`. `4008` tells it not to bother re-sending
497
+ * whatever it had buffered.
498
+ */
499
+ private dropForBackpressure(): void {
500
+ const socket = this.socket;
501
+ if (!socket) return;
502
+ this.socket = undefined;
503
+ this.stopPing();
504
+ socket.onopen = null;
505
+ socket.onmessage = null;
506
+ socket.onclose = null;
507
+ socket.onerror = null;
508
+ try {
509
+ socket.close(RELAY_CLOSE_BACKPRESSURE, "backpressure");
510
+ } catch {
511
+ // Already gone.
512
+ }
513
+ this.attempts = 0;
514
+ this.state = "closed";
515
+ if (!this.stopped) {
516
+ this.retryTimer = setTimeout(() => {
517
+ this.retryTimer = undefined;
518
+ if (!this.stopped) this.connect();
519
+ }, RELAY_RETRY_DELAY_MS);
520
+ this.retryTimer.unref?.();
521
+ }
522
+ }
523
+
524
+ /**
525
+ * The relay owns the control channel, so it is the one that gets to refuse
526
+ * us — and its refusals are not interchangeable: a revoked token and a build
527
+ * the relay considers too old both end in `4003`, and only the frame tells
528
+ * them apart.
529
+ */
530
+ private handleControlFrame(frame: RelayControlFrame): void {
531
+ switch (frame.type) {
532
+ case "auth_ok":
533
+ this.authError = undefined;
534
+ this.authed = true;
535
+ // Before anything the callback sends: parked frames are older, and the
536
+ // phone has to see them in the order they were produced.
537
+ this.flushOutbox();
538
+ this.options.onAuthenticated?.({
539
+ ...(frame.role !== undefined ? { role: frame.role } : {}),
540
+ ...(frame.hostId !== undefined ? { hostId: frame.hostId } : {}),
541
+ });
542
+ return;
543
+ case "auth_error":
544
+ this.authError = { error: frame.error };
545
+ this.options.onAuthError?.(this.authError);
546
+ return;
547
+ case "peer_disconnected":
548
+ this.options.onPeerDisconnected?.();
549
+ return;
550
+ case "resync_required":
551
+ this.options.onResyncRequired?.();
552
+ return;
553
+ case "ping":
554
+ this.sendControl({ type: "pong" });
555
+ return;
556
+ case "pong":
557
+ return;
558
+ }
559
+ }
560
+
561
+ /** Give up for good, with a reason worth showing a human. */
562
+ private fail(reason: string): void {
563
+ this.stopped = true;
564
+ this.clearTimers();
565
+ this.clearOutbox();
566
+ this.state = "closed";
567
+ this.options.onFatal?.(reason);
568
+ }
569
+
570
+ private startPing(): void {
571
+ this.stopPing();
572
+ this.pingTimer = setInterval(() => {
573
+ // Application-level on purpose: browsers cannot send WebSocket control
574
+ // pings, and this connection is the one a phone page would hold too. The
575
+ // relay answers it (`pong`) and forwards neither direction — with a
576
+ // `method` envelope instead, every heartbeat would be broadcast to every
577
+ // subscriber of this machine.
578
+ this.sendControl({ type: "ping" });
579
+ }, RELAY_PING_INTERVAL_MS);
580
+ this.pingTimer.unref?.();
581
+ }
582
+
583
+ private stopPing(): void {
584
+ if (this.pingTimer !== undefined) {
585
+ clearInterval(this.pingTimer);
586
+ this.pingTimer = undefined;
587
+ }
588
+ }
589
+
590
+ private clearTimers(): void {
591
+ if (this.retryTimer !== undefined) {
592
+ clearTimeout(this.retryTimer);
593
+ this.retryTimer = undefined;
594
+ }
595
+ this.stopPing();
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Read one **control** frame off the wire, or `undefined` when this is payload.
601
+ *
602
+ * The judge is the relay's own, read in reverse: a JSON object with a top-level
603
+ * string `type` and no `jsonrpc` marker. A `type` we do not know is dropped
604
+ * rather than thrown — a newer relay may grow control frames, and none of them
605
+ * should be able to disconnect a machine — and so is a malformed one, for the
606
+ * same reason `parseRelayFrame` drops those.
607
+ */
608
+ export function parseRelayControlFrame(
609
+ data: unknown,
610
+ ): RelayControlFrame | undefined {
611
+ if (typeof data !== "string") return undefined;
612
+ let parsed: unknown;
613
+ try {
614
+ parsed = JSON.parse(data);
615
+ } catch {
616
+ return undefined;
617
+ }
618
+ if (typeof parsed !== "object" || parsed === null) return undefined;
619
+ const frame = parsed as Record<string, unknown>;
620
+ const type = asString(frame.type);
621
+ if (type === undefined || frame.jsonrpc !== undefined) return undefined;
622
+
623
+ const hostId = asString(frame.hostId);
624
+ switch (type) {
625
+ case "auth_ok": {
626
+ const role = asString(frame.role);
627
+ return {
628
+ type: "auth_ok",
629
+ ...(role !== undefined ? { role } : {}),
630
+ ...(hostId !== undefined ? { hostId } : {}),
631
+ };
632
+ }
633
+ case "auth_error": {
634
+ const error = asString(frame.error);
635
+ if (error === undefined) return undefined;
636
+ // Any `minVersion` an older relay still sends is dropped here: the
637
+ // version floor is no longer part of the contract.
638
+ return { type: "auth_error", error };
639
+ }
640
+ case "peer_disconnected":
641
+ return {
642
+ type: "peer_disconnected",
643
+ ...(hostId !== undefined ? { hostId } : {}),
644
+ };
645
+ case "resync_required":
646
+ return {
647
+ type: "resync_required",
648
+ ...(hostId !== undefined ? { hostId } : {}),
649
+ };
650
+ case "ping":
651
+ return { type: "ping" };
652
+ case "pong":
653
+ return { type: "pong" };
654
+ default:
655
+ return undefined;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Read one envelope off the wire. A malformed frame is dropped rather than
661
+ * tearing the connection down: the relay is a shared component and a future
662
+ * control frame we do not understand must not take the phone offline.
663
+ *
664
+ * Only frames **without** a top-level `type` reach here — control frames are
665
+ * claimed by `parseRelayControlFrame` first, so this stays a JSON-RPC parser.
666
+ */
667
+ export function parseRelayFrame(data: unknown): RelayEnvelope | undefined {
668
+ if (typeof data !== "string") return undefined;
669
+ let parsed: unknown;
670
+ try {
671
+ parsed = JSON.parse(data);
672
+ } catch {
673
+ return undefined;
674
+ }
675
+ if (typeof parsed !== "object" || parsed === null) return undefined;
676
+ const frame = parsed as {
677
+ method?: unknown;
678
+ params?: unknown;
679
+ sessionId?: unknown;
680
+ id?: unknown;
681
+ };
682
+ if (typeof frame.method !== "string") return undefined;
683
+ const result: RelayEnvelope = { method: frame.method };
684
+ if (frame.params !== undefined) result.params = frame.params;
685
+ if (typeof frame.sessionId === "string") result.sessionId = frame.sessionId;
686
+ if (typeof frame.id === "string" || typeof frame.id === "number") {
687
+ result.id = frame.id;
688
+ }
689
+ return result;
690
+ }