@opengeni/react 0.46.1 → 0.52.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 (341) hide show
  1. package/README.md +158 -14
  2. package/dist/artifacts-document.d.ts +3 -0
  3. package/dist/artifacts-document.js +17 -0
  4. package/dist/artifacts-document.js.map +1 -0
  5. package/dist/artifacts-presentation.d.ts +3 -0
  6. package/dist/artifacts-presentation.js +17 -0
  7. package/dist/artifacts-presentation.js.map +1 -0
  8. package/dist/artifacts-spreadsheet.d.ts +3 -0
  9. package/dist/artifacts-spreadsheet.js +17 -0
  10. package/dist/artifacts-spreadsheet.js.map +1 -0
  11. package/dist/artifacts.d.ts +2 -0
  12. package/dist/artifacts.js +170 -0
  13. package/dist/artifacts.js.map +1 -0
  14. package/dist/chunk-6BBWWOOU.js +82 -0
  15. package/dist/chunk-6BBWWOOU.js.map +1 -0
  16. package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
  17. package/dist/chunk-724BW4TW.js.map +1 -0
  18. package/dist/chunk-7I2KB3OK.js +3895 -0
  19. package/dist/chunk-7I2KB3OK.js.map +1 -0
  20. package/dist/chunk-7WCLAEFV.js +2828 -0
  21. package/dist/chunk-7WCLAEFV.js.map +1 -0
  22. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  23. package/dist/chunk-FK6VG4LL.js.map +1 -0
  24. package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
  25. package/dist/chunk-FWS3KC3Z.js.map +1 -0
  26. package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
  27. package/dist/chunk-HDBG7XGO.js.map +1 -0
  28. package/dist/chunk-HUIKIDM5.js +2148 -0
  29. package/dist/chunk-HUIKIDM5.js.map +1 -0
  30. package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
  31. package/dist/chunk-JAGDUCZQ.js.map +1 -0
  32. package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
  33. package/dist/chunk-JR3QFY6B.js.map +1 -0
  34. package/dist/chunk-JVYQRRC4.js +53 -0
  35. package/dist/chunk-JVYQRRC4.js.map +1 -0
  36. package/dist/chunk-KCGXPP5V.js +157 -0
  37. package/dist/chunk-KCGXPP5V.js.map +1 -0
  38. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  39. package/dist/chunk-KZ73RL76.js.map +1 -0
  40. package/dist/chunk-PEI6RANI.js +3234 -0
  41. package/dist/chunk-PEI6RANI.js.map +1 -0
  42. package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
  43. package/dist/chunk-ROIRTM37.js.map +1 -0
  44. package/dist/chunk-VZTQ73XT.js +86 -0
  45. package/dist/chunk-VZTQ73XT.js.map +1 -0
  46. package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
  47. package/dist/chunk-WSK7G5LE.js.map +1 -0
  48. package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
  49. package/dist/chunk-WYA65Y3F.js.map +1 -0
  50. package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
  51. package/dist/chunk-X4S56Y2H.js.map +1 -0
  52. package/dist/client.d.ts +7 -1
  53. package/dist/clipboard.d.ts +1 -0
  54. package/dist/clipboard.js +7 -0
  55. package/dist/clipboard.js.map +1 -0
  56. package/dist/commands/index.d.ts +3 -3
  57. package/dist/commands/registry.d.ts +1 -1
  58. package/dist/commands/types.d.ts +1 -1
  59. package/dist/components/approval-surface.d.ts +2 -2
  60. package/dist/components/artifacts/artifact-surface.d.ts +18 -0
  61. package/dist/components/artifacts/document-editor.d.ts +172 -0
  62. package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
  63. package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
  64. package/dist/components/artifacts/editable-document.d.ts +24 -0
  65. package/dist/components/artifacts/editable-presentation.d.ts +24 -0
  66. package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
  67. package/dist/components/artifacts/index.d.ts +8 -0
  68. package/dist/components/artifacts/presentation-editor.d.ts +297 -0
  69. package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
  70. package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
  71. package/dist/components/browser-viewer.d.ts +29 -0
  72. package/dist/components/chat-composer.d.ts +13 -8
  73. package/dist/components/code-editor.d.ts +25 -3
  74. package/dist/components/command-palette.d.ts +2 -2
  75. package/dist/components/composer-transcription-control.d.ts +4 -2
  76. package/dist/components/composer.d.ts +29 -15
  77. package/dist/components/computer-viewer.d.ts +23 -0
  78. package/dist/components/copy-button.d.ts +2 -2
  79. package/dist/components/desktop-viewer.d.ts +25 -21
  80. package/dist/components/diff-view.d.ts +1 -1
  81. package/dist/components/enrollment-consent.d.ts +1 -1
  82. package/dist/components/enrollment-device-flow.d.ts +1 -1
  83. package/dist/components/file-browser.d.ts +2 -2
  84. package/dist/components/fleet-tile.d.ts +1 -1
  85. package/dist/components/generated-video-player.d.ts +10 -0
  86. package/dist/components/human-input-form.d.ts +1 -1
  87. package/dist/components/human-input-surface.d.ts +2 -2
  88. package/dist/components/interactive-workbench-viewers.d.ts +2 -0
  89. package/dist/components/machine-card.d.ts +2 -2
  90. package/dist/components/machine-dock-bar.d.ts +3 -3
  91. package/dist/components/machine-health-pill.d.ts +2 -2
  92. package/dist/components/machine-input-display.d.ts +1 -1
  93. package/dist/components/machine-metrics.d.ts +2 -2
  94. package/dist/components/machine-status-pill.d.ts +4 -4
  95. package/dist/components/machines/health.d.ts +1 -1
  96. package/dist/components/machines/machine-detail.d.ts +5 -3
  97. package/dist/components/machines/metric-history-chart.d.ts +1 -1
  98. package/dist/components/machines/metric-sparkline.d.ts +2 -2
  99. package/dist/components/machines/series.d.ts +2 -2
  100. package/dist/components/machines-dashboard.d.ts +5 -5
  101. package/dist/components/markdown.d.ts +1 -1
  102. package/dist/components/message-timeline.d.ts +22 -5
  103. package/dist/components/model-picker.d.ts +2 -2
  104. package/dist/components/model-policy-picker.d.ts +7 -7
  105. package/dist/components/pierre-diff.d.ts +1 -1
  106. package/dist/components/pierre-file.d.ts +1 -1
  107. package/dist/components/queue-draft-policy.d.ts +1 -1
  108. package/dist/components/queue-surface-implementation.d.ts +3 -3
  109. package/dist/components/queue-surface-state.d.ts +4 -4
  110. package/dist/components/queue-surface.d.ts +5 -5
  111. package/dist/components/sandbox-files.d.ts +5 -5
  112. package/dist/components/sandbox-terminal.d.ts +50 -4
  113. package/dist/components/sandbox-workspace.d.ts +30 -10
  114. package/dist/components/session-chrome.d.ts +4 -4
  115. package/dist/components/session-status.d.ts +2 -2
  116. package/dist/components/timeline-annotation-selection.d.ts +9 -0
  117. package/dist/components/timeline-annotations-dialog.d.ts +16 -0
  118. package/dist/components/timeline-annotations.d.ts +13 -0
  119. package/dist/components/tooltip.d.ts +4 -4
  120. package/dist/components/user-message-body.d.ts +2 -2
  121. package/dist/components/workbench-changes.d.ts +2 -2
  122. package/dist/components/workspace-dock.d.ts +3 -1
  123. package/dist/composer.d.ts +11 -11
  124. package/dist/composer.js +7 -7
  125. package/dist/hooks/internal.d.ts +7 -1
  126. package/dist/hooks/use-attached-browsers.d.ts +21 -0
  127. package/dist/hooks/use-available-models.d.ts +2 -2
  128. package/dist/hooks/use-billing-usage.d.ts +1 -1
  129. package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
  130. package/dist/hooks/use-browser-identities.d.ts +26 -0
  131. package/dist/hooks/use-browser-session.d.ts +28 -0
  132. package/dist/hooks/use-browser-sessions.d.ts +32 -0
  133. package/dist/hooks/use-codex-accounts.d.ts +3 -3
  134. package/dist/hooks/use-composer.d.ts +36 -9
  135. package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
  136. package/dist/hooks/use-computer-session.d.ts +28 -0
  137. package/dist/hooks/use-computer-sessions.d.ts +28 -0
  138. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  139. package/dist/hooks/use-environments.d.ts +11 -5
  140. package/dist/hooks/use-file-attachments.d.ts +1 -1
  141. package/dist/hooks/use-goal.d.ts +2 -2
  142. package/dist/hooks/use-human-input.d.ts +2 -2
  143. package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
  144. package/dist/hooks/use-machine-chip.d.ts +2 -2
  145. package/dist/hooks/use-machines.d.ts +10 -3
  146. package/dist/hooks/use-packs.d.ts +7 -2
  147. package/dist/hooks/use-rigs.d.ts +1 -1
  148. package/dist/hooks/use-sandbox-files.d.ts +25 -4
  149. package/dist/hooks/use-sandbox-git.d.ts +13 -5
  150. package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
  151. package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
  152. package/dist/hooks/use-session-capabilities.d.ts +5 -4
  153. package/dist/hooks/use-session-control.d.ts +1 -1
  154. package/dist/hooks/use-session-events.d.ts +2 -2
  155. package/dist/hooks/use-session-lineage.d.ts +1 -1
  156. package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
  157. package/dist/hooks/use-session.d.ts +2 -2
  158. package/dist/hooks/use-slash-commands.d.ts +1 -1
  159. package/dist/hooks/use-terminal-stream.d.ts +8 -2
  160. package/dist/hooks/use-transcription.d.ts +2 -2
  161. package/dist/hooks/use-turn-queue.d.ts +2 -2
  162. package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
  163. package/dist/hooks/use-voice-input.d.ts +6 -4
  164. package/dist/hooks/use-workspace-capture.d.ts +1 -1
  165. package/dist/hooks/use-workspace-edit.d.ts +1 -1
  166. package/dist/hooks/use-workspace-sessions.d.ts +1 -1
  167. package/dist/hooks/use-workspaces.d.ts +1 -1
  168. package/dist/index.d.ts +158 -154
  169. package/dist/index.js +1718 -908
  170. package/dist/index.js.map +1 -1
  171. package/dist/interaction.d.ts +23 -0
  172. package/dist/interaction.js +36 -0
  173. package/dist/interaction.js.map +1 -0
  174. package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
  175. package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
  176. package/dist/lib/composer-responsive-context.d.ts +9 -0
  177. package/dist/lib/relay-wire.d.ts +4 -0
  178. package/dist/lib/sandbox-liveness.d.ts +9 -0
  179. package/dist/lib/terminal-capability.d.ts +5 -0
  180. package/dist/lib/use-portal-token-style.d.ts +14 -3
  181. package/dist/lib/xterm-theme.d.ts +1 -1
  182. package/dist/machines.d.ts +30 -30
  183. package/dist/machines.js +3 -3
  184. package/dist/provider.d.ts +4 -4
  185. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
  186. package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
  187. package/dist/realtime/dropdown-menu.d.ts +8 -8
  188. package/dist/realtime/realtime-control.d.ts +15 -6
  189. package/dist/realtime.d.ts +4 -4
  190. package/dist/realtime.js +30 -33
  191. package/dist/realtime.js.map +1 -1
  192. package/dist/session-context.d.ts +19 -1
  193. package/dist/session-ui.d.ts +14 -14
  194. package/dist/session-ui.js +6 -5
  195. package/dist/session.d.ts +28 -28
  196. package/dist/session.js +5 -5
  197. package/dist/timeline/activity-rail.d.ts +6 -3
  198. package/dist/timeline/disclosure-context.d.ts +1 -1
  199. package/dist/timeline/fleet-decision-projection.d.ts +1 -1
  200. package/dist/timeline/fleet-decision-row.d.ts +3 -3
  201. package/dist/timeline/index.d.ts +19 -19
  202. package/dist/timeline/parsers.d.ts +6 -3
  203. package/dist/timeline/projection.d.ts +3 -3
  204. package/dist/timeline/registry.d.ts +10 -1
  205. package/dist/timeline/screenshot-lightbox.d.ts +2 -2
  206. package/dist/timeline/shared.d.ts +21 -10
  207. package/dist/timeline/tool-diff.d.ts +2 -2
  208. package/dist/timeline/tool-renderers.d.ts +1 -1
  209. package/dist/timeline/turn-summary.d.ts +3 -3
  210. package/dist/timeline/types.d.ts +42 -2
  211. package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
  212. package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
  213. package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
  214. package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
  215. package/dist/voice-recording-store.d.ts +8 -2
  216. package/package.json +52 -7
  217. package/src/artifacts-document.ts +31 -0
  218. package/src/artifacts-presentation.ts +36 -0
  219. package/src/artifacts-spreadsheet.ts +21 -0
  220. package/src/artifacts.ts +86 -0
  221. package/src/client.ts +90 -1
  222. package/src/clipboard.ts +2 -0
  223. package/src/components/artifacts/artifact-surface.tsx +93 -0
  224. package/src/components/artifacts/document-editor.tsx +2255 -0
  225. package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
  226. package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
  227. package/src/components/artifacts/editable-document.tsx +657 -0
  228. package/src/components/artifacts/editable-presentation.tsx +1020 -0
  229. package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
  230. package/src/components/artifacts/index.ts +99 -0
  231. package/src/components/artifacts/presentation-editor.tsx +3148 -0
  232. package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
  233. package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
  234. package/src/components/browser-viewer.tsx +1592 -0
  235. package/src/components/chat-composer.tsx +25 -2
  236. package/src/components/code-editor.tsx +90 -31
  237. package/src/components/command-palette.tsx +1 -1
  238. package/src/components/composer-transcription-control.tsx +51 -22
  239. package/src/components/composer.tsx +49 -23
  240. package/src/components/computer-viewer.tsx +1139 -0
  241. package/src/components/copy-button.tsx +1 -1
  242. package/src/components/desktop-viewer.tsx +103 -75
  243. package/src/components/file-browser.tsx +39 -17
  244. package/src/components/generated-video-player.tsx +89 -0
  245. package/src/components/human-input-form.tsx +2 -2
  246. package/src/components/interactive-workbench-viewers.ts +2 -0
  247. package/src/components/machine-card.tsx +1 -1
  248. package/src/components/machine-input-display.ts +3 -0
  249. package/src/components/machines/machine-detail.tsx +39 -15
  250. package/src/components/machines-dashboard.tsx +7 -6
  251. package/src/components/message-timeline.tsx +327 -37
  252. package/src/components/model-picker.tsx +2 -2
  253. package/src/components/model-policy-picker.tsx +16 -15
  254. package/src/components/queue-surface-implementation.tsx +135 -75
  255. package/src/components/queue-surface-state.tsx +2 -2
  256. package/src/components/sandbox-files.tsx +142 -62
  257. package/src/components/sandbox-terminal.tsx +119 -35
  258. package/src/components/sandbox-workspace.tsx +394 -228
  259. package/src/components/session-chrome.tsx +67 -27
  260. package/src/components/timeline-annotation-selection.tsx +221 -0
  261. package/src/components/timeline-annotations-dialog.tsx +213 -0
  262. package/src/components/timeline-annotations.tsx +84 -0
  263. package/src/components/tooltip.tsx +13 -16
  264. package/src/components/user-message-body.tsx +1 -1
  265. package/src/components/workbench-changes.tsx +192 -35
  266. package/src/components/workspace-dock.tsx +13 -5
  267. package/src/composer.ts +3 -0
  268. package/src/hooks/internal.ts +132 -38
  269. package/src/hooks/use-attached-browsers.ts +152 -0
  270. package/src/hooks/use-browser-frame-stream.ts +316 -0
  271. package/src/hooks/use-browser-identities.ts +225 -0
  272. package/src/hooks/use-browser-session.ts +476 -0
  273. package/src/hooks/use-browser-sessions.ts +281 -0
  274. package/src/hooks/use-codex-accounts.ts +4 -4
  275. package/src/hooks/use-composer.ts +769 -24
  276. package/src/hooks/use-computer-frame-stream.ts +311 -0
  277. package/src/hooks/use-computer-session.ts +442 -0
  278. package/src/hooks/use-computer-sessions.ts +245 -0
  279. package/src/hooks/use-environments.ts +24 -5
  280. package/src/hooks/use-file-attachments.ts +70 -32
  281. package/src/hooks/use-goal.ts +17 -5
  282. package/src/hooks/use-machine-chip.ts +5 -3
  283. package/src/hooks/use-machines.ts +79 -3
  284. package/src/hooks/use-packs.ts +51 -0
  285. package/src/hooks/use-sandbox-files.ts +382 -88
  286. package/src/hooks/use-sandbox-git.ts +208 -46
  287. package/src/hooks/use-sandbox-terminal.ts +3 -2
  288. package/src/hooks/use-session-capabilities.ts +94 -70
  289. package/src/hooks/use-session-events.ts +18 -4
  290. package/src/hooks/use-terminal-stream.ts +102 -23
  291. package/src/hooks/use-transcription.ts +4 -14
  292. package/src/hooks/use-turn-queue.ts +17 -5
  293. package/src/hooks/use-video-artifact-playback.ts +28 -0
  294. package/src/hooks/use-voice-input.ts +163 -19
  295. package/src/hooks/use-windowed-sections.ts +9 -7
  296. package/src/hooks/use-workspace-edit.ts +2 -1
  297. package/src/index.ts +21 -6
  298. package/src/interaction.ts +71 -0
  299. package/src/lib/composer-responsive-context.ts +15 -0
  300. package/src/lib/relay-wire.ts +4 -0
  301. package/src/lib/sandbox-liveness.ts +11 -0
  302. package/src/lib/terminal-capability.ts +10 -0
  303. package/src/lib/use-portal-token-style.ts +60 -15
  304. package/src/provider.tsx +52 -7
  305. package/src/realtime/dropdown-menu.tsx +15 -19
  306. package/src/realtime/realtime-control.tsx +16 -5
  307. package/src/session-context.ts +90 -0
  308. package/src/timeline/activity-rail.tsx +31 -6
  309. package/src/timeline/index.ts +4 -1
  310. package/src/timeline/parsers.ts +46 -20
  311. package/src/timeline/projection.ts +233 -13
  312. package/src/timeline/registry.ts +19 -0
  313. package/src/timeline/screenshot-lightbox.tsx +30 -9
  314. package/src/timeline/shared.tsx +59 -11
  315. package/src/timeline/tool-renderers.tsx +421 -24
  316. package/src/timeline/turn-summary.tsx +2 -2
  317. package/src/timeline/types.ts +58 -2
  318. package/src/voice-recording-store.ts +15 -0
  319. package/styles/compiled.css +5298 -0
  320. package/styles/compiled.d.ts +2 -0
  321. package/styles/effective-tokens.css +27 -0
  322. package/styles/index.css +18 -0
  323. package/styles/responsive.css +208 -0
  324. package/styles/responsive.d.ts +2 -0
  325. package/styles/tokens.css +50 -17
  326. package/dist/chunk-3PB7MIT6.js.map +0 -1
  327. package/dist/chunk-6EDV6YFE.js.map +0 -1
  328. package/dist/chunk-EB67J347.js.map +0 -1
  329. package/dist/chunk-EKZH6IDG.js.map +0 -1
  330. package/dist/chunk-FGZUCXZF.js.map +0 -1
  331. package/dist/chunk-HFO4ERGQ.js.map +0 -1
  332. package/dist/chunk-JB5725BI.js +0 -48
  333. package/dist/chunk-JB5725BI.js.map +0 -1
  334. package/dist/chunk-LGVMUIRV.js.map +0 -1
  335. package/dist/chunk-LWR4MXSS.js.map +0 -1
  336. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  337. package/dist/chunk-U3CSCMWA.js.map +0 -1
  338. package/dist/chunk-VCBFUSUZ.js.map +0 -1
  339. package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
  340. package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
  341. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
@@ -7,9 +7,13 @@ import {
7
7
  } from "@opengeni/sdk";
8
8
  import { useCallback, useEffect, useRef, useState } from "react";
9
9
  import { useOpenGeni, type ClientOverride } from "../provider";
10
+ import { sandboxAcceptsLiveIo } from "../lib/sandbox-liveness";
11
+ import { terminalCanAcquirePty } from "../lib/terminal-capability";
12
+ import { usePageLiveActivity } from "./internal";
10
13
 
11
- // "on-demand" is the boxless-benign resting state: the lease is cold and NOTHING
12
- // asked to warm a box (no viewer/consent action, no files-tab warm). Under lazy
14
+ // "on-demand" is the boxless-benign resting state: the lease is not currently
15
+ // holder-backed and NOTHING asked to warm a box (no viewer/consent action, no
16
+ // files-tab warm). Under lazy
13
17
  // provisioning a chat-only turn never creates a box, so this is the correct calm
14
18
  // terminal state — NOT an error. It is distinct from "cold", which now means "a
15
19
  // warm-up is genuinely in flight" (a viewer attach was requested) and therefore
@@ -83,7 +87,8 @@ export type UseSessionCapabilitiesResult = {
83
87
  error: Error | null;
84
88
  /**
85
89
  * 409 from the desktop attach: the un-redacted (or shared) plane needs explicit
86
- * acknowledgment before a viewer holder is granted. Drives the consent prompt.
90
+ * acknowledgment before a viewer holder is granted. The workbench uses this
91
+ * to complete acknowledgment automatically while Desktop is opening.
87
92
  */
88
93
  acknowledgmentRequired: "unredacted" | "shared" | null;
89
94
  /** 429 from the desktop attach: the per-session viewer cap is reached. */
@@ -111,24 +116,10 @@ function desktopAttachable(cell: SessionCapabilities["DesktopStream"]): boolean
111
116
 
112
117
  /**
113
118
  * Whether warming the box for the interactive terminal (pty-ws) is worth it.
114
- * The Terminal cell is ALWAYS feasible when the backend advertises a transport at
115
- * all (`sse-events` on a cold box, `pty-ws` once warm) only a genuinely
116
- * terminal-less backend reports transport:null with a hard reason. The attach is
117
- * what flips `sse-events` → `pty-ws` by warming the box and minting the ttyd
118
- * tunnel URL, so we attach whenever the terminal is not hard-unavailable.
119
+ * A real-PTY descriptor is attachable when already routed as `pty-ws`, or while
120
+ * transiently cold. A firehose-only backend and policy/OS/backend degradation
121
+ * cannot become interactive, so attaching there would only waste a holder.
119
122
  */
120
- function terminalAttachable(cell: SessionCapabilities["Terminal"]): boolean {
121
- if (cell.transport === null) {
122
- // No terminal at all unless the only blocker is a transient cold state.
123
- return (
124
- cell.reason === "lease_cold" || cell.reason === "not_provisioned" || cell.reason === null
125
- );
126
- }
127
- // Already pty-ws (warm) is fine to (re)attach; sse-events is the cold state the
128
- // attach upgrades. Either way it's attachable.
129
- return true;
130
- }
131
-
132
123
  /** Read `stream.url.rotated` payloads off the live event log, newest last. */
133
124
  function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
134
125
  const out: StreamUrlRotatedPayload[] = [];
@@ -148,8 +139,8 @@ function rotationsFrom(events: SessionEvent[]): StreamUrlRotatedPayload[] {
148
139
  *
149
140
  * Degradation is a value, never a crash: an unsupported surface comes back
150
141
  * `available:false`/`transport:null` + a `reason`; the components render the
151
- * reason-aware empty state. 409 (consent) and 429 (viewer cap) are surfaced as
152
- * typed signals, not thrown.
142
+ * reason-aware empty state. 409 (acknowledgment) and 429 (viewer cap) are
143
+ * surfaced as typed signals, not thrown.
153
144
  */
154
145
  export function useSessionCapabilities(
155
146
  sessionId: string | null | undefined,
@@ -157,6 +148,8 @@ export function useSessionCapabilities(
157
148
  ): UseSessionCapabilitiesResult {
158
149
  const { client, workspaceId } = useOpenGeni(options);
159
150
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
151
+ const pageLive = usePageLiveActivity();
152
+ const lifecycleEnabled = enabled && pageLive;
160
153
  const attachDesktop = options.attachDesktop ?? false;
161
154
  const attachTerminal = options.attachTerminal ?? false;
162
155
  const attachFiles = options.attachFiles ?? false;
@@ -201,21 +194,28 @@ export function useSessionCapabilities(
201
194
  epochRef.current = 0;
202
195
  viewerIdRef.current = null;
203
196
  }
204
- if (!enabled || !sessionId) {
197
+ if (!lifecycleEnabled || !sessionId) {
205
198
  setState("idle");
206
199
  setCapabilities(null);
200
+ viewerIdRef.current = null;
201
+ setViewerId(null);
207
202
  return;
208
203
  }
204
+ // The preceding effect cleanup released any old holder before this fresh
205
+ // lifecycle starts. Never project that stale id while re-negotiating or if
206
+ // the replacement grant degrades.
207
+ viewerIdRef.current = null;
208
+ setViewerId(null);
209
209
  let cancelled = false;
210
210
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
211
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
211
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
212
212
  let localViewerId: string | null = null;
213
213
  const requestAbort = new AbortController();
214
214
  const startedAt = Date.now();
215
215
 
216
216
  const clearTimers = () => {
217
217
  if (pollTimer !== null) clearTimeout(pollTimer);
218
- if (heartbeatTimer !== null) clearInterval(heartbeatTimer);
218
+ if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
219
219
  pollTimer = null;
220
220
  heartbeatTimer = null;
221
221
  };
@@ -231,27 +231,35 @@ export function useSessionCapabilities(
231
231
  if (!localViewerId || heartbeatTimer !== null) return;
232
232
  const intervalMs =
233
233
  caps.viewerHeartbeatIntervalMs > 0 ? caps.viewerHeartbeatIntervalMs : 30_000;
234
- heartbeatTimer = setInterval(() => {
234
+ const schedule = () => {
235
235
  if (cancelled || !localViewerId) return;
236
- void client
237
- .heartbeatViewer(workspaceId, sessionId, localViewerId, { leaseEpoch: epochRef.current })
238
- .then((res) => {
239
- // alive:false ⇒ the holder was reaped or the epoch moved under us;
240
- // re-negotiate to re-acquire against the new owner.
241
- if (!res.alive && !cancelled) {
242
- renegotiate();
243
- }
244
- })
245
- .catch((cause) => {
246
- if (cancelled) return;
247
- if (
248
- cause instanceof OpenGeniApiError &&
249
- (cause.status === 409 || cause.status === 410)
250
- ) {
251
- renegotiate();
252
- }
253
- });
254
- }, intervalMs);
236
+ heartbeatTimer = setTimeout(() => {
237
+ heartbeatTimer = null;
238
+ if (cancelled || !localViewerId) return;
239
+ void client
240
+ .heartbeatViewer(workspaceId, sessionId, localViewerId, {
241
+ leaseEpoch: epochRef.current,
242
+ })
243
+ .then((res) => {
244
+ // alive:false ⇒ the holder was reaped or the epoch moved under us;
245
+ // re-negotiate to re-acquire against the new owner.
246
+ if (!res.alive && !cancelled) {
247
+ renegotiate();
248
+ }
249
+ })
250
+ .catch((cause) => {
251
+ if (cancelled) return;
252
+ if (
253
+ cause instanceof OpenGeniApiError &&
254
+ (cause.status === 409 || cause.status === 410)
255
+ ) {
256
+ renegotiate();
257
+ }
258
+ })
259
+ .finally(schedule);
260
+ }, intervalMs);
261
+ };
262
+ schedule();
255
263
  };
256
264
 
257
265
  const pollUntilWarm = () => {
@@ -262,7 +270,7 @@ export function useSessionCapabilities(
262
270
  .then((caps) => {
263
271
  if (cancelled) return;
264
272
  settle(caps);
265
- if (caps.liveness === "warm" || caps.liveness === "draining") {
273
+ if (sandboxAcceptsLiveIo(caps.liveness)) {
266
274
  setState("ready");
267
275
  return;
268
276
  }
@@ -297,8 +305,8 @@ export function useSessionCapabilities(
297
305
  // attach serves BOTH live planes — the desktop pixel stream AND the
298
306
  // interactive terminal (ttyd pty-ws) ride the same warm box and the same
299
307
  // holder; the response folds the minted address for each requested plane.
300
- // The consent gate (409) and the viewer cap (429) surface as typed signals
301
- // rather than throwing — the tabs degrade gracefully.
308
+ // The acknowledgment requirement (409) and viewer cap (429) surface as
309
+ // typed signals rather than throwing — the tabs degrade gracefully.
302
310
  //
303
311
  // KEY: the handshake (getStreamCapabilities) NEVER spins up a cold box, so
304
312
  // on a cold/warming lease the desktop cell comes back transport:null and
@@ -310,7 +318,7 @@ export function useSessionCapabilities(
310
318
  // already live OR cold-but-feasible — and let POST /viewers warm the box and
311
319
  // mint the URLs. Only a genuinely-unsupported reason suppresses the attach.
312
320
  const wantDesktopAttach = attachDesktop && desktopAttachable(caps.DesktopStream);
313
- const wantTerminalAttach = attachTerminal && terminalAttachable(caps.Terminal);
321
+ const wantTerminalAttach = attachTerminal && terminalCanAcquirePty(caps.Terminal);
314
322
  // The Files attach warms the box for a file WRITE (wake-on-edit intent). It
315
323
  // folds no live URL (files are stateless HTTP) — the attach is purely a
316
324
  // liveness refcount. Unlike the previous "keep the box warm while the Files
@@ -328,6 +336,8 @@ export function useSessionCapabilities(
328
336
  // pty-ws terminal cell WITHOUT tripping the desktop consent 409.
329
337
  const holder = await client.attachViewer(workspaceId, sessionId, {
330
338
  desktop: wantDesktopAttach,
339
+ terminal: wantTerminalAttach,
340
+ files: wantFilesAttach,
331
341
  });
332
342
  if (cancelled) {
333
343
  // The attach crossed an identity/unmount boundary after the server
@@ -337,6 +347,18 @@ export function useSessionCapabilities(
337
347
  void client.detachViewer(workspaceId, sessionId, holder.viewerId).catch(() => {});
338
348
  return;
339
349
  }
350
+ if (
351
+ (wantDesktopAttach &&
352
+ (!holder.dataPlaneUrl || !holder.transport || !holder.client)) ||
353
+ (wantTerminalAttach && (!holder.terminalUrl || !holder.terminalTransport))
354
+ ) {
355
+ // Nullable plane cells are valid graceful-degradation values at
356
+ // the raw API boundary, but an explicit UI grant must not become
357
+ // a successful holder that waits forever. Release it and surface
358
+ // a retryable error instead of retaining stale credentials.
359
+ await client.detachViewer(workspaceId, sessionId, holder.viewerId).catch(() => {});
360
+ throw new Error("live plane unavailable");
361
+ }
340
362
  localViewerId = holder.viewerId;
341
363
  viewerIdRef.current = holder.viewerId;
342
364
  setViewerId(holder.viewerId);
@@ -356,24 +378,24 @@ export function useSessionCapabilities(
356
378
  ...prev.DesktopStream,
357
379
  transport: holder.transport ?? prev.DesktopStream.transport,
358
380
  client: holder.client ?? prev.DesktopStream.client,
359
- url: holder.dataPlaneUrl ?? prev.DesktopStream.url,
360
- token: holder.streamToken ?? prev.DesktopStream.token,
361
- expiresAt: holder.streamExpiresAt ?? prev.DesktopStream.expiresAt,
381
+ url: holder.dataPlaneUrl,
382
+ token: holder.streamToken,
383
+ expiresAt: holder.streamExpiresAt,
362
384
  resolution: holder.resolution ?? prev.DesktopStream.resolution,
363
385
  }
364
386
  : prev.DesktopStream,
365
- Terminal:
366
- holder.terminalUrl && holder.terminalTransport
367
- ? {
368
- ...prev.Terminal,
369
- transport: holder.terminalTransport,
370
- url: holder.terminalUrl,
371
- token: holder.terminalToken ?? prev.Terminal.token,
372
- // A live pty-ws means the box is pty-capable for real.
373
- ptyCapable: true,
374
- reason: null,
375
- }
376
- : prev.Terminal,
387
+ Terminal: wantTerminalAttach
388
+ ? {
389
+ ...prev.Terminal,
390
+ transport: holder.terminalTransport ?? prev.Terminal.transport,
391
+ url: holder.terminalUrl,
392
+ token: holder.terminalToken,
393
+ expiresAt: holder.terminalExpiresAt,
394
+ // A live pty-ws means the box is pty-capable for real.
395
+ ptyCapable: holder.terminalTransport ? true : prev.Terminal.ptyCapable,
396
+ reason: holder.terminalTransport ? null : prev.Terminal.reason,
397
+ }
398
+ : prev.Terminal,
377
399
  }
378
400
  : prev,
379
401
  );
@@ -382,8 +404,8 @@ export function useSessionCapabilities(
382
404
  if (cause instanceof OpenGeniApiError) {
383
405
  if (cause.status === 409) {
384
406
  // The un-redacted/shared consent gate is a DESKTOP requirement. A
385
- // terminal-only warm attach needs no consent, so a 409 there is not
386
- // a consent prompt — the terminal just stays on the read-only
407
+ // terminal-only warm attach needs no acknowledgment, so a 409 there
408
+ // is not a desktop-open signal — the terminal stays on the read-only
387
409
  // firehose. Only raise the consent requirement when the desktop was
388
410
  // the (or a) reason we attached.
389
411
  if (wantDesktopAttach) {
@@ -397,6 +419,8 @@ export function useSessionCapabilities(
397
419
  setState("error");
398
420
  setError(cause);
399
421
  return;
422
+ } else {
423
+ throw cause;
400
424
  }
401
425
  // 409/429 are recoverable: structured surfaces still negotiated;
402
426
  // keep going to set ready/cold below.
@@ -407,16 +431,16 @@ export function useSessionCapabilities(
407
431
  }
408
432
 
409
433
  // Whether ANYTHING asked to warm a box this negotiation. Only then is a
410
- // cold lease "warming in flight" — worth polling, and worth surfacing a
434
+ // non-warm lease "warming in flight" — worth polling, and worth surfacing a
411
435
  // stall as an error. With no warm-up requested a cold lease is the benign
412
436
  // boxless resting state (below), never an error.
413
437
  const warmUpRequested = wantDesktopAttach || wantTerminalAttach || wantFilesAttach;
414
- if (caps.liveness === "warm" || caps.liveness === "draining" || localViewerId) {
438
+ if (sandboxAcceptsLiveIo(caps.liveness) || localViewerId) {
415
439
  setState("ready");
416
440
  startHeartbeat(caps);
417
441
  } else if (warmUpRequested) {
418
442
  // A warm-up is genuinely in flight (a viewer attach was requested) but the
419
- // box isn't warm yet — poll until it warms, with the patience deadline so a
443
+ // box isn't holder-confirmed warm yet — poll until it is, with the deadline so a
420
444
  // real stall surfaces as an error.
421
445
  setState("cold");
422
446
  pollUntilWarm();
@@ -455,7 +479,7 @@ export function useSessionCapabilities(
455
479
  client,
456
480
  workspaceId,
457
481
  sessionId,
458
- enabled,
482
+ lifecycleEnabled,
459
483
  attachDesktop,
460
484
  attachTerminal,
461
485
  attachFiles,
@@ -4,6 +4,7 @@ import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../sessi
4
4
  import { buildTimeline, groupTimeline, sessionStatusFromEvents } from "../timeline/projection";
5
5
  import type { TimelineItem } from "../timeline/types";
6
6
  import type { EmbeddedSessionClientLike } from "../client";
7
+ import { usePageLiveActivity } from "./internal";
7
8
 
8
9
  export type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
9
10
 
@@ -135,6 +136,8 @@ export function useSessionEvents(
135
136
  ): UseSessionEventsResult {
136
137
  const { client, workspaceId, reconcileSession } = useEmbeddedSession(options);
137
138
  const enabled = options.enabled ?? true;
139
+ const pageLive = usePageLiveActivity();
140
+ const streamEnabled = enabled && pageLive;
138
141
  const after = options.after ?? 0;
139
142
  const replay = options.replay ?? "windowed";
140
143
  const fullReplay = replay === "full" || after !== 0;
@@ -224,7 +227,7 @@ export function useSessionEvents(
224
227
  loadingOlderRef.current = false;
225
228
  setLoadingOlder(false);
226
229
  }
227
- if (!sessionId || !enabled) {
230
+ if (!sessionId || !streamEnabled) {
228
231
  setConnectionState("idle");
229
232
  return;
230
233
  }
@@ -338,7 +341,18 @@ export function useSessionEvents(
338
341
  const stream = client.streamEvents(workspaceId, sessionId, {
339
342
  after: streamResumeSequenceRef.current,
340
343
  signal: controller.signal,
341
- beforeLive: async () => await reconcileSession(sessionId),
344
+ onOpen: () => {
345
+ // Reconciliation repairs projections, but it must not block reading
346
+ // the already-open SSE body. Otherwise live events accumulate in the
347
+ // browser and appear as one delayed burst after every reconciler ends.
348
+ void Promise.resolve()
349
+ .then(() => reconcileSession(sessionId))
350
+ .catch((cause) => {
351
+ if (isCurrent()) {
352
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
353
+ }
354
+ });
355
+ },
342
356
  onStateChange: (state) => {
343
357
  if (isCurrent()) {
344
358
  setConnectionState(state);
@@ -399,7 +413,7 @@ export function useSessionEvents(
399
413
  workspaceId,
400
414
  sessionId,
401
415
  after,
402
- enabled,
416
+ streamEnabled,
403
417
  fullReplay,
404
418
  streamKey,
405
419
  streamEpoch,
@@ -1175,7 +1189,7 @@ async function loadPreviousPage(
1175
1189
  compact: true,
1176
1190
  // Monitoring summaries omit correlation fields once a bounded durable
1177
1191
  // payload exceeds their preview threshold. The browser timeline needs the
1178
- // sanitized stored payload so persisted tool outputs still match their
1192
+ // exact stored payload so persisted tool outputs still match their
1179
1193
  // calls and expose truthful truncation telemetry after a reload.
1180
1194
  payloadMode: "full",
1181
1195
  });
@@ -17,7 +17,7 @@ export type UseTerminalStreamOptions = {
17
17
  * The stream connects ONLY when `transport === "pty-ws"` and `url` is set; on a
18
18
  * cold box (`transport === "sse-events"` / no url) it stays idle and the caller
19
19
  * falls back to the Channel-A read-only firehose. */
20
- capability: Pick<TerminalCapability, "transport" | "url" | "token"> | null;
20
+ capability: Pick<TerminalCapability, "transport" | "url" | "token" | "expiresAt"> | null;
21
21
  /** Called for each OUTPUT payload from ttyd (write verbatim into xterm). */
22
22
  onOutput?: ((data: string) => void) | undefined;
23
23
  /** Called when ttyd sends a SET_WINDOW_TITLE frame. */
@@ -31,7 +31,8 @@ export type UseTerminalStreamResult = {
31
31
  /** True once the ttyd socket is open (and the auth frame has been sent). */
32
32
  connected: boolean;
33
33
  status: TerminalStreamStatus;
34
- /** Pipe a keystroke/paste to the PTY stdin. No-op until the socket is open. */
34
+ /** Pipe a keystroke/paste to PTY stdin. Input during CONNECTING is bounded and
35
+ * replayed only after ttyd authentication succeeds. */
35
36
  write: (data: string) => void;
36
37
  /** Tell ttyd the PTY window changed size (on xterm fit/resize). */
37
38
  resize: (cols: number, rows: number) => void;
@@ -39,6 +40,27 @@ export type UseTerminalStreamResult = {
39
40
  disconnect: () => void;
40
41
  };
41
42
 
43
+ // Keystrokes entered during the websocket handshake are held until ttyd has
44
+ // accepted its auth/resize frames. Bound the queue by UTF-16 code units (a hard
45
+ // <=128 KiB string payload in current JS engines) so a paste cannot turn a slow
46
+ // handshake into unbounded renderer memory. Overflow rejects the whole pending
47
+ // input rather than ever executing a truncated shell command.
48
+ export const MAX_PENDING_TERMINAL_INPUT_CODE_UNITS = 64 * 1024;
49
+ const TERMINAL_CONNECT_EXPIRY_SKEW_MS = 5_000;
50
+
51
+ /** A bearer must remain valid long enough to finish a new websocket handshake.
52
+ * Existing open sockets are unaffected: this is evaluated only when the
53
+ * credential identity changes and the connection effect attempts a new socket. */
54
+ export function terminalStreamCredentialUsable(
55
+ capability: Pick<TerminalCapability, "transport" | "url" | "expiresAt"> | null | undefined,
56
+ nowMs = Date.now(),
57
+ ): boolean {
58
+ if (capability?.transport !== "pty-ws" || !capability.url) return false;
59
+ if (!capability.expiresAt) return true;
60
+ const expiresAt = Date.parse(capability.expiresAt);
61
+ return Number.isFinite(expiresAt) && expiresAt > nowMs + TERMINAL_CONNECT_EXPIRY_SKEW_MS;
62
+ }
63
+
42
64
  /** Decode an inbound ttyd frame's payload (everything after the 1-char command).
43
65
  * ttyd may send either a text frame (string) or a binary frame (ArrayBuffer);
44
66
  * for binary we slice off the first byte (the command) and utf-8 decode the rest. */
@@ -52,6 +74,14 @@ function decodeFrame(data: string | ArrayBuffer): { command: string; payload: st
52
74
  return { command, payload };
53
75
  }
54
76
 
77
+ function closeSocket(socket: WebSocket | null): void {
78
+ try {
79
+ socket?.close();
80
+ } catch {
81
+ // Already closed or rejected by the host WebSocket implementation.
82
+ }
83
+ }
84
+
55
85
  /**
56
86
  * Drive a ttyd PTY-over-websocket connection from a `pty-ws` Terminal capability,
57
87
  * symmetric with `use-desktop-stream` (the noVNC-over-tunnel hook). The scoped
@@ -73,6 +103,9 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
73
103
  const { capability, onOutput, onTitle, initialCols, initialRows } = options;
74
104
  const [status, setStatus] = useState<TerminalStreamStatus>("closed");
75
105
  const wsRef = useRef<WebSocket | null>(null);
106
+ const pendingInputRef = useRef("");
107
+ const socketFailedRef = useRef(false);
108
+ const socketAuthenticatedRef = useRef(false);
76
109
  // Latest size, so a resize() before the socket opens is replayed on open, and a
77
110
  // reconnect seeds the right geometry.
78
111
  const sizeRef = useRef<{ cols: number; rows: number }>({
@@ -89,12 +122,35 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
89
122
  const transport = capability?.transport ?? null;
90
123
  const url = capability?.url ?? null;
91
124
  const token = capability?.token ?? null;
125
+ const expiresAt = capability?.expiresAt ?? null;
92
126
 
93
127
  useEffect(() => {
94
128
  // SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
95
129
  // back to the Channel-A read-only firehose.
96
130
  if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
97
- if (transport !== "pty-ws" || !url) {
131
+ if (transport !== "pty-ws") {
132
+ // A transport downgrade is a real semantic boundary. Do not replay input
133
+ // captured for a PTY if this surface later changes back from firehose mode.
134
+ pendingInputRef.current = "";
135
+ socketAuthenticatedRef.current = false;
136
+ setStatus("closed");
137
+ return;
138
+ }
139
+ // A changed/cleared credential is a new connection attempt boundary. It may
140
+ // receive input while its exact grant is pending even if the prior socket
141
+ // failed; the unchanged failed identity remains fenced because this effect
142
+ // does not re-run for a status-only render.
143
+ socketFailedRef.current = false;
144
+ socketAuthenticatedRef.current = false;
145
+ if (!url) {
146
+ // Exact grants arrive asynchronously. Preserve the bounded first input
147
+ // while the descriptor remains pty-ws, but never open without a credential.
148
+ setStatus("closed");
149
+ return;
150
+ }
151
+ if (!terminalStreamCredentialUsable({ transport, url, expiresAt })) {
152
+ // 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.
98
154
  setStatus("closed");
99
155
  return;
100
156
  }
@@ -107,6 +163,7 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
107
163
  // it. The scoped token is already in the tunnel `url`.
108
164
  socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
109
165
  } catch {
166
+ socketFailedRef.current = true;
110
167
  setStatus("error");
111
168
  return;
112
169
  }
@@ -121,8 +178,18 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
121
178
  try {
122
179
  socket.send(ttydAuthFrame({ columns: sizeRef.current.cols, rows: sizeRef.current.rows }));
123
180
  socket.send(ttydResizeFrame(sizeRef.current.cols, sizeRef.current.rows));
181
+ if (pendingInputRef.current.length > 0) {
182
+ // One websocket frame makes the pre-open queue all-or-nothing from this
183
+ // client's perspective; never execute a prefix of a buffered command.
184
+ socket.send(ttydInputFrame(pendingInputRef.current));
185
+ pendingInputRef.current = "";
186
+ }
187
+ socketAuthenticatedRef.current = true;
124
188
  } catch {
125
- // a closed socket between open and send — onclose handles state.
189
+ socketFailedRef.current = true;
190
+ setStatus("error");
191
+ closeSocket(socket);
192
+ return;
126
193
  }
127
194
  setStatus("open");
128
195
  };
@@ -144,46 +211,60 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
144
211
  };
145
212
 
146
213
  socket.onerror = () => {
147
- if (!disposed) setStatus("error");
214
+ if (!disposed) {
215
+ socketFailedRef.current = true;
216
+ setStatus("error");
217
+ }
148
218
  };
149
219
  socket.onclose = () => {
150
- if (!disposed) setStatus("closed");
220
+ if (wsRef.current === socket) wsRef.current = null;
221
+ socketAuthenticatedRef.current = false;
222
+ if (!disposed) setStatus(socketFailedRef.current ? "error" : "closed");
151
223
  };
152
224
 
153
225
  return () => {
154
226
  disposed = true;
155
227
  wsRef.current = null;
228
+ socketAuthenticatedRef.current = false;
156
229
  // Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
157
230
  socket.onopen = null;
158
231
  socket.onmessage = null;
159
232
  socket.onerror = null;
160
233
  socket.onclose = null;
161
- try {
162
- socket.close();
163
- } catch {
164
- // ignore teardown errors
165
- }
234
+ closeSocket(socket);
166
235
  };
167
236
  // A url/token change (rotation) re-runs this effect → close old, open new.
168
237
  // eslint-disable-next-line react-hooks/exhaustive-deps
169
- }, [transport, url, token]);
238
+ }, [transport, url, token, expiresAt]);
170
239
 
171
240
  return useMemo<UseTerminalStreamResult>(() => {
172
241
  const write = (data: string) => {
173
242
  const ws = wsRef.current;
174
- if (ws && ws.readyState === WebSocket.OPEN) {
243
+ if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
175
244
  try {
176
245
  ws.send(ttydInputFrame(data));
177
246
  } catch {
178
- // socket raced closed — the reconnect effect will re-establish.
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;
179
259
  }
260
+ pendingInputRef.current += data;
180
261
  }
181
262
  };
182
263
  const resize = (cols: number, rows: number) => {
183
264
  if (cols <= 0 || rows <= 0) return;
184
265
  sizeRef.current = { cols, rows };
185
266
  const ws = wsRef.current;
186
- if (ws && ws.readyState === WebSocket.OPEN) {
267
+ if (ws && ws.readyState === WebSocket.OPEN && socketAuthenticatedRef.current) {
187
268
  try {
188
269
  ws.send(ttydResizeFrame(cols, rows));
189
270
  } catch {
@@ -194,14 +275,12 @@ export function useTerminalStream(options: UseTerminalStreamOptions): UseTermina
194
275
  const disconnect = () => {
195
276
  const ws = wsRef.current;
196
277
  wsRef.current = null;
197
- if (ws) {
198
- try {
199
- ws.close();
200
- } catch {
201
- // ignore
202
- }
203
- }
278
+ pendingInputRef.current = "";
279
+ socketFailedRef.current = false;
280
+ socketAuthenticatedRef.current = false;
281
+ setStatus("closed");
282
+ closeSocket(ws);
204
283
  };
205
284
  return { connected: status === "open", status, write, resize, disconnect };
206
- }, [status]);
285
+ }, [status, transport]);
207
286
  }
@@ -214,7 +214,7 @@ export type UseTranscriptionOptions = {
214
214
  disabled?: boolean | undefined;
215
215
  createLocalSessionId?: (() => string) | undefined;
216
216
  lifecycleTimeouts?: Partial<TranscriptionLifecycleTimeouts> | undefined;
217
- /** Receives bounded, redacted diagnostics; this detail is never rendered by the hook. */
217
+ /** Receives the exact diagnostic emitted by the transcription transport. */
218
218
  onDiagnostic?: ((diagnostic: TranscriptionDiagnostic) => void) | undefined;
219
219
  };
220
220
 
@@ -277,7 +277,7 @@ export function useTranscription({
277
277
  (diagnostic: unknown) => {
278
278
  if (!onDiagnostic) return;
279
279
  try {
280
- onDiagnostic(sanitizeTranscriptionDiagnostic(diagnostic));
280
+ onDiagnostic(normalizeTranscriptionDiagnostic(diagnostic));
281
281
  } catch {
282
282
  // Observability must never break the local privacy or UI lifecycle.
283
283
  }
@@ -625,7 +625,7 @@ export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErr
625
625
  }
626
626
  }
627
627
 
628
- export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
628
+ export function normalizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
629
629
  const source = isRecord(diagnostic) ? diagnostic : {};
630
630
  const operation =
631
631
  source.operation === "start" ||
@@ -635,20 +635,10 @@ export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): Transcript
635
635
  ? source.operation
636
636
  : "session";
637
637
  const rawDetail = typeof source.detail === "string" ? source.detail : "No diagnostic detail.";
638
- const detail = rawDetail
639
- .replace(/[\u0000-\u001f\u007f]+/gu, " ")
640
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]")
641
- .replace(/\bsk-[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED]")
642
- .replace(
643
- /\b(api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|token|secret)\b\s*[:=]\s*[^\s,;]+/giu,
644
- "$1=[REDACTED]",
645
- )
646
- .trim()
647
- .slice(0, 512);
648
638
  return {
649
639
  operation,
650
640
  code: normalizeTranscriptionErrorCode(source.code),
651
- detail: detail || "No diagnostic detail.",
641
+ detail: rawDetail,
652
642
  };
653
643
  }
654
644