@vantran-se/openchamber-web 1.23.1 → 1.24.4

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 (313) hide show
  1. package/bin/cli.test.js +29 -6
  2. package/bin/lib/cli-http.js +5 -1
  3. package/bin/lib/commands-status.js +5 -0
  4. package/dist/assets/{CodeMirrorCommentWidgets-BOpqXpFU.js → CodeMirrorCommentWidgets-B4a0JkUz.js} +1 -1
  5. package/dist/assets/DiffView-B2CD9wC8.js +4 -0
  6. package/dist/assets/FilesView-C4vMFclc.js +13 -0
  7. package/dist/assets/GitView-Byu2DY7s.js +6 -0
  8. package/dist/assets/GuestAttachDialog-B808E0-d.js +1 -0
  9. package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-VW_7czo3.js} +1 -1
  10. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  11. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  12. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  15. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  21. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  26. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  30. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  31. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  32. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  33. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  34. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  35. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  37. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  39. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  40. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  41. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  42. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  52. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  53. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  54. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  55. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  56. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  57. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  58. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  59. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  60. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  61. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  62. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  63. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  64. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  65. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  66. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  67. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  68. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  69. package/dist/assets/LinearIssuesView-EzGNCGrd.js +1 -0
  70. package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-B2YUDFaB.js} +1 -1
  71. package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +12 -0
  72. package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-CQHBSTEi.js} +1 -1
  73. package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DCjGhlYV.js} +1 -1
  74. package/dist/assets/PierreDiffViewer-CEDZNkU2.js +210 -0
  75. package/dist/assets/PlanView-D3x-pTkZ.js +5 -0
  76. package/dist/assets/PluginPane-DEcgDQ9h.js +3 -0
  77. package/dist/assets/SettingsView-CSinuH-1.js +29 -0
  78. package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-B5CHoJFZ.js} +1 -1
  79. package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +1 -0
  80. package/dist/assets/ToolOutputDialog-tLNWmfcW.js +15 -0
  81. package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CflzmK-x.js} +2 -2
  82. package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-NU0FHFhT.js} +3 -3
  83. package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-BZyjhqHv.js} +2 -2
  84. package/dist/assets/appThemeRegistry-DLicw4dH.js +1 -0
  85. package/dist/assets/commitAndPush-BDftrjr6.js +1 -0
  86. package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DlWlEM3x.js} +1 -1
  87. package/dist/assets/de-Bro6_zyl.js +10 -0
  88. package/dist/assets/es-Bb9XQZvd.js +16 -0
  89. package/dist/assets/fr-Dgfzn-IL.js +14 -0
  90. package/dist/assets/{index-eo1-D_7z.js → index-CtQNmqUL.js} +1 -1
  91. package/dist/assets/index-DqaMWNrC.css +1 -0
  92. package/dist/assets/index-Dqc6USl3.js +40 -0
  93. package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-CO4e5J_S.js} +1 -1
  94. package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-ue6kCou7.js} +1 -1
  95. package/dist/assets/ja-BYsakGFh.js +16 -0
  96. package/dist/assets/ko-BSaQz9aN.js +16 -0
  97. package/dist/assets/main-B62_pjL5.js +2 -0
  98. package/dist/assets/main-BEdcOI2P.js +896 -0
  99. package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
  100. package/dist/assets/markdownCore-CK48LG0z.js +5 -0
  101. package/dist/assets/miniChat-LT8Rqofr.js +2 -0
  102. package/dist/assets/mobile-CqarEynh.js +2 -0
  103. package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-D7QVlovD.js} +3 -3
  104. package/dist/assets/pl-OIviPdoj.js +16 -0
  105. package/dist/assets/pt-BR-Cltg-E6s.js +16 -0
  106. package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +2 -0
  107. package/dist/assets/renderMobileApp-BC1MHg6g.js +25 -0
  108. package/dist/assets/runtimeEndpointReset-DdrazOVH.js +25 -0
  109. package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-B2SWMGEU.js} +1 -1
  110. package/dist/assets/shikiHighlight-BPu3JiQs.js +1 -0
  111. package/dist/assets/textMateThemeFromAppTheme-DWqRPo9A.js +1 -0
  112. package/dist/assets/tr-CQI-KPre.js +7 -0
  113. package/dist/assets/uk-CqW7R4DA.js +16 -0
  114. package/dist/assets/useAppFontEffects-ne84JBVL.js +7333 -0
  115. package/dist/assets/useInlineCommentController-CgS0fnw_.js +2 -0
  116. package/dist/assets/useRootScrollLock-Cpf1qoGp.js +2 -0
  117. package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
  118. package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-Bx2EHc1O.js} +2 -2
  119. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
  120. package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
  121. package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
  122. package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
  123. package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
  124. package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
  125. package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
  126. package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
  127. package/dist/assets/{web-components-B_Z64So5.js → web-components-DxX8bZ1o.js} +2 -2
  128. package/dist/assets/zh-CN-CVZG_o_J.js +16 -0
  129. package/dist/assets/zh-TW-D5ZxzCZp.js +16 -0
  130. package/dist/index.html +6 -4
  131. package/dist/mini-chat.html +6 -4
  132. package/dist/mobile.html +6 -4
  133. package/package.json +12 -9
  134. package/server/built-in-extensions/registry.json +4 -0
  135. package/server/index.d.ts +2 -0
  136. package/server/index.js +44 -10
  137. package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
  138. package/server/lib/agent-tool/runtime.js +62 -15
  139. package/server/lib/agent-tool/runtime.test.js +88 -2
  140. package/server/lib/changelog/update-notes.js +2 -1
  141. package/server/lib/changelog/update-notes.test.js +19 -0
  142. package/server/lib/event-stream/DOCUMENTATION.md +23 -0
  143. package/server/lib/event-stream/delta-coalescer.js +0 -0
  144. package/server/lib/event-stream/delta-coalescer.test.js +292 -0
  145. package/server/lib/event-stream/global-hub.js +53 -23
  146. package/server/lib/event-stream/global-hub.test.js +160 -0
  147. package/server/lib/event-stream/global-ws-bridge.js +1 -0
  148. package/server/lib/event-stream/index.js +4 -0
  149. package/server/lib/fs/DOCUMENTATION.md +10 -1
  150. package/server/lib/fs/git-process.test.js +77 -0
  151. package/server/lib/fs/routes.js +58 -5
  152. package/server/lib/fs/routes.test.js +116 -0
  153. package/server/lib/fs/search.js +2 -1
  154. package/server/lib/git/DOCUMENTATION.md +20 -5
  155. package/server/lib/git/routes.js +16 -3
  156. package/server/lib/git/serial-refresh.js +93 -0
  157. package/server/lib/git/serial-refresh.test.js +112 -0
  158. package/server/lib/git/service.js +429 -70
  159. package/server/lib/git/service.test.js +418 -0
  160. package/server/lib/github/pr-status.js +5 -5
  161. package/server/lib/github/pr-status.test.js +1 -1
  162. package/server/lib/github/routes.js +4 -4
  163. package/server/lib/guests/DOCUMENTATION.md +68 -0
  164. package/server/lib/guests/auth-store.js +184 -0
  165. package/server/lib/guests/auth-store.test.js +112 -0
  166. package/server/lib/guests/background.test.js +83 -0
  167. package/server/lib/guests/builtins.js +34 -0
  168. package/server/lib/guests/builtins.test.js +174 -0
  169. package/server/lib/guests/catalog.js +477 -0
  170. package/server/lib/guests/catalog.test.js +390 -0
  171. package/server/lib/guests/clone.js +297 -0
  172. package/server/lib/guests/extract-zip.js +136 -0
  173. package/server/lib/guests/extract-zip.test.js +109 -0
  174. package/server/lib/guests/files.js +308 -0
  175. package/server/lib/guests/files.test.js +262 -0
  176. package/server/lib/guests/grant-scope.js +81 -0
  177. package/server/lib/guests/grant-scope.test.js +75 -0
  178. package/server/lib/guests/host-session.js +73 -0
  179. package/server/lib/guests/host-session.test.js +30 -0
  180. package/server/lib/guests/html-styles.js +6 -0
  181. package/server/lib/guests/html-styles.test.js +45 -0
  182. package/server/lib/guests/html-tokens.js +73 -0
  183. package/server/lib/guests/html-tokens.test.js +53 -0
  184. package/server/lib/guests/install.js +406 -0
  185. package/server/lib/guests/install.test.js +338 -0
  186. package/server/lib/guests/oauth.js +501 -0
  187. package/server/lib/guests/oauth.test.js +481 -0
  188. package/server/lib/guests/persist.js +336 -0
  189. package/server/lib/guests/persist.test.js +288 -0
  190. package/server/lib/guests/request.js +112 -0
  191. package/server/lib/guests/request.test.js +206 -0
  192. package/server/lib/guests/routes.js +838 -0
  193. package/server/lib/guests/service.js +538 -0
  194. package/server/lib/guests/service.test.js +277 -0
  195. package/server/lib/guests/sockets.js +129 -0
  196. package/server/lib/guests/sockets.test.js +110 -0
  197. package/server/lib/guests/ssh-install.test.js +188 -0
  198. package/server/lib/guests/storage.js +68 -0
  199. package/server/lib/guests/storage.test.js +59 -0
  200. package/server/lib/guests/updates.js +294 -0
  201. package/server/lib/guests/updates.test.js +334 -0
  202. package/server/lib/guests/upload.js +50 -0
  203. package/server/lib/guests/upload.test.js +131 -0
  204. package/server/lib/linear/DOCUMENTATION.md +1 -1
  205. package/server/lib/message-queue/DOCUMENTATION.md +10 -1
  206. package/server/lib/message-queue/runtime.js +21 -1
  207. package/server/lib/message-queue/runtime.test.js +52 -5
  208. package/server/lib/notifications/routes.js +5 -0
  209. package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
  210. package/server/lib/openchamber-sessions/routes.js +62 -29
  211. package/server/lib/openchamber-sessions/routes.test.js +96 -0
  212. package/server/lib/opencode/DOCUMENTATION.md +55 -1
  213. package/server/lib/opencode/bootstrap-runtime.js +1 -0
  214. package/server/lib/opencode/core-routes.js +39 -1
  215. package/server/lib/opencode/feature-routes-runtime.js +15 -0
  216. package/server/lib/opencode/lifecycle-process.test.js +84 -0
  217. package/server/lib/opencode/lifecycle.js +54 -67
  218. package/server/lib/opencode/openchamber-routes.js +72 -14
  219. package/server/lib/opencode/openchamber-routes.test.js +71 -3
  220. package/server/lib/opencode/session-runtime.js +68 -0
  221. package/server/lib/opencode/session-runtime.test.js +43 -0
  222. package/server/lib/opencode/settings-helpers.js +8 -0
  223. package/server/lib/opencode/settings-helpers.test.js +13 -1
  224. package/server/lib/opencode/settings-normalization-runtime.js +2 -0
  225. package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
  226. package/server/lib/opencode/settings-registry.json +15 -0
  227. package/server/lib/opencode/shutdown-runtime.js +3 -0
  228. package/server/lib/opencode/shutdown-runtime.test.js +24 -0
  229. package/server/lib/opencode/theme-archive.js +53 -0
  230. package/server/lib/opencode/theme-catalog.js +171 -0
  231. package/server/lib/opencode/theme-catalog.test.js +125 -0
  232. package/server/lib/opencode/theme-import.test.js +144 -0
  233. package/server/lib/opencode/theme-runtime.js +100 -28
  234. package/server/lib/opencode/theme-runtime.test.js +15 -0
  235. package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
  236. package/server/lib/permission-auto-accept/runtime.js +13 -0
  237. package/server/lib/permission-auto-accept/runtime.test.js +37 -1
  238. package/server/lib/projects/project-config.js +2 -2
  239. package/server/lib/quota/DOCUMENTATION.md +0 -1
  240. package/server/lib/quota/providers/index.js +0 -7
  241. package/server/lib/realtime-proxy-terminal.test.js +150 -0
  242. package/server/lib/realtime-proxy.js +2 -0
  243. package/server/lib/routing/DOCUMENTATION.md +83 -0
  244. package/server/lib/routing/defaults.js +98 -0
  245. package/server/lib/routing/feature-flag.js +11 -0
  246. package/server/lib/routing/history.js +34 -0
  247. package/server/lib/routing/jev.js +98 -0
  248. package/server/lib/routing/routes.http.test.js +100 -0
  249. package/server/lib/routing/routes.js +81 -0
  250. package/server/lib/routing/runtime.js +217 -0
  251. package/server/lib/routing/runtime.test.js +188 -0
  252. package/server/lib/routing/store.js +249 -0
  253. package/server/lib/routing/store.test.js +96 -0
  254. package/server/lib/scheduled-tasks/runtime.js +2 -2
  255. package/server/lib/scheduled-tasks/runtime.test.js +14 -0
  256. package/server/lib/security/request-security.js +9 -5
  257. package/server/lib/security/request-security.test.js +37 -0
  258. package/server/lib/small-model/DOCUMENTATION.md +14 -2
  259. package/server/lib/small-model/call.js +37 -6
  260. package/server/lib/small-model/call.test.js +182 -0
  261. package/server/lib/terminal/DOCUMENTATION.md +23 -2
  262. package/server/lib/terminal/runtime.js +27 -5
  263. package/server/lib/terminal/runtime.test.js +67 -0
  264. package/server/lib/terminal/shutdown.js +106 -0
  265. package/server/lib/terminal/shutdown.test.js +84 -0
  266. package/server/lib/tts/service.test.js +78 -0
  267. package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
  268. package/server/lib/ui-auth/session-cookie.js +51 -0
  269. package/server/lib/ui-auth/session-cookie.test.js +46 -0
  270. package/server/lib/ui-auth/ui-auth.js +56 -17
  271. package/server/lib/ui-auth/ui-auth.test.js +96 -0
  272. package/dist/assets/DiffView-CyLmb-wF.js +0 -4
  273. package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
  274. package/dist/assets/FilesView-WtJB0y_l.js +0 -13
  275. package/dist/assets/GitView-DkNrV0BK.js +0 -6
  276. package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
  277. package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
  278. package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
  279. package/dist/assets/PlanView-CAFziKH5.js +0 -5
  280. package/dist/assets/SettingsView-BRittavZ.js +0 -28
  281. package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
  282. package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
  283. package/dist/assets/de-c7CYLCAW.js +0 -10
  284. package/dist/assets/es-BKAVPQmQ.js +0 -16
  285. package/dist/assets/fr-Bj1glqfV.js +0 -14
  286. package/dist/assets/index-BYFyOu_D.css +0 -1
  287. package/dist/assets/index-BfwRClDe.js +0 -40
  288. package/dist/assets/ja-5jwSIbK0.js +0 -16
  289. package/dist/assets/ko-BaDbg-Eb.js +0 -16
  290. package/dist/assets/main-CGG2_uHq.js +0 -2
  291. package/dist/assets/main-CcrSaL4J.js +0 -930
  292. package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
  293. package/dist/assets/miniChat-DEaAhP4b.js +0 -2
  294. package/dist/assets/mobile-BC3rsvv0.js +0 -2
  295. package/dist/assets/pl-BFKVBDFu.js +0 -16
  296. package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
  297. package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
  298. package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
  299. package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
  300. package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
  301. package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
  302. package/dist/assets/tr-DYXPoEDG.js +0 -7
  303. package/dist/assets/uk-CwAoloR8.js +0 -16
  304. package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
  305. package/dist/assets/useGitComparison-CpxioduU.js +0 -1
  306. package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
  307. package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
  308. package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
  309. package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
  310. package/dist/assets/zh-TW-1HNK7shh.js +0 -16
  311. package/server/lib/quota/providers/crof.js +0 -96
  312. package/server/lib/quota/providers/crof.test.js +0 -98
  313. /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
@@ -6,6 +6,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
6
6
  ## Entrypoints and structure
7
7
  - `packages/web/server/lib/event-stream/index.js`: public entrypoint re-exporting protocol and runtime helpers.
8
8
  - `packages/web/server/lib/event-stream/global-hub.js`: shared global upstream SSE hub for server-side subscribers and browser WS fan-out.
9
+ - `packages/web/server/lib/event-stream/delta-coalescer.js`: merges consecutive `message.part.delta` events before replay and fan-out. Pure, with injectable clock and timers.
9
10
  - `packages/web/server/lib/event-stream/global-ws-bridge.js`: browser-facing global WS bridge that subscribes clients to the shared global hub.
10
11
  - `packages/web/server/lib/event-stream/directory-ws-bridge.js`: browser-facing per-directory WS bridge that owns one scoped upstream reader per connection.
11
12
  - `packages/web/server/lib/event-stream/protocol.js`: path constants, SSE envelope parsing, and WebSocket frame serialization helpers.
@@ -31,6 +32,9 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
31
32
  - `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
32
33
  - `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
33
34
 
35
+ ### Coalescing helpers
36
+ - `resolveDeltaCoalesceWindowMs(env)`: reads `OPENCHAMBER_EVENT_DELTA_COALESCE_MS`. Unset means 50ms, `0` turns coalescing off, and anything that is not a whole number from 0 to 1000 keeps the default with a warning.
37
+
34
38
  ### Upstream reader helpers
35
39
  - `DEFAULT_UPSTREAM_STALL_TIMEOUT_MS`: default idle timeout before an attached upstream SSE fetch is aborted for reconnect.
36
40
  - `DEFAULT_UPSTREAM_RECONNECT_DELAY_MS`: default delay between upstream reconnect attempts.
@@ -42,6 +46,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
42
46
  - The web server creates one shared global message-stream hub. OpenCode watcher side effects and global WS clients subscribe to that hub, so there is one upstream `/global/event` SSE reader for both server-side processing and browser fan-out.
43
47
  - The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
44
48
  - Replay retains at most 2,048 events and 8 MiB of UTF-8 wire frames. It stores encoded frames rather than retaining parsed payloads as well. Encoding is shared with live WS fanout. An oversized event still reaches live clients in full, but clears the retained suffix so replay cannot cross its gap. A missing replay cursor returns `null`, distinct from a complete empty tail; the bridge sends `ready` with `replayReset: true` and no partial replay. The client retires its cursor and requests authoritative repair, including during the early-boot reconnect grace period. A stopped hub retains its bounded suffix for clients reconnecting after the last socket closed.
49
+ - The hub numbers every event that arrives without an SSE id (`oc-<process>-<sequence>`). OpenCode 1.18 sends no ids at all, and an event without an id used to skip the replay buffer, so reconnecting clients had no cursor and lost whatever fell into the gap. The process prefix makes a cursor from before a server restart miss, which reports `replayReset`, instead of matching an unrelated sequence number.
45
50
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
46
51
  - If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
47
52
  - When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
@@ -52,6 +57,23 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
52
57
  - The reusable upstream reader centralizes SSE fetch/parsing/reconnect behavior for the WS runtime and OpenCode watcher. Additional event consumers should move to it only with parity tests for their lifecycle and error semantics.
53
58
  - Browser transport concerns live in the WS bridge modules; server-side global stream ownership lives in `global-hub.js`.
54
59
 
60
+ ## Delta coalescing
61
+ OpenCode publishes one `message.part.delta` per token fragment. A 5.7 KB answer measured 3,402 delta events of 1.7 characters each, 97% of all frames and 1.3 MB on the wire. The hub merges them before replay and fan-out, which cut that stream to 483 frames and 197 KB with identical text. Replay holds merged frames too, so its 2,048 entries cover about seven times more streaming time.
62
+
63
+ Merging is lossless because of three rules. Change one only with the randomized test in `delta-coalescer.test.js` still passing.
64
+
65
+ - Every event that is not a well-formed delta is a barrier. All pending text is emitted before it, so a delta never crosses a part snapshot or a status change.
66
+ - Deltas of different part fields commute, so several stay pending at once. They are emitted in the order their latest fragment arrived, which keeps event ids in upstream order.
67
+ - A merged event carries the id of its last fragment. A client cursor is the id of the last frame it received, so it always names a boundary between merged events.
68
+
69
+ Pending text is bounded by time (the 50ms window), by size (64 KiB), and by part fields (64). Reaching a bound emits early. Nothing is dropped. The first delta after a quiet spell is emitted at once, so time to first text does not grow.
70
+
71
+ The hub commits pending text when it stops, so it reaches the retained replay suffix, and the global bridge commits it before it marks a socket ready. A client readied later therefore receives exactly the events that arrived after it was ready, as it did before coalescing, and a reconnecting client receives the committed text through replay.
72
+
73
+ Verified against a live server by dropping the socket every 150ms to 3s during a stream and resuming from the cursor: the reconstructed text matched byte for byte with no duplicate ids. Server-side hub subscribers receive merged events as well. None of them reads individual deltas.
74
+
75
+ The directory WS bridge and the SSE proxy (`/api/global/event`, used by Capacitor) still forward unmerged events.
76
+
55
77
  ## Notes for contributors
56
78
  - Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
57
79
  - Keep `runtime.js` focused on WebSocket upgrade and endpoint dispatch. Put global browser-client lifecycle in `global-ws-bridge.js`, directory stream lifecycle in `directory-ws-bridge.js`, and upstream stream sharing in `global-hub.js`.
@@ -59,6 +81,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
59
81
  - Keep global replay bounded; do not turn it into an unbounded event log.
60
82
 
61
83
  ## Testing
84
+ - Run `bunx vitest run server/lib/event-stream` from `packages/web` for the whole module, including `delta-coalescer.test.js` and the resume-from-any-cursor cases in `global-hub.test.js`.
62
85
  - Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
63
86
  - Run `bun test packages/web/server/lib/event-stream/upstream-reader.test.js`
64
87
  - Run `bun test packages/web/server/lib/event-stream/runtime.test.js`
@@ -0,0 +1,292 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createDeltaCoalescer, DELTA_COALESCE_WINDOW_MS, resolveDeltaCoalesceWindowMs } from './delta-coalescer.js';
4
+
5
+ let nextId = 0;
6
+
7
+ const delta = (partID, text, { messageID = 'msg_1', field = 'text', directory = '/repo', sessionID = 'ses_1' } = {}) => {
8
+ nextId += 1;
9
+ const id = String(nextId).padStart(6, '0');
10
+ const payload = { id, type: 'message.part.delta', properties: { sessionID, messageID, partID, field, delta: text } };
11
+ return { envelope: { eventId: id, directory, payload }, payload };
12
+ };
13
+
14
+ const other = (type, properties = {}) => {
15
+ nextId += 1;
16
+ const id = String(nextId).padStart(6, '0');
17
+ const payload = { id, type, properties };
18
+ return { envelope: { eventId: id, directory: '/repo', payload }, payload };
19
+ };
20
+
21
+ // A clock and timer queue the test advances by hand, so every assertion about
22
+ // the window is exact instead of racing a real timeout.
23
+ const createHarness = (options = {}) => {
24
+ let time = 1_000_000;
25
+ let timers = [];
26
+ let timerSeq = 0;
27
+ const emitted = [];
28
+ const coalescer = createDeltaCoalescer({
29
+ emit: (event) => emitted.push(event),
30
+ now: () => time,
31
+ setTimer: (callback, delay) => {
32
+ timerSeq += 1;
33
+ timers.push({ id: timerSeq, at: time + delay, callback });
34
+ return timerSeq;
35
+ },
36
+ clearTimer: (id) => {
37
+ timers = timers.filter((timer) => timer.id !== id);
38
+ },
39
+ ...options,
40
+ });
41
+ const advance = (ms) => {
42
+ const target = time + ms;
43
+ for (;;) {
44
+ const due = timers.filter((timer) => timer.at <= target).sort((left, right) => left.at - right.at)[0];
45
+ if (!due) break;
46
+ timers = timers.filter((timer) => timer !== due);
47
+ time = due.at;
48
+ due.callback();
49
+ }
50
+ time = target;
51
+ };
52
+ return { coalescer, emitted, advance, pendingTimers: () => timers.length };
53
+ };
54
+
55
+ const isDelta = (event) => event.payload.type === 'message.part.delta';
56
+ const keyOf = (event) => `${event.payload.properties.messageID}:${event.payload.properties.partID}:${event.payload.properties.field}`;
57
+
58
+ const textByKey = (events) => {
59
+ const text = new Map();
60
+ for (const event of events) {
61
+ if (!isDelta(event)) continue;
62
+ text.set(keyOf(event), (text.get(keyOf(event)) ?? '') + event.payload.properties.delta);
63
+ }
64
+ return text;
65
+ };
66
+
67
+ describe('delta coalescer', () => {
68
+ it('emits the first delta after a quiet spell at once and unchanged', () => {
69
+ const { coalescer, emitted } = createHarness();
70
+ const first = delta('prt_a', 'He');
71
+
72
+ coalescer.push(first);
73
+
74
+ expect(emitted).toEqual([first]);
75
+ expect(emitted[0]).toBe(first);
76
+ });
77
+
78
+ it('merges following deltas for one part field and carries the last fragment id', () => {
79
+ const { coalescer, emitted, advance, pendingTimers } = createHarness();
80
+ const fragments = [delta('prt_a', 'He'), delta('prt_a', 'll'), delta('prt_a', 'o,'), delta('prt_a', ' wor'), delta('prt_a', 'ld')];
81
+
82
+ for (const fragment of fragments) coalescer.push(fragment);
83
+ expect(emitted).toHaveLength(1);
84
+ advance(50);
85
+
86
+ expect(emitted).toHaveLength(2);
87
+ expect(emitted[1].payload.properties).toEqual({ sessionID: 'ses_1', messageID: 'msg_1', partID: 'prt_a', field: 'text', delta: 'llo, world' });
88
+ expect(emitted[1].envelope.eventId).toBe(fragments[4].envelope.eventId);
89
+ expect(emitted[1].payload.id).toBe(fragments[4].payload.id);
90
+ expect(emitted[1].envelope.payload).toBe(emitted[1].payload);
91
+ expect(pendingTimers()).toBe(0);
92
+ // Upstream objects are never mutated: other hub subscribers may hold them.
93
+ expect(fragments.map((fragment) => fragment.payload.properties.delta)).toEqual(['He', 'll', 'o,', ' wor', 'ld']);
94
+ });
95
+
96
+ it('never holds text longer than the window', () => {
97
+ const { coalescer, emitted, advance } = createHarness();
98
+ coalescer.push(delta('prt_a', 'a'));
99
+ coalescer.push(delta('prt_a', 'b'));
100
+
101
+ advance(49);
102
+ expect(emitted).toHaveLength(1);
103
+ advance(1);
104
+ expect(emitted).toHaveLength(2);
105
+ });
106
+
107
+ it('treats every other event as a barrier, so a delta never crosses a part snapshot', () => {
108
+ const { coalescer, emitted, advance } = createHarness();
109
+ const snapshot = other('message.part.updated', { part: { id: 'prt_a', messageID: 'msg_1', text: 'abc' } });
110
+
111
+ coalescer.push(delta('prt_a', 'a'));
112
+ coalescer.push(delta('prt_a', 'b'));
113
+ coalescer.push(delta('prt_a', 'c'));
114
+ coalescer.push(snapshot);
115
+ coalescer.push(delta('prt_a', 'd'));
116
+ coalescer.push(delta('prt_a', 'e'));
117
+ advance(50);
118
+
119
+ expect(emitted.map((event) => (isDelta(event) ? event.payload.properties.delta : event.payload.type)))
120
+ .toEqual(['a', 'bc', 'message.part.updated', 'de']);
121
+ expect(emitted[2]).toBe(snapshot);
122
+ });
123
+
124
+ it('keeps several part fields pending at once and emits them in upstream id order', () => {
125
+ const { coalescer, emitted, advance } = createHarness();
126
+ coalescer.push(delta('prt_lead', '.'));
127
+
128
+ coalescer.push(delta('prt_a', 'a1'));
129
+ coalescer.push(delta('prt_b', 'b1', { messageID: 'msg_2', sessionID: 'ses_2' }));
130
+ coalescer.push(delta('prt_a', 'a2'));
131
+ const lastB = delta('prt_b', 'b2', { messageID: 'msg_2', sessionID: 'ses_2' });
132
+ coalescer.push(lastB);
133
+ advance(50);
134
+
135
+ const merged = emitted.slice(1);
136
+ expect(merged.map((event) => event.payload.properties.delta)).toEqual(['a1a2', 'b1b2']);
137
+ expect(merged[1].envelope.eventId).toBe(lastB.envelope.eventId);
138
+ const ids = emitted.map((event) => event.envelope.eventId);
139
+ expect(ids).toEqual([...ids].sort());
140
+ });
141
+
142
+ it('does not merge the same part across directories, sessions, messages, or fields', () => {
143
+ const { coalescer, emitted, advance } = createHarness();
144
+ coalescer.push(delta('prt_lead', '.'));
145
+
146
+ coalescer.push(delta('prt_a', '1'));
147
+ coalescer.push(delta('prt_a', '2', { directory: '/other' }));
148
+ coalescer.push(delta('prt_a', '3', { messageID: 'msg_2' }));
149
+ coalescer.push(delta('prt_a', '4', { field: 'reasoning' }));
150
+ coalescer.push(delta('prt_a', '5', { sessionID: 'ses_2' }));
151
+ advance(50);
152
+
153
+ expect(emitted.slice(1).map((event) => event.payload.properties.delta)).toEqual(['1', '2', '3', '4', '5']);
154
+ });
155
+
156
+ it('bounds pending text by characters and by part fields, emitting early instead of dropping', () => {
157
+ const { coalescer, emitted } = createHarness({ maxPendingChars: 10, maxPendingKeys: 3 });
158
+ coalescer.push(delta('prt_lead', '.'));
159
+
160
+ let peakChars = 0;
161
+ for (let index = 0; index < 40; index += 1) {
162
+ coalescer.push(delta('prt_a', 'xyz'));
163
+ peakChars = Math.max(peakChars, coalescer.pendingSize().chars);
164
+ }
165
+ expect(peakChars).toBeLessThan(10 + 3);
166
+ expect(textByKey(emitted).get('msg_1:prt_a:text')).toBe('xyz'.repeat(40));
167
+
168
+ let peakKeys = 0;
169
+ for (let index = 0; index < 20; index += 1) {
170
+ coalescer.push(delta(`prt_k${index}`, 'k'));
171
+ peakKeys = Math.max(peakKeys, coalescer.pendingSize().keys);
172
+ }
173
+ expect(peakKeys).toBeLessThanOrEqual(3);
174
+ coalescer.flush();
175
+ expect(emitted.filter((event) => event.payload.properties.delta === 'k')).toHaveLength(20);
176
+ });
177
+
178
+ it('flushes on demand and leaves nothing pending', () => {
179
+ const { coalescer, emitted, pendingTimers } = createHarness();
180
+ coalescer.push(delta('prt_a', 'a'));
181
+ coalescer.push(delta('prt_a', 'b'));
182
+
183
+ coalescer.flush();
184
+
185
+ expect(emitted.map((event) => event.payload.properties.delta)).toEqual(['a', 'b']);
186
+ expect(coalescer.pendingSize()).toEqual({ keys: 0, chars: 0 });
187
+ expect(pendingTimers()).toBe(0);
188
+ });
189
+
190
+ it('passes a malformed delta through as a barrier instead of guessing', () => {
191
+ const { coalescer, emitted } = createHarness();
192
+ const malformed = other('message.part.delta', { messageID: 'msg_1', partID: 'prt_a', field: 'text', delta: 42 });
193
+
194
+ coalescer.push(delta('prt_a', 'a'));
195
+ coalescer.push(delta('prt_a', 'b'));
196
+ coalescer.push(malformed);
197
+
198
+ expect(emitted.map((event) => event.payload.properties.delta)).toEqual(['a', 'b', 42]);
199
+ expect(emitted[2]).toBe(malformed);
200
+ });
201
+
202
+ it('is a pass-through when the window is zero', () => {
203
+ const { coalescer, emitted } = createHarness({ windowMs: 0 });
204
+ const events = [delta('prt_a', 'a'), delta('prt_a', 'b'), delta('prt_a', 'c')];
205
+
206
+ for (const event of events) coalescer.push(event);
207
+
208
+ expect(emitted).toEqual(events);
209
+ });
210
+
211
+ // The properties that make coalescing safe, checked over arbitrary streams
212
+ // rather than the handful of shapes someone thought of.
213
+ it('loses, duplicates, and reorders nothing across randomized streams', () => {
214
+ let seed = 0x5eed;
215
+ const random = () => {
216
+ seed = (seed * 1664525 + 1013904223) % 4294967296;
217
+ return seed / 4294967296;
218
+ };
219
+
220
+ for (let round = 0; round < 40; round += 1) {
221
+ const { coalescer, emitted, advance } = createHarness({ maxPendingChars: 200, maxPendingKeys: 4 });
222
+ const raw = [];
223
+ const partCount = 1 + Math.floor(random() * 6);
224
+ for (let index = 0; index < 600; index += 1) {
225
+ const roll = random();
226
+ let event;
227
+ if (roll < 0.9) {
228
+ const part = Math.floor(random() * partCount);
229
+ event = delta(`prt_${part}`, `${index}|`.repeat(1 + Math.floor(random() * 3)), { messageID: `msg_${part % 2}` });
230
+ } else if (roll < 0.95) {
231
+ event = other('message.part.updated', { part: { id: `prt_${Math.floor(random() * partCount)}`, messageID: 'msg_0' } });
232
+ } else {
233
+ event = other('session.status', { sessionID: 'ses_1', status: { type: 'busy' } });
234
+ }
235
+ raw.push(event);
236
+ coalescer.push(event);
237
+ if (random() < 0.3) advance(Math.floor(random() * 80));
238
+ }
239
+ advance(1_000);
240
+
241
+ // Nothing lost or duplicated: every part field ends with the same text.
242
+ expect(textByKey(emitted)).toEqual(textByKey(raw));
243
+
244
+ // Every other event is delivered once, as the same object, in order.
245
+ expect(emitted.filter((event) => !isDelta(event))).toEqual(raw.filter((event) => !isDelta(event)));
246
+
247
+ // Barrier: the text delivered before each barrier is exactly the text
248
+ // that arrived before it.
249
+ for (const barrier of raw.filter((event) => !isDelta(event))) {
250
+ expect(textByKey(emitted.slice(0, emitted.indexOf(barrier)))).toEqual(textByKey(raw.slice(0, raw.indexOf(barrier))));
251
+ }
252
+
253
+ // Replay cursors: ids stay unique and in upstream order.
254
+ const ids = emitted.map((event) => event.envelope.eventId);
255
+ expect(new Set(ids).size).toBe(ids.length);
256
+ expect(ids).toEqual([...ids].sort());
257
+
258
+ expect(coalescer.pendingSize()).toEqual({ keys: 0, chars: 0 });
259
+ }
260
+ });
261
+
262
+ it('collapses a dense single-part stream to a few frames per window', () => {
263
+ const { coalescer, emitted, advance } = createHarness();
264
+
265
+ // 150 fragments a second for two seconds, the rate measured from OpenCode.
266
+ for (let index = 0; index < 300; index += 1) {
267
+ coalescer.push(delta('prt_a', 'ab'));
268
+ advance(index % 3 === 2 ? 8 : 6);
269
+ }
270
+ advance(50);
271
+
272
+ expect(textByKey(emitted).get('msg_1:prt_a:text')).toBe('ab'.repeat(300));
273
+ expect(emitted.length).toBeLessThanOrEqual(45);
274
+ });
275
+ });
276
+
277
+ describe('resolveDeltaCoalesceWindowMs', () => {
278
+ it('uses the default when unset and accepts zero as off', () => {
279
+ expect(resolveDeltaCoalesceWindowMs({})).toBe(DELTA_COALESCE_WINDOW_MS);
280
+ expect(resolveDeltaCoalesceWindowMs({ OPENCHAMBER_EVENT_DELTA_COALESCE_MS: ' ' })).toBe(DELTA_COALESCE_WINDOW_MS);
281
+ expect(resolveDeltaCoalesceWindowMs({ OPENCHAMBER_EVENT_DELTA_COALESCE_MS: '0' })).toBe(0);
282
+ expect(resolveDeltaCoalesceWindowMs({ OPENCHAMBER_EVENT_DELTA_COALESCE_MS: '25' })).toBe(25);
283
+ });
284
+
285
+ it('keeps the default and says so for anything that is not 0-1000 whole milliseconds', () => {
286
+ for (const value of ['-1', '1001', '12.5', 'fast', 'NaN', '1e9']) {
287
+ const warnings = [];
288
+ expect(resolveDeltaCoalesceWindowMs({ OPENCHAMBER_EVENT_DELTA_COALESCE_MS: value }, (message) => warnings.push(message))).toBe(DELTA_COALESCE_WINDOW_MS);
289
+ expect(warnings).toHaveLength(1);
290
+ }
291
+ });
292
+ });
@@ -1,5 +1,8 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { createUpstreamSseReader } from './upstream-reader.js';
2
4
  import { serializeMessageStreamWsEvent } from './protocol.js';
5
+ import { createDeltaCoalescer, DELTA_COALESCE_WINDOW_MS } from './delta-coalescer.js';
3
6
 
4
7
  // Raised from 512 → 2048 to improve recovery after brief disconnects during
5
8
  // long-running agent sessions where many events accumulate quickly.
@@ -14,6 +17,7 @@ export function createGlobalMessageStreamHub({
14
17
  upstreamReconnectDelayMs,
15
18
  replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
16
19
  replayByteLimit = MESSAGE_STREAM_GLOBAL_REPLAY_BYTES,
20
+ deltaCoalesceWindowMs = DELTA_COALESCE_WINDOW_MS,
17
21
  }) {
18
22
  if (!Number.isSafeInteger(replayLimit) || replayLimit < 0 || !Number.isSafeInteger(replayByteLimit) || replayByteLimit < 0) {
19
23
  throw new RangeError('Replay limits must be nonnegative safe integers');
@@ -23,6 +27,15 @@ export function createGlobalMessageStreamHub({
23
27
  const replay = [];
24
28
  let replayBytes = 0;
25
29
  let latestEventId;
30
+ // OpenCode's event stream carries no SSE ids (verified on 1.18.30: not one
31
+ // frame in a full response), and an event without an id never entered the
32
+ // replay buffer, so a reconnecting browser had no cursor and every event in
33
+ // the gap was gone. The replay log is this hub's own, so the hub numbers
34
+ // what upstream leaves unnumbered. The per-process prefix makes a cursor
35
+ // from before a restart miss instead of matching an unrelated sequence
36
+ // number, which reports `replayReset` and sends the client to repair.
37
+ const replayIdPrefix = `oc-${randomUUID().slice(0, 8)}-`;
38
+ let replaySequence = 0;
26
39
 
27
40
  let controller = null;
28
41
  let reader = null;
@@ -52,7 +65,9 @@ export function createGlobalMessageStreamHub({
52
65
  const normalizeEvent = ({ envelope, payload }) => {
53
66
  const directory =
54
67
  typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
55
- const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
68
+ const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0
69
+ ? envelope.eventId
70
+ : `${replayIdPrefix}${String(++replaySequence).padStart(12, '0')}`;
56
71
  let serializedFrame;
57
72
  return {
58
73
  envelope,
@@ -66,6 +81,34 @@ export function createGlobalMessageStreamHub({
66
81
  };
67
82
  };
68
83
 
84
+ // Replay and fan-out see the same committed sequence: an event enters the
85
+ // replay buffer in the same step that delivers it, so a client's cursor
86
+ // always names a frame the buffer can find.
87
+ const commitEvent = (event) => {
88
+ const normalized = normalizeEvent(event);
89
+ latestEventId = normalized.eventId;
90
+ const serializedFrame = normalized.serialize();
91
+ const bytes = Buffer.byteLength(serializedFrame);
92
+ if (bytes > replayByteLimit) {
93
+ // An oversized live event creates a hole: retain only a contiguous
94
+ // suffix after it, never replay an older prefix across the gap.
95
+ replay.length = 0;
96
+ replayBytes = 0;
97
+ } else {
98
+ replay.push({ eventId: normalized.eventId, serializedFrame, bytes });
99
+ replayBytes += bytes;
100
+ while (replay.length > replayLimit || replayBytes > replayByteLimit) {
101
+ replayBytes -= replay.shift().bytes;
102
+ }
103
+ }
104
+
105
+ for (const subscriber of Array.from(eventSubscribers)) {
106
+ notifySubscriber('event', subscriber, normalized);
107
+ }
108
+ };
109
+
110
+ const coalescer = createDeltaCoalescer({ emit: commitEvent, windowMs: deltaCoalesceWindowMs });
111
+
69
112
  const start = () => {
70
113
  if (reader) {
71
114
  return;
@@ -98,28 +141,7 @@ export function createGlobalMessageStreamHub({
98
141
  notifyStatus({ type: 'disconnect', reason });
99
142
  },
100
143
  onEvent(event) {
101
- const normalized = normalizeEvent(event);
102
- if (normalized.eventId) {
103
- latestEventId = normalized.eventId;
104
- const serializedFrame = normalized.serialize();
105
- const bytes = Buffer.byteLength(serializedFrame);
106
- if (bytes > replayByteLimit) {
107
- // An oversized live event creates a hole: retain only a contiguous
108
- // suffix after it, never replay an older prefix across the gap.
109
- replay.length = 0;
110
- replayBytes = 0;
111
- } else {
112
- replay.push({ eventId: normalized.eventId, serializedFrame, bytes });
113
- replayBytes += bytes;
114
- while (replay.length > replayLimit || replayBytes > replayByteLimit) {
115
- replayBytes -= replay.shift().bytes;
116
- }
117
- }
118
- }
119
-
120
- for (const subscriber of Array.from(eventSubscribers)) {
121
- notifySubscriber('event', subscriber, normalized);
122
- }
144
+ coalescer.push(event);
123
145
  },
124
146
  onError(error) {
125
147
  if (controller?.signal.aborted) {
@@ -139,6 +161,8 @@ export function createGlobalMessageStreamHub({
139
161
 
140
162
  const stop = () => {
141
163
  connected = false;
164
+ // Text that already arrived belongs in the retained replay suffix.
165
+ coalescer.flush();
142
166
  reader?.stop();
143
167
  if (controller && !controller.signal.aborted) {
144
168
  controller.abort();
@@ -170,6 +194,12 @@ export function createGlobalMessageStreamHub({
170
194
  statusSubscribers.delete(subscriber);
171
195
  };
172
196
  },
197
+ // A client that becomes ready must not receive text from before it was
198
+ // ready merged into its first live delta, so the bridge commits pending
199
+ // deltas before it reads the replay tail.
200
+ flushPending() {
201
+ coalescer.flush();
202
+ },
173
203
  replayAfter(eventId) {
174
204
  if (!eventId) {
175
205
  return [];
@@ -62,6 +62,166 @@ async function waitForAssertion(assertion) {
62
62
  throw lastError;
63
63
  }
64
64
 
65
+ const deltaBlock = (id, text, partID = 'prt_a') => `id: ${id}\ndata: ${JSON.stringify({
66
+ id, type: 'message.part.delta',
67
+ properties: { sessionID: 'ses_1', messageID: 'msg_1', partID, field: 'text', delta: text },
68
+ })}\n\n`;
69
+
70
+ const createDeltaHub = ({ blocks, deltaCoalesceWindowMs }) => createGlobalMessageStreamHub({
71
+ buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
72
+ getOpenCodeAuthHeaders: () => ({}),
73
+ upstreamReconnectDelayMs: 60_000,
74
+ deltaCoalesceWindowMs,
75
+ fetchImpl: async () => createSseResponse({ blocks }),
76
+ });
77
+
78
+ // What a browser holds after applying frames in order: text per part, and the
79
+ // text each part had when a snapshot barrier passed.
80
+ const applyFrames = (frames) => {
81
+ const text = {};
82
+ const barriers = [];
83
+ for (const frame of frames) {
84
+ const payload = frame.payload;
85
+ if (payload.type === 'message.part.delta') {
86
+ text[payload.properties.partID] = (text[payload.properties.partID] ?? '') + payload.properties.delta;
87
+ } else {
88
+ barriers.push({ id: payload.id, seen: { ...text } });
89
+ }
90
+ }
91
+ return { text, barriers };
92
+ };
93
+
94
+ describe('delta coalescing in the global hub', () => {
95
+ it('delivers the same text in far fewer frames', async () => {
96
+ const words = Array.from({ length: 120 }, (_, index) => `w${index} `);
97
+ const hub = createDeltaHub({ blocks: words.map((word, index) => deltaBlock(`e${String(index).padStart(4, '0')}`, word)) });
98
+ const received = [];
99
+ hub.subscribeEvent((event) => received.push(event));
100
+ try {
101
+ hub.start();
102
+ await waitForAssertion(() => expect(applyFrames(received).text.prt_a).toBe(words.join('')));
103
+ expect(received.length).toBeLessThanOrEqual(3);
104
+ expect(received.at(-1).eventId).toBe('e0119');
105
+ } finally { hub.stop(); }
106
+ });
107
+
108
+ it('resumes from any cursor without losing or repeating text', async () => {
109
+ const blocks = [];
110
+ let id = 0;
111
+ const nextId = () => `e${String(id++).padStart(4, '0')}`;
112
+ for (let index = 0; index < 40; index += 1) blocks.push(deltaBlock(nextId(), `a${index}.`, index % 3 === 0 ? 'prt_b' : 'prt_a'));
113
+ const snapshotId = nextId();
114
+ blocks.push(`id: ${snapshotId}\ndata: ${JSON.stringify({ id: snapshotId, type: 'message.part.updated', properties: { part: { id: 'prt_a', messageID: 'msg_1' } } })}\n\n`);
115
+ for (let index = 0; index < 40; index += 1) blocks.push(deltaBlock(nextId(), `b${index}.`));
116
+
117
+ const hub = createDeltaHub({ blocks });
118
+ const received = [];
119
+ hub.subscribeEvent((event) => received.push(event));
120
+ try {
121
+ hub.start();
122
+ const expectedA = [...Array(40).keys()].filter((index) => index % 3 !== 0).map((index) => `a${index}.`).join('')
123
+ + [...Array(40).keys()].map((index) => `b${index}.`).join('');
124
+ await waitForAssertion(() => expect(applyFrames(received).text.prt_a).toBe(expectedA));
125
+ expect(received.length).toBeLessThan(blocks.length / 4);
126
+
127
+ const complete = applyFrames(received);
128
+ // The snapshot barrier saw exactly the text that arrived before it.
129
+ expect(complete.barriers).toHaveLength(1);
130
+ expect(complete.barriers[0].seen.prt_a).toBe([...Array(40).keys()].filter((index) => index % 3 !== 0).map((index) => `a${index}.`).join(''));
131
+
132
+ // A socket that drops after any frame reconnects with that frame's id.
133
+ for (let cut = 0; cut < received.length; cut += 1) {
134
+ const tail = hub.replayAfter(received[cut].eventId);
135
+ expect(tail).not.toBeNull();
136
+ const replayed = tail.map((entry) => JSON.parse(entry.serializedFrame));
137
+ expect(applyFrames([...received.slice(0, cut + 1), ...replayed])).toEqual(complete);
138
+ }
139
+ } finally { hub.stop(); }
140
+ });
141
+
142
+ // OpenCode 1.18 sends no SSE ids at all. Before the hub numbered such events
143
+ // itself the replay buffer stayed empty and every reconnect lost its gap.
144
+ it('numbers id-less upstream events so a reconnect resumes from any cursor', async () => {
145
+ const idless = (type, properties) => `data: ${JSON.stringify({ type, properties })}\n\n`;
146
+ const blocks = [];
147
+ for (let index = 0; index < 30; index += 1) {
148
+ blocks.push(idless('message.part.delta', { sessionID: 'ses_1', messageID: 'msg_1', partID: 'prt_a', field: 'text', delta: `a${index}.` }));
149
+ }
150
+ blocks.push(idless('message.part.updated', { part: { id: 'prt_a', messageID: 'msg_1' } }));
151
+ for (let index = 0; index < 30; index += 1) {
152
+ blocks.push(idless('message.part.delta', { sessionID: 'ses_1', messageID: 'msg_1', partID: 'prt_a', field: 'text', delta: `b${index}.` }));
153
+ }
154
+
155
+ const hub = createDeltaHub({ blocks });
156
+ const received = [];
157
+ hub.subscribeEvent((event) => received.push(event));
158
+ try {
159
+ hub.start();
160
+ const expected = [...Array(30).keys()].map((index) => `a${index}.`).join('') + [...Array(30).keys()].map((index) => `b${index}.`).join('');
161
+ await waitForAssertion(() => expect(applyFrames(received).text.prt_a).toBe(expected));
162
+
163
+ const ids = received.map((event) => event.eventId);
164
+ expect(ids.every((eventId) => typeof eventId === 'string' && eventId.length > 0)).toBe(true);
165
+ expect(new Set(ids).size).toBe(ids.length);
166
+ expect(ids).toEqual([...ids].sort());
167
+
168
+ const complete = applyFrames(received);
169
+ for (let cut = 0; cut < received.length; cut += 1) {
170
+ const tail = hub.replayAfter(received[cut].eventId);
171
+ expect(tail).not.toBeNull();
172
+ const replayed = tail.map((entry) => JSON.parse(entry.serializedFrame));
173
+ expect(replayed.every((frame) => typeof frame.eventId === 'string')).toBe(true);
174
+ expect(applyFrames([...received.slice(0, cut + 1), ...replayed])).toEqual(complete);
175
+ }
176
+
177
+ // A cursor minted by another server process must miss, never match by
178
+ // sequence number: the bridge then reports replayReset and the client
179
+ // repairs from HTTP instead of trusting a wrong tail.
180
+ const foreign = ids[0].replace(/^oc-[^-]+-/, 'oc-00000000-');
181
+ expect(foreign).not.toBe(ids[0]);
182
+ expect(hub.replayAfter(foreign)).toBeNull();
183
+ } finally { hub.stop(); }
184
+ });
185
+
186
+ it('keeps pending text for replay when the hub stops', async () => {
187
+ const hub = createDeltaHub({
188
+ deltaCoalesceWindowMs: 60_000,
189
+ blocks: ['one ', 'two ', 'three'].map((text, index) => deltaBlock(`e${index}`, text)),
190
+ });
191
+ const received = [];
192
+ hub.subscribeEvent((event) => received.push(event));
193
+ hub.start();
194
+ // The window is a minute, so only the leading delta has been delivered.
195
+ await waitForAssertion(() => expect(received).toHaveLength(1));
196
+ await new Promise((resolve) => setTimeout(resolve, 20));
197
+
198
+ hub.stop();
199
+
200
+ const tail = hub.replayAfter('e0').map((entry) => JSON.parse(entry.serializedFrame));
201
+ expect(applyFrames(tail).text.prt_a).toBe('two three');
202
+ expect(tail.at(-1).eventId).toBe('e2');
203
+ });
204
+
205
+ it('commits pending text on demand, so a client readied later starts after it', async () => {
206
+ const hub = createDeltaHub({
207
+ deltaCoalesceWindowMs: 60_000,
208
+ blocks: ['one ', 'two ', 'three'].map((text, index) => deltaBlock(`e${index}`, text)),
209
+ });
210
+ const received = [];
211
+ hub.subscribeEvent((event) => received.push(event));
212
+ try {
213
+ hub.start();
214
+ await waitForAssertion(() => expect(received).toHaveLength(1));
215
+ await new Promise((resolve) => setTimeout(resolve, 20));
216
+
217
+ hub.flushPending();
218
+
219
+ expect(applyFrames(received).text.prt_a).toBe('one two three');
220
+ expect(hub.replayAfter('e2')).toEqual([]);
221
+ } finally { hub.stop(); }
222
+ });
223
+ });
224
+
65
225
  describe('createGlobalMessageStreamHub', () => {
66
226
  it('continues fanout when an event subscriber throws', async () => {
67
227
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -46,6 +46,7 @@ export function createGlobalMessageStreamWsBridge({
46
46
  return;
47
47
  }
48
48
 
49
+ globalHub.flushPending();
49
50
  const replay = globalHub.replayAfter(requestedLastEventId);
50
51
  const ready = { type: 'ready', scope: 'global' };
51
52
  if (replay === null) ready.replayReset = true;
@@ -7,6 +7,10 @@ export {
7
7
  createGlobalMessageStreamHub,
8
8
  } from './global-hub.js';
9
9
 
10
+ export {
11
+ resolveDeltaCoalesceWindowMs,
12
+ } from './delta-coalescer.js';
13
+
10
14
  export {
11
15
  DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
12
16
  UPSTREAM_STALL_TIMEOUT_CONCURRENT_MS,