@ai-matrx/desktop-protocol 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ The phone console (lane E) needs a client that comes back the instant the person does.
6
+
7
+ - `client.wake({ probeMs })` — reconnecting: dial now instead of waiting out the backoff; open:
8
+ send a ping and, if nothing is heard within `probeMs` (default 4000), treat the socket as dead
9
+ and redial at once. A phone resumed from sleep often holds a socket the OS already killed.
10
+ - `bindDesktopWake(client)` (`./client`) and `useDesktopWake()` (`./react`) call `wake()` on
11
+ `visibilitychange` → visible, `pageshow`, `online` and `focus`.
12
+ - A caller-started `session.attach` (a page reload reattaching by the resource id it kept in the
13
+ URL) is now a resource stream from its first byte: `resourceId` is set from the params,
14
+ `lastSeq` starts at `since_seq` (no false `seq_gap` on the first replayed frame), and the stream
15
+ reattaches after a later drop like the stream that created the resource.
16
+
17
+ Consumer action: none required. Phone and browser consoles should call `useDesktopWake()` (or
18
+ `bindDesktopWake`) once per client.
19
+
20
+ ## 0.1.1
21
+
22
+ Automatic changed-only republish (docs/metadata drift since the last tag — see
23
+ `git diff npm/desktop-protocol/v0.1.0..npm/desktop-protocol/v0.1.1 -- apps/shared/desktop-protocol`).
24
+ No source changes intended and no consumer action required.
25
+
3
26
  ## 0.1.0
4
27
 
5
28
  First release — the Matrx 2 phone slice, lane A.
@@ -14,6 +37,13 @@ First release — the Matrx 2 phone slice, lane A.
14
37
  `@ai-matrx/realtime`'s `createBackoff`.
15
38
  - `./react` — `DesktopClientProvider`, `useDesktopConnection`, `useDesktopRequest`,
16
39
  `useDesktopEvent`.
40
+ - Relay ↔ device hop, as built by the relay (lane D): `BROADCAST_CID` (an ENVELOPE frame with
41
+ cid 0 from the device reaches every client), `connectionEndFrame` / `isConnectionEnd` (an
42
+ ENVELOPE frame with FIN and no payload: relay → device "client gone", device → relay "close this
43
+ client"), with zod twins `RelayHopEnvelopeHeader` and `RelayHopConnectionEnd`; the `/call` hop
44
+ shapes `RelayCallHello` (id `hello`) and `RelayCallEnvelope` (id `call`); close codes
45
+ `ABNORMAL` 1006 (how a relay deploy arrives), `DEVICE_RECONNECTED` 1012, `TRY_AGAIN_LATER` 1013,
46
+ and `CloseCodeValue` so Python sees the same table. Corpus cases 40–55.
17
47
  - Generated twins: `generated/desktop-protocol.schema.json` and
18
48
  `aidream/_generated/desktop_protocol.py` (Pydantic, `StrictModel` base, `OPS`/`EVENTS`,
19
49
  `validate_wire`), held identical by `pnpm generate --check`.
@@ -33,3 +63,4 @@ Defects found in the draft and fixed here (not preserved):
33
63
  ### Consumer action
34
64
 
35
65
  None — new package.
66
+
package/README.md CHANGED
@@ -14,7 +14,7 @@ pnpm add @ai-matrx/desktop-protocol
14
14
  | `@ai-matrx/desktop-protocol` | Schemas for the envelope and every `cap.op`, `OPS` / `EVENTS` registries, `ErrorCode`, close codes, `DesktopProtocolError`. Every schema name is also its inferred type. |
15
15
  | `@ai-matrx/desktop-protocol/frame` | The 16-byte binary frame codec. Zod-free (the relay Worker's hot path). |
16
16
  | `@ai-matrx/desktop-protocol/client` | `createDesktopClient` — reconnecting client with typed requests, credit-gated streams, transparent terminal reattach, heartbeat and relay reauth. |
17
- | `@ai-matrx/desktop-protocol/react` | `DesktopClientProvider`, `useDesktopConnection`, `useDesktopRequest`, `useDesktopEvent`. |
17
+ | `@ai-matrx/desktop-protocol/react` | `DesktopClientProvider`, `useDesktopConnection`, `useDesktopRequest`, `useDesktopEvent`, `useDesktopWake`. |
18
18
 
19
19
  ## Client
20
20
 
@@ -51,7 +51,15 @@ term.write("ls\n");
51
51
  those, never on messages. A dropped socket rejects unary work with `DEVICE_OFFLINE` (retryable).
52
52
  - **Terminal closes** (`4010` revoked, `4003` forbidden, `4004` not found, `4009` replaced, `4426`
53
53
  upgrade) end the client for good; everything else reconnects with the shared
54
- `@ai-matrx/realtime` backoff (stability reset + jitter).
54
+ `@ai-matrx/realtime` backoff (stability reset + jitter) — including `1006` (a relay deploy drops
55
+ every socket without a close frame) and `1012` (the relay's "the device reconnected": hello and
56
+ reattach against the new core session).
57
+ - **Coming back:** `client.wake()` (or `bindDesktopWake(client)` / `useDesktopWake()`, which call it
58
+ on `visibilitychange`, `pageshow`, `online` and `focus`) dials at once while reconnecting and
59
+ probes an open socket with a ping, redialing if nothing answers within 4 s.
60
+ - **Reload reattach:** keep `stream.resourceId` and `stream.lastSeq` (e.g. in the URL); after a reload
61
+ `client.stream("session.attach", { resource_id, since_seq })` replays the gap and then behaves
62
+ exactly like the original stream, including surviving later drops.
55
63
  - Background problems (malformed messages, sequence gaps, heartbeat death, reconnect alarm) go to
56
64
  `onDiagnostic` — loud by default (`console.warn`).
57
65
 
package/dist/client.cjs CHANGED
@@ -22,6 +22,7 @@ var client_exports = {};
22
22
  __export(client_exports, {
23
23
  DesktopProtocolError: () => DesktopProtocolError,
24
24
  RECONNECT_ALARM_ATTEMPTS: () => import_realtime.RECONNECT_ALARM_ATTEMPTS,
25
+ bindDesktopWake: () => bindDesktopWake,
25
26
  createDesktopClient: () => createDesktopClient,
26
27
  isDesktopProtocolError: () => isDesktopProtocolError
27
28
  });
@@ -283,7 +284,15 @@ var CoreToClientMsg = import_zod3.z.discriminatedUnion("type", [ResponseMsg, Err
283
284
  var CloseCode = {
284
285
  NORMAL: 1e3,
285
286
  GOING_AWAY: 1001,
286
- // relay deploy / app quit — reconnect immediately
287
+ // app quit; relay: the device was silent for 75 s — reconnect
288
+ /** Never sent by anyone: what a socket reports when it drops without a close frame. A relay
289
+ * deploy (every Durable Object restarts) arrives as this — reconnect immediately. */
290
+ ABNORMAL: 1006,
291
+ /** Relay → clients: the device (re)connected, so the core session they spoke to is gone —
292
+ * reconnect, hello, and reattach with since_seq. */
293
+ DEVICE_RECONNECTED: 1012,
294
+ TRY_AGAIN_LATER: 1013,
295
+ // relay: signing keys or the device registry unreachable — retry
287
296
  AUTH_INVALID: 4001,
288
297
  AUTH_FORBIDDEN: 4003,
289
298
  DEVICE_NOT_FOUND: 4004,
@@ -296,6 +305,7 @@ var CloseCode = {
296
305
  PROTOCOL_ERROR: 4400,
297
306
  UPGRADE_REQUIRED: 4426
298
307
  };
308
+ var CloseCodeValue = import_zod3.z.literal([1e3, 1001, 1006, 1012, 1013, 4001, 4003, 4004, 4008, 4009, 4010, 4400, 4426]);
299
309
 
300
310
  // src/schema/frame.ts
301
311
  var import_zod4 = require("zod");
@@ -307,6 +317,22 @@ var FrameHeader = import_zod4.z.strictObject({
307
317
  sid: u32,
308
318
  seq: u32
309
319
  });
320
+ var RelayHopEnvelopeHeader = import_zod4.z.strictObject({
321
+ version: import_zod4.z.literal(FRAME_VERSION),
322
+ flags: import_zod4.z.literal(0),
323
+ channel: import_zod4.z.literal(255),
324
+ cid: u32,
325
+ sid: import_zod4.z.literal(0),
326
+ seq: import_zod4.z.literal(0)
327
+ });
328
+ var RelayHopConnectionEnd = import_zod4.z.strictObject({
329
+ version: import_zod4.z.literal(FRAME_VERSION),
330
+ flags: import_zod4.z.literal(1),
331
+ channel: import_zod4.z.literal(255),
332
+ cid: import_zod4.z.int().min(1).max(4294967295),
333
+ sid: import_zod4.z.literal(0),
334
+ seq: import_zod4.z.literal(0)
335
+ });
310
336
  var DEFAULT_WINDOW_BYTES = 262144;
311
337
  var DEFAULT_MAX_FRAME_PAYLOAD_BYTES = 65536;
312
338
 
@@ -616,6 +642,22 @@ var RelayCallResponse = import_zod9.z.discriminatedUnion("ok", [
616
642
  import_zod9.z.strictObject({ ok: import_zod9.z.literal(true), result: import_zod9.z.unknown() }),
617
643
  import_zod9.z.strictObject({ ok: import_zod9.z.literal(false), error: ProtocolErrorBody })
618
644
  ]);
645
+ var RELAY_CALL_HELLO_ID = "hello";
646
+ var RELAY_CALL_REQUEST_ID = "call";
647
+ var RelayCallHello = import_zod9.z.strictObject({
648
+ type: import_zod9.z.literal("hello"),
649
+ id: import_zod9.z.literal(RELAY_CALL_HELLO_ID),
650
+ protocolVersion: ProtocolVersion,
651
+ clientType: import_zod9.z.literal("server"),
652
+ clientVersion: import_zod9.z.string().min(1).max(32)
653
+ });
654
+ var RelayCallEnvelope = import_zod9.z.strictObject({
655
+ id: import_zod9.z.literal(RELAY_CALL_REQUEST_ID),
656
+ type: import_zod9.z.literal("request"),
657
+ cap: import_zod9.z.string().min(1).max(32),
658
+ op: import_zod9.z.string().min(1).max(64),
659
+ params: import_zod9.z.unknown()
660
+ });
619
661
 
620
662
  // src/schema/registry.ts
621
663
  var FS_ERR = ["NOT_FOUND", "PERMISSION_DENIED_OS", "INVALID_PARAMS", "RESOURCE_BUSY", "INTERNAL"];
@@ -720,7 +762,9 @@ function createDesktopClient(options) {
720
762
  let helloId = null;
721
763
  let reconnectTimer = null;
722
764
  let heartbeatTimer = null;
765
+ let probeTimer = null;
723
766
  let lastHeardAt = 0;
767
+ let heardCount = 0;
724
768
  let closedByUser = false;
725
769
  const newId = (prefix) => `${prefix}${(++nextId).toString(36)}`;
726
770
  function setState(patch) {
@@ -775,6 +819,7 @@ function createDesktopClient(options) {
775
819
  ws.onmessage = (event) => {
776
820
  if (generation !== socketGeneration) return;
777
821
  lastHeardAt = timers.now();
822
+ heardCount++;
778
823
  onMessage(event.data);
779
824
  };
780
825
  ws.onerror = () => {
@@ -816,6 +861,10 @@ function createDesktopClient(options) {
816
861
  timers.clearTimeout(heartbeatTimer);
817
862
  heartbeatTimer = null;
818
863
  }
864
+ if (probeTimer !== null) {
865
+ timers.clearTimeout(probeTimer);
866
+ probeTimer = null;
867
+ }
819
868
  const terminal = code === null ? void 0 : TERMINAL_CLOSE.get(code);
820
869
  const lost = error ?? (terminal ? new DesktopProtocolError(terminal[0], terminal[1]) : new DesktopProtocolError("DEVICE_OFFLINE", "Connection lost", { retryable: true }));
821
870
  for (const [id, p] of pending) {
@@ -1192,15 +1241,16 @@ function createDesktopClient(options) {
1192
1241
  });
1193
1242
  result.catch(() => void 0);
1194
1243
  done.catch(() => void 0);
1244
+ const attach = op === "session.attach" ? params : null;
1195
1245
  const s = {
1196
1246
  op,
1197
1247
  handlers,
1198
1248
  options: opts,
1199
1249
  id: newId("s"),
1200
1250
  sid: null,
1201
- resourceId: null,
1202
- lastSeq: 0,
1203
- expectSeq: 1,
1251
+ resourceId: attach ? attach.resource_id : null,
1252
+ lastSeq: attach?.since_seq ?? 0,
1253
+ expectSeq: attach ? attach.since_seq === void 0 ? null : attach.since_seq + 1 : 1,
1204
1254
  windowBytes: opts.windowBytes ?? DEFAULT_WINDOW_BYTES,
1205
1255
  consumedUncredited: 0,
1206
1256
  sendWindow: DEFAULT_WINDOW_BYTES,
@@ -1289,6 +1339,34 @@ function createDesktopClient(options) {
1289
1339
  function reauth(token) {
1290
1340
  sendJson({ type: "relay", op: "reauth", token });
1291
1341
  }
1342
+ function wake(opts = {}) {
1343
+ if (closedByUser || state.status === "closed") return;
1344
+ if (state.status === "idle" || state.status === "reconnecting") {
1345
+ connect();
1346
+ return;
1347
+ }
1348
+ if (state.status === "connecting" || !socket || probeTimer !== null) return;
1349
+ const generation = socketGeneration;
1350
+ const heardBefore = heardCount;
1351
+ try {
1352
+ socket.send(PING_TEXT);
1353
+ } catch {
1354
+ }
1355
+ probeTimer = timers.setTimeout(() => {
1356
+ probeTimer = null;
1357
+ if (generation !== socketGeneration || !socket) return;
1358
+ if (heardCount > heardBefore) return;
1359
+ const silentMs = timers.now() - lastHeardAt;
1360
+ diagnose({ kind: "heartbeat_timeout", silentMs });
1361
+ const dead = socket;
1362
+ onSocketGone(generation, null, new DesktopProtocolError("TIMEOUT", "Connection went silent", { retryable: true }));
1363
+ try {
1364
+ dead.close(CloseCode.NORMAL, "wake probe timeout");
1365
+ } catch {
1366
+ }
1367
+ if (state.status === "reconnecting") connect();
1368
+ }, opts.probeMs ?? 4e3);
1369
+ }
1292
1370
  return {
1293
1371
  connect,
1294
1372
  close,
@@ -1303,7 +1381,29 @@ function createDesktopClient(options) {
1303
1381
  request,
1304
1382
  stream,
1305
1383
  on,
1306
- reauth
1384
+ reauth,
1385
+ wake
1386
+ };
1387
+ }
1388
+
1389
+ // src/client/wake.ts
1390
+ function bindDesktopWake(client, options = {}) {
1391
+ const targets = options.targets ?? (typeof window !== "undefined" && typeof document !== "undefined" ? { window, document } : null);
1392
+ if (!targets) return () => void 0;
1393
+ const wakeOptions = options.probeMs === void 0 ? {} : { probeMs: options.probeMs };
1394
+ const onVisibility = () => {
1395
+ if (targets.document.visibilityState === "visible") client.wake(wakeOptions);
1396
+ };
1397
+ const onWake = () => client.wake(wakeOptions);
1398
+ targets.document.addEventListener("visibilitychange", onVisibility);
1399
+ targets.window.addEventListener("pageshow", onWake);
1400
+ targets.window.addEventListener("online", onWake);
1401
+ targets.window.addEventListener("focus", onWake);
1402
+ return () => {
1403
+ targets.document.removeEventListener("visibilitychange", onVisibility);
1404
+ targets.window.removeEventListener("pageshow", onWake);
1405
+ targets.window.removeEventListener("online", onWake);
1406
+ targets.window.removeEventListener("focus", onWake);
1307
1407
  };
1308
1408
  }
1309
1409
  //# sourceMappingURL=client.cjs.map