@aiworker/web 1.22.2 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
package/dist/index.html CHANGED
@@ -509,16 +509,16 @@
509
509
  pointer-events: none;
510
510
  }
511
511
  </style>
512
- <script type="module" crossorigin src="/assets/main-DnBAoktx.js"></script>
512
+ <script type="module" crossorigin src="/assets/main-D3ZDQglc.js"></script>
513
513
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
514
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
514
515
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
515
516
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
516
517
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
517
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-CK21ihK-.js">
518
518
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
519
- <link rel="modulepreload" crossorigin href="/assets/index-DF5kMP-i.js">
520
- <link rel="modulepreload" crossorigin href="/assets/contextPanelEmbeddedChat-C0ZLAepL.js">
521
- <link rel="stylesheet" crossorigin href="/assets/index-Dl3xbAB_.css">
519
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
520
+ <link rel="modulepreload" crossorigin href="/assets/contextPanelEmbeddedChat-BdA6bdky.js">
521
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
522
522
  </head>
523
523
  <body class="h-full bg-background text-foreground">
524
524
  <div id="root" class="h-full">
@@ -603,44 +603,6 @@
603
603
  }, 10000);
604
604
  </script>
605
605
 
606
- <!-- CSS Font Loading API for reliable Nerd Font loading. The ~2 MB of
607
- Nerd Fonts are only used by the terminal viewport, so they are no
608
- longer downloaded on every cold start: the terminal requests them on
609
- first mount through this idempotent hook. -->
610
- <script>
611
- (function() {
612
- let nerdFontsPromise = null;
613
- window.__aiworkerEnsureNerdFonts = function() {
614
- if (nerdFontsPromise) {
615
- return nerdFontsPromise;
616
- }
617
- const fonts = [
618
- {
619
- name: 'JetBrainsMono Nerd Font',
620
- url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/JetBrainsMonoNerdFont-Regular.woff2'
621
- },
622
- {
623
- name: 'FiraCode Nerd Font',
624
- url: 'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/FiraCodeNerdFont-Regular.woff2'
625
- }
626
- ];
627
-
628
- const fontPromises = fonts.map(font => {
629
- const fontFace = new FontFace(font.name, `url(${font.url}) format('woff2')`);
630
- document.fonts.add(fontFace);
631
- return fontFace.load().catch(err => {
632
- console.warn(`Failed to load font: ${font.name}`, err);
633
- });
634
- });
635
-
636
- nerdFontsPromise = Promise.allSettled(fontPromises).then(() => {
637
- document.documentElement.classList.add('fonts-loaded');
638
- });
639
- return nerdFontsPromise;
640
- };
641
- })();
642
- </script>
643
-
644
606
  <!-- Polyfill for process before loading React -->
645
607
  <script>
646
608
  if (typeof process === 'undefined') {
@@ -74,15 +74,15 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-BKyvLseN.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-d01k801U.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
79
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
79
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
80
81
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
81
82
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
82
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-CK21ihK-.js">
83
83
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
84
- <link rel="modulepreload" crossorigin href="/assets/index-DF5kMP-i.js">
85
- <link rel="stylesheet" crossorigin href="/assets/index-Dl3xbAB_.css">
84
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
85
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
86
86
  </head>
87
87
  <body class="h-full bg-background text-foreground">
88
88
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,15 +63,15 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-DKjeFgMN.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-D7VV-Ufz.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
68
+ <link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
68
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
69
70
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
70
71
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
71
- <link rel="modulepreload" crossorigin href="/assets/vendor-zod-CK21ihK-.js">
72
72
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
73
- <link rel="modulepreload" crossorigin href="/assets/index-DF5kMP-i.js">
74
- <link rel="stylesheet" crossorigin href="/assets/index-Dl3xbAB_.css">
73
+ <link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
74
+ <link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
75
75
  </head>
76
76
  <body class="h-full bg-background text-foreground">
77
77
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiworker/web",
3
- "version": "1.22.2",
3
+ "version": "1.23.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -25,7 +25,7 @@
25
25
  "dependencies": {
26
26
  "@clack/prompts": "^1.1.0",
27
27
  "@octokit/rest": "^22.0.1",
28
- "@opencode-ai/sdk": "1.18.29",
28
+ "@opencode-ai/sdk": "1.18.30",
29
29
  "@simplewebauthn/server": "13.3.1",
30
30
  "bun-pty": "^0.4.5",
31
31
  "compression": "^1.8.1",
@@ -77,7 +77,6 @@
77
77
  "eslint": "^9.33.0",
78
78
  "eslint-plugin-react-hooks": "^5.2.0",
79
79
  "eslint-plugin-react-refresh": "^0.5.0",
80
- "ghostty-web": "0.4.0",
81
80
  "globals": "^16.3.0",
82
81
  "next-themes": "^0.4.6",
83
82
  "nodemon": "^3.1.7",
package/server/index.d.ts CHANGED
@@ -11,12 +11,27 @@ export interface WebUiServerController {
11
11
  stop: (options?: { exitProcess?: boolean }) => Promise<void>;
12
12
  }
13
13
 
14
+ export interface DesktopUpdateInfo {
15
+ available: boolean;
16
+ currentVersion?: string;
17
+ version?: string | null;
18
+ body?: string | null;
19
+ date?: string | null;
20
+ }
21
+
22
+ export interface DesktopUpdater {
23
+ check: () => Promise<DesktopUpdateInfo>;
24
+ install: () => Promise<DesktopUpdateInfo>;
25
+ restart: () => Promise<void> | void;
26
+ }
27
+
14
28
  export interface StartWebUiServerOptions {
15
29
  port?: number;
16
30
  host?: string;
17
31
  attachSignals?: boolean;
18
32
  exitOnShutdown?: boolean;
19
33
  uiPassword?: string | null;
34
+ desktopUpdater?: DesktopUpdater;
20
35
  }
21
36
 
22
37
  export declare function startWebUiServer(
package/server/index.js CHANGED
@@ -94,6 +94,7 @@ import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/
94
94
  import { createMessageQueueRuntime } from './lib/message-queue/runtime.js';
95
95
  import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
96
96
  import { createProjectConfigRuntime } from './lib/projects/project-config.js';
97
+ import { migrateLegacyUserDirs } from './lib/data-dir-migration.js';
97
98
  import { createProjectContextRuntime } from './lib/project-context/runtime.js';
98
99
  import { createAgentMemoryRuntime } from './lib/agent-memory/runtime.js';
99
100
  import { createAgentMemoryActions } from './lib/agent-memory/actions.js';
@@ -111,12 +112,15 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js';
111
112
  import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
112
113
  import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
113
114
  import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
114
- import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js';
115
115
  import { createAiWorkerSessionService } from './lib/openchamber-sessions/routes.js';
116
116
  import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
117
117
  import { createAiWorkerControlService } from './lib/openchamber-control/service.js';
118
118
  import { AiWorkerControlError } from './lib/openchamber-control/error.js';
119
119
  import webPush from 'web-push';
120
+ import { applyConnectAttemptTimeout } from './lib/network-defaults.js';
121
+
122
+ // Background CLI launches enter here in a fresh process, without CLI defaults.
123
+ applyConnectAttemptTimeout();
120
124
 
121
125
  const __filename = fileURLToPath(import.meta.url);
122
126
  const __dirname = path.dirname(__filename);
@@ -259,14 +263,20 @@ const normalizeManagedRemoteTunnelPresets = (...args) =>
259
263
  const normalizeManagedRemoteTunnelPresetTokens = (...args) =>
260
264
  settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresetTokens(...args);
261
265
  const isUnsafeSkillRelativePath = (...args) => settingsNormalizationRuntime.isUnsafeSkillRelativePath(...args);
262
- const sanitizeTypographySizesPartial = (...args) =>
263
- settingsNormalizationRuntime.sanitizeTypographySizesPartial(...args);
264
266
  const normalizeStringArray = (...args) => settingsNormalizationRuntime.normalizeStringArray(...args);
265
267
  const sanitizeModelRefs = (...args) => settingsNormalizationRuntime.sanitizeModelRefs(...args);
266
268
  const sanitizeSkillCatalogs = (...args) => settingsNormalizationRuntime.sanitizeSkillCatalogs(...args);
267
269
  const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProjects(...args);
268
270
 
269
- const AIWORKER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'aiworker');
271
+ // Every AiWorker-owned file and folder hangs off one root: the default
272
+ // `~/.config/openchamber`, or `AIWORKER_DATA_DIR` when set. The user
273
+ // folders (`projects/`, `themes/`, `speech-models/`) are copied into a custom
274
+ // root once at startup (`migrateLegacyUserDirs`), because they used to ignore
275
+ // the variable.
276
+ const AIWORKER_DEFAULT_CONFIG_ROOT = path.join(os.homedir(), '.config', 'aiworker');
277
+ const AIWORKER_USER_CONFIG_ROOT = process.env.AIWORKER_DATA_DIR
278
+ ? path.resolve(process.env.AIWORKER_DATA_DIR)
279
+ : AIWORKER_DEFAULT_CONFIG_ROOT;
270
280
  const AIWORKER_USER_THEMES_DIR = path.join(AIWORKER_USER_CONFIG_ROOT, 'themes');
271
281
  const AIWORKER_PROJECTS_CONFIG_DIR = path.join(AIWORKER_USER_CONFIG_ROOT, 'projects');
272
282
  // AIWORKER_CHATS_DIR relocates managed chat worktrees — needed when the
@@ -291,7 +301,6 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
291
301
  let notificationTemplateRuntime = null;
292
302
  let agentToolRuntime = null;
293
303
  let systemPromptRuntime = null;
294
- let mcpReconnectRuntime = null;
295
304
 
296
305
  const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
297
306
  const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
@@ -305,9 +314,7 @@ const maybeCacheSessionInfoFromEvent = (...args) => notificationTemplateRuntime.
305
314
  const buildTemplateVariables = (...args) => notificationTemplateRuntime.buildTemplateVariables(...args);
306
315
  const getCachedZenModels = (...args) => notificationTemplateRuntime.getCachedZenModels(...args);
307
316
 
308
- const AIWORKER_DATA_DIR = process.env.AIWORKER_DATA_DIR
309
- ? path.resolve(process.env.AIWORKER_DATA_DIR)
310
- : path.join(os.homedir(), '.config', 'aiworker');
317
+ const AIWORKER_DATA_DIR = AIWORKER_USER_CONFIG_ROOT;
311
318
  const SETTINGS_FILE_PATH = path.join(AIWORKER_DATA_DIR, 'settings.json');
312
319
  const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(AIWORKER_DATA_DIR, 'push-subscriptions.json');
313
320
  const APNS_TOKENS_FILE_PATH = path.join(AIWORKER_DATA_DIR, 'apns-tokens.json');
@@ -345,7 +352,6 @@ const settingsHelpers = createSettingsHelpers({
345
352
  normalizeManagedRemoteTunnelHostname,
346
353
  normalizeManagedRemoteTunnelPresets,
347
354
  normalizeManagedRemoteTunnelPresetTokens,
348
- sanitizeTypographySizesPartial,
349
355
  normalizeStringArray,
350
356
  sanitizeModelRefs,
351
357
  sanitizeSkillCatalogs,
@@ -484,6 +490,17 @@ const getUpstreamStallTimeoutMs = () => (
484
490
  : DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
485
491
  );
486
492
 
493
+ const movedUserDirs = await migrateLegacyUserDirs({
494
+ fsPromises,
495
+ path,
496
+ dataDir: AIWORKER_USER_CONFIG_ROOT,
497
+ legacyRoot: AIWORKER_DEFAULT_CONFIG_ROOT,
498
+ warn: (message) => console.warn(`[data-dir] ${message}`),
499
+ });
500
+ if (movedUserDirs.length > 0) {
501
+ console.log(`[data-dir] Copied ${movedUserDirs.join(', ')} into ${AIWORKER_USER_CONFIG_ROOT}`);
502
+ }
503
+
487
504
  const projectConfigRuntime = createProjectConfigRuntime({
488
505
  fsPromises,
489
506
  path,
@@ -494,6 +511,7 @@ const projectContextRuntime = createProjectContextRuntime({
494
511
  fsPromises,
495
512
  path,
496
513
  projectsDirPath: AIWORKER_PROJECTS_CONFIG_DIR,
514
+ resolveSharedPlansDir: (projectId) => projectConfigRuntime.resolveSharedPlansDir(projectId),
497
515
  });
498
516
 
499
517
  const agentMemoryRuntime = createAgentMemoryRuntime({
@@ -893,7 +911,13 @@ const messageQueueRuntime = createMessageQueueRuntime({
893
911
  buildOpenCodeUrl,
894
912
  getOpenCodeAuthHeaders,
895
913
  sessionKnowledgeRuntime,
896
- broadcastGlobalUiEvent,
914
+ // OpenCode's /global/event SSE proxy cannot carry AiWorker-owned events.
915
+ // Use the shared control stream for SSE clients and the existing WS fan-out.
916
+ broadcastGlobalUiEvent: createGlobalUiEventBroadcaster({
917
+ sseClients: uiAiWorkerEventClients,
918
+ wsClients: uiNotificationWsClients,
919
+ writeSseEvent,
920
+ }),
897
921
  onPromptSent: (sessionId) => sessionRuntime.markUserMessageSent(sessionId),
898
922
  dataDir: AIWORKER_DATA_DIR,
899
923
  });
@@ -1230,14 +1254,11 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
1230
1254
  ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
1231
1255
  : {};
1232
1256
 
1233
- // Each managed plugin appends itself to the config the previous one produced.
1234
- let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
1235
1257
  if (settings?.optimizeSystemPrompt === true) {
1236
- ({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
1258
+ const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
1259
+ Object.assign(managedEnv, await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
1237
1260
  }
1238
- // Always on for managed OpenCode: it only retries servers OpenCode gave up on.
1239
- const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent);
1240
- return { ...managedEnv, ...mcpReconnectEnv };
1261
+ return managedEnv;
1241
1262
  },
1242
1263
  });
1243
1264
 
@@ -1511,11 +1532,6 @@ async function main(options = {}) {
1511
1532
  path,
1512
1533
  dataDir: AIWORKER_DATA_DIR,
1513
1534
  });
1514
- mcpReconnectRuntime = createMcpReconnectRuntime({
1515
- fsPromises,
1516
- path,
1517
- dataDir: AIWORKER_DATA_DIR,
1518
- });
1519
1535
 
1520
1536
  // Pairing transports advertised to the create-device dialog. LAN reachability is
1521
1537
  // derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
@@ -1642,6 +1658,12 @@ async function main(options = {}) {
1642
1658
  const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function'
1643
1659
  ? options.getDesktopRuntimeConfig
1644
1660
  : null;
1661
+ const desktopUpdater = options.desktopUpdater
1662
+ && typeof options.desktopUpdater.check === 'function'
1663
+ && typeof options.desktopUpdater.install === 'function'
1664
+ && typeof options.desktopUpdater.restart === 'function'
1665
+ ? options.desktopUpdater
1666
+ : null;
1645
1667
 
1646
1668
  console.log(`Starting AiWorker on port ${port === 0 ? 'auto' : port}`);
1647
1669
 
@@ -1676,7 +1698,10 @@ async function main(options = {}) {
1676
1698
  res.setHeader('Access-Control-Allow-Origin', origin);
1677
1699
  res.setHeader('Access-Control-Allow-Credentials', 'true');
1678
1700
  res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
1679
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning');
1701
+ // The packaged desktop UI (openchamber-ui://) and the dev UI sit on a
1702
+ // different origin, so every custom request header must be listed here or
1703
+ // the browser refuses the request at preflight, before it reaches a route.
1704
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning,X-AiWorker-Surface');
1680
1705
  res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor');
1681
1706
  res.setHeader('Vary', 'Origin');
1682
1707
  if (req.method === 'OPTIONS') {
@@ -1817,6 +1842,7 @@ async function main(options = {}) {
1817
1842
  getCachedZenModels,
1818
1843
  setAutoAcceptSession,
1819
1844
  agentToolRuntime,
1845
+ desktopUpdater,
1820
1846
  });
1821
1847
  uiAuthController = bootstrapResult.uiAuthController;
1822
1848
  realtimeProxyRuntime = attachRealtimeProxy({
@@ -4,7 +4,10 @@
4
4
  * What the agent has learned and chose to keep, in two scopes:
5
5
  *
6
6
  * - **project** — `<projectsDir>/<projectId>/memory.json`. How this codebase
7
- * works, what was decided, where things live.
7
+ * works, what was decided, where things live. `<projectId>` is the bounded
8
+ * stem `projectConfigFileStemOf` gives the id (the id itself up to 200
9
+ * characters, `path_sha256_<digest>` beyond), the same folder that holds
10
+ * `project-context`'s `context.json` and sits beside the config file.
8
11
  * - **global** — `<userConfigRoot>/memory.json`. Who the user is and how they
9
12
  * want to be worked with. It belongs to no project, so it cannot live under
10
13
  * one.
@@ -51,6 +54,7 @@ const PROJECT_MEMORY_MAX_ITEMS = 200;
51
54
  */
52
55
  const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
53
56
 
57
+ import { projectConfigFileStemOf } from '../projects/project-id.js';
54
58
  import { findThreatPattern } from './threat-patterns.js';
55
59
 
56
60
  const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
@@ -211,7 +215,8 @@ export const createAgentMemoryRuntime = (deps) => {
211
215
  return {
212
216
  scope: 'project',
213
217
  key: `project:${projectId}`,
214
- filePath: path.join(projectsDirPath, projectId, 'memory.json'),
218
+ // Same bounded folder name as `project-context` and the config file.
219
+ filePath: path.join(projectsDirPath, projectConfigFileStemOf(projectId), 'memory.json'),
215
220
  };
216
221
  }
217
222
  throw new Error('scope is required');
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
6
7
  import { createAgentMemoryRuntime } from './runtime.js';
7
8
 
8
9
  const PROJECT_ID = 'path_dGVzdA';
@@ -48,6 +49,27 @@ describe('scope resolution', () => {
48
49
  await fsPromises.access(projectPath());
49
50
  });
50
51
 
52
+ test('a project id too long for a folder name is stored under the bounded stem', async () => {
53
+ // A checkout nested deep enough that its `path_<base64url>` id is longer
54
+ // than a folder name may be; the store lands under the bounded stem the
55
+ // config file and project context use, instead of failing with ENAMETOOLONG.
56
+ const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
57
+ const stem = projectConfigFileStemOf(longProjectId);
58
+ expect(longProjectId.length).toBeGreaterThan(240);
59
+ expect(stem.startsWith('path_sha256_')).toBe(true);
60
+ const target = { scope: 'project', projectId: longProjectId };
61
+
62
+ expect((await runtime.read(target)).entries).toEqual([]);
63
+ await runtime.create(target, { title: 'Uses bun', body: 'Tests run with bun test.' });
64
+
65
+ expect((await runtime.read(target)).entries.map((e) => e.title)).toEqual(['Uses bun']);
66
+ expect((await runtime.readAll(longProjectId)).project.map((e) => e.title)).toEqual(['Uses bun']);
67
+ await fsPromises.access(path.join(rootDir, 'config', 'projects', stem, 'memory.json'));
68
+ expect(await fsPromises.readdir(path.join(rootDir, 'config', 'projects'))).toEqual([stem]);
69
+ // A short id keeps naming its folder itself.
70
+ expect(runtime.resolveTarget(PROJECT).filePath).toBe(projectPath());
71
+ });
72
+
51
73
  test('rejects an unknown scope', async () => {
52
74
  await expect(runtime.read({ scope: 'nope' })).rejects.toThrow('scope is required');
53
75
  });
@@ -0,0 +1,40 @@
1
+ // One-time copy of the user-authored folders into a custom data directory.
2
+ //
3
+ // `AIWORKER_DATA_DIR` is documented as "the AiWorker data directory",
4
+ // but for a long time only the flat files (settings, auth, push tokens)
5
+ // followed it while `projects/`, `themes/`, and `speech-models/` stayed under
6
+ // `~/.config/openchamber`. Now every folder hangs off the data directory, so an
7
+ // instance that already ran with a custom directory finds those folders in
8
+ // the old place. This copies each one over once: only when the new location
9
+ // does not exist yet and the old one does. It copies rather than moves
10
+ // because a second instance on the same machine (a custom directory next to
11
+ // the default one) must not strip the default instance of its projects.
12
+
13
+ const USER_DIR_ENTRIES = Object.freeze(['projects', 'themes', 'speech-models']);
14
+
15
+ const exists = async (fsPromises, target) => fsPromises.access(target).then(() => true, () => false);
16
+
17
+ /**
18
+ * Copy the user folders from `legacyRoot` into `dataDir`. Returns the entries
19
+ * copied. A no-op when the two roots are the same directory. A folder whose
20
+ * copy fails is reported through `warn` and its partial copy removed; the
21
+ * server keeps starting, reading the (empty) new location.
22
+ */
23
+ export const migrateLegacyUserDirs = async ({ fsPromises, path, dataDir, legacyRoot, warn = () => {}, entries = USER_DIR_ENTRIES }) => {
24
+ if (path.resolve(dataDir) === path.resolve(legacyRoot)) return [];
25
+ const moved = [];
26
+ for (const entry of entries) {
27
+ const from = path.join(legacyRoot, entry);
28
+ const to = path.join(dataDir, entry);
29
+ if (await exists(fsPromises, to) || !(await exists(fsPromises, from))) continue;
30
+ try {
31
+ await fsPromises.mkdir(dataDir, { recursive: true });
32
+ await fsPromises.cp(from, to, { recursive: true, errorOnExist: true, force: false });
33
+ moved.push(entry);
34
+ } catch (error) {
35
+ await fsPromises.rm(to, { recursive: true, force: true }).catch(() => {});
36
+ warn(`Failed to copy ${from} to ${to}: ${error instanceof Error ? error.message : String(error)}`);
37
+ }
38
+ }
39
+ return moved;
40
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import fsPromises from 'fs/promises';
5
+
6
+ import { migrateLegacyUserDirs } from './data-dir-migration.js';
7
+
8
+ const setup = async () => {
9
+ const root = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-data-dir-'));
10
+ const legacyRoot = path.join(root, 'legacy');
11
+ const dataDir = path.join(root, 'custom');
12
+ await fsPromises.mkdir(path.join(legacyRoot, 'projects'), { recursive: true });
13
+ await fsPromises.writeFile(path.join(legacyRoot, 'projects', 'p.json'), '{"a":1}');
14
+ await fsPromises.mkdir(path.join(legacyRoot, 'themes'), { recursive: true });
15
+ return { root, legacyRoot, dataDir, cleanup: () => fsPromises.rm(root, { recursive: true, force: true }) };
16
+ };
17
+
18
+ describe('migrateLegacyUserDirs', () => {
19
+ it('copies the user folders once into a custom data dir and leaves the originals', async () => {
20
+ const { legacyRoot, dataDir, cleanup } = await setup();
21
+ try {
22
+ const warnings = [];
23
+ const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot, warn: (message) => warnings.push(message) });
24
+ expect(moved).toEqual(['projects', 'themes']);
25
+ expect(warnings).toEqual([]);
26
+ expect(await fsPromises.readFile(path.join(dataDir, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
27
+ // The default instance keeps its own copy: a second instance must not strip it.
28
+ expect(await fsPromises.readFile(path.join(legacyRoot, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
29
+ // A second start copies nothing more.
30
+ expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot })).toEqual([]);
31
+ } finally {
32
+ await cleanup();
33
+ }
34
+ });
35
+
36
+ it('never merges into a folder that already exists in the data dir', async () => {
37
+ const { legacyRoot, dataDir, cleanup } = await setup();
38
+ try {
39
+ await fsPromises.mkdir(path.join(dataDir, 'projects'), { recursive: true });
40
+ await fsPromises.writeFile(path.join(dataDir, 'projects', 'q.json'), '{}');
41
+ const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot });
42
+ expect(moved).toEqual(['themes']);
43
+ expect(await fsPromises.readdir(path.join(dataDir, 'projects'))).toEqual(['q.json']);
44
+ expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
45
+ } finally {
46
+ await cleanup();
47
+ }
48
+ });
49
+
50
+ it('is a no-op when the data dir is the default root', async () => {
51
+ const { legacyRoot, cleanup } = await setup();
52
+ try {
53
+ expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir: legacyRoot, legacyRoot })).toEqual([]);
54
+ expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
55
+ } finally {
56
+ await cleanup();
57
+ }
58
+ });
59
+ });
@@ -24,6 +24,7 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
24
24
  - `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
25
25
  - `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
26
26
  - `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
27
+ - `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
27
28
 
28
29
  ### Runtime helpers
29
30
  - `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
@@ -40,12 +41,14 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
40
41
  - AiWorker still fetches OpenCode upstream event streams over SSE.
41
42
  - 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
43
  - 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
+ - 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.
43
45
  - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
44
46
  - 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
47
  - 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
48
  - 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
49
  - Global synthetic events such as `aiworker:session-status`, `aiworker:session-activity`, `aiworker:notification`, and `aiworker:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
48
50
  - Global UI broadcasts are fan-out capable across both SSE and WS clients.
51
+ - 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
52
  - 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
53
  - Browser transport concerns live in the WS bridge modules; server-side global stream ownership lives in `global-hub.js`.
51
54
 
@@ -1,8 +1,10 @@
1
1
  import { createUpstreamSseReader } from './upstream-reader.js';
2
+ import { serializeMessageStreamWsEvent } from './protocol.js';
2
3
 
3
4
  // Raised from 512 → 2048 to improve recovery after brief disconnects during
4
5
  // long-running agent sessions where many events accumulate quickly.
5
6
  const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
7
+ const MESSAGE_STREAM_GLOBAL_REPLAY_BYTES = 8 * 1024 * 1024;
6
8
 
7
9
  export function createGlobalMessageStreamHub({
8
10
  buildOpenCodeUrl,
@@ -11,10 +13,16 @@ export function createGlobalMessageStreamHub({
11
13
  upstreamStallTimeoutMs,
12
14
  upstreamReconnectDelayMs,
13
15
  replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
16
+ replayByteLimit = MESSAGE_STREAM_GLOBAL_REPLAY_BYTES,
14
17
  }) {
18
+ if (!Number.isSafeInteger(replayLimit) || replayLimit < 0 || !Number.isSafeInteger(replayByteLimit) || replayByteLimit < 0) {
19
+ throw new RangeError('Replay limits must be nonnegative safe integers');
20
+ }
15
21
  const eventSubscribers = new Set();
16
22
  const statusSubscribers = new Set();
17
23
  const replay = [];
24
+ let replayBytes = 0;
25
+ let latestEventId;
18
26
 
19
27
  let controller = null;
20
28
  let reader = null;
@@ -45,11 +53,16 @@ export function createGlobalMessageStreamHub({
45
53
  const directory =
46
54
  typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
47
55
  const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
56
+ let serializedFrame;
48
57
  return {
49
58
  envelope,
50
59
  payload,
51
60
  directory,
52
61
  eventId,
62
+ serialize() {
63
+ serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
64
+ return serializedFrame;
65
+ },
53
66
  };
54
67
  };
55
68
 
@@ -87,9 +100,20 @@ export function createGlobalMessageStreamHub({
87
100
  onEvent(event) {
88
101
  const normalized = normalizeEvent(event);
89
102
  if (normalized.eventId) {
90
- replay.push(normalized);
91
- if (replay.length > replayLimit) {
92
- replay.splice(0, replay.length - replayLimit);
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
+ }
93
117
  }
94
118
  }
95
119
 
@@ -152,7 +176,8 @@ export function createGlobalMessageStreamHub({
152
176
  }
153
177
 
154
178
  const index = replay.findIndex((entry) => entry.eventId === eventId);
155
- return index === -1 ? [] : replay.slice(index + 1);
179
+ if (eventId === latestEventId) return [];
180
+ return index === -1 ? null : replay.slice(index + 1);
156
181
  },
157
182
  };
158
183
  }
@@ -2,6 +2,28 @@ import { describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { createGlobalMessageStreamHub } from './global-hub.js';
4
4
 
5
+ it('bounds a contiguous replay suffix by UTF-8 bytes and event count', async () => {
6
+ const blocks = Array.from({ length: 8 }, (_, i) => `id: e${i}\ndata: ${JSON.stringify({ type: 'message', properties: { text: '界'.repeat(40) } })}\n\n`);
7
+ const received = [];
8
+ const hub = createGlobalMessageStreamHub({
9
+ buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
10
+ getOpenCodeAuthHeaders: () => ({}), replayLimit: 3, replayByteLimit: 550,
11
+ upstreamReconnectDelayMs: 60_000,
12
+ fetchImpl: async () => createSseResponse({ blocks }),
13
+ });
14
+ hub.subscribeEvent(event => received.push(event.eventId));
15
+ try {
16
+ hub.start();
17
+ await waitForAssertion(() => expect(received).toHaveLength(8));
18
+ expect(hub.replayAfter('e0')).toBeNull();
19
+ expect(hub.replayAfter('e5')).toBeNull();
20
+ const tail = hub.replayAfter('e6');
21
+ expect(tail.map(entry => entry.eventId)).toEqual(['e7']);
22
+ expect(Buffer.byteLength(tail[0].serializedFrame) * 2).toBeLessThanOrEqual(550);
23
+ expect(Buffer.byteLength(tail[0].serializedFrame) * 3).toBeGreaterThan(550);
24
+ } finally { hub.stop(); }
25
+ });
26
+
5
27
  function createSseResponse({ blocks = [] } = {}) {
6
28
  const encoder = new TextEncoder();
7
29
  let index = 0;