@tapflowio/relay 0.27.1 → 0.28.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 (250) hide show
  1. package/dist/RelayServer.d.ts +54 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +384 -37
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +63 -2
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +123 -1
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/api/apps.d.ts +2 -1
  10. package/dist/api/apps.d.ts.map +1 -1
  11. package/dist/api/apps.js +20 -7
  12. package/dist/api/apps.js.map +1 -1
  13. package/dist/api/builds.d.ts +2 -1
  14. package/dist/api/builds.d.ts.map +1 -1
  15. package/dist/api/builds.js +13 -39
  16. package/dist/api/builds.js.map +1 -1
  17. package/dist/api/profile.d.ts.map +1 -1
  18. package/dist/api/profile.js +3 -1
  19. package/dist/api/profile.js.map +1 -1
  20. package/dist/api/runVideos.d.ts +85 -0
  21. package/dist/api/runVideos.d.ts.map +1 -0
  22. package/dist/api/runVideos.js +494 -0
  23. package/dist/api/runVideos.js.map +1 -0
  24. package/dist/api/runs.d.ts +77 -0
  25. package/dist/api/runs.d.ts.map +1 -0
  26. package/dist/api/runs.js +579 -0
  27. package/dist/api/runs.js.map +1 -0
  28. package/dist/api/settings.d.ts +1 -1
  29. package/dist/api/settings.d.ts.map +1 -1
  30. package/dist/api/settings.js +43 -3
  31. package/dist/api/settings.js.map +1 -1
  32. package/dist/db.d.ts +2 -0
  33. package/dist/db.d.ts.map +1 -1
  34. package/dist/db.js +4 -0
  35. package/dist/db.js.map +1 -1
  36. package/dist/lib/buildDeletion.d.ts +21 -0
  37. package/dist/lib/buildDeletion.d.ts.map +1 -0
  38. package/dist/lib/buildDeletion.js +58 -0
  39. package/dist/lib/buildDeletion.js.map +1 -0
  40. package/dist/lib/watchActions.d.ts +10 -0
  41. package/dist/lib/watchActions.d.ts.map +1 -0
  42. package/dist/lib/watchActions.js +27 -0
  43. package/dist/lib/watchActions.js.map +1 -0
  44. package/dist/middleware/auth.d.ts +1 -0
  45. package/dist/middleware/auth.d.ts.map +1 -1
  46. package/dist/middleware/auth.js +1 -1
  47. package/dist/middleware/auth.js.map +1 -1
  48. package/dist/migrations/015_flow_runs.sql +59 -0
  49. package/dist/migrations/016_flow_run_videos.sql +51 -0
  50. package/dist/router.d.ts +5 -0
  51. package/dist/router.d.ts.map +1 -1
  52. package/dist/router.js +24 -0
  53. package/dist/router.js.map +1 -1
  54. package/package.json +9 -9
  55. package/public/assets/AISessionWatch-C2h4NZ_I.js +31 -0
  56. package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
  57. package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
  58. package/public/assets/AndroidDeviceScreen-C55msKfh.js +35 -0
  59. package/public/assets/AndroidDeviceScreen-C55msKfh.js.br +0 -0
  60. package/public/assets/AndroidDeviceScreen-C55msKfh.js.gz +0 -0
  61. package/public/assets/AppCenter-S1y-8_7l.js +26 -0
  62. package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
  63. package/public/assets/AppCenter-S1y-8_7l.js.gz +0 -0
  64. package/public/assets/Default-C-l1O-g9.js +6 -0
  65. package/public/assets/Default-C-l1O-g9.js.br +0 -0
  66. package/public/assets/Default-C-l1O-g9.js.gz +0 -0
  67. package/public/assets/Invite-BCndVxaO.js +1 -0
  68. package/public/assets/Invite-BCndVxaO.js.br +0 -0
  69. package/public/assets/Invite-BCndVxaO.js.gz +0 -0
  70. package/public/assets/MacResources-41cFDgE1.js +1 -0
  71. package/public/assets/MacResources-41cFDgE1.js.br +0 -0
  72. package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
  73. package/public/assets/{NotFound-wHOnT2Fl.js → NotFound-DVq_rCKc.js} +1 -1
  74. package/public/assets/NotFound-DVq_rCKc.js.br +0 -0
  75. package/public/assets/NotFound-DVq_rCKc.js.gz +0 -0
  76. package/public/assets/QASession-B67RsIPm.js +81 -0
  77. package/public/assets/QASession-B67RsIPm.js.br +0 -0
  78. package/public/assets/QASession-B67RsIPm.js.gz +0 -0
  79. package/public/assets/ResetPassword-8rj7ASt1.js +1 -0
  80. package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
  81. package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
  82. package/public/assets/RunDetail-BIgq9gJ0.js +16 -0
  83. package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
  84. package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
  85. package/public/assets/Runs-DpK5aoBr.js +6 -0
  86. package/public/assets/Runs-DpK5aoBr.js.br +0 -0
  87. package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
  88. package/public/assets/Setup-DZrhA_7b.js +1 -0
  89. package/public/assets/Setup-DZrhA_7b.js.br +0 -0
  90. package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
  91. package/public/assets/Team-CJThCjd3.js +6 -0
  92. package/public/assets/Team-CJThCjd3.js.br +0 -0
  93. package/public/assets/Team-CJThCjd3.js.gz +0 -0
  94. package/public/assets/Tokens-DGURtHWi.js +1 -0
  95. package/public/assets/Tokens-DGURtHWi.js.br +0 -0
  96. package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
  97. package/public/assets/WatchedDevice-CpaKHa1x.js +1 -0
  98. package/public/assets/WatchedDevice-CpaKHa1x.js.br +0 -0
  99. package/public/assets/WatchedDevice-CpaKHa1x.js.gz +0 -0
  100. package/public/assets/aiSession-BW09Jhqz.js +26 -0
  101. package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
  102. package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
  103. package/public/assets/alert-dialog-t5vlOabq.js +1 -0
  104. package/public/assets/alert-dialog-t5vlOabq.js.br +0 -0
  105. package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
  106. package/public/assets/{badge-DNaS0Mu3.js → badge-DrDCTAQQ.js} +1 -1
  107. package/public/assets/badge-DrDCTAQQ.js.br +0 -0
  108. package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
  109. package/public/assets/build-format-ofd2iIzf.js +1 -0
  110. package/public/assets/build-format-ofd2iIzf.js.br +0 -0
  111. package/public/assets/build-format-ofd2iIzf.js.gz +0 -0
  112. package/public/assets/dialog-BOFZkCES.js +1 -0
  113. package/public/assets/dialog-BOFZkCES.js.br +0 -0
  114. package/public/assets/dialog-BOFZkCES.js.gz +0 -0
  115. package/public/assets/field-hint-B9fPUgi7.js +1 -0
  116. package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
  117. package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
  118. package/public/assets/index-Bixhy5MB.js +248 -0
  119. package/public/assets/index-Bixhy5MB.js.br +0 -0
  120. package/public/assets/index-Bixhy5MB.js.gz +0 -0
  121. package/public/assets/index-D17hSi50.js +1 -0
  122. package/public/assets/index-D17hSi50.js.br +0 -0
  123. package/public/assets/index-D17hSi50.js.gz +0 -0
  124. package/public/assets/index-h2hiZ2OM.css +1 -0
  125. package/public/assets/index-h2hiZ2OM.css.br +0 -0
  126. package/public/assets/index-h2hiZ2OM.css.gz +0 -0
  127. package/public/assets/list-view-BpOY1e-d.js +1 -0
  128. package/public/assets/list-view-BpOY1e-d.js.br +0 -0
  129. package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
  130. package/public/assets/{pencil-BN18R4oa.js → pencil-ZlrAsWuU.js} +2 -2
  131. package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
  132. package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
  133. package/public/assets/play-4CsGZdQU.js +6 -0
  134. package/public/assets/play-4CsGZdQU.js.br +0 -0
  135. package/public/assets/play-4CsGZdQU.js.gz +0 -0
  136. package/public/assets/resource-health-oWJ8hy9X.js +1 -0
  137. package/public/assets/resource-health-oWJ8hy9X.js.br +0 -0
  138. package/public/assets/resource-health-oWJ8hy9X.js.gz +0 -0
  139. package/public/assets/runs-CInQb6v1.js +1 -0
  140. package/public/assets/runs-CInQb6v1.js.br +0 -0
  141. package/public/assets/runs-CInQb6v1.js.gz +0 -0
  142. package/public/assets/search-input-G_QpBvPM.js +6 -0
  143. package/public/assets/search-input-G_QpBvPM.js.br +0 -0
  144. package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
  145. package/public/assets/select-DDzv2YfY.js +11 -0
  146. package/public/assets/select-DDzv2YfY.js.br +0 -0
  147. package/public/assets/select-DDzv2YfY.js.gz +0 -0
  148. package/public/assets/slider-DE7lQdK4.js +1 -0
  149. package/public/assets/slider-DE7lQdK4.js.br +0 -0
  150. package/public/assets/slider-DE7lQdK4.js.gz +0 -0
  151. package/public/assets/tabs-Co1N3x_X.js +1 -0
  152. package/public/assets/tabs-Co1N3x_X.js.br +0 -0
  153. package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
  154. package/public/assets/tech-label-BMvuJmUf.js +1 -0
  155. package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
  156. package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
  157. package/public/assets/{trash-D1l1Fm-Z.js → trash-Cwj2dbgB.js} +3 -3
  158. package/public/assets/trash-Cwj2dbgB.js.br +0 -0
  159. package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
  160. package/public/assets/{useFocusAfterSwap-BY3N9f9v.js → useFocusAfterSwap-BdItkQCu.js} +1 -1
  161. package/public/assets/useFocusAfterSwap-BdItkQCu.js.br +0 -0
  162. package/public/assets/useFocusAfterSwap-BdItkQCu.js.gz +0 -0
  163. package/public/assets/useRelay-CES3I1Mt.js +1 -0
  164. package/public/assets/useRelay-CES3I1Mt.js.br +0 -0
  165. package/public/assets/useRelay-CES3I1Mt.js.gz +0 -0
  166. package/public/assets/useShownAfter-By1Yxysz.js +1 -0
  167. package/public/assets/useShownAfter-By1Yxysz.js.br +0 -0
  168. package/public/assets/useShownAfter-By1Yxysz.js.gz +0 -0
  169. package/public/assets/{vendor-charts-BykzUV2D.js → vendor-charts-BE7R_8yA.js} +2 -2
  170. package/public/assets/vendor-charts-BE7R_8yA.js.br +0 -0
  171. package/public/assets/vendor-charts-BE7R_8yA.js.gz +0 -0
  172. package/public/assets/{vendor-react-BlPSzrEe.js → vendor-react-DTSdA8m-.js} +4 -4
  173. package/public/assets/vendor-react-DTSdA8m-.js.br +0 -0
  174. package/public/assets/{vendor-react-BlPSzrEe.js.gz → vendor-react-DTSdA8m-.js.gz} +0 -0
  175. package/public/assets/{vendor-router-B_lVHrKl.js → vendor-router-B1pQim9Z.js} +3 -3
  176. package/public/assets/vendor-router-B1pQim9Z.js.br +0 -0
  177. package/public/assets/vendor-router-B1pQim9Z.js.gz +0 -0
  178. package/public/assets/video-swsaHrfi.js +6 -0
  179. package/public/assets/video-swsaHrfi.js.br +0 -0
  180. package/public/assets/video-swsaHrfi.js.gz +0 -0
  181. package/public/index.html +30 -4
  182. package/public/index.html.br +0 -0
  183. package/public/index.html.gz +0 -0
  184. package/public/og-image.jpg +0 -0
  185. package/public/assets/AppCenter-CeLjUbnR.js +0 -26
  186. package/public/assets/AppCenter-CeLjUbnR.js.br +0 -0
  187. package/public/assets/AppCenter-CeLjUbnR.js.gz +0 -0
  188. package/public/assets/Default-UB0Pa3lN.js +0 -1
  189. package/public/assets/Default-UB0Pa3lN.js.br +0 -0
  190. package/public/assets/Default-UB0Pa3lN.js.gz +0 -0
  191. package/public/assets/Invite-Dv3omu3x.js +0 -1
  192. package/public/assets/Invite-Dv3omu3x.js.br +0 -0
  193. package/public/assets/Invite-Dv3omu3x.js.gz +0 -0
  194. package/public/assets/MacResources-CY4-OdGt.js +0 -1
  195. package/public/assets/MacResources-CY4-OdGt.js.br +0 -0
  196. package/public/assets/MacResources-CY4-OdGt.js.gz +0 -0
  197. package/public/assets/NotFound-wHOnT2Fl.js.br +0 -0
  198. package/public/assets/NotFound-wHOnT2Fl.js.gz +0 -0
  199. package/public/assets/QASession-H5eRSwZv.js +0 -150
  200. package/public/assets/QASession-H5eRSwZv.js.br +0 -0
  201. package/public/assets/QASession-H5eRSwZv.js.gz +0 -0
  202. package/public/assets/ResetPassword-nFTigYt2.js +0 -1
  203. package/public/assets/ResetPassword-nFTigYt2.js.br +0 -0
  204. package/public/assets/ResetPassword-nFTigYt2.js.gz +0 -0
  205. package/public/assets/Setup-UmLtF2PF.js +0 -1
  206. package/public/assets/Setup-UmLtF2PF.js.br +0 -0
  207. package/public/assets/Setup-UmLtF2PF.js.gz +0 -0
  208. package/public/assets/Team-DW40kt3H.js +0 -6
  209. package/public/assets/Team-DW40kt3H.js.br +0 -0
  210. package/public/assets/Team-DW40kt3H.js.gz +0 -0
  211. package/public/assets/Tokens-B1a4ChJK.js +0 -1
  212. package/public/assets/Tokens-B1a4ChJK.js.br +0 -0
  213. package/public/assets/Tokens-B1a4ChJK.js.gz +0 -0
  214. package/public/assets/alert-dialog-aoqYdAwx.js +0 -1
  215. package/public/assets/alert-dialog-aoqYdAwx.js.br +0 -0
  216. package/public/assets/alert-dialog-aoqYdAwx.js.gz +0 -0
  217. package/public/assets/badge-DNaS0Mu3.js.br +0 -0
  218. package/public/assets/badge-DNaS0Mu3.js.gz +0 -0
  219. package/public/assets/build-format-DF5aFcag.js +0 -11
  220. package/public/assets/build-format-DF5aFcag.js.br +0 -0
  221. package/public/assets/build-format-DF5aFcag.js.gz +0 -0
  222. package/public/assets/dialog-DE2WUMuX.js +0 -11
  223. package/public/assets/dialog-DE2WUMuX.js.br +0 -0
  224. package/public/assets/dialog-DE2WUMuX.js.gz +0 -0
  225. package/public/assets/field-hint-Csb40Gzp.js +0 -1
  226. package/public/assets/field-hint-Csb40Gzp.js.br +0 -2
  227. package/public/assets/field-hint-Csb40Gzp.js.gz +0 -0
  228. package/public/assets/index-BbCkTtjO.css +0 -1
  229. package/public/assets/index-BbCkTtjO.css.br +0 -0
  230. package/public/assets/index-BbCkTtjO.css.gz +0 -0
  231. package/public/assets/index-DSW9h8XX.js +0 -218
  232. package/public/assets/index-DSW9h8XX.js.br +0 -0
  233. package/public/assets/index-DSW9h8XX.js.gz +0 -0
  234. package/public/assets/list-view-C0w97ltJ.js +0 -1
  235. package/public/assets/list-view-C0w97ltJ.js.br +0 -0
  236. package/public/assets/list-view-C0w97ltJ.js.gz +0 -0
  237. package/public/assets/pencil-BN18R4oa.js.br +0 -0
  238. package/public/assets/pencil-BN18R4oa.js.gz +0 -0
  239. package/public/assets/resource-health-Bi20EUDs.js +0 -1
  240. package/public/assets/resource-health-Bi20EUDs.js.br +0 -0
  241. package/public/assets/resource-health-Bi20EUDs.js.gz +0 -0
  242. package/public/assets/trash-D1l1Fm-Z.js.br +0 -0
  243. package/public/assets/trash-D1l1Fm-Z.js.gz +0 -0
  244. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.br +0 -0
  245. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.gz +0 -0
  246. package/public/assets/vendor-charts-BykzUV2D.js.br +0 -0
  247. package/public/assets/vendor-charts-BykzUV2D.js.gz +0 -0
  248. package/public/assets/vendor-react-BlPSzrEe.js.br +0 -0
  249. package/public/assets/vendor-router-B_lVHrKl.js.br +0 -0
  250. package/public/assets/vendor-router-B_lVHrKl.js.gz +0 -0
@@ -5,11 +5,12 @@ import os from 'os';
5
5
  import path from 'path';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
- import { SessionManager } from './SessionManager.js';
8
+ import { SessionManager, isAiClientKind } from './SessionManager.js';
9
+ import { gestureAction, keyForWatchers } from './lib/watchActions.js';
9
10
  import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
10
11
  import { Router, json } from './router.js';
11
12
  import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
12
- import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
13
+ import { AGENT_SCOPE, VIEW_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
13
14
  import { isTunnelIngress, markTunnelIngress, proxiedWithoutForwardedFor, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
14
15
  import { BuildTicketStore } from './lib/buildTickets.js';
15
16
  import { resolveBuildFile } from './lib/buildFiles.js';
@@ -89,6 +90,8 @@ import { handleListTokens, handleCreateToken, handleRevokeToken } from './api/to
89
90
  import { handleGetSettings, handleUpdateSettings } from './api/settings.js';
90
91
  import { handleUpdateProfile } from './api/profile.js';
91
92
  import { handleUploadRecording, handleListRecordings, handleDownloadRecording, purgeExpiredRecordings } from './api/recordings.js';
93
+ import { handleEndVideo, handleOpenVideo, handleVideoChunk, handleVideoData, handleVideoIndex, purgeRunVideos, releaseStaleVideoReservations, runVideosDirFor, videoSummaries } from './api/runVideos.js';
94
+ import { abandonRunsOf, handleCreateRun, handleFinishRun, handleGetFlowScreenshot, handleGetRun, handleListRuns, handleReportFlow, handleUploadFlowScreenshot, purgeExpiredRuns, runScreenshotsDirFor } from './api/runs.js';
92
95
  import { handleListAgents, handleGetAgentResources } from './api/agents.js';
93
96
  const MIME_TYPES = {
94
97
  '.html': 'text/html',
@@ -210,6 +213,8 @@ export class RelayServer {
210
213
  resourceBuffers = new Map();
211
214
  logBuffer = [];
212
215
  recordingsDir = '';
216
+ runScreenshotsDir = '';
217
+ runVideosDir = '';
213
218
  purgeRecordingsTimer = null;
214
219
  purgeOldResourcesTimer = null;
215
220
  purgeBuildsTimer = null;
@@ -261,6 +266,19 @@ export class RelayServer {
261
266
  droppers = new Map();
262
267
  // Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
263
268
  idrRequesters = new Map();
269
+ /**
270
+ * **One keyframe-aware sender per watcher, never the session's.** The sender's `dropping` state lives in
271
+ * its closure, so sharing the holder's would let a slow watcher put the holder into drop-to-keyframe
272
+ * too — every P-frame to the holder discarded until the next IDR because someone else's link is slow.
273
+ * Keyed by socket, then session, because one socket can watch several sessions.
274
+ */
275
+ watcherDroppers = new Map();
276
+ /**
277
+ * Whether this connection may watch: a cookie, or a remote PAT with `view` — decided once at the
278
+ * handshake, from the credential. **Not** an unauthenticated loopback socket: a page in any browser on
279
+ * the relay's Mac reaches loopback without a credential, and watching shows a device's screen.
280
+ */
281
+ mayWatch = new WeakMap();
264
282
  /** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
265
283
  * `forgetSessionState`. */
266
284
  networkStateRequesters = new Map();
@@ -378,7 +396,16 @@ export class RelayServer {
378
396
  if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
379
397
  logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
380
398
  }
381
- this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
399
+ this.sessions = new SessionManager({
400
+ idleTimeoutMs: options.idleTimeoutMs,
401
+ // Before the sockets are forgotten, so each one is told why its picture stopped.
402
+ onWatchersEvicted: (sessionId, sockets, reason) => {
403
+ for (const w of sockets) {
404
+ this.forgetWatcherDropper(w, sessionId);
405
+ this.sendTo(w, { type: 'watch:ended', sessionId, reason });
406
+ }
407
+ },
408
+ });
382
409
  this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
383
410
  this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
384
411
  this.router = new Router();
@@ -431,7 +458,7 @@ export class RelayServer {
431
458
  this.router.get('/api/v1/apps', handleListApps);
432
459
  this.router.post('/api/v1/apps', handleCreateApp);
433
460
  this.router.patch('/api/v1/apps/:id', handleUpdateApp);
434
- this.router.delete('/api/v1/apps/:id', handleDeleteApp);
461
+ this.router.delete('/api/v1/apps/:id', (req, res, params) => handleDeleteApp(req, res, params, this.buildFileDirs()));
435
462
  // builds
436
463
  this.router.get('/api/v1/builds', handleListBuilds);
437
464
  this.router.get('/api/v1/builds/:id', handleGetBuild);
@@ -465,7 +492,7 @@ export class RelayServer {
465
492
  this.router.post('/api/v1/tokens', handleCreateToken);
466
493
  this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
467
494
  // settings
468
- this.router.get('/api/v1/settings', handleGetSettings);
495
+ this.router.get('/api/v1/settings', (req, res) => handleGetSettings(req, res, this.runVideosDir));
469
496
  this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
470
497
  this.router.patch('/api/v1/profile', (req, res) => handleUpdateProfile(req, res, u));
471
498
  // recordings
@@ -473,6 +500,22 @@ export class RelayServer {
473
500
  this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
474
501
  this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
475
502
  this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
503
+ // flow run records — written by `tapflow flow run`, read by the runs page
504
+ this.runScreenshotsDir = runScreenshotsDirFor(u);
505
+ this.router.post('/api/v1/runs', handleCreateRun);
506
+ this.router.get('/api/v1/runs', (req, res) => handleListRuns(req, res, this.runHolders));
507
+ this.router.get('/api/v1/runs/:id', (req, res, params) => handleGetRun(req, res, params, this.runHolders, videoSummaries));
508
+ this.router.post('/api/v1/runs/:id/finish', handleFinishRun);
509
+ this.router.post('/api/v1/runs/:id/flows/:idx', handleReportFlow);
510
+ this.router.post('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleUploadFlowScreenshot(req, res, params, this.runScreenshotsDir));
511
+ this.router.get('/api/v1/runs/:id/flows/:idx/screenshot', (req, res, params) => handleGetFlowScreenshot(req, res, params, this.runScreenshotsDir));
512
+ // a flow's video (A4) — uploaded by the runner while the flow runs, kept by the retention policy
513
+ this.runVideosDir = runVideosDirFor(u);
514
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/open', (req, res, params) => handleOpenVideo(req, res, params, this.runVideosDir));
515
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/chunks/:seq', (req, res, params) => handleVideoChunk(req, res, params, this.runVideosDir, this.runHolders));
516
+ this.router.post('/api/v1/runs/:id/flows/:idx/video/end', handleEndVideo);
517
+ this.router.get('/api/v1/runs/:id/flows/:idx/video/index', (req, res, params) => handleVideoIndex(req, res, params, this.runHolders));
518
+ this.router.get('/api/v1/runs/:id/flows/:idx/video', (req, res, params) => handleVideoData(req, res, params, this.runHolders, this.runVideosDir));
476
519
  // logs — the relay host only. The buffer holds the addresses of refused connections, nothing
477
520
  // remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
478
521
  // output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
@@ -532,8 +575,17 @@ export class RelayServer {
532
575
  purgeOldResources();
533
576
  this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
534
577
  this.purgeOldResourcesTimer.unref();
535
- purgeExpiredBuilds(this.recordingsDir, this.uploadsDir);
536
- this.purgeBuildsTimer = setInterval(() => purgeExpiredBuilds(this.recordingsDir, this.uploadsDir), 24 * 60 * 60 * 1000);
578
+ // Runs ride the builds' daily tick rather than a timer of their own: a build's runs go with it, and the
579
+ // runs left over (no build) expire on the same clock.
580
+ const purgeBuildsAndRuns = () => {
581
+ purgeExpiredBuilds(this.buildFileDirs());
582
+ purgeExpiredRuns(this.runScreenshotsDir, this.runVideosDir, this.runHolders);
583
+ purgeRunVideos(this.runVideosDir, this.runHolders);
584
+ };
585
+ // Before the first purge or write: no write in flight survived the last process.
586
+ releaseStaleVideoReservations();
587
+ purgeBuildsAndRuns();
588
+ this.purgeBuildsTimer = setInterval(purgeBuildsAndRuns, 24 * 60 * 60 * 1000);
537
589
  this.purgeBuildsTimer.unref();
538
590
  this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
539
591
  this.flushResourcesTimer.unref();
@@ -817,6 +869,32 @@ export class RelayServer {
817
869
  * negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
818
870
  * hands a live tester's session to whoever asked next.
819
871
  */
872
+ buildFileDirs() {
873
+ return { uploadsDir: this.uploadsDir, recordingsDir: this.recordingsDir, runScreenshotsDir: this.runScreenshotsDir, runVideosDir: this.runVideosDir };
874
+ }
875
+ /** Open sockets speaking for any of these owner keys. */
876
+ openSocketsOf(keys) {
877
+ return [...this.wss.clients].filter((ws) => ws.readyState === WebSocket.OPEN && keys.includes(this.ownerKey.get(ws)?.key ?? ''));
878
+ }
879
+ runHolders = {
880
+ connectedKeys: () => {
881
+ const keys = new Set();
882
+ for (const ws of this.wss.clients) {
883
+ const key = ws.readyState === WebSocket.OPEN ? this.ownerKey.get(ws)?.key : undefined;
884
+ if (key)
885
+ keys.add(key);
886
+ }
887
+ return keys;
888
+ },
889
+ heldSession: (keys) => {
890
+ for (const ws of this.openSocketsOf(keys)) {
891
+ const held = this.sessions.getByBrowserSocket(ws);
892
+ if (held.length > 0)
893
+ return held[0].id;
894
+ }
895
+ return null;
896
+ },
897
+ };
820
898
  isAlive(ws) {
821
899
  const at = this.lastPongAt.get(ws);
822
900
  // **No entry means just connected, not dead.** `handleConnection` seeds one, but a socket is in
@@ -859,6 +937,7 @@ export class RelayServer {
859
937
  */
860
938
  forgetSessionState(sessionId) {
861
939
  this.dropHandlers.delete(sessionId);
940
+ this.dropHandlers.delete(`${sessionId} watchers`);
862
941
  this.audioDropHandlers.delete(sessionId);
863
942
  this.droppers.delete(sessionId);
864
943
  this.idrRequesters.delete(sessionId);
@@ -1028,6 +1107,10 @@ export class RelayServer {
1028
1107
  if (pat)
1029
1108
  touchPat(pat.patId);
1030
1109
  userId = cookie?.userId ?? pat?.userId;
1110
+ // A loopback cookie is not re-judged later — `principals` below records remote sockets only — so a
1111
+ // watch on loopback lasts until the socket closes even if that user is removed. The same is true of
1112
+ // everything else such a socket does; watching adds nothing to it.
1113
+ this.mayWatch.set(ws, cookie !== null || (!isLocal && pat !== null && pat.scopes.includes(VIEW_SCOPE)));
1031
1114
  if (!isLocal) {
1032
1115
  if (cookie)
1033
1116
  this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp, pwv: cookie.pwv });
@@ -1070,43 +1153,73 @@ export class RelayServer {
1070
1153
  if (this.revokedSockets.has(ws))
1071
1154
  return;
1072
1155
  if (isBinary) {
1073
- // Binary frames arrive on the dedicated stream WS, route to the session's browser
1156
+ // Binary frames arrive on the dedicated stream WS, route to the session's browser and watchers.
1074
1157
  const session = this.sessions.getByStreamSocket(ws);
1075
- if (session?.browserSocket) {
1076
- const frameBuf = data;
1077
- // Audio rides the same socket (codec-tagged). Route it through a sender that YIELDS to
1078
- // video — it drops audio unless the socket is near-empty, so audio never inflates
1079
- // bufferedAmount enough to trip the video backpressure path. Must branch before the
1080
- // video dropper, which would (wrongly) treat audio as a droppable P-frame.
1081
- if (hasEnvelope(frameBuf) && readEnvelopeFlags(frameBuf).codec === CODEC_AUDIO) {
1082
- patchRelayedAt(frameBuf, Date.now());
1083
- let onAudioDrop = this.audioDropHandlers.get(session.id);
1084
- if (!onAudioDrop) {
1085
- onAudioDrop = createRateLimitedDropWarn(logger, `${session.id} audio`);
1086
- this.audioDropHandlers.set(session.id, onAudioDrop);
1087
- }
1088
- sendAudioYieldingToVideo(session.browserSocket, frameBuf, onAudioDrop);
1158
+ if (!session)
1159
+ return;
1160
+ // **Not gated on the holder.** Watchers keep receiving frames while the holder is away inside its
1161
+ // reconnect grace, which is when `browserSocket` is null.
1162
+ const watchers = this.sessions.watchersOf(session.id);
1163
+ // A holder that said it does not read the stream (`stream: false`) is sent nothing: the MCP server and a
1164
+ // runner that is not recording discard every frame, and an iOS session is ~34 Mbit/s of JPEG to do it with.
1165
+ const holder = session.holderStream ? session.browserSocket : null;
1166
+ if (!holder && watchers.length === 0)
1167
+ return;
1168
+ const frameBuf = data;
1169
+ // Audio rides the same socket (codec-tagged). Route it through a sender that YIELDS to
1170
+ // video — it drops audio unless the socket is near-empty, so audio never inflates
1171
+ // bufferedAmount enough to trip the video backpressure path. Must branch before the
1172
+ // video dropper, which would (wrongly) treat audio as a droppable P-frame.
1173
+ //
1174
+ // **The holder's alone.** A watch page has no audio control, and audio a teammate did not ask
1175
+ // to hear should not start playing in their tab.
1176
+ if (hasEnvelope(frameBuf) && readEnvelopeFlags(frameBuf).codec === CODEC_AUDIO) {
1177
+ if (!holder)
1089
1178
  return;
1179
+ patchRelayedAt(frameBuf, Date.now());
1180
+ let onAudioDrop = this.audioDropHandlers.get(session.id);
1181
+ if (!onAudioDrop) {
1182
+ onAudioDrop = createRateLimitedDropWarn(logger, `${session.id} audio`);
1183
+ this.audioDropHandlers.set(session.id, onAudioDrop);
1090
1184
  }
1091
- let onDrop = this.dropHandlers.get(session.id);
1092
- if (!onDrop) {
1093
- onDrop = createRateLimitedDropWarn(logger, session.id);
1094
- this.dropHandlers.set(session.id, onDrop);
1095
- }
1185
+ sendAudioYieldingToVideo(holder, frameBuf, onAudioDrop);
1186
+ return;
1187
+ }
1188
+ let onDrop = this.dropHandlers.get(session.id);
1189
+ if (!onDrop) {
1190
+ onDrop = createRateLimitedDropWarn(logger, session.id);
1191
+ this.dropHandlers.set(session.id, onDrop);
1192
+ }
1193
+ const requestIdr = this.idrRequester(session.id);
1194
+ // JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
1195
+ // Patched once: every recipient is sent the same buffer, so it carries one relay timestamp.
1196
+ let isKeyframe = true;
1197
+ if (hasEnvelope(frameBuf)) {
1198
+ patchRelayedAt(frameBuf, Date.now());
1199
+ const flags = readEnvelopeFlags(frameBuf);
1200
+ isKeyframe = flags.codec === CODEC_JPEG || flags.keyframe;
1201
+ }
1202
+ if (holder) {
1096
1203
  let dropper = this.droppers.get(session.id);
1097
1204
  if (!dropper) {
1098
1205
  dropper = createKeyframeAwareSender();
1099
1206
  this.droppers.set(session.id, dropper);
1100
1207
  }
1101
- const requestIdr = this.idrRequester(session.id);
1102
- // JPEG and H.264 IDRs are resync points; only P-frames must wait for a keyframe after a drop.
1103
- let isKeyframe = true;
1104
- if (hasEnvelope(frameBuf)) {
1105
- patchRelayedAt(frameBuf, Date.now());
1106
- const flags = readEnvelopeFlags(frameBuf);
1107
- isKeyframe = flags.codec === CODEC_JPEG || flags.keyframe;
1208
+ dropper.send(holder, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
1209
+ }
1210
+ // **No IDR request on a watcher's drop**, and its own drop warning. An IDR changes the encoder's
1211
+ // output for everyone, so a watcher on a slow link asking for one on every drain would cost the
1212
+ // holder up to two forced keyframes a second — the slow-watcher-hurts-holder case the separate
1213
+ // sender exists to prevent, by another route. A watcher resyncs on the next periodic keyframe.
1214
+ if (watchers.length > 0) {
1215
+ let onWatcherDrop = this.dropHandlers.get(`${session.id} watchers`);
1216
+ if (!onWatcherDrop) {
1217
+ onWatcherDrop = createRateLimitedDropWarn(logger, `${session.id} watchers`);
1218
+ this.dropHandlers.set(`${session.id} watchers`, onWatcherDrop);
1219
+ }
1220
+ for (const w of watchers) {
1221
+ this.watcherDropper(w, session.id).send(w, frameBuf, this.backpressureBytes, isKeyframe, onWatcherDrop);
1108
1222
  }
1109
- dropper.send(session.browserSocket, frameBuf, this.backpressureBytes, isKeyframe, onDrop, requestIdr);
1110
1223
  }
1111
1224
  return;
1112
1225
  }
@@ -1190,6 +1303,9 @@ export class RelayServer {
1190
1303
  const streamSession = this.sessions.getByStreamSocket(ws);
1191
1304
  if (streamSession)
1192
1305
  this.sessions.clearStreamSocket(streamSession.id);
1306
+ // A watcher leaving changes nothing for the session it watched — no release, no timer, no IDR.
1307
+ this.sessions.removeWatcherSocket(ws);
1308
+ this.watcherDroppers.delete(ws);
1193
1309
  // Browser socket disconnected → release **every** session it held, each with its own idle timer.
1194
1310
  //
1195
1311
  // `getByBrowserSocket` used to answer with one session because the index held one, and a socket
@@ -1204,6 +1320,20 @@ export class RelayServer {
1204
1320
  return;
1205
1321
  for (const held of this.sessions.getByBrowserSocket(ws)) {
1206
1322
  this.sessions.clearBrowser(held.id, () => this.idleShutdown(held.id));
1323
+ this.tellWatchers(held.id, { type: 'watch:holder-left', sessionId: held.id });
1324
+ }
1325
+ // A run is its runner's socket: the flow runner does not reconnect, so the last socket with its key
1326
+ // closing is the end of that run, finished or not. Not done while stopping (above) — a relay going
1327
+ // down has not seen the runner go, and the runner may still finish the run against the next one.
1328
+ //
1329
+ // Caught because this is an event listener: a database fault here (a full disk) would be uncaught and end
1330
+ // the relay — the rule `handleConnection` states for its own reads. A run missed here is closed by
1331
+ // `settleOrphanedRuns` on the next read.
1332
+ try {
1333
+ abandonRunsOf(this.ownerOf(ws), this.runHolders);
1334
+ }
1335
+ catch (e) {
1336
+ logger.error('could not close the runs of a disconnected runner:', e);
1207
1337
  }
1208
1338
  });
1209
1339
  }
@@ -1372,6 +1502,26 @@ export class RelayServer {
1372
1502
  case 'session:start':
1373
1503
  this.handleSessionStart(ws, msg);
1374
1504
  break;
1505
+ case 'session:activity': {
1506
+ // A holder's name for what it is about to do (A2), for its watchers only. Unanswered either way, so an
1507
+ // unowned one is simply dropped; the schema already capped its length. **Its content is trusted, not
1508
+ // checked**: the rule that it carries nothing typed binds the sender (`activityLabel` is the only producer),
1509
+ // and a holder that broke it could show no more than it already can on the screen its watchers see.
1510
+ const session = this.sessions.get(msg.sessionId);
1511
+ const label = msg.label.trim();
1512
+ if (session && label && this.ownsSession(ws, session))
1513
+ this.tellWatchersAction(session.id, { kind: 'label', label });
1514
+ break;
1515
+ }
1516
+ case 'watch:start':
1517
+ this.handleWatchStart(ws, msg);
1518
+ break;
1519
+ // Unanswered, like `session:leave`: nobody waits on it, and closing the socket does the same.
1520
+ case 'watch:stop': {
1521
+ if (this.sessions.removeWatcher(msg.sessionId, ws))
1522
+ this.forgetWatcherDropper(ws, msg.sessionId);
1523
+ break;
1524
+ }
1375
1525
  // ── the two session commands, gated but not answered ───────────────────────────────────────
1376
1526
  //
1377
1527
  // Both destroy state a viewer depends on, and until L5c both acted on the strength of the session
@@ -1405,6 +1555,10 @@ export class RelayServer {
1405
1555
  if (this.ownsSession(ws, this.sessions.get(msg.sessionId))) {
1406
1556
  this.sessions.clearBrowser(msg.sessionId);
1407
1557
  this.forgetSessionState(msg.sessionId);
1558
+ // **A leave is the end, not a blip.** The holder said it is done — a flow run finishing, an MCP
1559
+ // `disconnect_device` — so the watch ends. `watch:holder-left` is for a socket that dropped, which
1560
+ // may come back; sent here it left a watcher of every finished CI run waiting forever.
1561
+ this.sessions.endWatches(msg.sessionId, 'session-ended');
1408
1562
  }
1409
1563
  break;
1410
1564
  }
@@ -1440,6 +1594,7 @@ export class RelayServer {
1440
1594
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1441
1595
  session.browserSocket.send(JSON.stringify(raw));
1442
1596
  }
1597
+ this.forwardToWatchers(session.id, raw);
1443
1598
  break;
1444
1599
  }
1445
1600
  case 'device:postures': {
@@ -1454,6 +1609,7 @@ export class RelayServer {
1454
1609
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1455
1610
  session.browserSocket.send(JSON.stringify(raw));
1456
1611
  }
1612
+ this.forwardToWatchers(session.id, raw);
1457
1613
  break;
1458
1614
  }
1459
1615
  case 'session:deviceInfo': {
@@ -1465,6 +1621,7 @@ export class RelayServer {
1465
1621
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1466
1622
  session.browserSocket.send(JSON.stringify(raw));
1467
1623
  }
1624
+ this.forwardToWatchers(session.id, raw);
1468
1625
  break;
1469
1626
  }
1470
1627
  case 'device:booting': {
@@ -1476,6 +1633,7 @@ export class RelayServer {
1476
1633
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1477
1634
  session.browserSocket.send(JSON.stringify(raw));
1478
1635
  }
1636
+ this.forwardToWatchers(session.id, raw);
1479
1637
  break;
1480
1638
  }
1481
1639
  case 'device:boot-error': {
@@ -1529,6 +1687,7 @@ export class RelayServer {
1529
1687
  if (session.browserSocket?.readyState === WebSocket.OPEN) {
1530
1688
  session.browserSocket.send(JSON.stringify(raw));
1531
1689
  }
1690
+ this.forwardToWatchers(session.id, raw);
1532
1691
  break;
1533
1692
  }
1534
1693
  case 'app:install-done':
@@ -1613,6 +1772,7 @@ export class RelayServer {
1613
1772
  // presence check the old line carried is gone because the schema requires the payload — and
1614
1773
  // `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
1615
1774
  msg.payload['external'] = this.wsExternal.get(ws) ?? false;
1775
+ msg.payload['automation'] = isAiClientKind(boot.session.holderKind);
1616
1776
  // Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
1617
1777
  // tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
1618
1778
  this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
@@ -1635,6 +1795,18 @@ export class RelayServer {
1635
1795
  // command the relay never answers. `mcp-server`'s `shutdownDevice` waits 30s on `device:shutdown-done`
1636
1796
  // and then reports `Request timed out` with no cause, which is the silence, not a diagnosis.
1637
1797
  const session = this.sessions.get(msg.sessionId);
1798
+ // A watcher's gate comes first: `mayShutDown` passes anyone while the session is unheld, and the
1799
+ // holder's reconnect grace is exactly that — so a watch page closing at that moment would power
1800
+ // off the device an agent is in the middle of testing.
1801
+ if (session && this.sessions.isWatching(ws, session.id)) {
1802
+ this.sendTo(ws, {
1803
+ type: 'device:shutdown-error',
1804
+ sessionId: msg.sessionId,
1805
+ ...(msg.requestId === undefined ? {} : { requestId: msg.requestId }),
1806
+ message: 'This connection is watching the session and cannot shut its device down.',
1807
+ });
1808
+ break;
1809
+ }
1638
1810
  if (session && !this.mayShutDown(ws, session)) {
1639
1811
  this.sendTo(ws, {
1640
1812
  type: 'device:shutdown-error',
@@ -1693,6 +1865,8 @@ export class RelayServer {
1693
1865
  const target = this.dispatchTarget(ws, msg.sessionId);
1694
1866
  if (target.ok) {
1695
1867
  target.session.agentSocket.send(JSON.stringify(msg));
1868
+ // No URL: a deep link can carry a token, and watchers are the whole team.
1869
+ this.tellWatchersAction(msg.sessionId, { kind: 'open-url' });
1696
1870
  }
1697
1871
  else {
1698
1872
  this.sendTo(ws, {
@@ -1710,6 +1884,7 @@ export class RelayServer {
1710
1884
  const target = this.dispatchTarget(ws, msg.sessionId);
1711
1885
  if (target.ok) {
1712
1886
  target.session.agentSocket.send(JSON.stringify(msg));
1887
+ this.tellWatchersAction(msg.sessionId, { kind: 'clear-state' });
1713
1888
  }
1714
1889
  else {
1715
1890
  this.sendTo(ws, {
@@ -2008,6 +2183,7 @@ export class RelayServer {
2008
2183
  for (const s of sessions) {
2009
2184
  if (s.browserSocket)
2010
2185
  this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
2186
+ this.tellWatchers(s.id, { type: 'session:agent-away', sessionId: s.id });
2011
2187
  }
2012
2188
  const timer = setTimeout(() => {
2013
2189
  this.agentHolds.delete(ws);
@@ -2173,6 +2349,7 @@ export class RelayServer {
2173
2349
  if (s?.browserSocket) {
2174
2350
  this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
2175
2351
  }
2352
+ this.tellWatchers(sessionId, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
2176
2353
  }
2177
2354
  // After the rebound frames, so the client records `needsReboot` before the error below
2178
2355
  // arrives and classifies it with the cause attached. Only boots tied to the replaced
@@ -2203,6 +2380,17 @@ export class RelayServer {
2203
2380
  this.sendTo(ws, { type: 'error', sessionId: msg.sessionId, message: 'Session not found', reason: 'session-not-found' });
2204
2381
  return;
2205
2382
  }
2383
+ // **A watcher may not take the session it watches.** Inside the holder's reconnect grace the session
2384
+ // is unheld, so without this a watch page that re-sent `session:start` — a reused viewer path, a
2385
+ // reconnect — would become the holder, and the agent's own re-join would then be refused as busy.
2386
+ // `session-busy` because that is what the caller must act on: someone else's session, in use.
2387
+ if (this.sessions.isWatching(ws, session.id)) {
2388
+ this.sendTo(ws, {
2389
+ type: 'error', sessionId: msg.sessionId, reason: 'session-busy',
2390
+ message: 'This connection is watching the session. Stop watching to join it.',
2391
+ });
2392
+ return;
2393
+ }
2206
2394
  // Occupancy first, because it is the more specific answer and both can be true at once. A tester
2207
2395
  // joining a device someone else has open on a loaded Mac was being told "this Mac is overloaded,
2208
2396
  // pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
@@ -2234,7 +2422,9 @@ export class RelayServer {
2234
2422
  }
2235
2423
  }
2236
2424
  try {
2237
- const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h));
2425
+ const joined = this.sessions.join(msg.sessionId, ws, this.ownerRecord(ws), (h) => this.isAlive(h),
2426
+ // Recorded by the join itself, so it holds before the agent-away return below and across a rebind.
2427
+ { kind: msg.clientKind, label: this.holderLabel(ws), ...(msg.stream === false ? { stream: false } : {}) });
2238
2428
  if (!joined.ok) {
2239
2429
  // Both of these are answered above, so arriving here means the state moved between that check and
2240
2430
  // this call. They are **values now rather than throws** (#515), and that is what fixes the defect:
@@ -2265,6 +2455,7 @@ export class RelayServer {
2265
2455
  // deliberately instead of inferring anything from a timeout.
2266
2456
  this.sendTo(ws, {
2267
2457
  type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
2458
+ ...(isAiClientKind(msg.clientKind) ? this.watchUrlField(msg.sessionId) : {}),
2268
2459
  });
2269
2460
  if (agentAway) {
2270
2461
  // Joining into a held session. Refusing instead would be worse than it sounds: the viewer
@@ -2309,7 +2500,10 @@ export class RelayServer {
2309
2500
  // the P-frames around it, so the amplification lands on the tester who is actually watching. The
2310
2501
  // backpressure path already throttles this exact message per session; sharing that requester is
2311
2502
  // what keeps the two callers from having two policies.
2312
- this.idrRequester(session.id)();
2503
+ // Not for a holder that takes no frames: the keyframe would go to watchers alone, and it would spend the
2504
+ // throttle window a watcher's own `watch:start` request needs.
2505
+ if (session.holderStream)
2506
+ this.idrRequester(session.id)();
2313
2507
  // And ask what the device's network is doing (#614). `NetworkState` is agent-produced and is not
2314
2508
  // among the three replayed above, so without this a viewer that reconnects has no way to learn
2315
2509
  // whether the device is offline and its control renders in a guessed position.
@@ -2333,6 +2527,150 @@ export class RelayServer {
2333
2527
  this.networkStateRequester(session.id)();
2334
2528
  }
2335
2529
  }
2530
+ /**
2531
+ * Admit `ws` as a read-only watcher, or say why not. The order of the refusals is the order a viewer
2532
+ * can act on them: a connection that may never watch is told so before it learns anything about the
2533
+ * session.
2534
+ */
2535
+ handleWatchStart(ws, msg) {
2536
+ const refuse = (reason, message) => this.sendTo(ws, { type: 'watch:refused', sessionId: msg.sessionId, reason, message });
2537
+ if (!this.mayWatch.get(ws))
2538
+ return refuse('not-permitted', 'Sign in to watch a session.');
2539
+ const session = this.sessions.get(msg.sessionId);
2540
+ if (!session)
2541
+ return refuse('session-not-found', 'Session not found');
2542
+ if (session.owner === null || !isAiClientKind(session.holderKind)) {
2543
+ return refuse('not-watchable', 'Only a session an AI client is driving can be watched.');
2544
+ }
2545
+ // The holder already receives everything. Watching too would deliver each frame twice to one socket,
2546
+ // and the watcher gates would then refuse the holder's own shutdown and re-join.
2547
+ if (this.ownsSession(ws, session))
2548
+ return refuse('not-watchable', 'This connection already holds the session.');
2549
+ if (this.sessions.addWatcher(session.id, ws, session.owner) === 'full') {
2550
+ return refuse('watchers-full', 'This session already has as many watchers as it allows.');
2551
+ }
2552
+ this.sendTo(ws, { type: 'watch:started', sessionId: session.id });
2553
+ // The same replay a re-joining viewer gets, for the same reason: a watch can begin mid-session.
2554
+ if (session.agentSocket.readyState !== WebSocket.OPEN) {
2555
+ this.sendTo(ws, { type: 'session:agent-away', sessionId: session.id });
2556
+ return;
2557
+ }
2558
+ if (session.chromeData)
2559
+ this.sendTo(ws, { type: 'session:chrome', sessionId: session.id, payload: session.chromeData });
2560
+ if (session.deviceInfo)
2561
+ this.sendTo(ws, { type: 'session:deviceInfo', sessionId: session.id, payload: session.deviceInfo });
2562
+ if (session.postures)
2563
+ this.sendTo(ws, { type: 'device:postures', sessionId: session.id, payload: session.postures });
2564
+ if (session.readySent) {
2565
+ this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
2566
+ // Through the session's throttled requester, as the re-join does: a watcher arriving mid-GOP needs
2567
+ // a keyframe, and a socket starting watches repeatedly must not turn into a stream of IDRs.
2568
+ this.idrRequester(session.id)();
2569
+ }
2570
+ }
2571
+ /** The keyframe-aware sender for one watcher of one session — see `watcherDroppers`. */
2572
+ watcherDropper(ws, sessionId) {
2573
+ let bySession = this.watcherDroppers.get(ws);
2574
+ if (!bySession) {
2575
+ bySession = new Map();
2576
+ this.watcherDroppers.set(ws, bySession);
2577
+ }
2578
+ let dropper = bySession.get(sessionId);
2579
+ if (!dropper) {
2580
+ dropper = createKeyframeAwareSender();
2581
+ bySession.set(sessionId, dropper);
2582
+ }
2583
+ return dropper;
2584
+ }
2585
+ forgetWatcherDropper(ws, sessionId) {
2586
+ const bySession = this.watcherDroppers.get(ws);
2587
+ bySession?.delete(sessionId);
2588
+ if (bySession?.size === 0)
2589
+ this.watcherDroppers.delete(ws);
2590
+ }
2591
+ /**
2592
+ * Forward an agent's frame to the session's watchers. **Only the messages that describe the device**
2593
+ * call this — chrome, device info, postures, booting, ready — never a reply to something the holder
2594
+ * asked. An ack, a clipboard payload or a network state is the holder's, and a watcher shown one would
2595
+ * learn what the agent is doing on a channel it was never admitted to.
2596
+ */
2597
+ forwardToWatchers(sessionId, raw) {
2598
+ const watchers = this.sessions.watchersOf(sessionId);
2599
+ if (watchers.length === 0)
2600
+ return;
2601
+ const frame = JSON.stringify(raw);
2602
+ for (const w of watchers)
2603
+ if (w.readyState === WebSocket.OPEN)
2604
+ w.send(frame);
2605
+ }
2606
+ /**
2607
+ * What the holder just did, as its watchers are told it (A2): only for an input the relay passed on to the agent,
2608
+ * so a refused one is never shown. **Built, never forwarded**: the raw `input:type` carries the text typed, and a
2609
+ * watcher gets a count.
2610
+ */
2611
+ tellWatchersInput(session, msg) {
2612
+ let action = null;
2613
+ switch (msg.type) {
2614
+ case 'input:touch:end': {
2615
+ const g = session.gesture;
2616
+ session.gesture = null;
2617
+ action = gestureAction(g?.start ?? null, msg.payload ?? g?.last ?? null);
2618
+ break;
2619
+ }
2620
+ case 'input:pinch:end':
2621
+ action = { kind: 'pinch' };
2622
+ break;
2623
+ case 'input:key':
2624
+ action = { kind: 'key', key: keyForWatchers(msg.payload.code) };
2625
+ break;
2626
+ // One press is a down and an up from a client that sends both; told once, on the up.
2627
+ case 'input:button':
2628
+ action = msg.payload.phase === 'down' ? null : { kind: 'button', button: msg.payload.name };
2629
+ break;
2630
+ case 'input:type':
2631
+ action = { kind: 'type', chars: [...msg.payload.text].length };
2632
+ break;
2633
+ }
2634
+ if (action)
2635
+ this.tellWatchersAction(session.id, action);
2636
+ }
2637
+ tellWatchersAction(sessionId, action) {
2638
+ const watchers = this.sessions.watchersOf(sessionId);
2639
+ if (watchers.length === 0)
2640
+ return;
2641
+ for (const w of watchers)
2642
+ this.sendTo(w, { type: 'watch:action', sessionId, at: Date.now(), action });
2643
+ }
2644
+ /** The relay-originated half of what watchers hear: holder left, agent away, agent rebound. */
2645
+ tellWatchers(sessionId, msg) {
2646
+ for (const w of this.sessions.watchersOf(sessionId))
2647
+ this.sendTo(w, msg);
2648
+ }
2649
+ /** Who to show behind a holder: the signed-in user's email, or `local` for a loopback client with no
2650
+ * credential. A label for people only — nothing reads it back. */
2651
+ holderLabel(ws) {
2652
+ const user = this.userOf(ws);
2653
+ const id = Number(user);
2654
+ if (!Number.isInteger(id))
2655
+ return 'local';
2656
+ try {
2657
+ const row = getDb().prepare('SELECT email FROM users WHERE id = ?').get(id);
2658
+ return row?.email ?? 'unknown';
2659
+ }
2660
+ catch {
2661
+ return 'unknown';
2662
+ }
2663
+ }
2664
+ /**
2665
+ * The dashboard page that watches a session — **only at an address a teammate's browser can open**, the
2666
+ * gate invite links take (`forTeammates`). With no tunnel and no `relay.url` the only base this relay has
2667
+ * is localhost, which is wrong for a client on another machine; the field is left out and the client
2668
+ * builds the link from the address it dialled, which the person running it can always open.
2669
+ */
2670
+ watchUrlField(sessionId) {
2671
+ const base = forTeammates(resolvePublicBaseUrl(config, this.options.tunnel));
2672
+ return base === null ? {} : { watchUrl: `${base}/automation/sessions/${encodeURIComponent(sessionId)}` };
2673
+ }
2336
2674
  /** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
2337
2675
  * of the close handler so the release loop there reads as one line per session. */
2338
2676
  idleShutdown(sessionId) {
@@ -2482,6 +2820,12 @@ export class RelayServer {
2482
2820
  }
2483
2821
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
2484
2822
  session.agentSocket.send(JSON.stringify(msg));
2823
+ // Where a touch began and last was, for its end to be told to watchers (A2). Moves are not sent on: one
2824
+ // action per gesture, at its end.
2825
+ if (msg.type === 'input:touch:start')
2826
+ session.gesture = { start: msg.payload, last: msg.payload };
2827
+ else if (msg.type === 'input:touch:move' && session.gesture)
2828
+ session.gesture.last = msg.payload;
2485
2829
  }
2486
2830
  }
2487
2831
  /** The five inputs an ack answers, so the five that carry a required correlator.
@@ -2509,6 +2853,7 @@ export class RelayServer {
2509
2853
  }
2510
2854
  if (session?.agentSocket.readyState === WebSocket.OPEN) {
2511
2855
  session.agentSocket.send(JSON.stringify(msg));
2856
+ this.tellWatchersInput(session, msg);
2512
2857
  return;
2513
2858
  }
2514
2859
  // A request that cannot be dispatched. Two situations reach here and only one is the agent's fault:
@@ -2676,6 +3021,7 @@ export class RelayServer {
2676
3021
  buildBytes: bytes,
2677
3022
  },
2678
3023
  });
3024
+ this.tellWatchersAction(sessionId, { kind: 'install' });
2679
3025
  }
2680
3026
  /**
2681
3027
  * Says once, per agent, that this agent cannot fetch builds over HTTP.
@@ -2732,6 +3078,7 @@ export class RelayServer {
2732
3078
  requestId,
2733
3079
  payload: { bundleId: build.bundle_id },
2734
3080
  });
3081
+ this.tellWatchersAction(sessionId, { kind: 'launch' });
2735
3082
  }
2736
3083
  async handleGetScreenshot(req, res, params) {
2737
3084
  if (!requireViewAuth(req, res))