@openchambery/web 2.0.0-beta.3 → 2.0.0-beta.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 (178) hide show
  1. package/dist/assets/{AgentAvatar-B_JQoreg.js → AgentAvatar-BBmQXYDq.js} +1 -1
  2. package/dist/assets/AssistantView-BG8hK4y9.js +3 -0
  3. package/dist/assets/{DiagramView-BNnda8qO.js → DiagramView-CN0qWNU5.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-VYQTSAkr.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
  5. package/dist/assets/Icon-B9kZgYZn.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-CJHSsWin.js → MarkdownRenderer-Ds61--LT.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-DwrHFGUp.js → MarkdownRendererImpl-DOMmiOH0.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-CSnbQMu3.js → MarkstreamRendererImpl-F7MGg5ER.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-BXr6AGLT.js → MobileDetailNavigation-DcmVOjkw.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-D7OE9OP_.js → MobileShareBridge-C1Fc4ErB.js} +2 -2
  11. package/dist/assets/MobileSurface-BA7eS5Yd.js +2 -0
  12. package/dist/assets/{MultiRunWindow-DF3iIFCd.js → MultiRunWindow-BoTnH6Yp.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BjHmlwlw.js → OnboardingScreen-BxItVnnO.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-DLtWO_Gp.js → OverlayScrollbar-B4e0QD05.js} +1 -1
  15. package/dist/assets/SettingsView-CjesWNLw.js +25 -0
  16. package/dist/assets/{SettingsWindow-BI5swspb.js → SettingsWindow-CReMbg-w.js} +1 -1
  17. package/dist/assets/TerminalView-C64-QYOG.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-BEWhBcRd.js → ToolOutputDialog-CJu8DNrt.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-4MR0F7Jz.js → appThemeRegistry-CF4lIxNt.js} +1 -1
  20. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  21. package/dist/assets/{button-DKuz3L8l.js → button-CwXyux0a.js} +1 -1
  22. package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
  23. package/dist/assets/context-menu-DHrRifPE.js +1 -0
  24. package/dist/assets/{desktopHosts-CoDkOnyS.js → desktopHosts-JGFqhlaM.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-DDlVKyOE.js → desktopRelayRestore-CHEwrI93.js} +1 -1
  26. package/dist/assets/{diagram-D7T8HQgj.js → diagram-BwnPNKWt.js} +1 -1
  27. package/dist/assets/{dialog-DQYl3W_o.js → dialog-Dp0lQv5K.js} +1 -1
  28. package/dist/assets/es-DicJ8lKD.js +15 -0
  29. package/dist/assets/{fileMentionTouchSelection-BZ6ovBYH.js → fileMentionTouchSelection-DPZRxNa2.js} +3 -4
  30. package/dist/assets/{fileQueries-JrpcsOCs.js → fileQueries-DN6gbLhM.js} +1 -1
  31. package/dist/assets/fr-lhgVcBW_.js +13 -0
  32. package/dist/assets/{gitApi-CM42qUed.js → gitApi-D8R7ic-T.js} +3 -3
  33. package/dist/assets/{imageSource-CIl0FL10.js → imageSource-BMztRu0c.js} +1 -1
  34. package/dist/assets/{input-CzQy8UhV.js → input-Biw8aNzR.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DdwCD4S2.js +1 -0
  36. package/dist/assets/ja-B9EZwOcw.js +15 -0
  37. package/dist/assets/ko-Dxk-uJRb.js +15 -0
  38. package/dist/assets/main-BhlwwJ2c.js +169 -0
  39. package/dist/assets/main-D2BzpnUt.css +1 -0
  40. package/dist/assets/main-DSPCkT74.js +2 -0
  41. package/dist/assets/{markdownHeightCache-Cj1I5NDw.js → markdownHeightCache-BOOrGCuG.js} +1 -1
  42. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  43. package/dist/assets/{mcpQueries-D5mQymgM.js → mcpQueries-CE9tk-Jz.js} +1 -1
  44. package/dist/assets/miniChat-Ckk9ABql.js +2 -0
  45. package/dist/assets/mobile-cLMRZqI9.js +2 -0
  46. package/dist/assets/multirun-DVnFRb1V.js +4 -0
  47. package/dist/assets/persistence-D0y7226-.js +3 -0
  48. package/dist/assets/pl-CFhwWLJj.js +15 -0
  49. package/dist/assets/platform-D_c-T5nO.js +6 -0
  50. package/dist/assets/projectMeta-CSQgHequ.js +3 -0
  51. package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
  52. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  53. package/dist/assets/renderElectronMiniChatApp-gGFkwoL_.js +2 -0
  54. package/dist/assets/renderMobileApp-B2w50kAc.js +28 -0
  55. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  56. package/dist/assets/{runtimeConfig-BIGBRGQI.js → runtimeConfig-BHH1hJDd.js} +1 -1
  57. package/dist/assets/runtimeEndpointReset-5uJ4sJk0.js +149 -0
  58. package/dist/assets/{sessionChangeSummary-eIbpSfrO.js → sessionChangeSummary-spf_atO8.js} +2 -2
  59. package/dist/assets/{sessionLookup-myHe5ltu.js → sessionLookup-BGjjwiCq.js} +1 -1
  60. package/dist/assets/{sortable-tabs-strip-BQtmHx9r.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
  61. package/dist/assets/src-BjEyU6if.css +2 -0
  62. package/dist/assets/store-DmowK-GO.js +15 -0
  63. package/dist/assets/{terminalApi-BxcdiduX.js → terminalApi-Dhqqp4xi.js} +1 -1
  64. package/dist/assets/textarea-QxsmcBGj.js +1 -0
  65. package/dist/assets/{tooltip-Cx5gTbdY.js → tooltip-CpMLl5_k.js} +1 -1
  66. package/dist/assets/uk-ChchBzy6.js +15 -0
  67. package/dist/assets/{url-CDHnjlsm.js → url-CGbJbiJq.js} +1 -1
  68. package/dist/assets/{useAppFontEffects-CNeo0aZs.js → useAppFontEffects-B1h7ab_J.js} +55 -50
  69. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  70. package/dist/assets/useDesktopWindowControlsLayout-Cxsx84NO.js +1 -0
  71. package/dist/assets/{useEffectiveDirectory-B4LvR8Gt.js → useEffectiveDirectory-DsqhqsJR.js} +1 -1
  72. package/dist/assets/{useFontPreferences-CUnQ4TVm.js → useFontPreferences-Xw1vSkmC.js} +1 -1
  73. package/dist/assets/useMobileNavigationStore-B1axS4g-.js +414 -0
  74. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  75. package/dist/assets/useSessionAutoCleanup-B5FTG_qf.js +2 -0
  76. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  77. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  78. package/dist/assets/{useWorkerHighlightedLines-CJZbJDW3.js → useWorkerHighlightedLines-DC_Bc-RV.js} +1 -1
  79. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  80. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  81. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  82. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  83. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  84. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  85. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  86. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  87. package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
  88. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  89. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  90. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  91. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-B8parU1Y.js} +1 -1
  92. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  93. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  94. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  95. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  96. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
  97. package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
  98. package/dist/assets/zh-TW-CZeTm7kd.js +15 -0
  99. package/dist/index.html +14 -14
  100. package/dist/mini-chat.html +14 -14
  101. package/dist/mobile.html +14 -14
  102. package/package.json +3 -2
  103. package/server/index.js +38 -7
  104. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  105. package/server/lib/browser-provider/contract.js +312 -0
  106. package/server/lib/browser-provider/host.js +421 -0
  107. package/server/lib/browser-provider/host.test.js +304 -0
  108. package/server/lib/browser-provider/routes.js +122 -0
  109. package/server/lib/browser-provider/routes.test.js +120 -0
  110. package/server/lib/browser-provider/selection-store.js +43 -0
  111. package/server/lib/browser-provider/selection-store.test.js +35 -0
  112. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  113. package/server/lib/event-stream/reasoning-projection.js +16 -1
  114. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  115. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  116. package/server/lib/notifications/runtime.js +128 -31
  117. package/server/lib/notifications/runtime.test.js +103 -0
  118. package/server/lib/notifications/template-runtime.js +130 -38
  119. package/server/lib/notifications/template-runtime.test.js +96 -0
  120. package/server/lib/opencode/DOCUMENTATION.md +8 -0
  121. package/server/lib/opencode/config-entity-routes.js +6 -1
  122. package/server/lib/opencode/config-entity-routes.test.js +25 -0
  123. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  124. package/server/lib/opencode/feature-routes-runtime.js +10 -0
  125. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  126. package/server/lib/opencode/proxy.js +6 -1
  127. package/server/lib/opencode/proxy.test.js +18 -3
  128. package/server/lib/opencode/routes.js +30 -0
  129. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  130. package/server/lib/opencode/upgrade-screen.js +299 -0
  131. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  132. package/server/lib/session-metadata/DOCUMENTATION.md +27 -17
  133. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  134. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  135. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  136. package/server/lib/session-metadata/routes.js +5 -1
  137. package/server/lib/session-metadata/session-archive.js +1 -1
  138. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  139. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  140. package/server/lib/session-metadata/session-projection.js +26 -2
  141. package/dist/assets/AssistantView-ClQAfGOT.js +0 -3
  142. package/dist/assets/Icon-BiEQEcih.js +0 -1
  143. package/dist/assets/MobileSurface-D2jUkX9l.js +0 -2
  144. package/dist/assets/SettingsView-BRxELjow.js +0 -25
  145. package/dist/assets/TerminalView-BU3x8-fr.js +0 -8
  146. package/dist/assets/collapsible-DXbDynN4.js +0 -3
  147. package/dist/assets/context-menu-CqolsLCm.js +0 -1
  148. package/dist/assets/es-CN3E_zXj.js +0 -15
  149. package/dist/assets/fr-9zZvQZD2.js +0 -13
  150. package/dist/assets/insertionBoundaries-CtkZ9720.js +0 -1
  151. package/dist/assets/ja-Dnjk_82N.js +0 -15
  152. package/dist/assets/ko-BxCEE99C.js +0 -15
  153. package/dist/assets/main-BILgBI3B.js +0 -169
  154. package/dist/assets/main-BeveiW6F.js +0 -2
  155. package/dist/assets/main-qSeeJRW1.css +0 -1
  156. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  157. package/dist/assets/miniChat-BgrzM3u7.js +0 -2
  158. package/dist/assets/mobile-DtYoQE8C.js +0 -2
  159. package/dist/assets/multirun-jy8dEI96.js +0 -4
  160. package/dist/assets/persistence-C9LQpiOS.js +0 -3
  161. package/dist/assets/pl-CNNsWnPM.js +0 -15
  162. package/dist/assets/platform-BQghHRfp.js +0 -6
  163. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  164. package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +0 -2
  165. package/dist/assets/renderMobileApp-u3c4GtFC.js +0 -28
  166. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  167. package/dist/assets/runtimeEndpointReset-D2savkuw.js +0 -147
  168. package/dist/assets/src-CLN7Z-zx.css +0 -2
  169. package/dist/assets/store-DLCiZaA6.js +0 -15
  170. package/dist/assets/textarea-CakKvqFw.js +0 -1
  171. package/dist/assets/uk-rwQrxeYM.js +0 -15
  172. package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +0 -1
  173. package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +0 -414
  174. package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +0 -2
  175. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  176. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  177. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  178. package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
@@ -0,0 +1,316 @@
1
+ /**
2
+ * OpenCode session-record ownership for OpenChamber metadata.
3
+ *
4
+ * OpenCode 2.0.15 `PATCH /api/session/:id` (`session.update`) replaces
5
+ * `metadata` wholesale and can set `title`. Archive stays in
6
+ * `metadata.openchamber.archive` on that same record, then projects onto
7
+ * `time.archived`. This module is the only place that knows that call.
8
+ *
9
+ * A client whose types do not yet expose `session.update` metadata still goes
10
+ * through `writeSessionRecordViaClient` / `createHttpSessionRecordClient`.
11
+ * Missing the API throws — it is never an empty success.
12
+ *
13
+ * The side store is a migration source. A failed load is not an empty catalog.
14
+ * One session's migration failure does not delete other sessions or their rows.
15
+ */
16
+
17
+ import { mergeOwnedMetadata, projectSessionWithStoredMap } from './session-projection.js';
18
+
19
+ export { mergeOwnedMetadata };
20
+
21
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
22
+
23
+ const asNonEmptyString = (value) => {
24
+ if (typeof value !== 'string') return null;
25
+ const trimmed = value.trim();
26
+ return trimmed.length > 0 ? trimmed : null;
27
+ };
28
+
29
+ const isSessionNotFoundError = (error) => {
30
+ const status = Number(error?.status ?? error?.statusCode);
31
+ if (status === 404) return true;
32
+ const tag = error?._tag || error?.name;
33
+ return tag === 'SessionNotFoundError' || tag === 'Session.NotFoundError';
34
+ };
35
+
36
+ const deepEqual = (left, right) => {
37
+ if (left === right) return true;
38
+ if (Array.isArray(left) || Array.isArray(right)) {
39
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
40
+ return left.every((item, index) => deepEqual(item, right[index]));
41
+ }
42
+ if (!isPlainObject(left) || !isPlainObject(right)) return false;
43
+ const leftKeys = Object.keys(left);
44
+ const rightKeys = Object.keys(right);
45
+ if (leftKeys.length !== rightKeys.length) return false;
46
+ return leftKeys.every((key) => deepEqual(left[key], right[key]));
47
+ };
48
+
49
+ const unwrapSession = (payload) => {
50
+ if (!isPlainObject(payload)) return null;
51
+ if (isPlainObject(payload.data) && typeof payload.data.id === 'string') return payload.data;
52
+ if (typeof payload.id === 'string') return payload;
53
+ return null;
54
+ };
55
+
56
+ /**
57
+ * Structural adapter for `@opencode/client` session.update.
58
+ * Throws when the method is absent — never pretends the write landed.
59
+ *
60
+ * @param {{ session?: { update?: Function } } | null | undefined} client
61
+ * @param {{ sessionID: string, metadata?: object, title?: string, requestOptions?: object }} input
62
+ */
63
+ export const writeSessionRecordViaClient = async (client, input) => {
64
+ const update = client?.session?.update;
65
+ if (typeof update !== 'function') {
66
+ const error = new Error('session.update is unavailable');
67
+ error.code = 'session_update_unavailable';
68
+ throw error;
69
+ }
70
+ const sessionID = asNonEmptyString(input?.sessionID);
71
+ if (!sessionID) throw new Error('a session id is required to update the OpenCode session record');
72
+ const body = { sessionID };
73
+ if (input.metadata !== undefined) body.metadata = input.metadata;
74
+ if (input.title !== undefined) body.title = input.title;
75
+ await update(body, input.requestOptions);
76
+ };
77
+
78
+ /**
79
+ * @param {{ session?: { get?: Function } } | null | undefined} client
80
+ * @param {{ sessionID: string, requestOptions?: object }} input
81
+ * @returns {Promise<object | null>} null only when OpenCode confirms the session is gone
82
+ */
83
+ export const readSessionRecordViaClient = async (client, input) => {
84
+ const get = client?.session?.get;
85
+ if (typeof get !== 'function') {
86
+ const error = new Error('session.get is unavailable');
87
+ error.code = 'session_get_unavailable';
88
+ throw error;
89
+ }
90
+ const sessionID = asNonEmptyString(input?.sessionID);
91
+ if (!sessionID) throw new Error('a session id is required to read the OpenCode session record');
92
+ try {
93
+ const session = await get({ sessionID }, input.requestOptions);
94
+ return unwrapSession(session) || (isPlainObject(session) ? session : null);
95
+ } catch (error) {
96
+ if (isSessionNotFoundError(error)) return null;
97
+ throw error;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * HTTP owner of `GET` / `PATCH /api/session/:id`. Callers supply the URL so
103
+ * web and VS Code keep their own prefix rules.
104
+ *
105
+ * @param {{
106
+ * buildSessionUrl: (sessionID: string, directory?: string | null) => string,
107
+ * getHeaders?: () => Record<string, string>,
108
+ * fetchFn?: typeof fetch,
109
+ * }} options
110
+ */
111
+ export const createHttpSessionRecordClient = ({
112
+ buildSessionUrl,
113
+ getHeaders = () => ({}),
114
+ fetchFn = null,
115
+ }) => {
116
+ if (typeof buildSessionUrl !== 'function') {
117
+ throw new Error('createHttpSessionRecordClient requires buildSessionUrl');
118
+ }
119
+
120
+ const request = async (sessionID, { method, directory, body }) => {
121
+ const fetchImpl = fetchFn || globalThis.fetch;
122
+ const id = asNonEmptyString(sessionID);
123
+ if (!id) throw new Error('a session id is required');
124
+ const url = buildSessionUrl(id, directory);
125
+ const response = await fetchImpl(url, {
126
+ method,
127
+ headers: {
128
+ Accept: 'application/json',
129
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
130
+ ...(typeof getHeaders === 'function' ? getHeaders() : {}),
131
+ },
132
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
133
+ });
134
+ return response;
135
+ };
136
+
137
+ const read = async (sessionID, directory) => {
138
+ const response = await request(sessionID, { method: 'GET', directory });
139
+ if (response.status === 404) return null;
140
+ if (!response.ok) {
141
+ const error = new Error(`OpenCode session.get failed (${response.status})`);
142
+ error.status = response.status;
143
+ throw error;
144
+ }
145
+ const payload = await response.json().catch(() => null);
146
+ const session = unwrapSession(payload);
147
+ if (!session) {
148
+ const error = new Error('OpenCode session.get returned an unreadable record');
149
+ error.status = 502;
150
+ throw error;
151
+ }
152
+ return session;
153
+ };
154
+
155
+ const write = async ({ sessionID, directory, metadata, title } = {}) => {
156
+ const body = {};
157
+ if (metadata !== undefined) body.metadata = metadata;
158
+ if (title !== undefined) body.title = title;
159
+ if (Object.keys(body).length === 0) {
160
+ throw new Error('session record write requires metadata or title');
161
+ }
162
+ const response = await request(sessionID, { method: 'PATCH', directory, body });
163
+ if (response.status === 404) {
164
+ const error = new Error(`session ${sessionID} was not found`);
165
+ error.status = 404;
166
+ throw error;
167
+ }
168
+ if (!response.ok) {
169
+ const error = new Error(`OpenCode session.update failed (${response.status})`);
170
+ error.status = response.status;
171
+ throw error;
172
+ }
173
+ };
174
+
175
+ return { read, write };
176
+ };
177
+
178
+ /**
179
+ * Copy side-store rows onto OpenCode records. Refuses a failed load instead of
180
+ * treating it as an empty catalog. A failed session is reported and skipped;
181
+ * other sessions, including ones OpenCode has and the side store does not, are
182
+ * left in place.
183
+ *
184
+ * @param {{
185
+ * loadResult: { ok?: boolean, entries?: Record<string, object> | null } | null,
186
+ * readSession: (sessionID: string) => Promise<object | null>,
187
+ * writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
188
+ * }} input
189
+ */
190
+ export const migrateLoadedSideStore = async ({ loadResult, readSession, writeMetadata }) => {
191
+ if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
192
+ return { status: 'unavailable', migrated: [], failed: [], absent: [] };
193
+ }
194
+ if (typeof readSession !== 'function' || typeof writeMetadata !== 'function') {
195
+ throw new Error('side-store migration requires readSession and writeMetadata');
196
+ }
197
+
198
+ const migrated = [];
199
+ const failed = [];
200
+ const absent = [];
201
+
202
+ for (const [sessionID, sideMetadata] of Object.entries(loadResult.entries)) {
203
+ if (!isPlainObject(sideMetadata)) continue;
204
+ try {
205
+ const record = await readSession(sessionID);
206
+ if (record == null) {
207
+ absent.push(sessionID);
208
+ continue;
209
+ }
210
+ const merged = mergeOwnedMetadata(record.metadata, sideMetadata);
211
+ if (!deepEqual(merged, isPlainObject(record.metadata) ? record.metadata : {})) {
212
+ await writeMetadata(sessionID, merged, record);
213
+ }
214
+ migrated.push(sessionID);
215
+ } catch (error) {
216
+ failed.push({
217
+ id: sessionID,
218
+ error: error instanceof Error ? error.message : String(error),
219
+ });
220
+ }
221
+ }
222
+
223
+ return {
224
+ status: failed.length > 0 ? 'partial' : 'complete',
225
+ migrated,
226
+ failed,
227
+ absent,
228
+ };
229
+ };
230
+
231
+ /**
232
+ * Membership comes from OpenCode. A missing side-store row does not drop the
233
+ * session. A null side map is an unread store, not an empty catalog.
234
+ *
235
+ * @param {unknown} openCodeSessions
236
+ * @param {Record<string, object> | null | undefined} sideById
237
+ */
238
+ export const retainOpenCodeSessions = (openCodeSessions, sideById) => {
239
+ if (!Array.isArray(openCodeSessions)) {
240
+ throw new Error('OpenCode session list is unavailable');
241
+ }
242
+ if (sideById == null || typeof sideById !== 'object' || Array.isArray(sideById)) {
243
+ throw new Error('session metadata is unavailable');
244
+ }
245
+ return openCodeSessions.map((session) => projectSessionWithStoredMap(session, sideById));
246
+ };
247
+
248
+ const MIGRATION_RETRY_MS = 2_000;
249
+
250
+ /**
251
+ * Retry until every loaded side-store row is copied or confirmed absent.
252
+ * A failed load schedules another attempt and does not write empty metadata.
253
+ *
254
+ * @param {{
255
+ * load: () => Promise<{ ok?: boolean, entries?: Record<string, object> }>,
256
+ * readSession: (sessionID: string) => Promise<object | null>,
257
+ * writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
258
+ * setTimer?: typeof setTimeout,
259
+ * clearTimer?: typeof clearTimeout,
260
+ * }} options
261
+ * @returns {() => void} stop
262
+ */
263
+ export const startSideStoreMigration = ({
264
+ load,
265
+ readSession,
266
+ writeMetadata,
267
+ setTimer = setTimeout,
268
+ clearTimer = clearTimeout,
269
+ }) => {
270
+ const finished = new Set();
271
+ let stopped = false;
272
+ let timer = null;
273
+
274
+ const schedule = () => {
275
+ if (stopped || timer) return;
276
+ timer = setTimer(() => {
277
+ timer = null;
278
+ void attempt();
279
+ }, MIGRATION_RETRY_MS);
280
+ };
281
+
282
+ const attempt = async () => {
283
+ if (stopped) return;
284
+ let loadResult;
285
+ try {
286
+ loadResult = await load();
287
+ } catch {
288
+ schedule();
289
+ return;
290
+ }
291
+ if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
292
+ schedule();
293
+ return;
294
+ }
295
+ const pending = {};
296
+ for (const [id, metadata] of Object.entries(loadResult.entries)) {
297
+ if (!finished.has(id)) pending[id] = metadata;
298
+ }
299
+ if (Object.keys(pending).length === 0) return;
300
+ const result = await migrateLoadedSideStore({
301
+ loadResult: { ok: true, entries: pending },
302
+ readSession,
303
+ writeMetadata,
304
+ });
305
+ for (const id of result.migrated) finished.add(id);
306
+ for (const id of result.absent) finished.add(id);
307
+ if (result.failed.length > 0) schedule();
308
+ };
309
+
310
+ void attempt();
311
+ return () => {
312
+ stopped = true;
313
+ if (timer) clearTimer(timer);
314
+ timer = null;
315
+ };
316
+ };
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createSessionArchiveService } from './session-archive.js';
4
+ import { createSessionMetadataStore } from './session-metadata-store.js';
5
+ import {
6
+ createHttpSessionRecordClient,
7
+ mergeOwnedMetadata,
8
+ migrateLoadedSideStore,
9
+ readSessionRecordViaClient,
10
+ retainOpenCodeSessions,
11
+ writeSessionRecordViaClient,
12
+ } from './opencode-session-record.js';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ const makeDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-record-'));
18
+
19
+ describe('OpenCode session record ownership', () => {
20
+ it('keeps title and archive on the OpenCode record across archive and restore', async () => {
21
+ const dataDir = makeDataDir();
22
+ const records = new Map([
23
+ ['ses_1', {
24
+ id: 'ses_1',
25
+ title: 'Keep me',
26
+ metadata: { openchamber: { goal: { status: 'active' }, assistant: { assistantID: 'ast_1' } } },
27
+ time: { created: 1, updated: 2 },
28
+ }],
29
+ ]);
30
+ const writes = [];
31
+ const store = createSessionMetadataStore({
32
+ dataDir,
33
+ recordReader: async (sessionID) => records.get(sessionID) ?? null,
34
+ recordWriter: async (sessionID, metadata) => {
35
+ const current = records.get(sessionID);
36
+ if (!current) {
37
+ const error = new Error('missing');
38
+ error.status = 404;
39
+ throw error;
40
+ }
41
+ writes.push({ sessionID, metadata, title: current.title });
42
+ records.set(sessionID, { ...current, metadata });
43
+ },
44
+ });
45
+ const archive = createSessionArchiveService({
46
+ sessionMetadataStore: store,
47
+ fetchUpstreamSession: async ({ sessionID }) => records.get(sessionID) ?? null,
48
+ });
49
+
50
+ const archived = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 50, directory: '/repo' });
51
+ expect(archived.session.title).toBe('Keep me');
52
+ expect(archived.session.time.archived).toBe(50);
53
+ expect(records.get('ses_1').title).toBe('Keep me');
54
+ expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(50);
55
+ expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
56
+ expect(records.get('ses_1').metadata.openchamber.assistant.assistantID).toBe('ast_1');
57
+ expect(writes.at(-1).metadata.openchamber.assistant.assistantID).toBe('ast_1');
58
+
59
+ const restored = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 0 });
60
+ expect(restored.session.title).toBe('Keep me');
61
+ expect(restored.session.time.archived).toBeUndefined();
62
+ expect(records.get('ses_1').title).toBe('Keep me');
63
+ expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(0);
64
+ expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
65
+
66
+ const restarted = createSessionMetadataStore({
67
+ dataDir: makeDataDir(),
68
+ recordReader: async (sessionID) => records.get(sessionID) ?? null,
69
+ recordWriter: async () => undefined,
70
+ });
71
+ await expect(restarted.get('ses_1')).resolves.toMatchObject({
72
+ openchamber: {
73
+ archive: { archivedAt: 0 },
74
+ goal: { status: 'active' },
75
+ assistant: { assistantID: 'ast_1' },
76
+ },
77
+ });
78
+ const visible = retainOpenCodeSessions([records.get('ses_1')], {});
79
+ expect(visible).toHaveLength(1);
80
+ expect(visible[0].title).toBe('Keep me');
81
+ expect(visible[0].time.archived).toBeUndefined();
82
+ });
83
+
84
+ it('does not drop an OpenCode session when the side store has no row', () => {
85
+ const visible = retainOpenCodeSessions([
86
+ {
87
+ id: 'ses_1',
88
+ title: 'Still here',
89
+ metadata: { openchamber: { archive: { archivedAt: 9 } } },
90
+ time: { created: 1, updated: 2 },
91
+ },
92
+ { id: 'ses_2', title: 'Also here', time: { created: 1, updated: 2 } },
93
+ ], {});
94
+
95
+ expect(visible.map((session) => session.id)).toEqual(['ses_1', 'ses_2']);
96
+ expect(visible[0].title).toBe('Still here');
97
+ expect(visible[0].time.archived).toBe(9);
98
+ expect(visible[1].title).toBe('Also here');
99
+ });
100
+
101
+ it('does not treat an unread side store as an empty catalog', () => {
102
+ expect(() => retainOpenCodeSessions([{ id: 'ses_1', title: 'Stay' }], null)).toThrow(/unavailable/);
103
+ expect(() => retainOpenCodeSessions(null, {})).toThrow(/unavailable/);
104
+ });
105
+
106
+ it('does not treat a failed side-store load as an authoritative empty migration', async () => {
107
+ const writes = [];
108
+ const records = {
109
+ ses_1: { id: 'ses_1', title: 'Stay', metadata: { openchamber: { goal: { id: 'g1' } } } },
110
+ };
111
+ const result = await migrateLoadedSideStore({
112
+ loadResult: { ok: false, entries: {} },
113
+ readSession: async (id) => records[id] ?? null,
114
+ writeMetadata: async (id, metadata) => {
115
+ writes.push(id);
116
+ records[id] = { ...records[id], metadata };
117
+ },
118
+ });
119
+
120
+ expect(result.status).toBe('unavailable');
121
+ expect(writes).toEqual([]);
122
+ expect(records.ses_1.title).toBe('Stay');
123
+ expect(records.ses_1.metadata.openchamber.goal.id).toBe('g1');
124
+ });
125
+
126
+ it('keeps other sessions when one migration write fails', async () => {
127
+ const records = {
128
+ ses_ok: { id: 'ses_ok', title: 'Ok', metadata: {} },
129
+ ses_bad: { id: 'ses_bad', title: 'Bad', metadata: { openchamber: { llm: { purpose: 'keep' } } } },
130
+ ses_only_opencode: { id: 'ses_only_opencode', title: 'Stay', metadata: { fromOpenCode: true } },
131
+ };
132
+ const side = {
133
+ ses_ok: { openchamber: { goal: { id: 'g1' } } },
134
+ ses_bad: { openchamber: { assistant: { assistantID: 'ast_1' } } },
135
+ };
136
+
137
+ const result = await migrateLoadedSideStore({
138
+ loadResult: { ok: true, entries: side },
139
+ readSession: async (id) => records[id] ?? null,
140
+ writeMetadata: async (id, metadata) => {
141
+ if (id === 'ses_bad') throw new Error('write failed');
142
+ records[id] = { ...records[id], metadata };
143
+ },
144
+ });
145
+
146
+ expect(result.status).toBe('partial');
147
+ expect(result.migrated).toEqual(['ses_ok']);
148
+ expect(result.failed).toEqual([{ id: 'ses_bad', error: 'write failed' }]);
149
+ expect(records.ses_ok.metadata.openchamber.goal.id).toBe('g1');
150
+ expect(records.ses_ok.title).toBe('Ok');
151
+ expect(records.ses_bad.title).toBe('Bad');
152
+ expect(records.ses_bad.metadata.openchamber.llm.purpose).toBe('keep');
153
+ expect(records.ses_bad.metadata.openchamber.assistant).toBeUndefined();
154
+ expect(records.ses_only_opencode).toEqual({
155
+ id: 'ses_only_opencode',
156
+ title: 'Stay',
157
+ metadata: { fromOpenCode: true },
158
+ });
159
+ expect(side.ses_bad.openchamber.assistant.assistantID).toBe('ast_1');
160
+ });
161
+
162
+ it('fills side-store gaps without letting an empty side row wipe OpenCode fields', () => {
163
+ expect(mergeOwnedMetadata(
164
+ { openchamber: { goal: { status: 'paused' } }, fromOpenCode: true },
165
+ { openchamber: { goal: { id: 'g1' }, archive: { archivedAt: 4 } } },
166
+ )).toEqual({
167
+ fromOpenCode: true,
168
+ openchamber: { goal: { id: 'g1', status: 'paused' }, archive: { archivedAt: 4 } },
169
+ });
170
+ expect(mergeOwnedMetadata({ keep: true }, {})).toEqual({ keep: true });
171
+ expect(mergeOwnedMetadata({ keep: true }, null)).toEqual({ keep: true });
172
+ });
173
+
174
+ it('throws when the installed client has no session.update instead of writing nothing', async () => {
175
+ await expect(writeSessionRecordViaClient({ session: {} }, {
176
+ sessionID: 'ses_1',
177
+ metadata: { openchamber: { archive: { archivedAt: 1 } } },
178
+ })).rejects.toThrow(/session\.update is unavailable/);
179
+ });
180
+
181
+ it('writes metadata through session.update without sending title', async () => {
182
+ const update = vi.fn(async () => undefined);
183
+ await writeSessionRecordViaClient({ session: { update } }, {
184
+ sessionID: 'ses_1',
185
+ metadata: { openchamber: { archive: { archivedAt: 3 } } },
186
+ });
187
+ expect(update).toHaveBeenCalledWith({
188
+ sessionID: 'ses_1',
189
+ metadata: { openchamber: { archive: { archivedAt: 3 } } },
190
+ }, undefined);
191
+ expect(update.mock.calls[0][0].title).toBeUndefined();
192
+ });
193
+
194
+ it('reads a missing session as not-found and a transport failure as failure', async () => {
195
+ const missing = Object.assign(new Error('gone'), { status: 404 });
196
+ await expect(readSessionRecordViaClient({
197
+ session: { get: async () => { throw missing; } },
198
+ }, { sessionID: 'ses_1' })).resolves.toBeNull();
199
+
200
+ const down = Object.assign(new Error('down'), { status: 503 });
201
+ await expect(readSessionRecordViaClient({
202
+ session: { get: async () => { throw down; } },
203
+ }, { sessionID: 'ses_1' })).rejects.toThrow(/down/);
204
+ });
205
+
206
+ it('patches the session record over HTTP and does not turn a failed read into an empty session', async () => {
207
+ const calls = [];
208
+ const fetchFn = vi.fn(async (url, init) => {
209
+ calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
210
+ if (init.method === 'PATCH' && String(url).includes('ses_ok')) {
211
+ return { ok: true, status: 204, json: async () => null };
212
+ }
213
+ if (String(url).includes('ses_missing')) {
214
+ return { ok: false, status: 404, json: async () => ({}) };
215
+ }
216
+ if (String(url).includes('ses_down')) {
217
+ return { ok: false, status: 503, json: async () => ({}) };
218
+ }
219
+ return {
220
+ ok: true,
221
+ status: 200,
222
+ json: async () => ({ id: 'ses_ok', title: 'Keep', metadata: { fromOpenCode: true } }),
223
+ };
224
+ });
225
+ const client = createHttpSessionRecordClient({
226
+ buildSessionUrl: (sessionID) => `http://opencode.local/api/session/${sessionID}`,
227
+ fetchFn,
228
+ });
229
+
230
+ await expect(client.read('ses_ok')).resolves.toMatchObject({ id: 'ses_ok', title: 'Keep' });
231
+ await expect(client.read('ses_missing')).resolves.toBeNull();
232
+ await expect(client.read('ses_down')).rejects.toThrow(/503/);
233
+ await client.write({
234
+ sessionID: 'ses_ok',
235
+ metadata: { openchamber: { archive: { archivedAt: 8 } } },
236
+ });
237
+
238
+ const patch = calls.find((call) => call.method === 'PATCH');
239
+ expect(patch.body).toEqual({ metadata: { openchamber: { archive: { archivedAt: 8 } } } });
240
+ expect(patch.body.title).toBeUndefined();
241
+ });
242
+ });
@@ -83,13 +83,17 @@ export const registerSessionMetadataRoutes = (app, deps = {}) => {
83
83
  },
84
84
  },
85
85
  };
86
+ }, {
87
+ directory: asNonEmptyString(body.directory) || undefined,
86
88
  });
87
89
  if (!result?.committed) {
88
90
  throw new Error(result?.reason || 'session metadata mutate was rejected');
89
91
  }
90
92
  metadata = result.metadata;
91
93
  } else {
92
- metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch);
94
+ metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch, {
95
+ directory: asNonEmptyString(body.directory) || undefined,
96
+ });
93
97
  }
94
98
  const directory = asNonEmptyString(body.directory) || '';
95
99
  if (typeof onMetadataWritten === 'function') {
@@ -451,7 +451,7 @@ export const createSessionArchiveService = (deps = {}) => {
451
451
  const metadata = await sessionMetadataStore.setSessionMetadata(
452
452
  sessionID,
453
453
  { openchamber: { archive: { archivedAt } } },
454
- { allowArchive: true },
454
+ { allowArchive: true, directory: upstreamDirectory || directoryHint },
455
455
  );
456
456
 
457
457
  // New Host mutation wins: bump generation and cancel older repair timers
@@ -33,4 +33,6 @@ export function createSessionMetadataStore(options: {
33
33
  fsPromises?: typeof import('node:fs').promises;
34
34
  path?: typeof import('node:path');
35
35
  now?: () => number;
36
+ recordReader?: (sessionID: string, directory?: string | null) => Promise<object | null>;
37
+ recordWriter?: (sessionID: string, metadata: object, directory?: string | null) => Promise<void>;
36
38
  }): SessionMetadataStore;