@vantran-se/openchamber-web 1.23.0 → 1.24.3

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 (362) 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-DFg2wnpo.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
  5. package/dist/assets/DiffView-COVQhWGd.js +4 -0
  6. package/dist/assets/FilesView-B1V5JxFX.js +13 -0
  7. package/dist/assets/GitView-CDOJy5Ay.js +6 -0
  8. package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
  9. package/dist/assets/{JsonTreeView-BKdsjg5B.js → JsonTreeView-D0C9nU7K.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-D7FKg1XJ.js +1 -0
  70. package/dist/assets/{MarkdownImageGallery-_5gEVr0X.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
  71. package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
  72. package/dist/assets/{OnboardingScreen-DdP4_nuc.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
  73. package/dist/assets/{PatchDiff-am3SAss2.js → PatchDiff-DnEhjXcC.js} +1 -1
  74. package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
  75. package/dist/assets/{PlanView-BoJxdRZe.js → PlanView-LW2z_EAP.js} +4 -4
  76. package/dist/assets/PluginPane-B4tKno2h.js +3 -0
  77. package/dist/assets/SettingsView-DEaMulN9.js +29 -0
  78. package/dist/assets/{SettingsWindow-DVTdDSol.js → SettingsWindow-FLDJXOgQ.js} +1 -1
  79. package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
  80. package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
  81. package/dist/assets/{ToolPartDiffPreview-C7WLk5i9.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
  82. package/dist/assets/{VirtualizedFileDiff-DcdgkH-x.js → VirtualizedFileDiff-0pyr_mHY.js} +4 -4
  83. package/dist/assets/WalkthroughView-yNj4nOP5.js +4 -0
  84. package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
  85. package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
  86. package/dist/assets/{computeVirtualFileMetrics-C8byMnRh.js → computeVirtualFileMetrics-DxTBMuu-.js} +2 -2
  87. package/dist/assets/de-DgxgFyGG.js +10 -0
  88. package/dist/assets/es-BFsZj5F_.js +16 -0
  89. package/dist/assets/fr-jdZ5KKQi.js +14 -0
  90. package/dist/assets/{index-BB7ryz8y.js → index-CKHjPKbq.js} +1 -1
  91. package/dist/assets/index-Cbci4i1W.js +40 -0
  92. package/dist/assets/index-DqaMWNrC.css +1 -0
  93. package/dist/assets/{isDiffPlainText-45ujMPGb.js → isDiffPlainText-D7FkBMP6.js} +1 -1
  94. package/dist/assets/{isFilePlainText-Cc4KFSYi.js → isFilePlainText-CL2vsrIZ.js} +1 -1
  95. package/dist/assets/ja-C5kKrhO4.js +16 -0
  96. package/dist/assets/ko-DXDTT_0O.js +16 -0
  97. package/dist/assets/main-CZALtVek.js +896 -0
  98. package/dist/assets/main-CgXMBNvG.js +2 -0
  99. package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
  100. package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
  101. package/dist/assets/miniChat-CwTmDixi.js +2 -0
  102. package/dist/assets/mobile-CLf2vCtO.js +2 -0
  103. package/dist/assets/patchFileDiff-Bsba8KFp.js +13 -0
  104. package/dist/assets/pl-D-7x4Y8Q.js +16 -0
  105. package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
  106. package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
  107. package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
  108. package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
  109. package/dist/assets/scheduledTasksApi-DzjbLd8P.js +1 -0
  110. package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
  111. package/dist/assets/surface-B6n7wJtt.js +1 -0
  112. package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
  113. package/dist/assets/tr-Dr-oJ22L.js +7 -0
  114. package/dist/assets/uk-9Nx83UcI.js +16 -0
  115. package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
  116. package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
  117. package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
  118. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-CsXYnR5c.js} +1 -1
  119. package/dist/assets/{vendor-codemirror-language-data-DzlSIu_W.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
  120. package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
  121. package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
  122. package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
  123. package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
  124. package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
  125. package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
  126. package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
  127. package/dist/assets/vendor-zod-CgAdXWZO.js +39 -0
  128. package/dist/assets/{web-components-CdGfR2XJ.js → web-components-B8IcVQAA.js} +2 -2
  129. package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
  130. package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
  131. package/dist/index.html +6 -4
  132. package/dist/mini-chat.html +6 -4
  133. package/dist/mobile.html +6 -4
  134. package/package.json +12 -9
  135. package/server/built-in-extensions/registry.json +4 -0
  136. package/server/index.d.ts +2 -0
  137. package/server/index.js +47 -23
  138. package/server/lib/agent-memory/runtime.js +7 -2
  139. package/server/lib/agent-memory/runtime.test.js +22 -0
  140. package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
  141. package/server/lib/agent-tool/runtime.js +62 -15
  142. package/server/lib/agent-tool/runtime.test.js +88 -2
  143. package/server/lib/changelog/update-notes.js +2 -1
  144. package/server/lib/changelog/update-notes.test.js +19 -0
  145. package/server/lib/event-stream/DOCUMENTATION.md +26 -0
  146. package/server/lib/event-stream/delta-coalescer.js +0 -0
  147. package/server/lib/event-stream/delta-coalescer.test.js +292 -0
  148. package/server/lib/event-stream/global-hub.js +68 -13
  149. package/server/lib/event-stream/global-hub.test.js +182 -0
  150. package/server/lib/event-stream/global-ws-bridge.js +16 -18
  151. package/server/lib/event-stream/index.js +4 -0
  152. package/server/lib/event-stream/protocol.js +21 -7
  153. package/server/lib/event-stream/runtime.js +9 -6
  154. package/server/lib/event-stream/runtime.test.js +60 -0
  155. package/server/lib/fs/DOCUMENTATION.md +10 -1
  156. package/server/lib/fs/git-process.test.js +77 -0
  157. package/server/lib/fs/routes.js +58 -5
  158. package/server/lib/fs/routes.test.js +116 -0
  159. package/server/lib/fs/search.js +2 -1
  160. package/server/lib/git/DOCUMENTATION.md +30 -6
  161. package/server/lib/git/routes.js +37 -11
  162. package/server/lib/git/routes.test.js +78 -0
  163. package/server/lib/git/serial-refresh.js +93 -0
  164. package/server/lib/git/serial-refresh.test.js +112 -0
  165. package/server/lib/git/service.js +588 -74
  166. package/server/lib/git/service.test.js +492 -0
  167. package/server/lib/github/DOCUMENTATION.md +5 -0
  168. package/server/lib/github/pr-status.js +5 -5
  169. package/server/lib/github/pr-status.test.js +1 -1
  170. package/server/lib/github/routes.js +6 -6
  171. package/server/lib/guests/DOCUMENTATION.md +68 -0
  172. package/server/lib/guests/auth-store.js +184 -0
  173. package/server/lib/guests/auth-store.test.js +112 -0
  174. package/server/lib/guests/background.test.js +83 -0
  175. package/server/lib/guests/builtins.js +34 -0
  176. package/server/lib/guests/builtins.test.js +174 -0
  177. package/server/lib/guests/catalog.js +477 -0
  178. package/server/lib/guests/catalog.test.js +390 -0
  179. package/server/lib/guests/clone.js +297 -0
  180. package/server/lib/guests/extract-zip.js +136 -0
  181. package/server/lib/guests/extract-zip.test.js +109 -0
  182. package/server/lib/guests/files.js +308 -0
  183. package/server/lib/guests/files.test.js +262 -0
  184. package/server/lib/guests/grant-scope.js +81 -0
  185. package/server/lib/guests/grant-scope.test.js +75 -0
  186. package/server/lib/guests/host-session.js +73 -0
  187. package/server/lib/guests/host-session.test.js +30 -0
  188. package/server/lib/guests/html-styles.js +6 -0
  189. package/server/lib/guests/html-styles.test.js +45 -0
  190. package/server/lib/guests/html-tokens.js +73 -0
  191. package/server/lib/guests/html-tokens.test.js +53 -0
  192. package/server/lib/guests/install.js +406 -0
  193. package/server/lib/guests/install.test.js +338 -0
  194. package/server/lib/guests/oauth.js +501 -0
  195. package/server/lib/guests/oauth.test.js +481 -0
  196. package/server/lib/guests/persist.js +336 -0
  197. package/server/lib/guests/persist.test.js +288 -0
  198. package/server/lib/guests/request.js +112 -0
  199. package/server/lib/guests/request.test.js +206 -0
  200. package/server/lib/guests/routes.js +838 -0
  201. package/server/lib/guests/service.js +538 -0
  202. package/server/lib/guests/service.test.js +277 -0
  203. package/server/lib/guests/sockets.js +129 -0
  204. package/server/lib/guests/sockets.test.js +110 -0
  205. package/server/lib/guests/ssh-install.test.js +188 -0
  206. package/server/lib/guests/storage.js +68 -0
  207. package/server/lib/guests/storage.test.js +59 -0
  208. package/server/lib/guests/updates.js +294 -0
  209. package/server/lib/guests/updates.test.js +334 -0
  210. package/server/lib/guests/upload.js +50 -0
  211. package/server/lib/guests/upload.test.js +131 -0
  212. package/server/lib/linear/DOCUMENTATION.md +1 -1
  213. package/server/lib/message-queue/DOCUMENTATION.md +17 -1
  214. package/server/lib/message-queue/runtime.js +34 -1
  215. package/server/lib/message-queue/runtime.test.js +83 -6
  216. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  217. package/server/lib/notifications/emitter-runtime.js +4 -3
  218. package/server/lib/notifications/routes.js +5 -0
  219. package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
  220. package/server/lib/openchamber-sessions/routes.js +62 -29
  221. package/server/lib/openchamber-sessions/routes.test.js +96 -0
  222. package/server/lib/opencode/DOCUMENTATION.md +73 -5
  223. package/server/lib/opencode/bootstrap-runtime.js +1 -0
  224. package/server/lib/opencode/core-routes.js +39 -1
  225. package/server/lib/opencode/env-runtime.js +5 -1
  226. package/server/lib/opencode/env-runtime.test.js +72 -0
  227. package/server/lib/opencode/feature-routes-runtime.js +30 -1
  228. package/server/lib/opencode/lifecycle-process.test.js +84 -0
  229. package/server/lib/opencode/lifecycle.js +58 -70
  230. package/server/lib/opencode/lifecycle.test.js +29 -0
  231. package/server/lib/opencode/managed-plugin-config.js +1 -1
  232. package/server/lib/opencode/openchamber-routes.js +72 -14
  233. package/server/lib/opencode/openchamber-routes.test.js +71 -3
  234. package/server/lib/opencode/proxy.js +33 -0
  235. package/server/lib/opencode/session-runtime.js +68 -0
  236. package/server/lib/opencode/session-runtime.test.js +43 -0
  237. package/server/lib/opencode/settings-helpers.js +11 -0
  238. package/server/lib/opencode/settings-helpers.test.js +24 -2
  239. package/server/lib/opencode/settings-normalization-runtime.js +2 -0
  240. package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
  241. package/server/lib/opencode/settings-registry.json +18 -0
  242. package/server/lib/opencode/settings-runtime.js +36 -9
  243. package/server/lib/opencode/settings-runtime.test.js +158 -2
  244. package/server/lib/opencode/shutdown-runtime.js +3 -0
  245. package/server/lib/opencode/shutdown-runtime.test.js +24 -0
  246. package/server/lib/opencode/theme-archive.js +53 -0
  247. package/server/lib/opencode/theme-catalog.js +171 -0
  248. package/server/lib/opencode/theme-catalog.test.js +125 -0
  249. package/server/lib/opencode/theme-import.test.js +144 -0
  250. package/server/lib/opencode/theme-runtime.js +100 -28
  251. package/server/lib/opencode/theme-runtime.test.js +15 -0
  252. package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
  253. package/server/lib/permission-auto-accept/runtime.js +13 -0
  254. package/server/lib/permission-auto-accept/runtime.test.js +37 -1
  255. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  256. package/server/lib/project-context/runtime.js +8 -2
  257. package/server/lib/project-context/runtime.test.js +54 -0
  258. package/server/lib/projects/DOCUMENTATION.md +2 -1
  259. package/server/lib/projects/project-config.js +40 -11
  260. package/server/lib/projects/project-config.test.js +97 -0
  261. package/server/lib/projects/project-id.js +26 -0
  262. package/server/lib/quota/DOCUMENTATION.md +0 -1
  263. package/server/lib/quota/providers/index.js +0 -7
  264. package/server/lib/realtime-proxy-terminal.test.js +150 -0
  265. package/server/lib/realtime-proxy.js +2 -0
  266. package/server/lib/relay/DOCUMENTATION.md +20 -0
  267. package/server/lib/relay/host-client.js +7 -0
  268. package/server/lib/relay/host-client.test.js +14 -0
  269. package/server/lib/routing/DOCUMENTATION.md +83 -0
  270. package/server/lib/routing/defaults.js +98 -0
  271. package/server/lib/routing/feature-flag.js +11 -0
  272. package/server/lib/routing/history.js +34 -0
  273. package/server/lib/routing/jev.js +98 -0
  274. package/server/lib/routing/routes.http.test.js +100 -0
  275. package/server/lib/routing/routes.js +81 -0
  276. package/server/lib/routing/runtime.js +217 -0
  277. package/server/lib/routing/runtime.test.js +188 -0
  278. package/server/lib/routing/store.js +249 -0
  279. package/server/lib/routing/store.test.js +96 -0
  280. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  281. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  282. package/server/lib/scheduled-tasks/runtime.js +14 -4
  283. package/server/lib/scheduled-tasks/runtime.test.js +67 -0
  284. package/server/lib/security/request-security.js +9 -5
  285. package/server/lib/security/request-security.test.js +37 -0
  286. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  287. package/server/lib/session-assist/context.js +127 -0
  288. package/server/lib/session-assist/context.test.js +132 -0
  289. package/server/lib/session-assist/prompt.js +54 -0
  290. package/server/lib/session-assist/runtime.js +111 -257
  291. package/server/lib/session-assist/runtime.test.js +203 -0
  292. package/server/lib/small-model/DOCUMENTATION.md +14 -2
  293. package/server/lib/small-model/call.js +37 -6
  294. package/server/lib/small-model/call.test.js +182 -0
  295. package/server/lib/terminal/DOCUMENTATION.md +23 -2
  296. package/server/lib/terminal/runtime.js +27 -5
  297. package/server/lib/terminal/runtime.test.js +67 -0
  298. package/server/lib/terminal/shutdown.js +106 -0
  299. package/server/lib/terminal/shutdown.test.js +84 -0
  300. package/server/lib/tts/service.test.js +78 -0
  301. package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
  302. package/server/lib/ui-auth/session-cookie.js +51 -0
  303. package/server/lib/ui-auth/session-cookie.test.js +46 -0
  304. package/server/lib/ui-auth/ui-auth.js +56 -17
  305. package/server/lib/ui-auth/ui-auth.test.js +96 -0
  306. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  307. package/server/lib/walkthrough/pr-source.test.js +21 -0
  308. package/server/lib/walkthrough/pull-request.js +7 -4
  309. package/server/lib/walkthrough/pull-request.test.js +16 -0
  310. package/server/lib/walkthrough/routes.js +19 -0
  311. package/server/lib/walkthrough/routes.test.js +26 -0
  312. package/server/lib/walkthrough/sources.js +13 -2
  313. package/server/opencode-worktree-readiness.test.js +130 -0
  314. package/dist/assets/DiffView-3Dn4QHbu.js +0 -4
  315. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +0 -1
  316. package/dist/assets/FilesView-BdTOpew9.js +0 -17
  317. package/dist/assets/GitView-Dqf9vHJh.js +0 -6
  318. package/dist/assets/LinearIssuesView-CmbeL7I6.js +0 -1
  319. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +0 -12
  320. package/dist/assets/PierreDiffViewer-DCsydlKs.js +0 -161
  321. package/dist/assets/SettingsView-CcM8pF5O.js +0 -28
  322. package/dist/assets/ToolOutputDialog-BgfTbDZ4.js +0 -15
  323. package/dist/assets/WalkthroughView-BEVUDxuw.js +0 -4
  324. package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
  325. package/dist/assets/de-C8jV7J9y.js +0 -10
  326. package/dist/assets/es-BI6J1M5R.js +0 -16
  327. package/dist/assets/fr-D6oDFt7E.js +0 -14
  328. package/dist/assets/index-BqhiCmYS.js +0 -39
  329. package/dist/assets/index-FqSOBz0J.css +0 -1
  330. package/dist/assets/ja-C_4i5EQu.js +0 -16
  331. package/dist/assets/ko-BFbOTpbq.js +0 -16
  332. package/dist/assets/main-C9ETLKFc.js +0 -2
  333. package/dist/assets/main-CJmb2dF8.js +0 -930
  334. package/dist/assets/markdownCore-DVuZpnli.js +0 -5
  335. package/dist/assets/miniChat-B4Ytoq2n.js +0 -2
  336. package/dist/assets/mobile-BUT6-lo1.js +0 -2
  337. package/dist/assets/patchFileDiff-8j1apICU.js +0 -13
  338. package/dist/assets/pl-COFvT56l.js +0 -16
  339. package/dist/assets/pt-BR-DtVOO2Lj.js +0 -16
  340. package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
  341. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +0 -2
  342. package/dist/assets/renderMobileApp-BNgHraq6.js +0 -25
  343. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +0 -17
  344. package/dist/assets/scheduledTasksApi-GC4epIYA.js +0 -1
  345. package/dist/assets/shikiHighlight-BJISeZsQ.js +0 -1
  346. package/dist/assets/surface-BvRzS58U.js +0 -1
  347. package/dist/assets/tr-B8Q0Go2l.js +0 -7
  348. package/dist/assets/uk-Dk4a-Fn-.js +0 -16
  349. package/dist/assets/useAppFontEffects-DpFNefUA.js +0 -7284
  350. package/dist/assets/useGitComparison-Dfxaqd0s.js +0 -1
  351. package/dist/assets/useInlineCommentController-CscsE4t-.js +0 -2
  352. package/dist/assets/useRootScrollLock-BaCvuRlT.js +0 -1
  353. package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
  354. package/dist/assets/vendor-zod-BPAANqvH.js +0 -39
  355. package/dist/assets/zh-CN-B_BXxG1Q.js +0 -16
  356. package/dist/assets/zh-TW-DLrl1SPZ.js +0 -16
  357. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  358. package/server/lib/mcp-reconnect/runtime.js +0 -122
  359. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
  360. package/server/lib/quota/providers/crof.js +0 -96
  361. package/server/lib/quota/providers/crof.test.js +0 -98
  362. /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
@@ -121,8 +121,8 @@ describe('managed agent tool runtime', () => {
121
121
  const pluginModule = await import(`${pathToFileURL(pluginPath).href}?both=${Date.now()}`);
122
122
  const { tool } = await pluginModule.OpenChamberPlugin();
123
123
 
124
- const controlActions = tool.openchamber.args.action.enum;
125
- const webActions = tool.openchamber_web.args.action.enum;
124
+ const controlActions = tool.openchamber.args.action.oneOf.map((entry) => entry.const);
125
+ const webActions = tool.openchamber_web.args.action.oneOf.map((entry) => entry.const);
126
126
  expect(webActions).toContain('browser.open');
127
127
  expect(controlActions).not.toContain('browser.open');
128
128
  expect(webActions).not.toContain('session.create');
@@ -133,6 +133,23 @@ describe('managed agent tool runtime', () => {
133
133
  expect(Object.keys(tool.openchamber.args.parameters.properties)).toContain('sessionId');
134
134
  });
135
135
 
136
+ it('keeps the action schema to one validator keyword', async () => {
137
+ // A node carrying both `enum` and `oneOf` is valid JSON Schema, but some
138
+ // OpenAI-compatible gateways reject it and answer with an empty completion
139
+ // instead of an error. `oneOf` is the keyword that stayed. Its branches
140
+ // carry the per-action descriptions the model reads.
141
+ const { runtime, dataDir } = await createRuntime();
142
+ await runtime.prepareManagedOpenCodeEnv();
143
+ const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
144
+ const pluginModule = await import(`${pathToFileURL(pluginPath).href}?validator=${Date.now()}`);
145
+ const { tool } = await pluginModule.OpenChamberPlugin();
146
+
147
+ for (const entry of Object.values(tool)) {
148
+ expect(entry.args.action.oneOf).toBeInstanceOf(Array);
149
+ expect(entry.args.action).not.toHaveProperty('enum');
150
+ }
151
+ });
152
+
136
153
  it('accepts inputs passed beside the action, not only inside parameters', async () => {
137
154
  const { runtime, dataDir } = await createRuntime();
138
155
  const prepared = await runtime.prepareManagedOpenCodeEnv();
@@ -354,6 +371,75 @@ describe('managed agent tool runtime', () => {
354
371
  expect(response.body).toEqual(expect.objectContaining({ ok: true, action: 'projects.list' }));
355
372
  });
356
373
 
374
+ it.each([
375
+ ['0.0.0.0', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
376
+ ['::', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
377
+ [null, 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
378
+ ['127.0.0.1', 'http://127.0.0.1:3901/api/openchamber/agent-tool'],
379
+ ['100.100.0.3', 'http://100.100.0.3:3901/api/openchamber/agent-tool'],
380
+ ['fd7a:115c::3', 'http://[fd7a:115c::3]:3901/api/openchamber/agent-tool'],
381
+ ])('points the callback at where a listener bound to %s answers', async (boundAddress, expectedUrl) => {
382
+ const { runtime } = await createRuntime({ getActiveHost: () => boundAddress });
383
+ const env = await runtime.prepareManagedOpenCodeEnv();
384
+ expect(env.OPENCHAMBER_AGENT_TOOL_URL).toBe(expectedUrl);
385
+ });
386
+
387
+ it.each([
388
+ ['100.100.0.3', '100.100.0.3', 200],
389
+ ['100.100.0.3', '::ffff:100.100.0.3', 200],
390
+ ['100.100.0.3', '100.100.0.7', 401],
391
+ ['fd7a:115c::3', 'fd7a:115c::3', 200],
392
+ ['fd7a:115c::3', 'fd7a:115c::7', 401],
393
+ ['0.0.0.0', '192.168.1.20', 401],
394
+ ['0.0.0.0', '0.0.0.0', 401],
395
+ [null, '192.168.1.20', 401],
396
+ ])('bound to %s, answers a token-bearing caller from %s with %i', async (boundAddress, remoteAddress, status) => {
397
+ const { runtime } = await createRuntime({ getActiveHost: () => boundAddress });
398
+ const env = await runtime.prepareManagedOpenCodeEnv();
399
+ const app = express();
400
+ app.use((req, _res, next) => {
401
+ Object.defineProperty(req.socket, 'remoteAddress', { value: remoteAddress, configurable: true });
402
+ next();
403
+ });
404
+ runtime.registerRoutes(app, express);
405
+
406
+ await request(app)
407
+ .post('/api/openchamber/agent-tool')
408
+ .set('authorization', `Bearer ${env.OPENCHAMBER_AGENT_TOOL_TOKEN}`)
409
+ .send({ input: { action: 'projects.list' } })
410
+ .expect(status);
411
+ });
412
+
413
+ it.each([
414
+ ['http://100.100.0.3:3901/api/openchamber/agent-tool', 'localhost,127.0.0.1', 'localhost,127.0.0.1,100.100.0.3'],
415
+ ['http://[fd7a:115c::3]:3901/api/openchamber/agent-tool', undefined, 'fd7a:115c::3'],
416
+ ['http://100.100.0.3:3901/api/openchamber/agent-tool', '100.100.0.3', '100.100.0.3'],
417
+ ['not a url', 'localhost', 'localhost'],
418
+ ])('keeps the callback %s away from an environment proxy', async (callbackUrl, existing, expected) => {
419
+ const { runtime, dataDir } = await createRuntime();
420
+ await runtime.prepareManagedOpenCodeEnv();
421
+ const keys = ['OPENCHAMBER_AGENT_TOOL_URL', 'NO_PROXY', 'no_proxy'];
422
+ const previous = keys.map((key) => process.env[key]);
423
+ try {
424
+ process.env.OPENCHAMBER_AGENT_TOOL_URL = callbackUrl;
425
+ for (const key of ['NO_PROXY', 'no_proxy']) {
426
+ if (existing === undefined) delete process.env[key];
427
+ else process.env[key] = existing;
428
+ }
429
+ const pluginPath = path.join(dataDir, 'agent-tool', 'openchamber-plugin.js');
430
+ const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}`);
431
+ await pluginModule.OpenChamberPlugin();
432
+
433
+ expect(process.env.NO_PROXY).toBe(expected);
434
+ expect(process.env.no_proxy).toBe(expected);
435
+ } finally {
436
+ keys.forEach((key, index) => {
437
+ if (previous[index] === undefined) delete process.env[key];
438
+ else process.env[key] = previous[index];
439
+ });
440
+ }
441
+ });
442
+
357
443
  it('executes through the materialized plugin and authenticated callback', async () => {
358
444
  let activePort = null;
359
445
  const { runtime, dataDir } = await createRuntime({ getActivePort: () => activePort });
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The repo publishes `changelog/index.json` on `main`: one object per release,
4
4
  // newest first, with the app notes grouped as New / Improvements / Fixes /
5
- // Misc. This module fetches it and renders the releases between the installed
5
+ // SDK / Misc. This module fetches it and renders the releases between the installed
6
6
  // version (exclusive) and the offered one (inclusive) as the Markdown the
7
7
  // dialog already understands: a `## [x.y.z] - YYYY-MM-DD` header per release,
8
8
  // the release title in bold, the intro, then the groups.
@@ -16,6 +16,7 @@ const GROUPS = [
16
16
  ['new', 'New'],
17
17
  ['improvements', 'Improvements'],
18
18
  ['fixes', 'Fixes'],
19
+ ['sdk', 'SDK'],
19
20
  ['misc', 'Misc'],
20
21
  ];
21
22
 
@@ -37,6 +37,25 @@ A short intro.
37
37
  - Faster.`);
38
38
  });
39
39
 
40
+ it('shows SDK notes between Fixes and Misc and omits an empty SDK group', () => {
41
+ const withSdk = [{
42
+ ...index[0],
43
+ app: { ...index[0].app, sdk: ['Actions: background execution.'], misc: ['Packaging update.'] },
44
+ }];
45
+ expect(renderUpdateNotes(withSdk, '1.2.2', '1.2.3', compare)).toContain(`### Fixes
46
+
47
+ - Chat: no freeze.
48
+
49
+ ### SDK
50
+
51
+ - Actions: background execution.
52
+
53
+ ### Misc
54
+
55
+ - Packaging update.`);
56
+ expect(renderUpdateNotes([{ ...index[0], app: { sdk: [] } }], '1.2.2', '1.2.3', compare)).not.toContain('### SDK');
57
+ });
58
+
40
59
  it('returns null when nothing lies in the range or the payload is not an index', () => {
41
60
  expect(renderUpdateNotes(index, '1.2.3', '1.2.3', compare)).toBe(null);
42
61
  expect(renderUpdateNotes({ entries: [] }, '1.0.0', '9.9.9', compare)).toBe(null);
@@ -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.
@@ -24,12 +25,16 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
24
25
  - `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
25
26
  - `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
26
27
  - `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
28
+ - `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
27
29
 
28
30
  ### Runtime helpers
29
31
  - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
30
32
  - `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
31
33
  - `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
32
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
+
33
38
  ### Upstream reader helpers
34
39
  - `DEFAULT_UPSTREAM_STALL_TIMEOUT_MS`: default idle timeout before an attached upstream SSE fetch is aborted for reconnect.
35
40
  - `DEFAULT_UPSTREAM_RECONNECT_DELAY_MS`: default delay between upstream reconnect attempts.
@@ -40,15 +45,35 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
40
45
  - OpenChamber still fetches OpenCode upstream event streams over SSE.
41
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.
42
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`.
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.
43
50
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
44
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.
45
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.
46
53
  - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
47
54
  - Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
48
55
  - Global UI broadcasts are fan-out capable across both SSE and WS clients.
56
+ - Global UI broadcasts serialize once per wire format, irrespective of client count. The SSE writer accepts an optional pre-serialized payload; isolated callers keep the two-argument contract. Failed or backpressured clients cannot block delivery to healthy clients.
49
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.
50
58
  - Browser transport concerns live in the WS bridge modules; server-side global stream ownership lives in `global-hub.js`.
51
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
+
52
77
  ## Notes for contributors
53
78
  - Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
54
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`.
@@ -56,6 +81,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti
56
81
  - Keep global replay bounded; do not turn it into an unbounded event log.
57
82
 
58
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`.
59
85
  - Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
60
86
  - Run `bun test packages/web/server/lib/event-stream/upstream-reader.test.js`
61
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,8 +1,13 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { createUpstreamSseReader } from './upstream-reader.js';
4
+ import { serializeMessageStreamWsEvent } from './protocol.js';
5
+ import { createDeltaCoalescer, DELTA_COALESCE_WINDOW_MS } from './delta-coalescer.js';
2
6
 
3
7
  // Raised from 512 → 2048 to improve recovery after brief disconnects during
4
8
  // long-running agent sessions where many events accumulate quickly.
5
9
  const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
10
+ const MESSAGE_STREAM_GLOBAL_REPLAY_BYTES = 8 * 1024 * 1024;
6
11
 
7
12
  export function createGlobalMessageStreamHub({
8
13
  buildOpenCodeUrl,
@@ -11,10 +16,26 @@ export function createGlobalMessageStreamHub({
11
16
  upstreamStallTimeoutMs,
12
17
  upstreamReconnectDelayMs,
13
18
  replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
19
+ replayByteLimit = MESSAGE_STREAM_GLOBAL_REPLAY_BYTES,
20
+ deltaCoalesceWindowMs = DELTA_COALESCE_WINDOW_MS,
14
21
  }) {
22
+ if (!Number.isSafeInteger(replayLimit) || replayLimit < 0 || !Number.isSafeInteger(replayByteLimit) || replayByteLimit < 0) {
23
+ throw new RangeError('Replay limits must be nonnegative safe integers');
24
+ }
15
25
  const eventSubscribers = new Set();
16
26
  const statusSubscribers = new Set();
17
27
  const replay = [];
28
+ let replayBytes = 0;
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;
18
39
 
19
40
  let controller = null;
20
41
  let reader = null;
@@ -44,15 +65,50 @@ export function createGlobalMessageStreamHub({
44
65
  const normalizeEvent = ({ envelope, payload }) => {
45
66
  const directory =
46
67
  typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
47
- 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')}`;
71
+ let serializedFrame;
48
72
  return {
49
73
  envelope,
50
74
  payload,
51
75
  directory,
52
76
  eventId,
77
+ serialize() {
78
+ serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
79
+ return serializedFrame;
80
+ },
53
81
  };
54
82
  };
55
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
+
56
112
  const start = () => {
57
113
  if (reader) {
58
114
  return;
@@ -85,17 +141,7 @@ export function createGlobalMessageStreamHub({
85
141
  notifyStatus({ type: 'disconnect', reason });
86
142
  },
87
143
  onEvent(event) {
88
- const normalized = normalizeEvent(event);
89
- if (normalized.eventId) {
90
- replay.push(normalized);
91
- if (replay.length > replayLimit) {
92
- replay.splice(0, replay.length - replayLimit);
93
- }
94
- }
95
-
96
- for (const subscriber of Array.from(eventSubscribers)) {
97
- notifySubscriber('event', subscriber, normalized);
98
- }
144
+ coalescer.push(event);
99
145
  },
100
146
  onError(error) {
101
147
  if (controller?.signal.aborted) {
@@ -115,6 +161,8 @@ export function createGlobalMessageStreamHub({
115
161
 
116
162
  const stop = () => {
117
163
  connected = false;
164
+ // Text that already arrived belongs in the retained replay suffix.
165
+ coalescer.flush();
118
166
  reader?.stop();
119
167
  if (controller && !controller.signal.aborted) {
120
168
  controller.abort();
@@ -146,13 +194,20 @@ export function createGlobalMessageStreamHub({
146
194
  statusSubscribers.delete(subscriber);
147
195
  };
148
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
+ },
149
203
  replayAfter(eventId) {
150
204
  if (!eventId) {
151
205
  return [];
152
206
  }
153
207
 
154
208
  const index = replay.findIndex((entry) => entry.eventId === eventId);
155
- return index === -1 ? [] : replay.slice(index + 1);
209
+ if (eventId === latestEventId) return [];
210
+ return index === -1 ? null : replay.slice(index + 1);
156
211
  },
157
212
  };
158
213
  }