@opengeni/react 0.52.1 → 1.0.1

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 (132) hide show
  1. package/README.md +51 -1
  2. package/dist/artifacts.d.ts +1 -1
  3. package/dist/artifacts.js +43 -14
  4. package/dist/artifacts.js.map +1 -1
  5. package/dist/{chunk-ROIRTM37.js → chunk-5UQPNLIO.js} +6 -6
  6. package/dist/{chunk-JR3QFY6B.js → chunk-6KLX2RH5.js} +49 -2
  7. package/dist/chunk-6KLX2RH5.js.map +1 -0
  8. package/dist/{chunk-FWS3KC3Z.js → chunk-B6WPZSTT.js} +104 -104
  9. package/dist/{chunk-FWS3KC3Z.js.map → chunk-B6WPZSTT.js.map} +1 -1
  10. package/dist/{chunk-HDBG7XGO.js → chunk-C5ZPSSS5.js} +50 -1
  11. package/dist/chunk-C5ZPSSS5.js.map +1 -0
  12. package/dist/{chunk-X4S56Y2H.js → chunk-HA46CYQK.js} +135 -11
  13. package/dist/chunk-HA46CYQK.js.map +1 -0
  14. package/dist/{chunk-724BW4TW.js → chunk-IGC6A2ZI.js} +2 -2
  15. package/dist/chunk-LLOLUHVG.js +5937 -0
  16. package/dist/chunk-LLOLUHVG.js.map +1 -0
  17. package/dist/{chunk-WYA65Y3F.js → chunk-MKPIAGVL.js} +13 -3
  18. package/dist/chunk-MKPIAGVL.js.map +1 -0
  19. package/dist/chunk-R3AZOAB5.js +933 -0
  20. package/dist/chunk-R3AZOAB5.js.map +1 -0
  21. package/dist/{chunk-23EJ676W.js → chunk-XBZWOM2K.js} +9 -4
  22. package/dist/chunk-XBZWOM2K.js.map +1 -0
  23. package/dist/{chunk-JAGDUCZQ.js → chunk-Y7OEDUJ5.js} +17 -13
  24. package/dist/chunk-Y7OEDUJ5.js.map +1 -0
  25. package/dist/client.d.ts +6 -4
  26. package/dist/components/artifacts/index.d.ts +1 -0
  27. package/dist/components/artifacts/published-html-artifact-frame.d.ts +14 -0
  28. package/dist/components/browser-input.d.ts +7 -0
  29. package/dist/components/browser-viewer.d.ts +5 -4
  30. package/dist/components/computer-viewer.d.ts +2 -1
  31. package/dist/components/desktop-viewer.d.ts +26 -2
  32. package/dist/components/interaction-intervention-banner.d.ts +11 -0
  33. package/dist/components/sandbox-terminal.d.ts +4 -1
  34. package/dist/components/sandbox-workspace.d.ts +3 -0
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/use-auth-runs.d.ts +32 -0
  37. package/dist/hooks/use-browser-downloads.d.ts +25 -0
  38. package/dist/hooks/use-browser-identities.d.ts +5 -1
  39. package/dist/hooks/use-browser-session.d.ts +2 -1
  40. package/dist/hooks/use-browser-sessions.d.ts +1 -1
  41. package/dist/hooks/use-channels.d.ts +26 -0
  42. package/dist/hooks/use-computer-frame-stream.d.ts +1 -1
  43. package/dist/hooks/use-computer-session.d.ts +2 -1
  44. package/dist/hooks/use-computer-sessions.d.ts +1 -1
  45. package/dist/hooks/use-desktop-stream.d.ts +4 -0
  46. package/dist/hooks/use-interaction-interventions.d.ts +27 -0
  47. package/dist/hooks/use-interaction-invalidation.d.ts +19 -0
  48. package/dist/hooks/use-network-routes.d.ts +25 -0
  49. package/dist/hooks/use-site-auth-connections.d.ts +25 -0
  50. package/dist/hooks/use-supergrok-accounts.d.ts +26 -0
  51. package/dist/hooks/use-terminal-stream.d.ts +3 -0
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.js +1220 -1545
  54. package/dist/index.js.map +1 -1
  55. package/dist/interaction.d.ts +13 -3
  56. package/dist/interaction.js +520 -6
  57. package/dist/interaction.js.map +1 -1
  58. package/dist/interactive-workbench-viewers-TOHBXJEE.js +15 -0
  59. package/dist/lib/host-platform.d.ts +5 -0
  60. package/dist/lib/relay-wire.d.ts +20 -1
  61. package/dist/machines.js +3 -3
  62. package/dist/model-policy.d.ts +1 -1
  63. package/dist/model-policy.js +1 -1
  64. package/dist/provider.d.ts +3 -2
  65. package/dist/realtime/realtime-control.d.ts +6 -2
  66. package/dist/realtime.js +138 -24
  67. package/dist/realtime.js.map +1 -1
  68. package/dist/session-context.d.ts +10 -2
  69. package/dist/session-ui.js +3 -3
  70. package/dist/session.js +11 -11
  71. package/dist/timeline/types.d.ts +5 -0
  72. package/package.json +2 -2
  73. package/src/artifacts.ts +3 -0
  74. package/src/client.ts +73 -3
  75. package/src/components/artifacts/index.ts +5 -0
  76. package/src/components/artifacts/published-html-artifact-frame.tsx +38 -0
  77. package/src/components/browser-input.ts +65 -0
  78. package/src/components/browser-viewer.tsx +1287 -177
  79. package/src/components/computer-viewer.tsx +611 -114
  80. package/src/components/desktop-viewer.tsx +114 -1
  81. package/src/components/interaction-intervention-banner.tsx +99 -0
  82. package/src/components/machines/machine-detail.tsx +1 -1
  83. package/src/components/model-policy-picker.tsx +15 -0
  84. package/src/components/sandbox-terminal.tsx +24 -7
  85. package/src/components/sandbox-workspace.tsx +42 -4
  86. package/src/components/workspace-dock.tsx +59 -5
  87. package/src/hooks/use-attached-browsers.ts +13 -11
  88. package/src/hooks/use-auth-runs.ts +278 -0
  89. package/src/hooks/use-browser-downloads.ts +218 -0
  90. package/src/hooks/use-browser-frame-stream.ts +168 -59
  91. package/src/hooks/use-browser-identities.ts +47 -6
  92. package/src/hooks/use-browser-session.ts +94 -27
  93. package/src/hooks/use-browser-sessions.ts +19 -14
  94. package/src/hooks/use-channels.ts +99 -0
  95. package/src/hooks/use-computer-frame-stream.ts +184 -61
  96. package/src/hooks/use-computer-session.ts +22 -2
  97. package/src/hooks/use-computer-sessions.ts +19 -13
  98. package/src/hooks/use-desktop-stream.ts +39 -7
  99. package/src/hooks/use-interaction-interventions.ts +242 -0
  100. package/src/hooks/use-interaction-invalidation.ts +66 -0
  101. package/src/hooks/use-machine-chip.ts +13 -3
  102. package/src/hooks/use-network-routes.ts +214 -0
  103. package/src/hooks/use-site-auth-connections.ts +226 -0
  104. package/src/hooks/use-supergrok-accounts.ts +81 -0
  105. package/src/hooks/use-terminal-stream.ts +288 -55
  106. package/src/index.ts +8 -0
  107. package/src/interaction.ts +30 -0
  108. package/src/lib/host-platform.ts +10 -0
  109. package/src/lib/relay-wire.ts +43 -4
  110. package/src/lib/terminal-capability.ts +1 -1
  111. package/src/model-policy.ts +19 -4
  112. package/src/provider.tsx +133 -1
  113. package/src/realtime/realtime-control.tsx +181 -26
  114. package/src/session-context.ts +66 -1
  115. package/src/timeline/activity-rail.tsx +6 -0
  116. package/src/timeline/projection.ts +58 -0
  117. package/src/timeline/types.ts +2 -0
  118. package/styles/compiled.css +64 -6
  119. package/dist/chunk-23EJ676W.js.map +0 -1
  120. package/dist/chunk-6BBWWOOU.js +0 -82
  121. package/dist/chunk-6BBWWOOU.js.map +0 -1
  122. package/dist/chunk-7I2KB3OK.js +0 -3895
  123. package/dist/chunk-7I2KB3OK.js.map +0 -1
  124. package/dist/chunk-HDBG7XGO.js.map +0 -1
  125. package/dist/chunk-JAGDUCZQ.js.map +0 -1
  126. package/dist/chunk-JR3QFY6B.js.map +0 -1
  127. package/dist/chunk-WYA65Y3F.js.map +0 -1
  128. package/dist/chunk-X4S56Y2H.js.map +0 -1
  129. package/dist/interactive-workbench-viewers-KHNOHWQE.js +0 -13
  130. /package/dist/{chunk-ROIRTM37.js.map → chunk-5UQPNLIO.js.map} +0 -0
  131. /package/dist/{chunk-724BW4TW.js.map → chunk-IGC6A2ZI.js.map} +0 -0
  132. /package/dist/{interactive-workbench-viewers-KHNOHWQE.js.map → interactive-workbench-viewers-TOHBXJEE.js.map} +0 -0
@@ -7,7 +7,16 @@ import {
7
7
  ttydResizeFrame,
8
8
  TtydServerCommand,
9
9
  } from "@opengeni/sdk";
10
- import { useEffect, useMemo, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import {
12
+ decodeStreamFrame,
13
+ decodeStreamOpenAck,
14
+ encodeStreamFrame,
15
+ encodeStreamOpen,
16
+ type RelayChannel,
17
+ STREAM_KIND_PTY,
18
+ STREAM_ROLE_CLIENT,
19
+ } from "../lib/relay-wire";
11
20
 
12
21
  /** The ttyd connection lifecycle as surfaced to the component. */
13
22
  export type TerminalStreamStatus = "connecting" | "open" | "closed" | "error";
@@ -22,6 +31,9 @@ export type UseTerminalStreamOptions = {
22
31
  onOutput?: ((data: string) => void) | undefined;
23
32
  /** Called when ttyd sends a SET_WINDOW_TITLE frame. */
24
33
  onTitle?: ((title: string) => void) | undefined;
34
+ /** Requests a fresh negotiated terminal grant after an unexpected transport
35
+ * failure. Intentional disconnects and effect cleanup never call it. */
36
+ onReconnectNeeded?: (() => void) | undefined;
25
37
  /** Initial PTY size to seed the ttyd auth frame + first resize. */
26
38
  initialCols?: number | undefined;
27
39
  initialRows?: number | undefined;
@@ -47,6 +59,58 @@ export type UseTerminalStreamResult = {
47
59
  // input rather than ever executing a truncated shell command.
48
60
  export const MAX_PENDING_TERMINAL_INPUT_CODE_UNITS = 64 * 1024;
49
61
  const TERMINAL_CONNECT_EXPIRY_SKEW_MS = 5_000;
62
+ const TERMINAL_RECONNECT_INITIAL_MS = 100;
63
+ const TERMINAL_RECONNECT_MAX_MS = 3_000;
64
+ const RELAY_TAG_OPEN = 1;
65
+ const RELAY_TAG_OPEN_ACK = 2;
66
+ const RELAY_TAG_FRAME = 3;
67
+
68
+ function relayDatagram(tag: number, body: Uint8Array): ArrayBuffer {
69
+ const bytes = new Uint8Array(body.length + 1);
70
+ bytes[0] = tag;
71
+ bytes.set(body, 1);
72
+ return bytes.buffer;
73
+ }
74
+
75
+ function relayChannelFromUrl(url: string): RelayChannel {
76
+ const endpoint = new URL(url);
77
+ const channel: RelayChannel = {
78
+ channelId: endpoint.searchParams.get("channel") ?? "",
79
+ workspaceId: endpoint.searchParams.get("ws") ?? "",
80
+ agentId: endpoint.searchParams.get("agent") ?? "",
81
+ kind: STREAM_KIND_PTY,
82
+ port: Number(endpoint.searchParams.get("port") ?? "0"),
83
+ };
84
+ if (
85
+ !channel.channelId ||
86
+ !channel.workspaceId ||
87
+ !channel.agentId ||
88
+ !Number.isSafeInteger(channel.port) ||
89
+ channel.port <= 0
90
+ ) {
91
+ throw new Error("terminal relay endpoint is incomplete");
92
+ }
93
+ return channel;
94
+ }
95
+
96
+ function sendRelayInput(
97
+ socket: WebSocket,
98
+ channel: RelayChannel,
99
+ sequence: bigint,
100
+ data: string,
101
+ ): void {
102
+ socket.send(
103
+ relayDatagram(
104
+ RELAY_TAG_FRAME,
105
+ encodeStreamFrame({
106
+ channelId: channel.channelId,
107
+ seq: sequence.toString(),
108
+ data: new TextEncoder().encode(data),
109
+ producedAtMs: String(Date.now()),
110
+ }),
111
+ ),
112
+ );
113
+ }
50
114
 
51
115
  /** A bearer must remain valid long enough to finish a new websocket handshake.
52
116
  * Existing open sockets are unaffected: this is evaluated only when the
@@ -55,7 +119,12 @@ export function terminalStreamCredentialUsable(
55
119
  capability: Pick<TerminalCapability, "transport" | "url" | "expiresAt"> | null | undefined,
56
120
  nowMs = Date.now(),
57
121
  ): boolean {
58
- if (capability?.transport !== "pty-ws" || !capability.url) return false;
122
+ if (
123
+ (capability?.transport !== "pty-ws" && capability?.transport !== "relay-pty") ||
124
+ !capability.url
125
+ ) {
126
+ return false;
127
+ }
59
128
  if (!capability.expiresAt) return true;
60
129
  const expiresAt = Date.parse(capability.expiresAt);
61
130
  return Number.isFinite(expiresAt) && expiresAt > nowMs + TERMINAL_CONNECT_EXPIRY_SKEW_MS;
@@ -100,12 +169,21 @@ function closeSocket(socket: WebSocket | null): void {
100
169
  * SSR-safe: the socket open lives in `useEffect`, so a server render is a no-op.
101
170
  */
102
171
  export function useTerminalStream(options: UseTerminalStreamOptions): UseTerminalStreamResult {
103
- const { capability, onOutput, onTitle, initialCols, initialRows } = options;
172
+ const { capability, onOutput, onTitle, onReconnectNeeded, initialCols, initialRows } = options;
104
173
  const [status, setStatus] = useState<TerminalStreamStatus>("closed");
174
+ const [reconnectGeneration, setReconnectGeneration] = useState(0);
105
175
  const wsRef = useRef<WebSocket | null>(null);
106
176
  const pendingInputRef = useRef("");
107
177
  const socketFailedRef = useRef(false);
108
178
  const socketAuthenticatedRef = useRef(false);
179
+ const relayChannelRef = useRef<RelayChannel | null>(null);
180
+ const relayIdentityRef = useRef<string | null>(null);
181
+ const relayInputSequenceRef = useRef(0n);
182
+ const relayOutputSequenceRef = useRef(0n);
183
+ const reconnectAttemptRef = useRef(0);
184
+ const credentialIdentityRef = useRef<string | null>(null);
185
+ const refreshRequestedRef = useRef(false);
186
+ const intentionalCloseRef = useRef<WeakSet<WebSocket>>(new WeakSet());
109
187
  // Latest size, so a resize() before the socket opens is replayed on open, and a
110
188
  // reconnect seeds the right geometry.
111
189
  const sizeRef = useRef<{ cols: number; rows: number }>({
@@ -116,26 +194,43 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
116
194
  // render (the parent passes fresh closures each time).
117
195
  const onOutputRef = useRef(onOutput);
118
196
  const onTitleRef = useRef(onTitle);
197
+ const onReconnectNeededRef = useRef(onReconnectNeeded);
119
198
  onOutputRef.current = onOutput;
120
199
  onTitleRef.current = onTitle;
200
+ onReconnectNeededRef.current = onReconnectNeeded;
121
201
 
122
202
  const transport = capability?.transport ?? null;
123
203
  const url = capability?.url ?? null;
124
204
  const token = capability?.token ?? null;
125
205
  const expiresAt = capability?.expiresAt ?? null;
206
+ const transportRef = useRef(transport);
207
+ transportRef.current = transport;
126
208
 
127
209
  useEffect(() => {
128
210
  // SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
129
211
  // back to the Channel-A read-only firehose.
130
212
  if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
131
- if (transport !== "pty-ws") {
213
+ const relay = transport === "relay-pty";
214
+ if (transport !== "pty-ws" && !relay) {
132
215
  // A transport downgrade is a real semantic boundary. Do not replay input
133
216
  // captured for a PTY if this surface later changes back from firehose mode.
134
217
  pendingInputRef.current = "";
135
218
  socketAuthenticatedRef.current = false;
219
+ relayIdentityRef.current = null;
220
+ relayInputSequenceRef.current = 0n;
221
+ relayOutputSequenceRef.current = 0n;
222
+ reconnectAttemptRef.current = 0;
223
+ credentialIdentityRef.current = null;
224
+ refreshRequestedRef.current = false;
136
225
  setStatus("closed");
137
226
  return;
138
227
  }
228
+ const credentialIdentity = `${transport}\u0000${url ?? ""}\u0000${token ?? ""}\u0000${expiresAt ?? ""}`;
229
+ if (credentialIdentityRef.current !== credentialIdentity) {
230
+ credentialIdentityRef.current = credentialIdentity;
231
+ reconnectAttemptRef.current = 0;
232
+ refreshRequestedRef.current = false;
233
+ }
139
234
  // A changed/cleared credential is a new connection attempt boundary. It may
140
235
  // receive input while its exact grant is pending even if the prior socket
141
236
  // failed; the unchanged failed identity remains fenced because this effect
@@ -150,18 +245,42 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
150
245
  }
151
246
  if (!terminalStreamCredentialUsable({ transport, url, expiresAt })) {
152
247
  // A descriptor from an older rolling server may still carry a bearer that
153
- // aged out before this surface mounted. Wait for the exact fresh grant.
248
+ // aged out before this surface mounted. Ask once for the exact fresh grant.
154
249
  setStatus("closed");
250
+ if (!refreshRequestedRef.current) {
251
+ refreshRequestedRef.current = true;
252
+ onReconnectNeededRef.current?.();
253
+ }
155
254
  return;
156
255
  }
157
256
 
158
257
  let disposed = false;
258
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
159
259
  let socket: WebSocket;
260
+ let relayChannel: RelayChannel | null = null;
261
+ const relayOutputDecoder = new TextDecoder();
160
262
  setStatus("connecting");
161
263
  try {
162
- // The ttyd "tty" subprotocol is REQUIRED — ttyd rejects a handshake without
163
- // it. The scoped token is already in the tunnel `url`.
164
- socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
264
+ if (relay) {
265
+ relayChannel = relayChannelFromUrl(url);
266
+ const relayIdentity = [
267
+ relayChannel.workspaceId,
268
+ relayChannel.agentId,
269
+ relayChannel.port,
270
+ relayChannel.channelId,
271
+ ].join("\u0000");
272
+ if (relayIdentityRef.current !== relayIdentity) {
273
+ relayIdentityRef.current = relayIdentity;
274
+ relayInputSequenceRef.current = 0n;
275
+ relayOutputSequenceRef.current = 0n;
276
+ }
277
+ relayChannelRef.current = relayChannel;
278
+ socket = new WebSocket(url);
279
+ } else {
280
+ // The ttyd "tty" subprotocol is REQUIRED — ttyd rejects a handshake without
281
+ // it. The scoped token is already in the tunnel `url`.
282
+ socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
283
+ }
165
284
  } catch {
166
285
  socketFailedRef.current = true;
167
286
  setStatus("error");
@@ -170,8 +289,54 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
170
289
  socket.binaryType = "arraybuffer";
171
290
  wsRef.current = socket;
172
291
 
292
+ const requestFreshGrant = () => {
293
+ if (disposed || refreshRequestedRef.current || intentionalCloseRef.current.has(socket))
294
+ return;
295
+ refreshRequestedRef.current = true;
296
+ onReconnectNeededRef.current?.();
297
+ };
298
+
299
+ const scheduleTransportReconnect = () => {
300
+ if (disposed || reconnectTimer !== null || intentionalCloseRef.current.has(socket)) return;
301
+ if (refreshRequestedRef.current) return;
302
+ if (!terminalStreamCredentialUsable({ transport, url, expiresAt })) {
303
+ requestFreshGrant();
304
+ return;
305
+ }
306
+ const attempt = reconnectAttemptRef.current++;
307
+ const delay = Math.min(
308
+ TERMINAL_RECONNECT_INITIAL_MS * 2 ** Math.min(attempt, 8),
309
+ TERMINAL_RECONNECT_MAX_MS,
310
+ );
311
+ setStatus("connecting");
312
+ reconnectTimer = setTimeout(() => {
313
+ reconnectTimer = null;
314
+ if (!disposed) setReconnectGeneration((generation) => generation + 1);
315
+ }, delay);
316
+ };
317
+
173
318
  socket.onopen = () => {
174
319
  if (disposed) return;
320
+ if (relay && relayChannel) {
321
+ try {
322
+ socket.send(
323
+ relayDatagram(
324
+ RELAY_TAG_OPEN,
325
+ encodeStreamOpen({
326
+ channel: relayChannel,
327
+ token: token ?? "",
328
+ role: STREAM_ROLE_CLIENT,
329
+ resumeFromSeq: relayOutputSequenceRef.current.toString(),
330
+ }),
331
+ ),
332
+ );
333
+ } catch {
334
+ socketFailedRef.current = false;
335
+ setStatus("connecting");
336
+ closeSocket(socket);
337
+ }
338
+ return;
339
+ }
175
340
  // ttyd's required first frame: the auth message (empty token — the gate is
176
341
  // the tunnel url + scoped stream token, not a ttyd -c credential), seeded
177
342
  // with the current PTY geometry. Then an explicit resize to be safe.
@@ -186,16 +351,62 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
186
351
  }
187
352
  socketAuthenticatedRef.current = true;
188
353
  } catch {
189
- socketFailedRef.current = true;
190
- setStatus("error");
354
+ socketFailedRef.current = false;
355
+ setStatus("connecting");
191
356
  closeSocket(socket);
192
357
  return;
193
358
  }
194
359
  setStatus("open");
360
+ reconnectAttemptRef.current = 0;
361
+ refreshRequestedRef.current = false;
195
362
  };
196
363
 
197
364
  socket.onmessage = (ev: MessageEvent) => {
198
365
  if (disposed) return;
366
+ if (relay) {
367
+ if (!(ev.data instanceof ArrayBuffer) || !relayChannel) return;
368
+ const bytes = new Uint8Array(ev.data);
369
+ if (bytes.length < 1) return;
370
+ const tag = bytes[0];
371
+ const body = bytes.subarray(1);
372
+ if (tag === RELAY_TAG_OPEN_ACK) {
373
+ try {
374
+ const ack = decodeStreamOpenAck(body);
375
+ if (!ack.accepted) throw new Error(ack.error?.message ?? "terminal relay rejected");
376
+ reconnectAttemptRef.current = 0;
377
+ refreshRequestedRef.current = false;
378
+ socketAuthenticatedRef.current = true;
379
+ if (pendingInputRef.current.length > 0) {
380
+ sendRelayInput(
381
+ socket,
382
+ relayChannel,
383
+ relayInputSequenceRef.current++,
384
+ pendingInputRef.current,
385
+ );
386
+ pendingInputRef.current = "";
387
+ }
388
+ setStatus("open");
389
+ } catch {
390
+ socketFailedRef.current = true;
391
+ setStatus("error");
392
+ requestFreshGrant();
393
+ closeSocket(socket);
394
+ }
395
+ } else if (tag === RELAY_TAG_FRAME && socketAuthenticatedRef.current) {
396
+ try {
397
+ const frame = decodeStreamFrame(body);
398
+ const sequence = BigInt(frame.seq);
399
+ if (sequence < relayOutputSequenceRef.current) return;
400
+ relayOutputSequenceRef.current = sequence + 1n;
401
+ if (frame.data.length > 0) {
402
+ onOutputRef.current?.(relayOutputDecoder.decode(frame.data, { stream: true }));
403
+ }
404
+ } catch {
405
+ // Ignore one malformed frame; the live PTY remains usable.
406
+ }
407
+ }
408
+ return;
409
+ }
199
410
  const { command, payload } = decodeFrame(ev.data as string | ArrayBuffer);
200
411
  switch (command) {
201
412
  case TtydServerCommand.OUTPUT:
@@ -212,20 +423,28 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
212
423
 
213
424
  socket.onerror = () => {
214
425
  if (!disposed) {
215
- socketFailedRef.current = true;
216
- setStatus("error");
426
+ // A network/relay outage is retryable with the same credential and PTY.
427
+ // Keep accepting bounded input while the transport comes back.
428
+ socketFailedRef.current = false;
429
+ setStatus("connecting");
430
+ closeSocket(socket);
217
431
  }
218
432
  };
219
433
  socket.onclose = () => {
220
434
  if (wsRef.current === socket) wsRef.current = null;
221
435
  socketAuthenticatedRef.current = false;
222
- if (!disposed) setStatus(socketFailedRef.current ? "error" : "closed");
436
+ if (!disposed) {
437
+ if (socketFailedRef.current) setStatus("error");
438
+ else scheduleTransportReconnect();
439
+ }
223
440
  };
224
441
 
225
442
  return () => {
226
443
  disposed = true;
227
444
  wsRef.current = null;
228
445
  socketAuthenticatedRef.current = false;
446
+ relayChannelRef.current = null;
447
+ if (reconnectTimer !== null) clearTimeout(reconnectTimer);
229
448
  // Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
230
449
  socket.onopen = null;
231
450
  socket.onmessage = null;
@@ -235,52 +454,66 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
235
454
  };
236
455
  // A url/token change (rotation) re-runs this effect → close old, open new.
237
456
  // eslint-disable-next-line react-hooks/exhaustive-deps
238
- }, [transport, url, token, expiresAt]);
457
+ }, [transport, url, token, expiresAt, reconnectGeneration]);
239
458
 
240
- return useMemo<UseTerminalStreamResult>(() => {
241
- const write = (data: string) => {
242
- const ws = wsRef.current;
243
- if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
244
- try {
459
+ const write = useCallback((data: string) => {
460
+ const currentTransport = transportRef.current;
461
+ const ws = wsRef.current;
462
+ if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
463
+ try {
464
+ const relayChannel = relayChannelRef.current;
465
+ if (currentTransport === "relay-pty" && relayChannel) {
466
+ sendRelayInput(ws, relayChannel, relayInputSequenceRef.current++, data);
467
+ } else {
245
468
  ws.send(ttydInputFrame(data));
246
- } catch {
247
- socketFailedRef.current = true;
248
- setStatus("error");
249
- closeSocket(ws);
250
- }
251
- } else if (transport === "pty-ws" && !socketFailedRef.current) {
252
- const nextSize = pendingInputRef.current.length + data.length;
253
- if (nextSize > MAX_PENDING_TERMINAL_INPUT_CODE_UNITS) {
254
- pendingInputRef.current = "";
255
- socketFailedRef.current = true;
256
- setStatus("error");
257
- closeSocket(ws);
258
- return;
259
469
  }
260
- pendingInputRef.current += data;
470
+ } catch {
471
+ socketFailedRef.current = false;
472
+ setStatus("connecting");
473
+ closeSocket(ws);
261
474
  }
262
- };
263
- const resize = (cols: number, rows: number) => {
264
- if (cols <= 0 || rows <= 0) return;
265
- sizeRef.current = { cols, rows };
266
- const ws = wsRef.current;
267
- if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
268
- try {
269
- ws.send(ttydResizeFrame(cols, rows));
270
- } catch {
271
- // socket raced closed — geometry is replayed on the next open.
272
- }
475
+ } else if (
476
+ (currentTransport === "pty-ws" || currentTransport === "relay-pty") &&
477
+ !socketFailedRef.current
478
+ ) {
479
+ const nextSize = pendingInputRef.current.length + data.length;
480
+ if (nextSize > MAX_PENDING_TERMINAL_INPUT_CODE_UNITS) {
481
+ pendingInputRef.current = "";
482
+ socketFailedRef.current = true;
483
+ setStatus("error");
484
+ closeSocket(ws);
485
+ return;
273
486
  }
274
- };
275
- const disconnect = () => {
276
- const ws = wsRef.current;
277
- wsRef.current = null;
278
- pendingInputRef.current = "";
279
- socketFailedRef.current = false;
280
- socketAuthenticatedRef.current = false;
281
- setStatus("closed");
282
- closeSocket(ws);
283
- };
487
+ pendingInputRef.current += data;
488
+ }
489
+ }, []);
490
+ const resize = useCallback((cols: number, rows: number) => {
491
+ if (cols <= 0 || rows <= 0) return;
492
+ sizeRef.current = { cols, rows };
493
+ const ws = wsRef.current;
494
+ if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
495
+ try {
496
+ // Self-hosted relay PTYs carry raw input/output frames. Resize remains
497
+ // an out-of-band control operation; do not send ttyd framing into them.
498
+ if (transportRef.current === "pty-ws") ws.send(ttydResizeFrame(cols, rows));
499
+ } catch {
500
+ // socket raced closed — geometry is replayed on the next open.
501
+ }
502
+ }
503
+ }, []);
504
+ const disconnect = useCallback(() => {
505
+ const ws = wsRef.current;
506
+ wsRef.current = null;
507
+ pendingInputRef.current = "";
508
+ socketFailedRef.current = false;
509
+ socketAuthenticatedRef.current = false;
510
+ relayChannelRef.current = null;
511
+ setStatus("closed");
512
+ if (ws) intentionalCloseRef.current.add(ws);
513
+ closeSocket(ws);
514
+ }, []);
515
+
516
+ return useMemo<UseTerminalStreamResult>(() => {
284
517
  return { connected: status === "open", status, write, resize, disconnect };
285
- }, [status, transport]);
518
+ }, [status, write, resize, disconnect]);
286
519
  }
package/src/index.ts CHANGED
@@ -175,6 +175,8 @@ export type {
175
175
  UseEnvironmentsOptions,
176
176
  UseEnvironmentsResult,
177
177
  } from "./hooks/use-environments";
178
+ export { useChannels } from "./hooks/use-channels";
179
+ export type { UseChannelsOptions, UseChannelsResult } from "./hooks/use-channels";
178
180
  export { useRigs, useRig, useRigVersions, useRigChanges } from "./hooks/use-rigs";
179
181
  export type {
180
182
  UseRigsOptions,
@@ -543,6 +545,12 @@ export type {
543
545
  UseCodexAccountsOptions,
544
546
  UseCodexAccountsResult,
545
547
  } from "./hooks/use-codex-accounts";
548
+ export { useSuperGrokAccounts } from "./hooks/use-supergrok-accounts";
549
+ export type {
550
+ SuperGrokAccountsClientLike,
551
+ UseSuperGrokAccountsOptions,
552
+ UseSuperGrokAccountsResult,
553
+ } from "./hooks/use-supergrok-accounts";
546
554
 
547
555
  // Sandbox helpers
548
556
  export { gitFileDiffToPatch } from "./lib/git-patch";
@@ -4,17 +4,40 @@ export type {
4
4
  EmbeddedBrowserInteractionClientLike,
5
5
  EmbeddedComputerInteractionClientLike,
6
6
  EmbeddedInteractionClientLike,
7
+ EmbeddedInterventionClientLike,
7
8
  } from "./client";
8
9
  export {
9
10
  useEmbeddedBrowserInteraction,
10
11
  useEmbeddedComputerInteraction,
11
12
  useEmbeddedInteraction,
13
+ useEmbeddedInterventions,
12
14
  } from "./session-context";
13
15
  export type {
14
16
  EmbeddedBrowserInteractionClientOverride,
15
17
  EmbeddedComputerInteractionClientOverride,
16
18
  EmbeddedInteractionClientOverride,
19
+ EmbeddedInterventionClientOverride,
17
20
  } from "./session-context";
21
+ export { useNetworkRoutes } from "./hooks/use-network-routes";
22
+ export type {
23
+ NetworkRoutesClient,
24
+ UseNetworkRoutesOptions,
25
+ UseNetworkRoutesResult,
26
+ } from "./hooks/use-network-routes";
27
+ export { useSiteAuthConnections } from "./hooks/use-site-auth-connections";
28
+ export type {
29
+ SiteAuthConnectionsClient,
30
+ UseSiteAuthConnectionsOptions,
31
+ UseSiteAuthConnectionsResult,
32
+ } from "./hooks/use-site-auth-connections";
33
+ export { useAuthRuns } from "./hooks/use-auth-runs";
34
+ export type { AuthRunsClient, UseAuthRunsOptions, UseAuthRunsResult } from "./hooks/use-auth-runs";
35
+ export { useInteractionInterventions } from "./hooks/use-interaction-interventions";
36
+ export type {
37
+ InteractionInterventionsClient,
38
+ UseInteractionInterventionsOptions,
39
+ UseInteractionInterventionsResult,
40
+ } from "./hooks/use-interaction-interventions";
18
41
  export { useAttachedBrowsers } from "./hooks/use-attached-browsers";
19
42
  export type {
20
43
  AttachedBrowsersClient,
@@ -38,6 +61,13 @@ export type {
38
61
  UseBrowserSessionOptions,
39
62
  UseBrowserSessionResult,
40
63
  } from "./hooks/use-browser-session";
64
+ export { useBrowserDownloads } from "./hooks/use-browser-downloads";
65
+ export type {
66
+ BrowserDownloadsClient,
67
+ BrowserDownloadSaveOptions,
68
+ UseBrowserDownloadsOptions,
69
+ UseBrowserDownloadsResult,
70
+ } from "./hooks/use-browser-downloads";
41
71
  export { useBrowserFrameStream } from "./hooks/use-browser-frame-stream";
42
72
  export type {
43
73
  BrowserFrameConnectionState,
@@ -0,0 +1,10 @@
1
+ export type InteractionHostPlatform = "mac" | "other";
2
+
3
+ /** Physical client platform. Remote input adapters use this only to identify
4
+ * the user's primary shortcut modifier; the target platform is resolved
5
+ * separately. */
6
+ export function interactionHostPlatform(): InteractionHostPlatform {
7
+ if (typeof navigator === "undefined") return "other";
8
+ const platform = navigator.platform || navigator.userAgent;
9
+ return /Mac|iPhone|iPad|iPod/u.test(platform) ? "mac" : "other";
10
+ }
@@ -1,5 +1,5 @@
1
1
  // Minimal HAND-MIRROR of the relay stream wire messages the desktop frame client
2
- // needs (`StreamOpen` encode; `StreamOpenAck` + `StreamFrame` decode). It exists
2
+ // needs (`StreamOpen`/`StreamClose` encode; `StreamOpenAck` + `StreamFrame` decode). It exists
3
3
  // because the publish-closure guard (scripts/publish-closure-guard.ts) forbids
4
4
  // `@opengeni/react` from depending on `@opengeni/agent-proto` — the published SDK
5
5
  // closure may only reach `@opengeni/sdk` among `@opengeni/*` packages. So, exactly
@@ -13,16 +13,21 @@
13
13
  // StreamOpenAck: accepted=1(bool), error=2(AgentError msg), resumeFromSeq=3
14
14
  // AgentError: code=1, message=2, retryable=3, detail=4 (we read only message)
15
15
  // StreamFrame: channelId=1, seq=2, data=3(bytes), producedAtMs=4 (we read only data)
16
+ // StreamClose: channelId=1, reason=2(int32), message=3
16
17
  import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
17
18
 
18
19
  /** `StreamKind.STREAM_KIND_DESKTOP` (agent-proto enum value). */
19
20
  export const STREAM_KIND_DESKTOP = 2;
21
+ /** `StreamKind.STREAM_KIND_PTY` (agent-proto enum value). */
22
+ export const STREAM_KIND_PTY = 1;
20
23
  /** `StreamKind.STREAM_KIND_BROWSER` (agent-proto enum value). */
21
24
  export const STREAM_KIND_BROWSER = 3;
22
25
  /** `StreamKind.STREAM_KIND_COMPUTER` (agent-proto enum value). */
23
26
  export const STREAM_KIND_COMPUTER = 4;
24
27
  /** `StreamRole.STREAM_ROLE_CLIENT` (agent-proto enum value). */
25
28
  export const STREAM_ROLE_CLIENT = 2;
29
+ /** `StreamCloseReason.STREAM_CLOSE_REASON_NORMAL`. */
30
+ export const STREAM_CLOSE_REASON_NORMAL = 1;
26
31
 
27
32
  export interface RelayChannel {
28
33
  channelId: string;
@@ -58,6 +63,35 @@ export function encodeStreamOpen(message: RelayStreamOpen): Uint8Array {
58
63
  return writer.finish();
59
64
  }
60
65
 
66
+ /** Encode an intentional viewer detach. Transport loss omits this message so
67
+ * reconnect/resume remains available. */
68
+ export function encodeStreamClose(message: {
69
+ channelId: string;
70
+ reason: number;
71
+ message: string;
72
+ }): Uint8Array {
73
+ const writer = new BinaryWriter();
74
+ if (message.channelId !== "") writer.uint32(10).string(message.channelId);
75
+ if (message.reason !== 0) writer.uint32(16).int32(message.reason);
76
+ if (message.message !== "") writer.uint32(26).string(message.message);
77
+ return writer.finish();
78
+ }
79
+
80
+ /** Encode a viewer→agent PTY input `StreamFrame`. */
81
+ export function encodeStreamFrame(message: {
82
+ channelId: string;
83
+ seq: string;
84
+ data: Uint8Array;
85
+ producedAtMs: string;
86
+ }): Uint8Array {
87
+ const writer = new BinaryWriter();
88
+ if (message.channelId !== "") writer.uint32(10).string(message.channelId);
89
+ if (message.seq !== "0") writer.uint32(16).uint64(message.seq);
90
+ if (message.data.length > 0) writer.uint32(26).bytes(message.data);
91
+ if (message.producedAtMs !== "0") writer.uint32(32).uint64(message.producedAtMs);
92
+ return writer.finish();
93
+ }
94
+
61
95
  /** The only `AgentError` field we surface from a rejected ack. */
62
96
  function decodeAgentErrorMessage(reader: BinaryReader, length: number): { message?: string } {
63
97
  const end = reader.pos + length;
@@ -99,13 +133,18 @@ export function decodeStreamOpenAck(bytes: Uint8Array): {
99
133
  return error !== undefined ? { accepted, error } : { accepted };
100
134
  }
101
135
 
102
- /** Decode a `StreamFrame`, extracting only the `data` (framebuffer PNG) bytes. */
103
- export function decodeStreamFrame(bytes: Uint8Array): { data: Uint8Array } {
136
+ /** Decode the sequence plus `data` from a `StreamFrame`. */
137
+ export function decodeStreamFrame(bytes: Uint8Array): { seq: string; data: Uint8Array } {
104
138
  const reader = new BinaryReader(bytes);
105
139
  const end = reader.len;
140
+ let seq = "0";
106
141
  let data = new Uint8Array(0);
107
142
  while (reader.pos < end) {
108
143
  const tag = reader.uint32();
144
+ if (tag >>> 3 === 2 && tag === 16) {
145
+ seq = reader.uint64().toString();
146
+ continue;
147
+ }
109
148
  if (tag >>> 3 === 3 && tag === 26) {
110
149
  // Copy into a fresh ArrayBuffer-backed view: BinaryReader.bytes() returns
111
150
  // `Uint8Array<ArrayBufferLike>` (a view into the message buffer), which both
@@ -116,5 +155,5 @@ export function decodeStreamFrame(bytes: Uint8Array): { data: Uint8Array } {
116
155
  if ((tag & 7) === 4 || tag === 0) break;
117
156
  reader.skip(tag & 7);
118
157
  }
119
- return { data };
158
+ return { seq, data };
120
159
  }
@@ -5,6 +5,6 @@ import type { TerminalCapability } from "@opengeni/sdk";
5
5
  * real-PTY backend reports sse-events until the just-in-time grant warms it. */
6
6
  export function terminalCanAcquirePty(cell: TerminalCapability | null | undefined): boolean {
7
7
  if (!cell?.ptyCapable) return false;
8
- if (cell.transport === "pty-ws") return true;
8
+ if (cell.transport === "pty-ws" || cell.transport === "relay-pty") return true;
9
9
  return cell.reason === "lease_cold" || cell.reason === "not_provisioned";
10
10
  }