@vantran-se/openchamber-web 2.0.3 → 2.0.5

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 (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -132,6 +132,7 @@ describe('rebindUpstream (#2638)', () => {
132
132
  expect(fetchCalls).toBe(2);
133
133
  expect(fetchImpl.mock.calls[1][0]).toContain(':5000/api/event');
134
134
  expect(socket.sent.some((frame) => frame.type === 'event' && frame.eventId === 'evt-2')).toBe(true);
135
+ expect(socket.sent.filter((frame) => frame.type === 'ready' && frame.scope === 'global')).toHaveLength(2);
135
136
 
136
137
  socket.close();
137
138
  await runtime.close();
@@ -182,8 +182,7 @@ export function createMessageStreamWsRuntime({
182
182
  * `Last-Event-ID` and re-establish the stream against the new port.
183
183
  */
184
184
  rebindUpstream() {
185
- globalHub.stop();
186
- globalHub.start();
185
+ globalHub.rebind();
187
186
  for (const socket of Array.from(directorySockets)) {
188
187
  try {
189
188
  socket.close(1012, 'OpenCode upstream restarted');
@@ -144,7 +144,7 @@ export function createUpstreamSseReader({
144
144
  response,
145
145
  });
146
146
  await cancelResponseBody(response);
147
- await waitForReconnectDelay(reconnectDelayMs, signal);
147
+ await waitForReconnectDelay(resolveTimeoutMs(reconnectDelayMs, DEFAULT_UPSTREAM_RECONNECT_DELAY_MS), signal);
148
148
  continue;
149
149
  }
150
150
 
@@ -219,7 +219,8 @@ export function createUpstreamSseReader({
219
219
  }
220
220
 
221
221
  if (!stopped && !signal?.aborted) {
222
- await waitForReconnectDelay(reconnectDelayMs, signal);
222
+ // A function is read before every wait, so a caller can back off between attempts.
223
+ await waitForReconnectDelay(resolveTimeoutMs(reconnectDelayMs, DEFAULT_UPSTREAM_RECONNECT_DELAY_MS), signal);
223
224
  }
224
225
  }
225
226
  })().finally(() => {
@@ -43,7 +43,7 @@ The following functions are exported and used by the web server:
43
43
  - `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). Inside the index mutation queue, the server verifies that the complete patch exactly matches one current three-context-line hunk for that file and scope, then runs `--check` before applying. Applicability alone cannot prove an unstaged change: old staged or committed hunks can reverse cleanly too. Stale, historical and multi-file patches fail with a refresh error. Temporary patch files are removed on success and failure; hunk content retains CRLF bytes.
44
44
 
45
45
  ### Branch Operations
46
- - `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
46
+ - `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. A source that is the branch's own remote copy (`git switch feat` records `Created from refs/remotes/origin/feat`) is not a parent either and also returns `null`. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
47
47
  - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
48
48
  - `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
49
49
  - `createBranch(directory, branchName, options)`: Create and checkout a new branch.
@@ -3097,6 +3097,21 @@ export function parseBranchCreationSource(reflogText) {
3097
3097
  return null;
3098
3098
  }
3099
3099
 
3100
+ async function isOwnRemoteCopy(git, source, branchName) {
3101
+ const fullName = await git
3102
+ .raw(['rev-parse', '--symbolic-full-name', source])
3103
+ .then((value) => String(value || '').trim())
3104
+ .catch(() => '');
3105
+ if (!fullName.startsWith('refs/remotes/')) return false;
3106
+ const upstream = await git
3107
+ .raw(['rev-parse', '--symbolic-full-name', `refs/heads/${branchName}@{upstream}`])
3108
+ .then((value) => String(value || '').trim())
3109
+ .catch(() => '');
3110
+ if (upstream && fullName === upstream) return true;
3111
+ // Upstream may be unset; a remote ref with the branch's own name is still its copy.
3112
+ return fullName.slice('refs/remotes/'.length).split('/').slice(1).join('/') === branchName;
3113
+ }
3114
+
3100
3115
  /**
3101
3116
  * Resolve the branch the given branch was created from, from its reflog.
3102
3117
  * Returns { base: null } when git has no authoritative record (clone, detached
@@ -3130,6 +3145,13 @@ export async function getBranchBase(directory, branch) {
3130
3145
  return { base: null };
3131
3146
  }
3132
3147
 
3148
+ // `git switch feat` from a remote branch records "Created from
3149
+ // refs/remotes/origin/feat": the branch's own remote copy, not a parent.
3150
+ // Comparing against it hides every pushed commit.
3151
+ if (await isOwnRemoteCopy(git, source, branchName)) {
3152
+ return { base: null };
3153
+ }
3154
+
3133
3155
  return { base: source };
3134
3156
  }
3135
3157
 
@@ -2316,6 +2316,14 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
2316
2316
  }
2317
2317
  });
2318
2318
 
2319
+ it('does not treat a branch checked out from its own remote copy as its base', async () => {
2320
+ const { repository } = createRepositoryWithRemote();
2321
+ runGit(repository, ['checkout', '-b', 'react', '--track', 'origin/react']);
2322
+ expect(await getBranchBase(repository, 'react')).toEqual({ base: null });
2323
+ runGit(repository, ['checkout', '--no-track', '-b', 'loose', 'origin/react']);
2324
+ expect(await getBranchBase(repository, 'loose')).toEqual({ base: 'origin/react' });
2325
+ });
2326
+
2319
2327
  it('asks for a new base after restacking and compares against the selected parent', async () => {
2320
2328
  const { repository } = createRepositoryWithRemote();
2321
2329
  runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
@@ -7,6 +7,7 @@ OpenChamber-owned install and static serve for iframe guests. This is not the Op
7
7
  ## Routes
8
8
 
9
9
  - `POST /api/guests/:id/storage` accepts the SDK's `GuestStorageRequest` and returns `GuestStorageResult`. It requires an installed, enabled, fully approved guest with either a panel or background entry, but no additional capability. The UI host supplies the frame's own identity. Guest-scoped asset tokens cannot call this route. Data lives beside `extensions.json` in `guest-storage/<id>.json`. Each key is 1 to 128 characters, each JSON value at most 64 KiB UTF-8, the whole namespace at most 2 MiB and 2,000 keys. `storage.js` owns serialized authorization/read/write/removal; writes are atomic and failures preserve prior bytes. `uninstallGuest` removes storage for every install source. Missing keys return `found: false`, including after removal; stored JSON null returns `found: true`.
10
+ - `GET /api/guests` also exposes `statusEntry` and optional `statusTitle` / `statusHeight` for `contributes.statusSection`, the extension's Work Status section. Its HTML and built scripts pass the same install checks, and it may be the package's only frame: the storage route and the asset route's `hasRuntime` accept `entry`, `backgroundEntry`, or `statusEntry`.
10
11
  - `GET /api/guests` also exposes `pageEntry` and optional `pageTitle` for `contributes.page`. Full-screen page HTML and its built scripts pass the same install checks as attach HTML. No new serving/auth path is required.
11
12
 
12
13
  - `GET /api/guests` returns installed guests. `attach` is `"panel"` or `"dialog"` when declared, and `attachEntry` is the dialog's separate HTML. `version` is the package semver. `integration` is the public slice: name, description, `auth`, and settings; OAuth URLs and tokens stay on the server. `service` includes `runtime`, optional `permissions`, optional `socketBindings`, and `granted`. `capabilities` carries `{ requested, granted }`, derived through the SDK's `requestedGuestCapabilities`. `actions`, `commands`, `tools`, and `filesystem` retain their parsed declarations; UI owns menu placement, command collisions, and tool precedence. `entry` is the optional visible panel and `backgroundEntry` is independent on-demand execution HTML. Background-only extensions get no rail surface; tools-only extensions with neither entry mount no frame. A guest without every requested grant is inactive until approval. `enabled: false` pauses it. A failed listing is 500, never an empty list.
@@ -30,13 +31,13 @@ OpenChamber-owned install and static serve for iframe guests. This is not the Op
30
31
  - `POST /api/guests/updates/check` — `{ force?: boolean }` → `{ updates: Record<guestId, { version }> }` for every git install whose origin now carries a newer `package.json` `version`. The check runs `git fetch --depth 1 origin <ref|HEAD>` inside the installed clone and reads `FETCH_HEAD:package.json` through the SDK manifest parser; the only signal is semver (`updates.js` `compareSemver`, prerelease below release), no commit hashes. Results sit in a memory cache per store file and guest id for `UPDATE_CHECK_TTL_MS` (one hour); `force` asks the network again. That cache is separate from the catalog cache, so a store write does not drop it. One guest's failed fetch never hides another's update. Folder and zip installs are never checked.
31
32
  - `POST /api/guests/:id/update` — replace the installed git copy with a fresh clone of its stored origin. Answers `{ guest }` (the refreshed catalog row) or 400 `{ error }` with `not-git` (folder, zip, or no stored origin), `clone-failed`, `invalid-manifest` (also when the clone declares a different panel id), `missing-build`, `host-too-old` (body may include `required`), or `swap-failed`. Unknown id is 404. Grants, tokens, settings, and the paused flag are keyed by guest id and stay as they were; a version that asks for more capabilities shows up as needs-approval in the catalog and goes through the usual review dialog.
32
33
  - `DELETE /api/guests/:id` — drops the store row, any service grant, and socket overrides; stops a running service. Path-install does not delete the user's folder. Zip and git copies under `{openchamberDataDir}/extensions/` are deleted. Unknown id is 404.
33
- - `GET /api/guests/:id/{*filePath}` serves package-local files. Unknown IDs, escapes, missing files, and unknown types return 404. HTML and JS require either `entry` or `backgroundEntry`, expressed by `resolveGuestServedFile`'s `hasRuntime` option. Tools-only extensions still serve their SVG icons but no executable content. Guest HTML propagates the scoped `oc_url_token` onto relative script/link/image URLs. The token is minted for `guest:<id>` and works only under that guest's asset route, never `/api/fs/raw` or event streams. Every response carries `Content-Security-Policy: sandbox allow-scripts`, including direct document navigation. The host serves built files from disk and never compiles guest sources.
34
+ - `GET /api/guests/:id/{*filePath}` serves package-local files. Unknown IDs, escapes, missing files, and unknown types return 404. HTML and JS require `entry`, `backgroundEntry`, or `statusEntry`, expressed by `resolveGuestServedFile`'s `hasRuntime` option. Tools-only extensions still serve their SVG icons but no executable content. Guest HTML propagates the scoped `oc_url_token` onto relative script/link/image URLs. The token is minted for `guest:<id>` and works only under that guest's asset route, never `/api/fs/raw` or event streams. Every response carries `Content-Security-Policy: sandbox allow-scripts`, including direct document navigation. The host serves built files from disk and never compiles guest sources.
34
35
 
35
36
  ## Install
36
37
 
37
38
  `contributes.background.entry` is optional package-local `.html`, separate from `panel.entry`. Install validates its HTML and every referenced built script using the same path confinement as visible panels. `backgroundEntry` is exposed on the catalog independently of `entry`; adding it does not create a rail icon or a visible page. Capabilities, services, integrations, and storage work with either execution entry. The SDK refuses visible contributions without `panel.entry` and all runtime contributions when neither entry exists. See `packages/sdk/DOCUMENTATION.md` for on-demand invocation ownership.
38
39
 
39
- `html-styles.js` appends the SDK's shared scrollbar stylesheet to served HTML, including requests without an asset token. This applies to existing panel, page, and attach bundles in direct and relay mode; the relay loader retains the inline style in `srcDoc`. Appending preserves the doctype and avoids rewriting tag-like text inside scripts or comments. Authored CSP stays intact and can block inline styling. Package files on disk and non-HTML responses are unchanged. Scrollbar colors follow the SDK theme variables inside the frame.
40
+ `html-styles.js` appends the SDK's shared scrollbar stylesheet and its small inline activity script (`GUEST_SCROLLBAR_SCRIPT`: shows a scrollbar while its element scrolls) to served HTML, including requests without an asset token. An authored CSP that blocks inline scripts leaves hover-only scrollbars. This applies to existing panel, page, and attach bundles in direct and relay mode; the relay loader retains the inline style in `srcDoc`. Appending preserves the doctype and avoids rewriting tag-like text inside scripts or comments. Authored CSP stays intact and can block inline styling. Package files on disk and non-HTML responses are unchanged. Scrollbar colors follow the SDK theme variables inside the frame.
40
41
 
41
42
  Built-ins ship with the app; user extensions are installed separately. Add a folder, local ZIP, HTTPS Git/ZIP URL, or SSH Git address from Settings → Extensions. The ZIP picker and drop zone upload the archive on web and remote desktop instances. A guest with a page must ship built classic-IIFE JavaScript, for example from `bunx openchamber-guest-bundle panel/main.ts panel/main.js`; the sandbox does not support ESM imports. `package.json` needs a semver version. A missing panel SVG or invalid manifest fails installation; page-less tools-only packages skip HTML/script checks. Optional `engines.openchamber` is checked against this host. Provider accounts are connected separately in Settings → Integrations.
42
43
 
@@ -63,7 +64,7 @@ A missing folder is omitted from the list. Other guests stay. A corrupt `extensi
63
64
  - `contributes.attach` is copied as `"panel"` or `"dialog"`. The composer + menu and New Worktree read that field. Omitted guests stay off those menus. The object form's `entry` (dialog only) is checked at install like `panel.entry`: missing HTML is `invalid-manifest`, an unbuilt script it loads is `missing-build`; the catalog exposes it as `attachEntry` and the attach window loads that page instead of `panel.entry`. The optional attach `data` payload lives only in the UI's chip, queued context part, and session metadata snapshot; no server route parses it.
64
65
  - `request` never leaves the declared `apiOrigin`. A 401 refreshes once. If that fails, tokens for that guest are dropped. Host Linear does not drop Linear auth on a failed GraphQL query. A paused guest never proxies `request` or `serviceRequest`.
65
66
  - A service with `provides: ["browser"]` is also driven by the host (`../browser-control/provider.js`), not only by a panel: `proxyGuestServiceRequest` takes `timeoutMs`, `responseMax`, `idleStopMs`, and `signal` for that. A service that never became ready is `SERVICE_FAILED` (nothing was sent); a request that was sent and got no usable answer is `REQUEST_FAILED` (the service may have acted on it), and callers word their errors accordingly. `idleStopMs` arms a per-guest timer re-armed at the end of every request; when it fires with no request in flight the process is discarded (not a Pause: no epoch bump) and the next request respawns it. A user-facing stop clears the timer. Panel requests pass none of these and keep the SDK limits. The guest routes call `onGuestDeactivated({ guestId, guestName })` after a pause, a removal, or a withdrawn approval so a host role the extension held is handed back and the user told.
66
- - `surface.js` hosts shared surfaces (`contributes.service.surface`). `/api/guests/:id/surface/ws` (URL-token WebSocket, allowlisted in `ui-auth.js` and `relay/tunnel-host.js`; upgrade refused with 404 unless `isSurfaceGuest`: enabled, fully approved, declares the surface) attaches a viewer to the extension's one session. The session holds the service (`holdGuestService`) and runs one pump: `GET /surface/frame?after=<seq>&wait=25000` through `openGuestServiceRequest`, newest frame kept, delivered per viewer only after that viewer acknowledged the previous one (`frame` text message + one binary message). Control is the session's: `none`, `agent` (set by `noteAgentActivity`, expires after `SURFACE_AGENT_HOLD_MS`), or a viewer id; the first deliberate input (anything but a button-less pointer move) from a viewer while nobody or the agent holds it takes it, `release` or the holder's disconnect gives it back, a second viewer's input is refused with a `control` message. Every change is broadcast (`mine` per connection) and posted to `POST /surface/control`. Input, control notices, resizes, and clipboard reads run through one promise chain per session, in socket order; work queued for an ended session is dropped, and an ended session's close handlers never call the service, so closing a panel after a pause, removal, or withdrawn approval cannot restart the process under the session's old authorization. When the last viewer leaves normally the session stays registered until its queue (including the final `none`) has drained, so a deactivation in that window still finds and cancels it; a viewer that reattaches meanwhile restarts the pump. Every hand-off (release, holder disconnect) bumps `session.handoffs`; an input batch remembers the count it arrived under and, if it no longer holds control, may take control only when nothing was handed off since, so a click queued behind a slow request cannot re-take control after the user let go, and a viewer that left (`gone`) never becomes the controller. `userControls` gates the browser provider; `endForGuest` (pause, removal, withdrawn approval) and a failed frame request end the session with an `ended` reason, and `stop()` closes every viewer on shutdown.
67
+ - `surface.js` hosts shared surfaces (`contributes.service.surface`). `/api/guests/:id/surface/ws` (URL-token WebSocket, allowlisted in `ui-auth.js` and `relay/tunnel-host.js`; upgrade refused with 404 unless `isSurfaceGuest`: enabled, fully approved, declares the surface) attaches a viewer to the extension's one session. The session holds the service (`holdGuestService`) and runs one pump: `GET /surface/frame?after=<seq>&wait=25000` through `openGuestServiceRequest`, newest frame kept, delivered per viewer only after that viewer acknowledged the previous one (`frame` text message + one binary message). Control is the session's: `none`, `agent` (set by `noteAgentActivity`, expires after `SURFACE_AGENT_HOLD_MS`), or a viewer id; the first deliberate input (anything but a button-less pointer move) from a viewer while nobody or the agent holds it takes it, `release` or the holder's disconnect gives it back, a second viewer's input is refused with a `control` message. Every change is broadcast (`mine` per connection) and posted to `POST /surface/control`. Input, control notices, resizes, and clipboard reads run through one promise chain per session, in socket order; work queued for an ended session is dropped, and an ended session's close handlers never call the service, so closing a panel after a pause, removal, or withdrawn approval cannot restart the process under the session's old authorization. When the last viewer leaves normally the session stays registered until its queue (including the final `none`) has drained, so a deactivation in that window still finds and cancels it; a viewer that reattaches meanwhile restarts the pump. Every hand-off (release, holder disconnect) bumps `session.handoffs`; an input batch remembers the count it arrived under and, if it no longer holds control, may take control only when nothing was handed off since, so a click queued behind a slow request cannot re-take control after the user let go, and a viewer that left (`gone`) never becomes the controller. Viewer ids are random UUIDs. Every input batch reaches the service with `x-surface-viewer` and `x-surface-frame-seq`: the service's own sequence number (kept apart from the host's bumped `seq`) of the frame that viewer last acknowledged, read when the batch arrives, not when the queue reaches it. A 409 answer becomes an `INPUT_STALE` error to that viewer. Control notices carry `viewer` for a user controller. `viewerHeaders(guestId, viewerId)` gives `/api/guests/:id/service/request` the same viewer, control, and frame headers when the panel names a live viewer of that extension (`viewerId` in the body, from `surface-viewers.ts` in the UI); an unknown id adds nothing. `userControls` gates the browser provider; `endForGuest` (pause, removal, withdrawn approval) and a failed frame request end the session with an `ended` reason, and `stop()` closes every viewer on shutdown.
67
68
  - `serviceRequest` never leaves that guest's loopback port. Auth to the service is `Authorization: Bearer` with a host-minted token. The service env is an allowlist (PATH, HOME, temp, locale, Windows system variables) plus `OPENCHAMBER_SERVICE_*`; host secrets are not inherited. There is no sandbox: an allowed service has the user's full access, and the grant card says so. Ready is `GET /health` → 200. Parallel first requests share one spawn; they must not kill each other mid-start. Host quit stops every service. Disabling a guest stops its service without clearing OAuth or grants.
68
69
  - File access is decided in `files.js` on canonical paths, never on the string the guest sent. `matchesFilesystemPattern`, `expandHome`, and `globToRegExp` are pure; `resolveGuestFilePath` and `runGuestFileOperation` take `fsPromises` / `nodePath` so tests can stub the OS. The project directory is whatever `resolveOptionalProjectDirectory` validated for this request, so a guest cannot name a project the UI did not open.
69
70
  - Update swap (`updates.js` `updateGuest`): the new clone lands in `{dataDir}/extensions/.tmp-<id>-<random>` and is inspected exactly like an install before anything else moves. Only then: stop the guest's service, rename current → `.old-<id>`, rename temp → final, delete `.old-<id>`. Any failure before the swap deletes the temp dir and leaves the install untouched; a failed second rename moves `.old-<id>` back. The store row is not rewritten (same path, same id), the catalog cache and that guest's update cache entry are dropped.
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import { hasGuestPage, requestedGuestCapabilities, resolveAttachEntry, resolveAttachMode, resolvePageEntry, toPublicService, toPublicIntegration, hostMeetsOpenChamberEngine, openChamberEngineMinimum } from '@openchamber/sdk';
4
+ import { hasGuestPage, requestedGuestCapabilities, resolveAttachEntry, resolveAttachMode, resolvePageEntry, resolveStatusSectionEntry, toPublicService, toPublicIntegration, hostMeetsOpenChamberEngine, openChamberEngineMinimum } from '@openchamber/sdk';
5
5
  import { parseManifestJson } from '@openchamber/sdk/schemas';
6
6
 
7
7
  import { listRelativeGuestScriptHrefs, resolveGuestHtmlRelativePath } from './html-tokens.js';
@@ -247,6 +247,21 @@ export const inspectGuestPackage = async (packageRoot, { openchamberVersion, ski
247
247
  const page = parsed.manifest.contributes.page;
248
248
  if (page !== true && page?.title) guest.pageTitle = page.title;
249
249
  }
250
+ // The Work Status section is checked like any other entry, and may be the
251
+ // package's only frame.
252
+ const statusEntry = resolveStatusSectionEntry(parsed.manifest.contributes);
253
+ if (statusEntry) {
254
+ if (!await resolveGuestAssetPath(packageRoot, statusEntry)) {
255
+ return { ok: false, code: 'invalid-manifest' };
256
+ }
257
+ if (!await guestBuiltScriptsReady(packageRoot, statusEntry)) {
258
+ return { ok: false, code: 'missing-build' };
259
+ }
260
+ guest.statusEntry = statusEntry;
261
+ const section = parsed.manifest.contributes.statusSection;
262
+ if (section !== true && section?.title) guest.statusTitle = section.title;
263
+ if (section !== true && section?.height !== undefined) guest.statusHeight = section.height;
264
+ }
250
265
  if (parsed.manifest.contributes.capabilities?.length) {
251
266
  guest.capabilities = [...parsed.manifest.contributes.capabilities];
252
267
  }
@@ -324,6 +339,9 @@ export const toPublicGuest = (guest) => {
324
339
  const attach = resolveAttachMode(guest.attach);
325
340
  if (guest.pageEntry) row.pageEntry = guest.pageEntry;
326
341
  if (guest.pageTitle) row.pageTitle = guest.pageTitle;
342
+ if (guest.statusEntry) row.statusEntry = guest.statusEntry;
343
+ if (guest.statusTitle) row.statusTitle = guest.statusTitle;
344
+ if (Number.isInteger(guest.statusHeight)) row.statusHeight = guest.statusHeight;
327
345
  if (attach) {
328
346
  row.attach = attach;
329
347
  }
@@ -257,6 +257,28 @@ describe('page-less packages', () => {
257
257
  expect(toPublicGuest(inspected.guest)).toMatchObject({ pageEntry: 'panel/page.html', pageTitle: 'Tasks' });
258
258
  } finally { await fs.rm(dir, { recursive: true, force: true }); }
259
259
  });
260
+ test('a status-section-only package validates its HTML and scripts and publishes title and height without a panel entry', async () => {
261
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-status-'));
262
+ try {
263
+ await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0', openchamber: {
264
+ apiVersion: 1, contributes: {
265
+ panel: { id: 'git-graph', name: 'Git graph', icon: 'git-commit' },
266
+ statusSection: { entry: 'status/index.html', title: 'Recent commits', height: 160 },
267
+ },
268
+ } }));
269
+ expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'invalid-manifest' });
270
+ await fs.mkdir(path.join(dir, 'status'));
271
+ await fs.writeFile(path.join(dir, 'status/index.html'), '<script src="main.js"></script>');
272
+ expect(await inspectGuestPackage(dir)).toMatchObject({ ok: false, code: 'missing-build' });
273
+ await fs.writeFile(path.join(dir, 'status/main.js'), 'console.log("status")');
274
+ const inspected = await inspectGuestPackage(dir);
275
+ expect(inspected.ok).toBe(true);
276
+ const row = toPublicGuest(inspected.guest);
277
+ expect(row).toMatchObject({ statusEntry: 'status/index.html', statusTitle: 'Recent commits', statusHeight: 160 });
278
+ expect(row).not.toHaveProperty('entry');
279
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
280
+ });
281
+
260
282
  test('installs a tools-only package without entry, omits entry from the row, and never serves it a frame', async () => {
261
283
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oc-guest-'));
262
284
  const guestRoot = path.join(dir, 'tools-only');
@@ -1,6 +1,8 @@
1
- import { GUEST_SCROLLBAR_CSS } from '@openchamber/sdk';
1
+ import { GUEST_SCROLLBAR_CSS, GUEST_SCROLLBAR_SCRIPT } from '@openchamber/sdk';
2
2
 
3
3
  /** Append instead of matching tags inside untrusted comments, scripts, or templates.
4
- * Browsers accept the style after the document; its doctype and authored CSP stay intact.
4
+ * Browsers accept the style and script after the document; its doctype and authored CSP stay intact.
5
+ * The script only marks scrolling elements so their scrollbars show while scrolling; an authored
6
+ * CSP that blocks inline scripts leaves the hover-only behavior of the stylesheet.
5
7
  */
6
- export const injectGuestDocumentStyles = (html) => `${html}\n<style data-openchamber-guest-styles>${GUEST_SCROLLBAR_CSS}</style>`;
8
+ export const injectGuestDocumentStyles = (html) => `${html}\n<style data-openchamber-guest-styles>${GUEST_SCROLLBAR_CSS}</style>\n<script data-openchamber-guest-scrollbar>${GUEST_SCROLLBAR_SCRIPT}</script>`;
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import express from 'express';
6
6
  import request from 'supertest';
7
- import { GUEST_SCROLLBAR_CSS } from '@openchamber/sdk';
7
+ import { GUEST_SCROLLBAR_CSS, GUEST_SCROLLBAR_SCRIPT } from '@openchamber/sdk';
8
8
  import { injectGuestDocumentStyles } from './html-styles.js';
9
9
  import { registerGuestRoutes } from './routes.js';
10
10
  import { writeExtensionStore } from './persist.js';
@@ -19,6 +19,7 @@ describe('guest document styles', () => {
19
19
  expect(decorated.startsWith(html)).toBe(true);
20
20
  expect(decorated).toContain(GUEST_SCROLLBAR_CSS);
21
21
  expect(decorated).toContain('data-openchamber-guest-styles');
22
+ expect(decorated).toContain(`<script data-openchamber-guest-scrollbar>${GUEST_SCROLLBAR_SCRIPT}</script>`);
22
23
  });
23
24
 
24
25
  test('serves scrollbar defaults to existing guests with or without an asset token', async () => {
@@ -36,6 +37,7 @@ describe('guest document styles', () => {
36
37
  expect(response.headers['content-security-policy']).toBe('sandbox allow-scripts');
37
38
  expect(response.headers['cache-control']).toBe('no-store');
38
39
  expect(response.text).toContain(GUEST_SCROLLBAR_CSS);
40
+ expect(response.text).toContain(GUEST_SCROLLBAR_SCRIPT);
39
41
  expect(response.text.startsWith('<!doctype html>')).toBe(true);
40
42
  expect(response.text).toContain(suffix ? 'main.js?oc_url_token=fixture-scope' : 'src="main.js"');
41
43
  }
@@ -88,6 +88,12 @@ const requestBodySchema = z.object({
88
88
  body: z.string().max(64_000).optional(),
89
89
  });
90
90
 
91
+ const serviceRequestBodySchema = requestBodySchema.extend({
92
+ // The shared-surface viewer open in the same window, if any. The host
93
+ // resolves it; an id that is not a live viewer of this extension is ignored.
94
+ viewerId: z.string().min(1).max(128).optional(),
95
+ });
96
+
91
97
  const fileBodySchema = z.object({
92
98
  op: z.enum(['read', 'write', 'list', 'stat']),
93
99
  path: z.string().min(1).max(GUEST_FILE_PATH_MAX),
@@ -196,6 +202,7 @@ export const registerGuestRoutes = (app, {
196
202
  resolveOptionalProjectDirectory,
197
203
  getSmallModelService,
198
204
  onGuestDeactivated = async () => false,
205
+ surfaceViewerHeaders = () => null,
199
206
  }) => {
200
207
  const persistPath = extensionsPersistPath(openchamberDataDir);
201
208
  const authPath = guestAuthPersistPath(openchamberDataDir);
@@ -547,7 +554,7 @@ export const registerGuestRoutes = (app, {
547
554
  if (!guest?.service) {
548
555
  return res.status(404).json({ error: 'not-found' });
549
556
  }
550
- const parsed = requestBodySchema.safeParse(req.body);
557
+ const parsed = serviceRequestBodySchema.safeParse(req.body);
551
558
  if (!parsed.success) {
552
559
  return res.status(400).json({ error: 'invalid-request' });
553
560
  }
@@ -562,6 +569,7 @@ export const registerGuestRoutes = (app, {
562
569
  path: parsed.data.path,
563
570
  query: parsed.data.query,
564
571
  body: parsed.data.body,
572
+ headers: parsed.data.viewerId ? surfaceViewerHeaders(guest.id, parsed.data.viewerId) ?? undefined : undefined,
565
573
  });
566
574
  res.json(result);
567
575
  } catch (error) {
@@ -580,7 +588,7 @@ export const registerGuestRoutes = (app, {
580
588
  try {
581
589
  const result = await runGuestStorage(persistPath, req.params.id, parsed.data, async () => {
582
590
  const guest = await loadGuest(req.params.id);
583
- if (!guest || guest.enabled === false || (!guest.entry && !guest.backgroundEntry)) throw new Error('Extension is unavailable.');
591
+ if (!guest || guest.enabled === false || (!guest.entry && !guest.backgroundEntry && !guest.statusEntry)) throw new Error('Extension is unavailable.');
584
592
  if (!requestedGuestCapabilities(guest).every((capability) => guest.capabilityGrants.includes(capability))) throw new Error('Extension needs approval.');
585
593
  });
586
594
  return res.json(result);
@@ -814,7 +822,7 @@ export const registerGuestRoutes = (app, {
814
822
  return res.status(404).end();
815
823
  }
816
824
  const served = await resolveGuestServedFile(guest.packageRoot, relativePath, {
817
- hasRuntime: Boolean(guest.entry || guest.backgroundEntry),
825
+ hasRuntime: Boolean(guest.entry || guest.backgroundEntry || guest.statusEntry),
818
826
  });
819
827
  if (!served) {
820
828
  return res.status(404).end();
@@ -552,6 +552,7 @@ const ensureGuestService = async (params) => {
552
552
  * query?: Record<string, string>,
553
553
  * body?: string,
554
554
  * accept?: string,
555
+ * headers?: Record<string, string>,
555
556
  * timeoutMs?: number,
556
557
  * idleStopMs?: number,
557
558
  * signal?: AbortSignal,
@@ -572,6 +573,7 @@ export const openGuestServiceRequest = async ({
572
573
  query,
573
574
  body,
574
575
  accept = 'application/json',
576
+ headers: extraHeaders,
575
577
  timeoutMs = GUEST_REQUEST_TIMEOUT_MS,
576
578
  idleStopMs,
577
579
  signal,
@@ -645,6 +647,9 @@ export const openGuestServiceRequest = async ({
645
647
 
646
648
  /** @type {Record<string, string>} */
647
649
  const headers = {
650
+ // Host-set context (surface viewer headers) first: nothing in it can
651
+ // replace the bearer or the content type below.
652
+ ...extraHeaders,
648
653
  Accept: accept,
649
654
  [OPENCHAMBER_SERVICE_AUTH_HEADER]: `Bearer ${runtime.token}`,
650
655
  };
@@ -20,6 +20,8 @@
20
20
  * holding it releases it.
21
21
  */
22
22
 
23
+ import { randomUUID } from 'node:crypto';
24
+
23
25
  import { WebSocketServer } from 'ws';
24
26
 
25
27
  import {
@@ -30,6 +32,7 @@ import {
30
32
  SURFACE_FRAME_MAX_BYTES,
31
33
  SURFACE_FRAME_MIMES,
32
34
  SURFACE_FRAME_PATH,
35
+ SURFACE_FRAME_SEQ_HEADER,
33
36
  SURFACE_FRAME_WAIT_MS,
34
37
  SURFACE_HEIGHT_HEADER,
35
38
  SURFACE_INPUT_PATH,
@@ -37,6 +40,8 @@ import {
37
40
  SURFACE_SEQ_HEADER,
38
41
  SURFACE_TITLE_HEADER,
39
42
  SURFACE_TITLE_MAX,
43
+ SURFACE_VIEWER_CONTROLS_HEADER,
44
+ SURFACE_VIEWER_HEADER,
40
45
  SURFACE_WIDTH_HEADER,
41
46
  isGuestApproved,
42
47
  requestedGuestCapabilities,
@@ -97,7 +102,9 @@ export const createGuestSurfaceRuntime = ({
97
102
  idleStopMs,
98
103
  openServiceRequest = openGuestServiceRequest,
99
104
  holdService = holdGuestService,
100
- createViewerId = () => `viewer-${Math.random().toString(36).slice(2, 10)}`,
105
+ // Unguessable: a page call that names a viewer is told whether that viewer
106
+ // holds control, so the id works as the viewer's lease.
107
+ createViewerId = () => `viewer-${randomUUID()}`,
101
108
  setTimer = setTimeout,
102
109
  clearTimer = clearTimeout,
103
110
  }) => {
@@ -109,13 +116,15 @@ export const createGuestSurfaceRuntime = ({
109
116
  * id: string,
110
117
  * ws: import('ws').WebSocket,
111
118
  * sentSeq: number,
119
+ * sentServiceSeq: number,
120
+ * shownServiceSeq: number,
112
121
  * awaitingAck: boolean,
113
122
  * }} Viewer
114
123
  * @typedef {{
115
124
  * guestId: string,
116
125
  * guest: object,
117
126
  * viewers: Map<string, Viewer>,
118
- * latest: { seq: number, width: number, height: number, mime: string, title?: string, agentActive: boolean, bytes: Buffer } | null,
127
+ * latest: { seq: number, serviceSeq: number, width: number, height: number, mime: string, title?: string, agentActive: boolean, bytes: Buffer } | null,
119
128
  * controller: 'none' | 'agent' | string,
120
129
  * agentTimer: ReturnType<typeof setTimeout> | null,
121
130
  * pumpAbort: AbortController | null,
@@ -164,13 +173,13 @@ export const createGuestSurfaceRuntime = ({
164
173
  return run;
165
174
  };
166
175
 
167
- const notifyService = async (session, controller) => {
176
+ const notifyService = async (session, controller, viewerId) => {
168
177
  if (session.ended) return;
169
178
  try {
170
179
  const { response, finished } = await serviceRequest(session, {
171
180
  method: 'POST',
172
181
  path: SURFACE_CONTROL_PATH,
173
- body: JSON.stringify({ controller }),
182
+ body: JSON.stringify(controller === 'user' && viewerId ? { controller, viewer: viewerId } : { controller }),
174
183
  timeoutMs: QUICK_REQUEST_TIMEOUT_MS,
175
184
  });
176
185
  await response.arrayBuffer().catch(() => undefined);
@@ -186,7 +195,8 @@ export const createGuestSurfaceRuntime = ({
186
195
  for (const viewer of session.viewers.values()) {
187
196
  send(viewer, { type: 'control', controller: kind, mine: session.controller === viewer.id });
188
197
  }
189
- void enqueue(session, () => notifyService(session, kind));
198
+ const holder = kind === 'user' ? session.controller : undefined;
199
+ void enqueue(session, () => notifyService(session, kind, holder));
190
200
  };
191
201
 
192
202
  const clearAgentTimer = (session) => {
@@ -208,6 +218,7 @@ export const createGuestSurfaceRuntime = ({
208
218
  if (!frame || viewer.awaitingAck || viewer.sentSeq >= frame.seq || viewer.ws.readyState !== 1) return;
209
219
  viewer.awaitingAck = true;
210
220
  viewer.sentSeq = frame.seq;
221
+ viewer.sentServiceSeq = frame.serviceSeq;
211
222
  const meta = {
212
223
  type: 'frame',
213
224
  seq: frame.seq,
@@ -260,6 +271,9 @@ export const createGuestSurfaceRuntime = ({
260
271
  const title = (response.headers.get(SURFACE_TITLE_HEADER) || '').slice(0, SURFACE_TITLE_MAX);
261
272
  const frame = {
262
273
  seq,
274
+ // The service's own number, kept when `seq` is bumped below: input is
275
+ // stamped with it, and only the service knows which view it belongs to.
276
+ serviceSeq: seq,
263
277
  width,
264
278
  height,
265
279
  mime,
@@ -357,7 +371,7 @@ export const createGuestSurfaceRuntime = ({
357
371
  * control back: the user already let go, and re-taking it from a queued
358
372
  * click would lock the agent out with nobody to press the button.
359
373
  */
360
- const handleInput = async (session, viewer, events, handoff) => {
374
+ const handleInput = async (session, viewer, events, handoff, frameSeq) => {
361
375
  if (viewer.gone) return;
362
376
  if (session.controller !== viewer.id) {
363
377
  if (session.controller !== 'none' && session.controller !== 'agent') {
@@ -373,10 +387,20 @@ export const createGuestSurfaceRuntime = ({
373
387
  method: 'POST',
374
388
  path: SURFACE_INPUT_PATH,
375
389
  body: JSON.stringify({ events }),
390
+ headers: {
391
+ [SURFACE_VIEWER_HEADER]: viewer.id,
392
+ [SURFACE_FRAME_SEQ_HEADER]: String(frameSeq),
393
+ },
376
394
  timeoutMs: QUICK_REQUEST_TIMEOUT_MS,
377
395
  });
378
396
  await response.arrayBuffer().catch(() => undefined);
379
397
  finished();
398
+ // 409: made on a picture the service no longer shows. Nothing reached
399
+ // the page, and the next frame shows the viewer what is there now.
400
+ if (response.status === 409) {
401
+ send(viewer, { type: 'error', code: 'INPUT_STALE', message: 'The picture changed before this input arrived; it was not applied.' });
402
+ return;
403
+ }
380
404
  if (response.status < 200 || response.status >= 300) {
381
405
  send(viewer, { type: 'error', code: 'INPUT_REJECTED', message: `The extension refused this input (HTTP ${response.status}).` });
382
406
  }
@@ -445,7 +469,8 @@ export const createGuestSurfaceRuntime = ({
445
469
  const handleViewerMessage = (session, viewer, raw) => {
446
470
  let parsed;
447
471
  try {
448
- parsed = surfaceViewerMessageSchema.safeParse(JSON.parse(String(raw)));
472
+ // Node's `ws` delivers a Buffer, Bun's a Uint8Array; both decode here.
473
+ parsed = surfaceViewerMessageSchema.safeParse(JSON.parse(Buffer.from(raw).toString('utf8')));
449
474
  } catch {
450
475
  parsed = { success: false };
451
476
  }
@@ -458,12 +483,17 @@ export const createGuestSurfaceRuntime = ({
458
483
  case 'ack':
459
484
  if (message.seq === viewer.sentSeq) {
460
485
  viewer.awaitingAck = false;
486
+ // Acknowledged after drawing: this is the picture its next input is made on.
487
+ viewer.shownServiceSeq = viewer.sentServiceSeq;
461
488
  deliverLatest(session, viewer);
462
489
  }
463
490
  return;
464
491
  case 'input': {
465
492
  const handoff = session.handoffs;
466
- void enqueue(session, () => handleInput(session, viewer, message.events, handoff));
493
+ // Read on arrival, not when the queue reaches the batch: the socket
494
+ // delivers acks and input in the order the viewer drew and acted.
495
+ const frameSeq = viewer.shownServiceSeq;
496
+ void enqueue(session, () => handleInput(session, viewer, message.events, handoff, frameSeq));
467
497
  return;
468
498
  }
469
499
  case 'release':
@@ -485,7 +515,7 @@ export const createGuestSurfaceRuntime = ({
485
515
 
486
516
  const attachViewer = (guestId, guest, ws) => {
487
517
  const session = ensureSession(guestId, guest);
488
- const viewer = { id: createViewerId(), ws, sentSeq: 0, awaitingAck: false, gone: false };
518
+ const viewer = { id: createViewerId(), ws, sentSeq: 0, sentServiceSeq: 0, shownServiceSeq: 0, awaitingAck: false, gone: false };
489
519
  session.viewers.set(viewer.id, viewer);
490
520
  // Started with the first viewer in place: the pump's loop condition is
491
521
  // the viewer count, and it is checked before the first await.
@@ -609,6 +639,22 @@ export const createGuestSurfaceRuntime = ({
609
639
  if (session) endSession(session, 'extension-unavailable');
610
640
  },
611
641
 
642
+ /**
643
+ * Headers for a page `serviceRequest` made from the window that holds
644
+ * `viewerId`, or null when that id is not a live viewer of this
645
+ * extension (then the request goes out without any).
646
+ */
647
+ viewerHeaders(guestId, viewerId) {
648
+ const session = sessions.get(guestId);
649
+ const viewer = session && !session.ended ? session.viewers.get(viewerId) : undefined;
650
+ if (!viewer || viewer.gone) return null;
651
+ return {
652
+ [SURFACE_VIEWER_HEADER]: viewer.id,
653
+ [SURFACE_VIEWER_CONTROLS_HEADER]: session.controller === viewer.id ? '1' : '0',
654
+ [SURFACE_FRAME_SEQ_HEADER]: String(viewer.shownServiceSeq),
655
+ };
656
+ },
657
+
612
658
  /** Test seam. */
613
659
  sessionOf(guestId) {
614
660
  return sessions.get(guestId) ?? null;