@vantran-se/openchamber-web 2.0.4 → 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 (243) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  2. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  3. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  4. package/dist/assets/{GitView-DwQwqDvu.js → GitView-BmibZC7n.js} +4 -4
  5. package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  6. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  7. package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-BNT2yUVu.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-D3Azksju.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  11. package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-D_41H2qC.js} +1 -1
  12. package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-C0RSqa9W.js} +1 -1
  13. package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  14. package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-Bqc-xuDe.js} +4 -4
  15. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  16. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  17. package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DuC4ecLw.js} +1 -1
  18. package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  20. package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  21. package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  22. package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-BJicp0RC.js} +2 -2
  23. package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  24. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  25. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  26. package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  27. package/dist/assets/de-BMnfXm-G.js +10 -0
  28. package/dist/assets/es-DKEvCCL6.js +16 -0
  29. package/dist/assets/fr-D9fgE2xE.js +14 -0
  30. package/dist/assets/index-DUke1nme.css +1 -0
  31. package/dist/assets/{index-OKDYjcMF.js → index-DWB8bR_g.js} +1 -1
  32. package/dist/assets/index-Di6zUe5h.js +41 -0
  33. package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  34. package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-bt5VHvcd.js} +1 -1
  35. package/dist/assets/ja-BEXad-KW.js +16 -0
  36. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  37. package/dist/assets/{main-CGEvLMd2.js → main-BpLQbZC2.js} +2 -2
  38. package/dist/assets/main-CI9DtHJv.js +896 -0
  39. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  40. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  41. package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-CIWaR8wG.js} +2 -2
  42. package/dist/assets/{mobile-C5KRTNOB.js → mobile-CnWKEYIP.js} +2 -2
  43. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  44. package/dist/assets/pl-BlhkmR5b.js +16 -0
  45. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  46. package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  47. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  48. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  49. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  50. package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-l-MbaNS4.js} +1 -1
  51. package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  52. package/dist/assets/tr-CehUK1M9.js +7 -0
  53. package/dist/assets/uk-Bno5WGNz.js +16 -0
  54. package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  55. package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  56. package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  57. package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  58. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  59. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  60. package/dist/assets/{web-components-BEo4-ISu.js → web-components-Bc1WPd4i.js} +1 -1
  61. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  62. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  63. package/dist/index.html +5 -5
  64. package/dist/mini-chat.html +5 -5
  65. package/dist/mobile.html +5 -5
  66. package/package.json +3 -3
  67. package/server/index.js +82 -0
  68. package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
  69. package/server/lib/agent-tool/runtime.js +34 -15
  70. package/server/lib/agent-tool/runtime.test.js +15 -0
  71. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  72. package/server/lib/event-stream/global-hub.js +23 -1
  73. package/server/lib/event-stream/global-hub.test.js +35 -0
  74. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  75. package/server/lib/event-stream/upstream-reader.js +3 -2
  76. package/server/lib/git/DOCUMENTATION.md +1 -1
  77. package/server/lib/git/service.js +22 -0
  78. package/server/lib/git/service.test.js +8 -0
  79. package/server/lib/guests/DOCUMENTATION.md +4 -3
  80. package/server/lib/guests/catalog.js +19 -1
  81. package/server/lib/guests/catalog.test.js +22 -0
  82. package/server/lib/guests/html-styles.js +5 -3
  83. package/server/lib/guests/html-styles.test.js +3 -1
  84. package/server/lib/guests/routes.js +11 -3
  85. package/server/lib/guests/service.js +5 -0
  86. package/server/lib/guests/surface.js +55 -9
  87. package/server/lib/guests/surface.test.js +56 -6
  88. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  89. package/server/lib/notifications/emit-route.js +118 -0
  90. package/server/lib/notifications/emit-route.test.js +112 -0
  91. package/server/lib/openchamber-control/actions.js +16 -2
  92. package/server/lib/openchamber-control/service.js +23 -0
  93. package/server/lib/openchamber-control/service.test.js +31 -0
  94. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  95. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  96. package/server/lib/openchamber-sessions/routes.js +11 -0
  97. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  98. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  99. package/server/lib/opencode/compatibility.js +6 -3
  100. package/server/lib/opencode/compatibility.test.js +8 -0
  101. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  102. package/server/lib/opencode/config-paths.test.js +12 -0
  103. package/server/lib/opencode/config-v2.d.ts +2 -0
  104. package/server/lib/opencode/config-v2.js +42 -0
  105. package/server/lib/opencode/config-v2.test.js +17 -0
  106. package/server/lib/opencode/core-routes.js +20 -4
  107. package/server/lib/opencode/core-routes.test.js +41 -0
  108. package/server/lib/opencode/env-runtime.js +1 -8
  109. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  110. package/server/lib/opencode/lifecycle.js +30 -2
  111. package/server/lib/opencode/lifecycle.test.js +27 -0
  112. package/server/lib/opencode/managed-config-file.js +18 -6
  113. package/server/lib/opencode/managed-config-file.test.js +23 -7
  114. package/server/lib/opencode/mcp.js +7 -2
  115. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  116. package/server/lib/opencode/npm-registry-config.js +123 -0
  117. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  118. package/server/lib/opencode/npm-registry.js +5 -6
  119. package/server/lib/opencode/npm-registry.test.js +30 -0
  120. package/server/lib/opencode/openchamber-routes.js +29 -11
  121. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  122. package/server/lib/opencode/project-directory-runtime.js +8 -0
  123. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  124. package/server/lib/opencode/provider-source-route.test.js +59 -0
  125. package/server/lib/opencode/providers.js +3 -0
  126. package/server/lib/opencode/providers.test.js +62 -0
  127. package/server/lib/opencode/proxy.js +38 -2
  128. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  129. package/server/lib/opencode/routes.js +36 -1
  130. package/server/lib/opencode/server-utils-runtime.js +5 -0
  131. package/server/lib/opencode/settings-helpers.js +10 -0
  132. package/server/lib/opencode/settings-helpers.test.js +2 -2
  133. package/server/lib/opencode/settings-registry.json +17 -0
  134. package/server/lib/opencode/settings-runtime.js +1 -0
  135. package/server/lib/opencode/shared.js +49 -4
  136. package/server/lib/opencode/shared.test.js +78 -1
  137. package/server/lib/opencode/shutdown-runtime.js +3 -0
  138. package/server/lib/opencode/skill-routes.js +28 -12
  139. package/server/lib/opencode/skill-routes.test.js +121 -1
  140. package/server/lib/opencode/upgrade-capability.js +0 -14
  141. package/server/lib/opencode/v1-migration-topup.js +21 -15
  142. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  143. package/server/lib/opencode/v2-install.d.ts +1 -0
  144. package/server/lib/opencode/v2-install.js +8 -7
  145. package/server/lib/opencode/v2-install.test.js +13 -4
  146. package/server/lib/opencode/watcher.js +3 -1
  147. package/server/lib/opencode/websearch-config.js +13 -1
  148. package/server/lib/quota/providers/kimi.js +1 -1
  149. package/server/lib/realtime-proxy.js +5 -1
  150. package/server/lib/realtime-proxy.test.js +29 -0
  151. package/server/lib/relay/tunnel-host.js +4 -1
  152. package/server/lib/relay/tunnel-host.test.js +11 -0
  153. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  154. package/server/lib/session-assist/runtime.js +19 -2
  155. package/server/lib/session-assist/runtime.test.js +8 -0
  156. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  157. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  158. package/server/lib/small-model/index.js +27 -32
  159. package/server/lib/small-model/index.test.js +28 -16
  160. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  161. package/server/lib/spaces/code-in-bait.js +145 -6
  162. package/server/lib/spaces/code-in.js +10 -150
  163. package/server/lib/spaces/code-in.test.js +7 -83
  164. package/server/lib/spaces/code-out.js +1819 -0
  165. package/server/lib/spaces/code-out.test.js +3945 -0
  166. package/server/lib/spaces/code-transfer.js +173 -0
  167. package/server/lib/spaces/command-stream.test.js +181 -0
  168. package/server/lib/spaces/dispatcher.js +670 -0
  169. package/server/lib/spaces/dispatcher.test.js +582 -0
  170. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  171. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  172. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  173. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  174. package/server/lib/spaces/hardening.js +20 -4
  175. package/server/lib/spaces/hardening.test.js +18 -3
  176. package/server/lib/spaces/host-git.js +7 -4
  177. package/server/lib/spaces/host-git.test.js +6 -2
  178. package/server/lib/spaces/host.js +310 -0
  179. package/server/lib/spaces/host.test.js +273 -0
  180. package/server/lib/spaces/journey.js +552 -0
  181. package/server/lib/spaces/journey.test.js +589 -0
  182. package/server/lib/spaces/layout.js +42 -8
  183. package/server/lib/spaces/manager.js +4 -3
  184. package/server/lib/spaces/manager.test.js +6 -0
  185. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  186. package/server/lib/spaces/places/contract-suite.js +36 -0
  187. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  188. package/server/lib/spaces/places/docker-live-support.js +27 -4
  189. package/server/lib/spaces/places/docker.js +33 -5
  190. package/server/lib/spaces/places/docker.test.js +42 -3
  191. package/server/lib/spaces/places/escape-suite.js +118 -14
  192. package/server/lib/spaces/places/fake-docker.js +3 -1
  193. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  194. package/server/lib/spaces/places/memory-place.js +24 -1
  195. package/server/lib/spaces/places/registry.js +1 -1
  196. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  197. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  198. package/server/lib/spaces/routes.js +216 -0
  199. package/server/lib/spaces/routes.test.js +213 -0
  200. package/server/lib/spaces/run-command.js +261 -8
  201. package/server/lib/spaces/run-command.test.js +193 -3
  202. package/server/lib/spaces/space-events.js +153 -0
  203. package/server/lib/spaces/space-events.test.js +160 -0
  204. package/server/lib/spaces/space-opencode.js +52 -0
  205. package/server/lib/spaces/space-opencode.test.js +41 -0
  206. package/server/lib/spaces/space-records.js +150 -0
  207. package/server/lib/spaces/space-records.test.js +113 -0
  208. package/server/lib/spaces/space-sessions.js +210 -0
  209. package/server/lib/spaces/space-sessions.test.js +161 -0
  210. package/server/lib/spaces/websocket.js +329 -0
  211. package/server/lib/spaces/websocket.test.js +345 -0
  212. package/server/lib/ui-auth/ui-auth.js +8 -2
  213. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  214. package/server/opencode-proxy.test.js +113 -0
  215. package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
  216. package/dist/assets/FilesView-BeTtbsip.js +0 -17
  217. package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
  218. package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
  219. package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
  220. package/dist/assets/PluginPane-B39JEUL8.js +0 -3
  221. package/dist/assets/SettingsView-13B2_G-A.js +0 -29
  222. package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
  223. package/dist/assets/de-BtYnUgII.js +0 -10
  224. package/dist/assets/es-crP5qPye.js +0 -16
  225. package/dist/assets/fr-CLSRuALk.js +0 -14
  226. package/dist/assets/index-CLcM8HKx.css +0 -1
  227. package/dist/assets/index-CvLgnuNI.js +0 -41
  228. package/dist/assets/ja-SZ1-ucLV.js +0 -16
  229. package/dist/assets/ko-HthbT_Ve.js +0 -16
  230. package/dist/assets/main-BJklC7Cl.js +0 -896
  231. package/dist/assets/markdownCore-BzQuETm0.js +0 -5
  232. package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
  233. package/dist/assets/pl-C8eYfjL1.js +0 -16
  234. package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
  235. package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
  236. package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
  237. package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
  238. package/dist/assets/tr-B4IQ5t-T.js +0 -7
  239. package/dist/assets/uk-DCoXG6ve.js +0 -16
  240. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  241. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  242. package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
  243. package/dist/assets/zh-TW-C6oelpnL.js +0 -16
@@ -35,8 +35,9 @@ const createFakeService = () => {
35
35
  };
36
36
  const nextFrameAfter = (seq) => frames.find((frame) => frame.seq > seq) ?? null;
37
37
  const completed = [];
38
- const openServiceRequest = async ({ path, method, body, query, signal }) => {
39
- calls.push({ path, method, body: body ? JSON.parse(body) : undefined, query });
38
+ let inputStatus = 204;
39
+ const openServiceRequest = async ({ path, method, body, query, headers, signal }) => {
40
+ calls.push({ path, method, body: body ? JSON.parse(body) : undefined, query, headers });
40
41
  if (failWith) throw failWith;
41
42
  if (path === SURFACE_INPUT_PATH && inputGate) {
42
43
  const gate = inputGate;
@@ -75,6 +76,8 @@ const createFakeService = () => {
75
76
  response = Response.json({ width: 640, height: 400 });
76
77
  } else if (path === SURFACE_CLIPBOARD_PATH) {
77
78
  response = Response.json({ text: 'copied inside' });
79
+ } else if (path === SURFACE_INPUT_PATH) {
80
+ response = new Response(null, { status: inputStatus });
78
81
  } else {
79
82
  response = new Response(null, { status: 204 });
80
83
  }
@@ -85,6 +88,7 @@ const createFakeService = () => {
85
88
  completed,
86
89
  pushFrame,
87
90
  gateNextInput: (promise) => { inputGate = promise; },
91
+ answerInputWith: (status) => { inputStatus = status; },
88
92
  fail: (error) => { failWith = error; for (const wake of waiters.splice(0)) wake(); },
89
93
  openServiceRequest,
90
94
  callsTo: (path) => calls.filter((call) => call.path === path),
@@ -101,7 +105,8 @@ const openViewer = (port, guestId = 'sim') => new Promise((resolve, reject) => {
101
105
  const inbox = [];
102
106
  const waiting = [];
103
107
  ws.on('message', (data, isBinary) => {
104
- const item = isBinary ? { binary: Buffer.from(data) } : JSON.parse(data.toString());
108
+ // Bun's `ws` hands text frames over as a Uint8Array, not a Buffer.
109
+ const item = isBinary ? { binary: Buffer.from(data) } : JSON.parse(Buffer.from(data).toString('utf8'));
105
110
  const waiter = waiting.shift();
106
111
  if (waiter) waiter(item);
107
112
  else inbox.push(item);
@@ -214,13 +219,13 @@ describe('guest surface runtime', () => {
214
219
  await settle();
215
220
  expect(runtime.userControls('sim')).toBe(true);
216
221
  expect(service.callsTo(SURFACE_INPUT_PATH)[0].body).toEqual({ events: [click] });
217
- expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }]);
222
+ expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }]);
218
223
 
219
224
  viewer.send({ type: 'release' });
220
225
  expect(await viewer.next()).toEqual({ type: 'control', controller: 'none', mine: false });
221
226
  await settle();
222
227
  expect(runtime.userControls('sim')).toBe(false);
223
- expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }, { controller: 'none' }]);
228
+ expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }, { controller: 'none' }]);
224
229
  });
225
230
 
226
231
  test('a second viewer cannot take control from the first and is told who has it', async () => {
@@ -367,7 +372,7 @@ describe('guest surface runtime', () => {
367
372
  viewer.ws.close();
368
373
  await viewer.closed;
369
374
  await settle();
370
- expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user' }, { controller: 'none' }]);
375
+ expect(service.callsTo(SURFACE_CONTROL_PATH).map((call) => call.body)).toEqual([{ controller: 'user', viewer: 'viewer-1' }, { controller: 'none' }]);
371
376
  });
372
377
 
373
378
  test('a queued input from before a hand-back does not take control again', async () => {
@@ -483,4 +488,49 @@ describe('guest surface runtime', () => {
483
488
  await again.closed;
484
489
  expect(runtime.sessionOf('sim')).toBeNull();
485
490
  });
491
+
492
+ test('stamps input with the viewer and the service frame it last drew', async () => {
493
+ const { port, service, runtime } = await createHarness();
494
+ const viewer = await openViewer(port);
495
+ await viewer.next(); // hello
496
+ await viewer.next(); // control
497
+ const click = { type: 'pointer', action: 'down', x: 1, y: 1, button: 0, buttons: 1, modifiers: { alt: false, ctrl: false, meta: false, shift: false } };
498
+
499
+ // Nothing drawn yet: frame 0.
500
+ viewer.send({ type: 'input', events: [click] });
501
+ await settle();
502
+ expect(service.callsTo(SURFACE_INPUT_PATH)[0].headers).toEqual({ 'x-surface-viewer': 'viewer-1', 'x-surface-frame-seq': '0' });
503
+ expect(service.callsTo(SURFACE_CONTROL_PATH).at(-1).body).toEqual({ controller: 'user', viewer: 'viewer-1' });
504
+ await viewer.next(); // control: mine
505
+
506
+ service.pushFrame({ seq: 7, bytes: Buffer.from('frame-7') });
507
+ await viewer.next();
508
+ await viewer.next();
509
+ // Sent but not yet drawn: input still counts as made on the old picture.
510
+ viewer.send({ type: 'input', events: [click] });
511
+ viewer.send({ type: 'ack', seq: 7 });
512
+ viewer.send({ type: 'input', events: [click] });
513
+ await settle();
514
+ const stamped = service.callsTo(SURFACE_INPUT_PATH).map((call) => call.headers['x-surface-frame-seq']);
515
+ expect(stamped).toEqual(['0', '0', '7']);
516
+
517
+ expect(runtime.viewerHeaders('sim', 'viewer-1')).toEqual({ 'x-surface-viewer': 'viewer-1', 'x-surface-viewer-controls': '1', 'x-surface-frame-seq': '7' });
518
+ expect(runtime.viewerHeaders('sim', 'viewer-2')).toBeNull();
519
+ expect(runtime.viewerHeaders('other', 'viewer-1')).toBeNull();
520
+
521
+ viewer.send({ type: 'release' });
522
+ await settle();
523
+ expect(runtime.viewerHeaders('sim', 'viewer-1')['x-surface-viewer-controls']).toBe('0');
524
+ });
525
+
526
+ test('tells the viewer when the service refuses input made on an old picture', async () => {
527
+ const { port, service } = await createHarness();
528
+ const viewer = await openViewer(port);
529
+ await viewer.next();
530
+ await viewer.next();
531
+ service.answerInputWith(409);
532
+ viewer.send({ type: 'input', events: [{ type: 'text', text: 'hi' }] });
533
+ await viewer.next(); // control: mine
534
+ expect(await viewer.next()).toMatchObject({ type: 'error', code: 'INPUT_STALE' });
535
+ });
486
536
  });
@@ -9,6 +9,7 @@ This module provides notification message preparation utilities for the web serv
9
9
  - `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
10
10
  - `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`.
11
11
  - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
12
+ - `packages/web/server/lib/notifications/emit-route.js`: `POST /api/notifications/emit` for OpenCode plugins.
12
13
  - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
13
14
  - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
14
15
  - `packages/web/server/lib/notifications/message.js`: helper implementation module.
@@ -82,6 +83,15 @@ This module provides notification message preparation utilities for the web serv
82
83
  - `emitDesktopNotification(payload)`
83
84
  - `broadcastUiNotification(payload)`
84
85
 
86
+ ### Plugin notification route (emit-route.js)
87
+ - `createPluginNotificationEmitter(dependencies)` owns validation, the rate window and delivery; one instance is shared by the HTTP route and the `openchamber_notify` agent tool (`notify.send`).
88
+ - `registerNotificationEmitRoutes(app, dependencies)` returns `registerPluginRoute()` and `registerApiRoute()`. Bootstrap calls the first before API auth and the second after it.
89
+ - Callers: a plugin in the managed OpenCode (agent-tool bearer token, the only reason for the pre-auth registration) or any client that passes regular API auth. No other auth path exists.
90
+ - Body `{ title?, body?, tag?, sessionId?, directory?, showWhenFocused? }`, at least one of title/body; title ≤120, body ≤500 chars, 16kb JSON. `kind` is always `plugin` so callers cannot trigger kinds the UI treats specially.
91
+ - One shared rate window: 10 notifications per 10s, then 429 with `Retry-After`.
92
+ - Follows `nativeNotificationsEnabled` (returns `{ delivered: false }`) and `notificationMode` (`requireHidden`); `showWhenFocused: true` sends `requireHidden: false` so the notice shows even while OpenChamber is focused. Live delivery only: no web push or APNs fanout.
93
+ - Advertised as the `api.notifications.emit.v1` server capability.
94
+
85
95
  ### Template runtime API (template-runtime.js)
86
96
  - `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime. Model-backed summarization was retired after the Zen provider became unavailable.
87
97
  - Returned API:
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+
3
+ const NOTIFICATION_EMIT_PATH = '/api/notifications/emit';
4
+
5
+ const RATE_WINDOW_MS = 10_000;
6
+ const RATE_MAX_PER_WINDOW = 10;
7
+
8
+ const optionalText = (max) => z.string().trim().min(1).max(max).optional();
9
+
10
+ const emitBodySchema = z.object({
11
+ title: optionalText(120),
12
+ body: optionalText(500),
13
+ tag: optionalText(128),
14
+ sessionId: optionalText(128),
15
+ directory: optionalText(4096),
16
+ // Opt-in for notices that matter even while OpenChamber is in front.
17
+ showWhenFocused: z.boolean().optional(),
18
+ }).refine((value) => value.title || value.body, { message: 'title or body required' });
19
+
20
+ /**
21
+ * Raises a notification on behalf of a plugin or the agent, through the same
22
+ * live delivery the built-in triggers use. `kind` is always `plugin`, so a
23
+ * caller cannot impersonate kinds the UI treats specially. One rate window is
24
+ * shared by every caller, the HTTP route and the agent tool alike.
25
+ *
26
+ * `emit` resolves to `{ status, body }`: 200 delivered or skipped by settings,
27
+ * 400 invalid input, 429 rate limited (with `retryAfter` seconds).
28
+ */
29
+ export const createPluginNotificationEmitter = (dependencies) => {
30
+ const {
31
+ readSettingsFromDiskMigrated,
32
+ emitDesktopNotification,
33
+ broadcastUiNotification,
34
+ now = Date.now,
35
+ } = dependencies;
36
+
37
+ let windowStartedAt = 0;
38
+ let windowCount = 0;
39
+
40
+ const takeRateSlot = () => {
41
+ const current = now();
42
+ if (current - windowStartedAt >= RATE_WINDOW_MS) {
43
+ windowStartedAt = current;
44
+ windowCount = 0;
45
+ }
46
+ if (windowCount >= RATE_MAX_PER_WINDOW) {
47
+ return Math.ceil((windowStartedAt + RATE_WINDOW_MS - current) / 1000);
48
+ }
49
+ windowCount += 1;
50
+ return 0;
51
+ };
52
+
53
+ const emit = async (input) => {
54
+ const parsed = emitBodySchema.safeParse(input);
55
+ if (!parsed.success) {
56
+ return { status: 400, body: { error: parsed.error.issues[0]?.message ?? 'invalid notification' } };
57
+ }
58
+
59
+ const retryAfter = takeRateSlot();
60
+ if (retryAfter > 0) {
61
+ return { status: 429, retryAfter, body: { error: 'too many notifications' } };
62
+ }
63
+
64
+ const settings = await readSettingsFromDiskMigrated();
65
+ if (settings.nativeNotificationsEnabled === false) {
66
+ return { status: 200, body: { delivered: false, reason: 'notifications-disabled' } };
67
+ }
68
+
69
+ const payload = {
70
+ title: parsed.data.title ?? 'OpenChamber',
71
+ body: parsed.data.body,
72
+ tag: parsed.data.tag,
73
+ kind: 'plugin',
74
+ sessionId: parsed.data.sessionId,
75
+ directory: parsed.data.directory,
76
+ requireHidden: parsed.data.showWhenFocused !== true && settings.notificationMode !== 'always',
77
+ };
78
+ const desktopNotificationDelivered = emitDesktopNotification(payload) === true;
79
+ broadcastUiNotification(payload, { desktopNotificationDelivered });
80
+ return { status: 200, body: { delivered: true } };
81
+ };
82
+
83
+ return { emit };
84
+ };
85
+
86
+ /**
87
+ * `POST /api/notifications/emit`. Two callers are accepted: a plugin inside
88
+ * the managed OpenCode (the agent-tool bearer token, checked before API auth)
89
+ * and any client that passes the regular API auth.
90
+ */
91
+ export const registerNotificationEmitRoutes = (app, dependencies) => {
92
+ const { express, isAgentToolRequestAuthorized, emitter } = dependencies;
93
+
94
+ const jsonBody = express.json({ limit: '16kb' });
95
+
96
+ const respond = async (req, res, next) => {
97
+ try {
98
+ const result = await emitter.emit(req.body);
99
+ if (result.retryAfter) res.setHeader('Retry-After', String(result.retryAfter));
100
+ return res.status(result.status).json(result.body);
101
+ } catch (error) {
102
+ return next(error);
103
+ }
104
+ };
105
+
106
+ return {
107
+ // Registered before API auth: a managed plugin carries no UI session.
108
+ registerPluginRoute: () => {
109
+ app.post(NOTIFICATION_EMIT_PATH, jsonBody, (req, res, next) => (
110
+ isAgentToolRequestAuthorized(req) ? respond(req, res, next) : next()
111
+ ));
112
+ },
113
+ // Registered after API auth: everything else is an ordinary API caller.
114
+ registerApiRoute: () => {
115
+ app.post(NOTIFICATION_EMIT_PATH, jsonBody, respond);
116
+ },
117
+ };
118
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import express from 'express';
3
+ import request from 'supertest';
4
+ import { createPluginNotificationEmitter, registerNotificationEmitRoutes } from './emit-route.js';
5
+
6
+ const PLUGIN_TOKEN = 'Bearer plugin-token';
7
+ const UI_TOKEN = 'Bearer ui-token';
8
+
9
+ const createApp = ({ settings = { nativeNotificationsEnabled: true, notificationMode: 'hidden-only' }, now = () => 0 } = {}) => {
10
+ const delivered = [];
11
+ const app = express();
12
+ const emitter = createPluginNotificationEmitter({
13
+ readSettingsFromDiskMigrated: async () => settings,
14
+ emitDesktopNotification: () => false,
15
+ broadcastUiNotification: (payload, options) => delivered.push({ payload, options }),
16
+ now,
17
+ });
18
+ const routes = registerNotificationEmitRoutes(app, {
19
+ express,
20
+ isAgentToolRequestAuthorized: (req) => req.headers.authorization === PLUGIN_TOKEN,
21
+ emitter,
22
+ });
23
+ routes.registerPluginRoute();
24
+ // Stands in for the server's API auth gate between the two registrations.
25
+ app.use('/api', (req, res, next) => (
26
+ req.headers.authorization === UI_TOKEN ? next() : res.status(401).json({ error: 'UI session missing' })
27
+ ));
28
+ routes.registerApiRoute();
29
+ return { app, delivered };
30
+ };
31
+
32
+ describe('POST /api/notifications/emit', () => {
33
+ it('delivers a plugin notification without a UI session', async () => {
34
+ const { app, delivered } = createApp();
35
+ const response = await request(app)
36
+ .post('/api/notifications/emit')
37
+ .set('Authorization', PLUGIN_TOKEN)
38
+ .send({ title: ' Build done ', body: 'Ready', sessionId: 'ses_1' });
39
+
40
+ expect(response.status).toBe(200);
41
+ expect(response.body).toEqual({ delivered: true });
42
+ expect(delivered).toEqual([{
43
+ payload: {
44
+ title: 'Build done',
45
+ body: 'Ready',
46
+ tag: undefined,
47
+ kind: 'plugin',
48
+ sessionId: 'ses_1',
49
+ directory: undefined,
50
+ requireHidden: true,
51
+ },
52
+ options: { desktopNotificationDelivered: false },
53
+ }]);
54
+ });
55
+
56
+ it('accepts an ordinary API caller and rejects an unauthenticated one', async () => {
57
+ const { app, delivered } = createApp();
58
+ await request(app).post('/api/notifications/emit').set('Authorization', UI_TOKEN).send({ body: 'Hi' }).expect(200);
59
+ await request(app).post('/api/notifications/emit').send({ body: 'Hi' }).expect(401);
60
+ await request(app).post('/api/notifications/emit').set('Authorization', 'Bearer wrong').send({ body: 'Hi' }).expect(401);
61
+ expect(delivered.map(({ payload }) => payload.title)).toEqual(['OpenChamber']);
62
+ });
63
+
64
+ it('never lets a caller choose the notification kind', async () => {
65
+ const { app, delivered } = createApp();
66
+ await request(app)
67
+ .post('/api/notifications/emit')
68
+ .set('Authorization', PLUGIN_TOKEN)
69
+ .send({ title: 'x', kind: 'opencode-restart-interrupted' })
70
+ .expect(200);
71
+ expect(delivered[0].payload.kind).toBe('plugin');
72
+ });
73
+
74
+ it('rejects empty and oversized payloads', async () => {
75
+ const { app, delivered } = createApp();
76
+ const post = (body) => request(app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send(body);
77
+ await post({}).expect(400);
78
+ await post({ title: ' ' }).expect(400);
79
+ await post({ title: 'x'.repeat(121) }).expect(400);
80
+ await post({ body: 'x'.repeat(501) }).expect(400);
81
+ expect(delivered).toEqual([]);
82
+ });
83
+
84
+ it('limits the rate and recovers after the window', async () => {
85
+ let clock = 0;
86
+ const { app, delivered } = createApp({ now: () => clock });
87
+ const post = () => request(app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'tick' });
88
+ for (let index = 0; index < 10; index += 1) await post().expect(200);
89
+ const limited = await post();
90
+ expect(limited.status).toBe(429);
91
+ expect(limited.headers['retry-after']).toBe('10');
92
+ clock = 10_000;
93
+ await post().expect(200);
94
+ expect(delivered).toHaveLength(11);
95
+ });
96
+
97
+ it('follows the notifications setting and the always mode', async () => {
98
+ const off = createApp({ settings: { nativeNotificationsEnabled: false } });
99
+ const skipped = await request(off.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x' });
100
+ expect(skipped.body).toEqual({ delivered: false, reason: 'notifications-disabled' });
101
+ expect(off.delivered).toEqual([]);
102
+
103
+ const always = createApp({ settings: { nativeNotificationsEnabled: true, notificationMode: 'always' } });
104
+ await request(always.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x' }).expect(200);
105
+ expect(always.delivered[0].payload.requireHidden).toBe(false);
106
+
107
+ const focused = createApp();
108
+ await request(focused.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x', showWhenFocused: true }).expect(200);
109
+ expect(focused.delivered[0].payload.requireHidden).toBe(false);
110
+ await request(focused.app).post('/api/notifications/emit').set('Authorization', PLUGIN_TOKEN).send({ body: 'x', showWhenFocused: 'yes' }).expect(400);
111
+ });
112
+ });
@@ -38,8 +38,8 @@ export const OPENCHAMBER_AGENT_TOOL_ACTIONS = Object.freeze(
38
38
  );
39
39
 
40
40
  export const OPENCHAMBER_WEB_ACTION_DEFINITIONS = Object.freeze([
41
- { action: 'browser.open', title: 'Open a page in the browser panel', description: 'Open url in the in-app browser panel; use it to look at the running app. Set viewport to mobile, tablet or desktop to lay the page out at that size' },
42
- { action: 'browser.snapshot', title: 'Read the open page', description: 'Read the open page: url, title, visible text, and interactive elements with the selectors the other browser actions accept. Pass selector to read only that part of a long page. Reports any errors the page logged' },
41
+ { action: 'browser.open', title: 'Open a page in the browser panel', description: 'Open url in the in-app browser panel; use it to look at the running app. Without tabId it opens a new background tab and answers with its tabId: pass that tabId to the following actions to keep working there. With tabId it loads url in that tab. Set viewport to mobile, tablet or desktop to lay the page out at that size' },
42
+ { action: 'browser.snapshot', title: 'Read the open page', description: 'Read the open page: url, title, visible text, and interactive elements with the selectors the other browser actions accept. Pass selector to read only that part of a long page. Reports any errors the page logged, and lists the open tabs with the ids tabId accepts' },
43
43
  { action: 'browser.click', title: 'Click on the open page', description: 'Click an element; give selector, or text to match a link or button by its visible label' },
44
44
  { action: 'browser.type', title: 'Type into the open page', description: 'Type value into the field matched by selector; set submit to press Enter afterwards' },
45
45
  { action: 'browser.scroll', title: 'Scroll the open page', description: 'Scroll the page; direction is up, down, top, or bottom, or pass selector to bring one element into view' },
@@ -76,6 +76,18 @@ export const OPENCHAMBER_MEMORY_ACTIONS = Object.freeze(
76
76
  OPENCHAMBER_MEMORY_ACTION_DEFINITIONS.map(({ action }) => action),
77
77
  );
78
78
 
79
+ /**
80
+ * Notify is its own tool so it can stay off by default and disappear
81
+ * completely: an agent that can page the user is a choice the user makes.
82
+ */
83
+ export const OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS = Object.freeze([
84
+ { action: 'notify.send', title: 'Notify the user', description: 'Send the user a notification; requires title, body is optional. By default it appears only while the user is away from OpenChamber; set showWhenFocused only when it cannot wait' },
85
+ ]);
86
+
87
+ export const OPENCHAMBER_NOTIFY_ACTIONS = Object.freeze(
88
+ OPENCHAMBER_NOTIFY_ACTION_DEFINITIONS.map(({ action }) => action),
89
+ );
90
+
79
91
  /**
80
92
  * Which actions each managed tool may ask for.
81
93
  *
@@ -90,6 +102,7 @@ const ACTIONS_BY_TOOL = Object.freeze({
90
102
  openchamber: OPENCHAMBER_AGENT_TOOL_ACTIONS,
91
103
  openchamber_web: OPENCHAMBER_WEB_ACTIONS,
92
104
  openchamber_memory: OPENCHAMBER_MEMORY_ACTIONS,
105
+ openchamber_notify: OPENCHAMBER_NOTIFY_ACTIONS,
93
106
  });
94
107
 
95
108
  const bareName = (action) => {
@@ -136,4 +149,5 @@ export const OPENCHAMBER_ALL_ACTIONS = Object.freeze([
136
149
  ...OPENCHAMBER_CONTROL_ACTIONS,
137
150
  ...OPENCHAMBER_WEB_ACTIONS,
138
151
  ...OPENCHAMBER_MEMORY_ACTIONS,
152
+ ...OPENCHAMBER_NOTIFY_ACTIONS,
139
153
  ]);
@@ -154,6 +154,7 @@ export const createOpenChamberControlService = (dependencies) => {
154
154
  scheduledTaskService,
155
155
  browserControl = null,
156
156
  fileOpen = null,
157
+ notifyUser = null,
157
158
  agentMemoryActions = null,
158
159
  // Archive lives in OpenChamber's own store now — v2 has no route that sets
159
160
  // Session.time.archived — so an unwired store simply means nothing is archived.
@@ -365,6 +366,12 @@ export const createOpenChamberControlService = (dependencies) => {
365
366
  */
366
367
  const browserAction = async (action, input, signal, contextDirectory, contextSessionId) => {
367
368
  const parameters = {};
369
+ // Any action may name a tab; the browser that issued the id resolves it.
370
+ const tabId = asNonEmptyString(input.tabId);
371
+ if (tabId) {
372
+ if (tabId.length > 128) throw new OpenChamberControlError('tabId must be an id from browser.snapshot tabs', 400);
373
+ parameters.tabId = tabId;
374
+ }
368
375
 
369
376
  const readViewport = (required) => {
370
377
  const viewport = asNonEmptyString(input.viewport);
@@ -511,6 +518,22 @@ export const createOpenChamberControlService = (dependencies) => {
511
518
  }
512
519
  return browserAction(action, input, options.signal, contextDirectory, options.contextSessionId);
513
520
  }
521
+ if (action === 'notify.send') {
522
+ if (!notifyUser) {
523
+ throw new OpenChamberControlError('Notifications are not available on this server', 503);
524
+ }
525
+ const result = await notifyUser({
526
+ title: input.title,
527
+ body: input.body,
528
+ showWhenFocused: input.showWhenFocused,
529
+ sessionId: asNonEmptyString(options.contextSessionId) || undefined,
530
+ directory: asNonEmptyString(contextDirectory) || undefined,
531
+ });
532
+ if (result.status !== 200) {
533
+ throw new OpenChamberControlError(result.body.error, result.status);
534
+ }
535
+ return result.body;
536
+ }
514
537
  if (action === 'file.open') {
515
538
  if (!fileOpen) {
516
539
  throw new OpenChamberControlError('The file viewer is not available on this server', 503);
@@ -294,6 +294,31 @@ describe('file.open', () => {
294
294
  });
295
295
  });
296
296
 
297
+ describe('notify.send', () => {
298
+ it('sends the notice for the calling session and returns what was delivered', async () => {
299
+ const notifyUser = vi.fn(async () => ({ status: 200, body: { delivered: true } }));
300
+ const { service } = createService({ notifyUser });
301
+
302
+ const result = await service.execute('notify.send', { title: 'Done', body: 'All green', showWhenFocused: true }, '/repo', { contextSessionId: 'ses_1' });
303
+
304
+ expect(notifyUser).toHaveBeenCalledWith({ title: 'Done', body: 'All green', showWhenFocused: true, sessionId: 'ses_1', directory: '/repo' });
305
+ expect(result).toEqual({ delivered: true });
306
+ });
307
+
308
+ it('turns a refused notice into an error the agent can read', async () => {
309
+ const notifyUser = vi.fn(async () => ({ status: 429, retryAfter: 4, body: { error: 'too many notifications' } }));
310
+ const { service } = createService({ notifyUser });
311
+
312
+ await expect(service.execute('notify.send', { title: 'Done' }, '/repo'))
313
+ .rejects.toMatchObject({ statusCode: 429, message: 'too many notifications' });
314
+ });
315
+
316
+ it('answers 503 when this server has no notifier wired', async () => {
317
+ const { service } = createService({});
318
+ await expect(service.execute('notify.send', { title: 'Done' }, '/repo')).rejects.toMatchObject({ statusCode: 503 });
319
+ });
320
+ });
321
+
297
322
  describe('browser capture', () => {
298
323
  const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
299
324
 
@@ -338,6 +363,12 @@ describe('browser capture', () => {
338
363
  await expect(service.execute('browser.capture', {})).rejects.toThrow(/directory is required/);
339
364
  });
340
365
 
366
+ it('passes the tab the agent named to the browser', async () => {
367
+ const { service, directory, request } = await createBrowserService({ base64: pixel, mime: 'image/png' });
368
+ await service.execute('browser.capture', { tabId: ' tab-2 ' }, directory);
369
+ expect(request).toHaveBeenCalledWith('browser.capture', { tabId: 'tab-2' }, expect.anything());
370
+ });
371
+
341
372
  it('passes a label through to the browser and leaves other actions untouched', async () => {
342
373
  const { service, directory, request } = await createBrowserService({ base64: pixel, mime: 'image/png' });
343
374
  await service.execute('browser.capture', { label: 'before' }, directory);
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * What a fork made through the OpenChamber sessions API (CLI, scheduled tasks)
5
+ * keeps from its source session's OpenChamber metadata. Server twin of the UI's
6
+ * `packages/ui/src/lib/sessionForkInheritance.ts`: UI forks call OpenCode
7
+ * directly and repair themselves; forks made here are repaired here.
8
+ *
9
+ * OpenCode 2.x copies the source's `metadata` wholesale into the fork, so the
10
+ * goal already arrives. Two repairs remain:
11
+ *
12
+ * - Source-owned links: the source's btw fork (`btwSessionID`), its review
13
+ * session (`reviewSessionID`), and the btw marker when the source is itself a
14
+ * btw side thread.
15
+ * - A file-backed goal objective: the text lives in a file keyed by the session
16
+ * id, so the fork needs its own copy. When the copy cannot be written the
17
+ * fork's goal carries the text inline instead.
18
+ */
19
+
20
+ const NamespaceSchema = z.looseObject({
21
+ btwSessionID: z.unknown().optional(),
22
+ reviewSessionID: z.unknown().optional(),
23
+ kind: z.unknown().optional(),
24
+ goal: z.unknown().optional(),
25
+ });
26
+ const FileBackedGoalSchema = z.looseObject({ id: z.string().min(1), objectiveFile: z.literal(true) });
27
+
28
+ const openChamberNamespace = (metadata) => {
29
+ const parsed = z.looseObject({ openchamber: NamespaceSchema }).safeParse(metadata);
30
+ return parsed.success ? parsed.data.openchamber : null;
31
+ };
32
+
33
+ /** Merge patch (RFC 7386) removing source-owned links; null when there are none. */
34
+ export const sourceOwnedLinksPatch = (metadata) => {
35
+ const namespace = openChamberNamespace(metadata);
36
+ if (!namespace) return null;
37
+ const patch = {};
38
+ for (const key of ['btwSessionID', 'reviewSessionID']) {
39
+ if (key in namespace) patch[key] = null;
40
+ }
41
+ if (namespace.kind === 'btw') {
42
+ for (const key of ['kind', 'originalSessionID', 'btwBoundaryMessageID']) {
43
+ if (key in namespace) patch[key] = null;
44
+ }
45
+ }
46
+ // A fork must not pursue the source's goal in parallel: an active goal
47
+ // arrives paused and the user resumes it (same rule as UI forks).
48
+ const goal = namespace.goal;
49
+ if (goal && typeof goal === 'object' && !Array.isArray(goal) && goal.status === 'active') {
50
+ patch.goal = { status: 'paused', statusReason: 'paused in fork' };
51
+ }
52
+ return Object.keys(patch).length > 0 ? patch : null;
53
+ };
54
+
55
+ const fileBackedGoal = (metadata) => {
56
+ const parsed = FileBackedGoalSchema.safeParse(openChamberNamespace(metadata)?.goal);
57
+ return parsed.success ? parsed.data : null;
58
+ };
59
+
60
+ /**
61
+ * Repair a freshly created fork. Best-effort: the fork already exists and is
62
+ * usable, so every failure is logged and swallowed.
63
+ *
64
+ * @param {object} options
65
+ * @param {string} options.sourceSessionID
66
+ * @param {{ id: string, metadata?: object }} options.fork The fork record OpenCode returned.
67
+ * @param {(sessionID: string) => Promise<string | null>} options.readObjective
68
+ * @param {(sessionID: string, content: string) => Promise<unknown>} options.writeObjective Throws on failure.
69
+ * @param {(sessionID: string, patch: object) => Promise<unknown>} options.writeMetadata
70
+ */
71
+ export const applyForkInheritance = async ({ sourceSessionID, fork, readObjective, writeObjective, writeMetadata }) => {
72
+ const metadata = fork?.metadata;
73
+ const goal = fileBackedGoal(metadata);
74
+ let inlineObjective = null;
75
+ if (goal) {
76
+ try {
77
+ const content = await readObjective(sourceSessionID);
78
+ if (content === null) {
79
+ console.warn('[fork] source goal objective unavailable; fork keeps the inline fallback');
80
+ } else {
81
+ try {
82
+ await writeObjective(fork.id, content);
83
+ } catch (error) {
84
+ console.warn('[fork] failed to copy the goal objective file; inlining it', error?.message ?? error);
85
+ inlineObjective = content;
86
+ }
87
+ }
88
+ } catch (error) {
89
+ console.warn('[fork] failed to read the source goal objective', error?.message ?? error);
90
+ }
91
+ }
92
+
93
+ const namespacePatch = sourceOwnedLinksPatch(metadata) ?? {};
94
+ if (inlineObjective !== null) {
95
+ namespacePatch.goal = { ...namespacePatch.goal, objective: inlineObjective, objectiveFile: false };
96
+ }
97
+ if (Object.keys(namespacePatch).length === 0) return;
98
+ try {
99
+ await writeMetadata(fork.id, { openchamber: namespacePatch });
100
+ } catch (error) {
101
+ console.warn('[fork] failed to update the fork metadata', error?.message ?? error);
102
+ }
103
+ };