@openchambery/web 1.19.20 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -319,11 +319,23 @@ export const createSessionGoalRuntime = ({
319
319
  * timers for the session tree (root + descendants).
320
320
  */
321
321
  onGoalPaused,
322
+ /**
323
+ * OpenChamber session-metadata store seams. When both are functions, goal
324
+ * state is read/written only through the Host store (OpenCode 2.x has no
325
+ * session-metadata PATCH). When either is missing, keep the legacy OpenCode
326
+ * PATCH path so existing tests and unwired servers still work.
327
+ */
328
+ persistSessionGoal = null,
329
+ readSessionMetadata = null,
322
330
  }) => {
323
331
  const timers = new Map();
324
332
  const inflight = new Set();
325
333
  let stopped = false;
326
334
 
335
+ const isMetadataStoreWired = () => (
336
+ typeof persistSessionGoal === 'function' && typeof readSessionMetadata === 'function'
337
+ );
338
+
327
339
  const clearTimer = (sessionId) => {
328
340
  const existing = timers.get(sessionId);
329
341
  if (existing) {
@@ -383,7 +395,28 @@ export const createSessionGoalRuntime = ({
383
395
  // metadata writes (assist payloads, dismissals, UI goal edits) survive.
384
396
  // Returns the written goal, or null when the stored goal no longer matches
385
397
  // the expected id (user replaced/cleared it while we worked).
398
+ /**
399
+ * When the Host metadata store is wired, the goal lives there — OpenCode 2.x
400
+ * accepts session metadata only at create time. Re-read before every write so
401
+ * a concurrent edit (the user pausing from the UI) is not overwritten.
402
+ */
403
+ const readGoal = async (sessionId, directory) => {
404
+ if (isMetadataStoreWired()) {
405
+ return parseGoalMetadata({ metadata: await readSessionMetadata(sessionId) });
406
+ }
407
+ const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
408
+ return parseGoalMetadata(session);
409
+ };
410
+
386
411
  const writeGoal = async (sessionId, directory, expectedGoalId, mutate) => {
412
+ if (isMetadataStoreWired()) {
413
+ const currentGoal = await readGoal(sessionId, directory);
414
+ if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
415
+ const nextGoal = { ...currentGoal, ...mutate(currentGoal), updatedAt: Date.now() };
416
+ await persistSessionGoal(sessionId, directory, nextGoal);
417
+ return nextGoal;
418
+ }
419
+
387
420
  const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
388
421
  const currentGoal = parseGoalMetadata(session);
389
422
  if (!currentGoal || currentGoal.id !== expectedGoalId) return null;
@@ -537,7 +570,9 @@ export const createSessionGoalRuntime = ({
537
570
  // Sub-agent/task sessions never carry user goals — skip them.
538
571
  if (typeof session.parentID === 'string' && session.parentID) return;
539
572
 
540
- const goal = parseGoalMetadata(session);
573
+ const goal = isMetadataStoreWired()
574
+ ? await readGoal(sessionId, directory)
575
+ : parseGoalMetadata(session);
541
576
  if (!goal || goal.status !== 'active') return;
542
577
 
543
578
  // File-backed objectives: the metadata carries only a flag; the objective
@@ -835,7 +870,9 @@ export const createSessionGoalRuntime = ({
835
870
  // auto-delegate timers are cancelled (abort path used to no-op on non-active).
836
871
  const pauseAfterAbort = async (sessionId, directory) => {
837
872
  const owner = await resolveGoalOwner(sessionId, directory);
838
- const goal = parseGoalMetadata(owner.session);
873
+ const goal = isMetadataStoreWired()
874
+ ? await readGoal(owner.sessionId, owner.directory)
875
+ : parseGoalMetadata(owner.session);
839
876
  if (!goal) return;
840
877
  if (goal.status === 'paused') {
841
878
  if (!isQuestionDrivenPause(goal)) {
@@ -857,9 +894,14 @@ export const createSessionGoalRuntime = ({
857
894
  // from a child/sub-agent (any depth) pauses the root owner session's goal.
858
895
  const pauseForQuestion = async (sessionId, directory) => {
859
896
  const owner = await resolveGoalOwner(sessionId, directory);
860
- if (!owner.session) return;
897
+ // When the Host store is wired, goal state does not live on the OpenCode
898
+ // record — owner.session may be null only if the session itself is missing.
899
+ if (!isMetadataStoreWired() && !owner.session) return;
900
+ if (isMetadataStoreWired() && !owner.sessionId) return;
861
901
  clearTimer(owner.sessionId);
862
- const goal = parseGoalMetadata(owner.session);
902
+ const goal = isMetadataStoreWired()
903
+ ? await readGoal(owner.sessionId, owner.directory)
904
+ : parseGoalMetadata(owner.session);
863
905
  if (!goal || goal.status !== 'active') return;
864
906
  await writeGoal(owner.sessionId, owner.directory, goal.id, () => ({
865
907
  status: 'paused',
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'bun:test';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { accountGoalTokenSpend, createSessionGoalRuntime, messageTokenSpend } from './runtime.js';
3
3
 
4
4
  // A session whose last assistant message is an orphaned incomplete turn: the
@@ -641,3 +641,64 @@ describe('session-goal runtime — pause for question', () => {
641
641
  });
642
642
  });
643
643
  });
644
+
645
+ describe('session-goal runtime — Host metadata store seams', () => {
646
+ it('writes through persistSessionGoal and never PATCHes OpenCode metadata when seams are injected', async () => {
647
+ const active = buildSession('active');
648
+ const goal = active.metadata.openchamber.goal;
649
+ const metadata = {
650
+ openchamber: {
651
+ goal: { ...goal },
652
+ assist: { recap: 'keep-me' },
653
+ },
654
+ };
655
+ const readSessionMetadata = vi.fn(async () => metadata);
656
+ const persistSessionGoal = vi.fn(async (_sessionId, _directory, nextGoal) => {
657
+ metadata.openchamber = { ...metadata.openchamber, goal: nextGoal };
658
+ });
659
+ const fetchMock = vi.fn(async (input, init = {}) => {
660
+ const url = String(input);
661
+ const path = url.split('?')[0];
662
+ if (init.method === 'PATCH') {
663
+ throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${path}`);
664
+ }
665
+ // resolveGoalOwner walks parentID via GET /session/:id
666
+ if (path.endsWith('/session/ses-goal')) {
667
+ return new Response(JSON.stringify({ id: 'ses-goal', directory: '/repo' }), { status: 200 });
668
+ }
669
+ return new Response(JSON.stringify({}), { status: 200 });
670
+ });
671
+
672
+ await withFetch(fetchMock, async () => {
673
+ const runtime = createSessionGoalRuntime({
674
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
675
+ getOpenCodeAuthHeaders: () => ({}),
676
+ getSmallModelService: async () => {
677
+ throw new Error('small model must not run');
678
+ },
679
+ idleQuietMs: 1_000_000,
680
+ kickoffQuietMs: 1,
681
+ maxAutoTurns: 20,
682
+ readSessionMetadata,
683
+ persistSessionGoal,
684
+ });
685
+
686
+ await runtime.pauseForQuestion('ses-goal', '/repo');
687
+
688
+ expect(readSessionMetadata).toHaveBeenCalledWith('ses-goal');
689
+ expect(persistSessionGoal).toHaveBeenCalledTimes(1);
690
+ expect(persistSessionGoal.mock.calls[0][0]).toBe('ses-goal');
691
+ expect(persistSessionGoal.mock.calls[0][1]).toBe('/repo');
692
+ expect(persistSessionGoal.mock.calls[0][2]).toMatchObject({
693
+ id: goal.id,
694
+ status: 'paused',
695
+ statusReason: 'paused for question',
696
+ });
697
+ // Neighbouring Host namespaces must survive the goal write (caller owns
698
+ // merge; this seam only receives the goal object).
699
+ expect(metadata.openchamber.assist).toEqual({ recap: 'keep-me' });
700
+ expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
701
+ runtime.stop();
702
+ });
703
+ });
704
+ });
@@ -117,21 +117,14 @@ export const createSessionIndexSyncRuntime = ({
117
117
 
118
118
  const fetchDirectory = async (task) => {
119
119
  const cached = readDirectory(task.directory);
120
- // Empty snapshots are not incrementally eligible. A successful [] still
121
- // stores lastSyncedAt as a worktree topology hint; using that watermark as
122
- // start would hide older historical roots until the 24h full reconcile.
123
- const useIncremental = Boolean(
124
- (cached?.sessions?.length ?? 0) > 0
125
- && cached?.lastSyncedAt > 0
126
- && now() - cached.lastFullSyncedAt < FULL_RECONCILE_INTERVAL_MS
127
- );
128
- // Keep parity with @opencode-ai/sdk/v2 experimental.session.list. The
129
- // regular /session endpoint has different pagination semantics.
130
- const url = new URL(buildOpenCodeUrl('/experimental/session'));
120
+ // v2 `session.list` (`GET /api/session`) exposes cursor pagination only —
121
+ // no timestamp-incremental (`start`) or `roots` filter. Sync therefore
122
+ // always fetches a fresh full page and lets replaceDirectory reconcile.
123
+ // Empty snapshots still store lastSyncedAt as a worktree topology hint.
124
+ const useIncremental = false;
125
+ const url = new URL(buildOpenCodeUrl('/session'));
131
126
  url.searchParams.set('directory', task.directory);
132
- url.searchParams.set('roots', 'true');
133
127
  url.searchParams.set('limit', String(SESSION_LIMIT));
134
- if (useIncremental) url.searchParams.set('start', String(cached.lastSyncedAt));
135
128
 
136
129
  const controller = new AbortController();
137
130
  currentController = controller;
@@ -148,7 +141,10 @@ export const createSessionIndexSyncRuntime = ({
148
141
  error.status = response.status;
149
142
  throw error;
150
143
  }
151
- const sessions = await response.json();
144
+ const payload = await response.json();
145
+ // v2 responds `{ data: Session[], cursor }`; tolerate a bare array for
146
+ // non-v2 test doubles.
147
+ const sessions = Array.isArray(payload) ? payload : payload?.data;
152
148
  if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
153
149
  const nextSessions = useIncremental
154
150
  ? mergeIncrementalSessions(cached?.sessions, sessions)
@@ -187,7 +183,10 @@ export const createSessionIndexSyncRuntime = ({
187
183
  currentWasPreempted = false;
188
184
  const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
189
185
  try {
190
- const url = new URL(buildOpenCodeUrl(`/session/${encodeURIComponent(task.sessionID)}/children`));
186
+ // v2 protocol has no `/session/:id/children` route; child sessions are a
187
+ // `parentID` filter on the session list (`GET /api/session`).
188
+ const url = new URL(buildOpenCodeUrl('/session'));
189
+ url.searchParams.set('parentID', task.sessionID);
191
190
  url.searchParams.set('directory', task.directory);
192
191
  const response = await fetchFn(url, {
193
192
  method: 'GET',
@@ -199,8 +198,12 @@ export const createSessionIndexSyncRuntime = ({
199
198
  error.status = response.status;
200
199
  throw error;
201
200
  }
202
- const children = await response.json();
203
- if (!Array.isArray(children)) throw new Error('Invalid OpenCode child-session list payload');
201
+ const payload = await response.json();
202
+ // v2 wraps list responses in `{ data, cursor }`; accept a bare array too.
203
+ const children = Array.isArray(payload)
204
+ ? payload
205
+ : (Array.isArray(payload?.data) ? payload.data : null);
206
+ if (!children) throw new Error('Invalid OpenCode child-session list payload');
204
207
  sessionIndexService.replaceChildSessions(task.directory, task.sessionID, children);
205
208
  return 'completed';
206
209
  } catch (error) {
@@ -68,7 +68,7 @@ describe('session index background sync runtime', () => {
68
68
 
69
69
  expect(maxActive).toBe(1);
70
70
  expect(fetchFn).toHaveBeenCalledTimes(4);
71
- expect(fetchFn.mock.calls[0][0].pathname).toBe('/experimental/session');
71
+ expect(fetchFn.mock.calls[0][0].pathname).toBe('/session');
72
72
  expect(service.replaceDirectory).toHaveBeenCalledTimes(2);
73
73
  expect(service.replaceChildSessions).toHaveBeenCalledTimes(2);
74
74
  expect(runtime.snapshot().sync).toMatchObject({ completed: 2, total: 2, failedDirectories: [] });
@@ -89,7 +89,7 @@ describe('session index background sync runtime', () => {
89
89
  await expect(changed).resolves.toMatchObject({ revision: initialRevision + 1 });
90
90
  });
91
91
 
92
- it('uses the persisted watermark for a true incremental merge', async () => {
92
+ it('v2 session.list has no incremental start — always fetches a full page', async () => {
93
93
  const service = createService([{
94
94
  directory: '/repo',
95
95
  sessions: [session('ses_old', 10)],
@@ -105,7 +105,7 @@ describe('session index background sync runtime', () => {
105
105
  getOpenCodeAuthHeaders: () => ({}),
106
106
  waitForOpenCodeReady: async () => true,
107
107
  fetchFn: async (url) => {
108
- if (url.pathname === '/experimental/session') requestedUrl = url;
108
+ if (url.pathname === '/session') requestedUrl = url;
109
109
  return new Response(JSON.stringify([session('ses_new', 20)]), { status: 200 });
110
110
  },
111
111
  now: () => 2000,
@@ -114,10 +114,11 @@ describe('session index background sync runtime', () => {
114
114
  runtime.enqueue(['/repo']);
115
115
  await waitUntil(() => isFullyIdle(runtime));
116
116
 
117
- expect(requestedUrl.searchParams.get('start')).toBe('1000');
117
+ expect(requestedUrl.searchParams.get('start')).toBeNull();
118
+ expect(requestedUrl.searchParams.get('roots')).toBeNull();
118
119
  expect(service.replaceDirectory.mock.calls[0][0]).toMatchObject({
119
- fullSync: false,
120
- sessions: [expect.objectContaining({ id: 'ses_new' }), expect.objectContaining({ id: 'ses_old' })],
120
+ fullSync: true,
121
+ sessions: [expect.objectContaining({ id: 'ses_new' })],
121
122
  });
122
123
  });
123
124
 
@@ -195,7 +196,7 @@ describe('session index background sync runtime', () => {
195
196
  });
196
197
  });
197
198
 
198
- it('omits start when recovering an empty cached directory', async () => {
199
+ it('recovers an empty cached directory with a full page', async () => {
199
200
  const service = createService([{
200
201
  directory: '/repo',
201
202
  sessions: [],
@@ -211,7 +212,7 @@ describe('session index background sync runtime', () => {
211
212
  getOpenCodeAuthHeaders: () => ({}),
212
213
  waitForOpenCodeReady: async () => true,
213
214
  fetchFn: async (url) => {
214
- if (url.pathname === '/experimental/session') requestedUrl = url;
215
+ if (url.pathname === '/session') requestedUrl = url;
215
216
  return new Response(JSON.stringify([session('ses_history', 10)]), { status: 200 });
216
217
  },
217
218
  now: () => 2000,
@@ -0,0 +1,41 @@
1
+ # Session metadata store
2
+
3
+ OpenChamber-owned per-session metadata for Host features that used to live on
4
+ OpenCode `session.metadata`.
5
+
6
+ ## Why
7
+
8
+ OpenCode 2.x accepts `metadata` only at session create time. There is no
9
+ `PATCH /session/:id` (or equivalent) to update it afterwards. Session goal
10
+ progress — and later other Host state under the same namespace — therefore
11
+ lives in this Host store and is folded back onto proxy session list/detail
12
+ responses so clients still read `session.metadata`.
13
+
14
+ ## Layout
15
+
16
+ | File | Role |
17
+ |---|---|
18
+ | `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, refuse-failed-read refuses write |
19
+ | `routes.js` | `PUT /api/openchamber/sessions/:sessionId/metadata` + re-exports `createSessionMetadataStore` |
20
+ | `*.test.js` | Store + route contracts |
21
+
22
+ ## Contracts
23
+
24
+ - Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
25
+ - A failed file read is **not** empty success — the store refuses writes until a load succeeds, so unknown disk state is never overwritten with `{}`.
26
+ - Entries are keyed by `sessionID` only (not directory).
27
+ - Successful `PUT` may broadcast `openchamber:session-metadata` with the **full** merged metadata object.
28
+
29
+ ## Server wiring
30
+
31
+ Wired from `packages/web/server/index.js`:
32
+
33
+ 1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })`
34
+ 2. proxy `getStoredSessionMetadata: () => store.getAll()`
35
+ 3. session-goal `readSessionMetadata` / `persistSessionGoal`
36
+ 4. `registerSessionMetadataRoutes` — a successful PUT notifies the goal runtime with a synthetic `session.updated` so a UI create/resume arms the loop
37
+
38
+ Capability is open (`supported: true`). Manual UI metadata writes, session-goal
39
+ routes, and scheduled-task goal create all persist `openchamber.goal` through
40
+ this store. After first create/resume persist, the server notifies the goal
41
+ runtime with a synthetic `session.updated` so the loop can arm.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * HTTP surface for OpenChamber-owned session metadata.
3
+ *
4
+ * OpenCode 2.x has no session-metadata update route. Clients (and Host
5
+ * features such as session goal) merge-patch state here; the proxy folds the
6
+ * store back onto session list/detail responses.
7
+ *
8
+ * This module exports the store factory and the route registrar. Wire them
9
+ * from `server/index.js` (store instance, proxy `getStoredSessionMetadata`,
10
+ * session-goal / scheduled-tasks `readSessionMetadata` / `persistSessionGoal`,
11
+ * then `registerSessionMetadataRoutes`).
12
+ */
13
+
14
+ import express from 'express';
15
+
16
+ import { createSessionMetadataStore } from './session-metadata-store.js';
17
+
18
+ const asNonEmptyString = (value) => {
19
+ if (typeof value !== 'string') return null;
20
+ const trimmed = value.trim();
21
+ return trimmed.length > 0 ? trimmed : null;
22
+ };
23
+
24
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
+
26
+ /**
27
+ * @param {import('express').Express} app
28
+ * @param {object} deps
29
+ * @param {{ get: Function, setSessionMetadata: Function }} deps.sessionMetadataStore
30
+ * @param {(event: { type: string, properties: object }) => void} [deps.broadcastGlobalUiEvent]
31
+ * @param {(event: { sessionID: string, directory: string, metadata: object }) => void} [deps.onMetadataWritten]
32
+ */
33
+ export const registerSessionMetadataRoutes = (app, deps = {}) => {
34
+ const {
35
+ sessionMetadataStore = null,
36
+ broadcastGlobalUiEvent = null,
37
+ onMetadataWritten = null,
38
+ } = deps;
39
+
40
+ if (!sessionMetadataStore || typeof sessionMetadataStore.setSessionMetadata !== 'function') {
41
+ throw new Error('registerSessionMetadataRoutes requires a sessionMetadataStore');
42
+ }
43
+
44
+ const putMetadata = async (req, res) => {
45
+ const sessionId = asNonEmptyString(req.params?.sessionId);
46
+ if (!sessionId) {
47
+ return res.status(400).json({ error: 'a session id is required' });
48
+ }
49
+
50
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
51
+ const patch = body.patch;
52
+ if (!isPlainObject(patch)) {
53
+ return res.status(400).json({ error: 'patch must be an object' });
54
+ }
55
+
56
+ try {
57
+ const metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch);
58
+ const directory = asNonEmptyString(body.directory) || '';
59
+ if (typeof onMetadataWritten === 'function') {
60
+ try {
61
+ onMetadataWritten({ sessionID: sessionId, directory, metadata });
62
+ } catch (error) {
63
+ console.warn('[session-metadata] metadata listener failed:', error?.message ?? error);
64
+ }
65
+ }
66
+ if (typeof broadcastGlobalUiEvent === 'function') {
67
+ try {
68
+ broadcastGlobalUiEvent({
69
+ type: 'openchamber:session-metadata',
70
+ properties: { sessionID: sessionId, metadata },
71
+ });
72
+ } catch (error) {
73
+ console.warn('[session-metadata] broadcast failed:', error?.message ?? error);
74
+ }
75
+ }
76
+ return res.json({ metadata });
77
+ } catch (error) {
78
+ const message = error instanceof Error ? error.message : 'Failed to store session metadata';
79
+ // Load/read failure must not be answered as empty success — clients must
80
+ // not treat a refused write as cleared metadata.
81
+ const status = /could not be read|unavailable/i.test(message) ? 503 : 500;
82
+ console.error('[session-metadata] failed to store session metadata:', message);
83
+ return res.status(status).json({ error: message });
84
+ }
85
+ };
86
+
87
+ app.put(
88
+ '/api/openchamber/sessions/:sessionId/metadata',
89
+ express.json({ limit: '1mb' }),
90
+ putMetadata,
91
+ );
92
+ };
93
+
94
+ export { createSessionMetadataStore, mergeMetadataPatch } from './session-metadata-store.js';
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import express from 'express';
5
+ import request from 'supertest';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { createSessionMetadataStore, registerSessionMetadataRoutes } from './routes.js';
9
+
10
+ const tempDirs = [];
11
+
12
+ const makeDataDir = () => {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-metadata-routes-'));
14
+ tempDirs.push(dir);
15
+ return dir;
16
+ };
17
+
18
+ afterEach(() => {
19
+ while (tempDirs.length > 0) {
20
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
21
+ }
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ const mount = (overrides = {}) => {
26
+ const store = overrides.sessionMetadataStore
27
+ ?? createSessionMetadataStore({ dataDir: makeDataDir() });
28
+ const broadcastGlobalUiEvent = overrides.broadcastGlobalUiEvent ?? vi.fn();
29
+ const app = express();
30
+ registerSessionMetadataRoutes(app, {
31
+ sessionMetadataStore: store,
32
+ broadcastGlobalUiEvent,
33
+ onMetadataWritten: overrides.onMetadataWritten,
34
+ });
35
+ return { app, store, broadcastGlobalUiEvent };
36
+ };
37
+
38
+ describe('registerSessionMetadataRoutes', () => {
39
+ it('merge-patches metadata and broadcasts the full merged object', async () => {
40
+ const { app, store, broadcastGlobalUiEvent } = mount();
41
+
42
+ const first = await request(app)
43
+ .put('/api/openchamber/sessions/ses_1/metadata')
44
+ .send({ patch: { openchamber: { assist: { recap: 'a' } } } })
45
+ .expect(200);
46
+ expect(first.body.metadata).toEqual({ openchamber: { assist: { recap: 'a' } } });
47
+
48
+ const second = await request(app)
49
+ .put('/api/openchamber/sessions/ses_1/metadata')
50
+ .send({ patch: { openchamber: { goal: { status: 'active' } } }, directory: '/repo' })
51
+ .expect(200);
52
+ expect(second.body.metadata).toEqual({
53
+ openchamber: { assist: { recap: 'a' }, goal: { status: 'active' } },
54
+ });
55
+ await expect(store.get('ses_1')).resolves.toEqual(second.body.metadata);
56
+ expect(broadcastGlobalUiEvent).toHaveBeenLastCalledWith({
57
+ type: 'openchamber:session-metadata',
58
+ properties: { sessionID: 'ses_1', metadata: second.body.metadata },
59
+ });
60
+ });
61
+
62
+ it('notifies the metadata listener with the session directory', async () => {
63
+ const onMetadataWritten = vi.fn();
64
+ const { app } = mount({ onMetadataWritten });
65
+
66
+ await request(app)
67
+ .put('/api/openchamber/sessions/ses_2/metadata')
68
+ .send({ patch: { openchamber: { goal: { id: 'g1', status: 'active', objective: 'ship' } } }, directory: '/repo' })
69
+ .expect(200);
70
+
71
+ expect(onMetadataWritten).toHaveBeenCalledWith({
72
+ sessionID: 'ses_2',
73
+ directory: '/repo',
74
+ metadata: { openchamber: { goal: { id: 'g1', status: 'active', objective: 'ship' } } },
75
+ });
76
+ });
77
+
78
+ it('rejects a blank session id and a non-object patch with 400', async () => {
79
+ const { app } = mount();
80
+ await request(app)
81
+ .put('/api/openchamber/sessions/%20%20/metadata')
82
+ .send({ patch: { a: 1 } })
83
+ .expect(400);
84
+ await request(app)
85
+ .put('/api/openchamber/sessions/ses_1/metadata')
86
+ .send({})
87
+ .expect(400);
88
+ await request(app)
89
+ .put('/api/openchamber/sessions/ses_1/metadata')
90
+ .send({ patch: 'nope' })
91
+ .expect(400);
92
+ await request(app)
93
+ .put('/api/openchamber/sessions/ses_1/metadata')
94
+ .send({ patch: ['a'] })
95
+ .expect(400);
96
+ });
97
+
98
+ it('does not answer a failed store load as empty success', async () => {
99
+ const store = {
100
+ setSessionMetadata: async () => {
101
+ throw new Error('session metadata is unavailable: its file could not be read');
102
+ },
103
+ };
104
+ const { app } = mount({ sessionMetadataStore: store });
105
+ const res = await request(app)
106
+ .put('/api/openchamber/sessions/ses_1/metadata')
107
+ .send({ patch: { a: 1 } })
108
+ .expect(503);
109
+ expect(res.body).toMatchObject({ error: expect.stringMatching(/could not be read/) });
110
+ });
111
+ });