@openchambery/web 2.0.0-beta.4 → 2.0.0-beta.6

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 (172) hide show
  1. package/dist/assets/{AgentAvatar-Q1SmFRx2.js → AgentAvatar-BBmQXYDq.js} +1 -1
  2. package/dist/assets/AssistantView-BpvbpZBC.js +3 -0
  3. package/dist/assets/{DiagramView-BW92schO.js → DiagramView-UwKDr8HS.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BZmwl-zE.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
  5. package/dist/assets/Icon-B9kZgYZn.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-Bv05iDZZ.js → MarkdownRenderer-C8tDsO5F.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-C81xqYib.js → MarkdownRendererImpl-DRZ1iTbg.js} +1 -1
  8. package/dist/assets/MarkstreamRendererImpl-fU6uhDTr.js +3 -0
  9. package/dist/assets/{MobileDetailNavigation-BMgIu7zc.js → MobileDetailNavigation-MXUDe0nG.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BOXGNHmC.js → MobileShareBridge-C7x4jOGb.js} +2 -2
  11. package/dist/assets/MobileSurface-BQSmXYJ_.js +2 -0
  12. package/dist/assets/{MultiRunWindow-B5At2Z95.js → MultiRunWindow-ClfxxgP5.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-bsPLgz4r.js → OnboardingScreen-BxItVnnO.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-4wp4migh.js → OverlayScrollbar-B4e0QD05.js} +1 -1
  15. package/dist/assets/SettingsView-DtWgey4y.js +25 -0
  16. package/dist/assets/{SettingsWindow-CMtX2bMl.js → SettingsWindow-Du8y1EI8.js} +1 -1
  17. package/dist/assets/TerminalView-DL9qkQDj.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-uH1ZZDSS.js → ToolOutputDialog-BXawDmmf.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DK7q2juD.js → appThemeRegistry-DsSahEsq.js} +1 -1
  20. package/dist/assets/{button-CcNJUxUd.js → button-CwXyux0a.js} +1 -1
  21. package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
  22. package/dist/assets/context-menu-DHrRifPE.js +1 -0
  23. package/dist/assets/{desktopHosts-C6HrP0jq.js → desktopHosts-JGFqhlaM.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-DdVBp45U.js → desktopRelayRestore-CHEwrI93.js} +1 -1
  25. package/dist/assets/{diagram-CxTs9hCQ.js → diagram-BwnPNKWt.js} +1 -1
  26. package/dist/assets/{dialog-KMVL9QKF.js → dialog-Dp0lQv5K.js} +1 -1
  27. package/dist/assets/es-DicJ8lKD.js +15 -0
  28. package/dist/assets/fileMentionTouchSelection-PZQrywzZ.js +3 -0
  29. package/dist/assets/{fileQueries-CjdBRy8y.js → fileQueries-DN6gbLhM.js} +1 -1
  30. package/dist/assets/fr-lhgVcBW_.js +13 -0
  31. package/dist/assets/{gitApi-yuWg9BN4.js → gitApi-C1oGENfw.js} +4 -4
  32. package/dist/assets/{imageSource-9vQBq3zp.js → imageSource-BMztRu0c.js} +1 -1
  33. package/dist/assets/{input-CrIvk3HZ.js → input-Biw8aNzR.js} +1 -1
  34. package/dist/assets/insertionBoundaries-BmQh-tke.js +1 -0
  35. package/dist/assets/ja-B9EZwOcw.js +15 -0
  36. package/dist/assets/ko-Dxk-uJRb.js +15 -0
  37. package/dist/assets/main-BnyE5H4i.js +169 -0
  38. package/dist/assets/main-D2BzpnUt.css +1 -0
  39. package/dist/assets/main-DOgXHO44.js +2 -0
  40. package/dist/assets/{markdownHeightCache-BmXVY_gc.js → markdownHeightCache-BOOrGCuG.js} +1 -1
  41. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  42. package/dist/assets/{mcpQueries-C7jknHV8.js → mcpQueries-D9Dyzcve.js} +1 -1
  43. package/dist/assets/miniChat-C7FecHIX.js +2 -0
  44. package/dist/assets/mobile-_zrfbam4.js +2 -0
  45. package/dist/assets/multirun-BLFbv4I9.js +4 -0
  46. package/dist/assets/persistence-D0y7226-.js +3 -0
  47. package/dist/assets/pl-CFhwWLJj.js +15 -0
  48. package/dist/assets/platform-D_c-T5nO.js +6 -0
  49. package/dist/assets/projectMeta-BCdCRuYS.js +3 -0
  50. package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
  51. package/dist/assets/renderElectronMiniChatApp-QQq7dj-0.js +2 -0
  52. package/dist/assets/renderMobileApp-BVIkeTKi.js +28 -0
  53. package/dist/assets/{runtimeConfig-BEANkvOd.js → runtimeConfig-S9D4fVI5.js} +1 -1
  54. package/dist/assets/runtimeEndpointReset-B6eE14f9.js +149 -0
  55. package/dist/assets/sessionChangeSummary-DLo3Iv_g.js +2 -0
  56. package/dist/assets/{sessionLookup-34XFmP9C.js → sessionLookup-BXXTw54z.js} +1 -1
  57. package/dist/assets/{sortable-tabs-strip-DyEV0Heg.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
  58. package/dist/assets/src-lS1keDVV.css +2 -0
  59. package/dist/assets/store-DmowK-GO.js +15 -0
  60. package/dist/assets/{terminalApi-CXaBEoa_.js → terminalApi-Dhqqp4xi.js} +1 -1
  61. package/dist/assets/{textarea-BBOlRI9O.js → textarea-QxsmcBGj.js} +1 -1
  62. package/dist/assets/{tooltip-CPI4fr1a.js → tooltip-CpMLl5_k.js} +1 -1
  63. package/dist/assets/uk-ChchBzy6.js +15 -0
  64. package/dist/assets/{url-DRDQXjyX.js → url-CGbJbiJq.js} +1 -1
  65. package/dist/assets/{useAppFontEffects-CiDe42I5.js → useAppFontEffects-BDs0O2qK.js} +56 -51
  66. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  67. package/dist/assets/useDesktopWindowControlsLayout-ojoBEglc.js +1 -0
  68. package/dist/assets/{useEffectiveDirectory-C_CouL5v.js → useEffectiveDirectory-B_nHyDMJ.js} +1 -1
  69. package/dist/assets/{useFontPreferences-B0oUZAiB.js → useFontPreferences-Xw1vSkmC.js} +1 -1
  70. package/dist/assets/useMobileNavigationStore-D_96D661.js +414 -0
  71. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  72. package/dist/assets/useSessionAutoCleanup-Dw34f-EW.js +2 -0
  73. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  74. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  75. package/dist/assets/{useWorkerHighlightedLines-CPNYz6Wf.js → useWorkerHighlightedLines-CNlfP7nA.js} +1 -1
  76. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  77. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  78. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  79. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  80. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  81. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  82. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  83. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  84. package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
  85. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  86. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  87. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  88. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
  89. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  90. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  91. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  92. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  93. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
  94. package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
  95. package/dist/assets/zh-TW-CZeTm7kd.js +15 -0
  96. package/dist/index.html +13 -13
  97. package/dist/mini-chat.html +13 -13
  98. package/dist/mobile.html +13 -13
  99. package/package.json +3 -2
  100. package/server/index.js +38 -7
  101. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  102. package/server/lib/browser-provider/contract.js +312 -0
  103. package/server/lib/browser-provider/host.js +421 -0
  104. package/server/lib/browser-provider/host.test.js +304 -0
  105. package/server/lib/browser-provider/routes.js +122 -0
  106. package/server/lib/browser-provider/routes.test.js +120 -0
  107. package/server/lib/browser-provider/selection-store.js +43 -0
  108. package/server/lib/browser-provider/selection-store.test.js +35 -0
  109. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  110. package/server/lib/notifications/runtime.js +110 -26
  111. package/server/lib/notifications/runtime.test.js +92 -0
  112. package/server/lib/notifications/template-runtime.js +130 -38
  113. package/server/lib/notifications/template-runtime.test.js +96 -0
  114. package/server/lib/opencode/DOCUMENTATION.md +4 -0
  115. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  116. package/server/lib/opencode/feature-routes-runtime.js +10 -0
  117. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  118. package/server/lib/opencode/proxy.js +3 -1
  119. package/server/lib/opencode/proxy.test.js +3 -3
  120. package/server/lib/opencode/routes.js +30 -0
  121. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  122. package/server/lib/opencode/upgrade-screen.js +299 -0
  123. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  124. package/server/lib/session-metadata/DOCUMENTATION.md +27 -17
  125. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  126. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  127. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  128. package/server/lib/session-metadata/routes.js +5 -1
  129. package/server/lib/session-metadata/session-archive.js +1 -1
  130. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  131. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  132. package/server/lib/session-metadata/session-projection.js +26 -2
  133. package/server/lib/session-title/runtime.js +2 -2
  134. package/dist/assets/AssistantView-CwXkiCAd.js +0 -3
  135. package/dist/assets/Icon-AQPvxMiU.js +0 -1
  136. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +0 -3
  137. package/dist/assets/MobileSurface-xuc_z0_G.js +0 -2
  138. package/dist/assets/SettingsView-CJ6tcH5_.js +0 -25
  139. package/dist/assets/TerminalView-Dvr-ysTS.js +0 -8
  140. package/dist/assets/collapsible-ZmZ6rLWs.js +0 -3
  141. package/dist/assets/context-menu-B2LXJgXL.js +0 -1
  142. package/dist/assets/es-CN3E_zXj.js +0 -15
  143. package/dist/assets/fileMentionTouchSelection-7RIwlpkB.js +0 -4
  144. package/dist/assets/fr-9zZvQZD2.js +0 -13
  145. package/dist/assets/insertionBoundaries-BiUCSkx-.js +0 -1
  146. package/dist/assets/ja-Dnjk_82N.js +0 -15
  147. package/dist/assets/ko-BxCEE99C.js +0 -15
  148. package/dist/assets/main-C9mIBo77.js +0 -2
  149. package/dist/assets/main-DiL6w6KF.js +0 -169
  150. package/dist/assets/main-qSeeJRW1.css +0 -1
  151. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  152. package/dist/assets/miniChat-CwtUaElU.js +0 -2
  153. package/dist/assets/mobile-DB-6--ws.js +0 -2
  154. package/dist/assets/multirun-TAsxFI8e.js +0 -4
  155. package/dist/assets/persistence-56kq8Z4V.js +0 -3
  156. package/dist/assets/pl-CNNsWnPM.js +0 -15
  157. package/dist/assets/platform-C13fMVEs.js +0 -6
  158. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  159. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +0 -2
  160. package/dist/assets/renderMobileApp-CGMMqbsO.js +0 -28
  161. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +0 -147
  162. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +0 -2
  163. package/dist/assets/src-ubCWXZhR.css +0 -2
  164. package/dist/assets/store-DLCiZaA6.js +0 -15
  165. package/dist/assets/uk-rwQrxeYM.js +0 -15
  166. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +0 -1
  167. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +0 -414
  168. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +0 -2
  169. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  170. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  171. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  172. package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
@@ -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;
@@ -1,26 +1,28 @@
1
1
  /**
2
- * OpenChamber-owned session metadata.
2
+ * OpenChamber session metadata cache and migration source.
3
3
  *
4
- * OpenCode 2.x accepts `metadata` only when a session is created; the v1
5
- * `PATCH /session/{id}` route is gone and nothing replaces it. Session goal
6
- * progress (and later assist / pinned notes) live here: one JSON file per data
7
- * dir, `{ [sessionID]: metadata }`, folded back onto the sessions the proxy
8
- * serves so clients keep reading `session.metadata` where they always did.
4
+ * The OpenCode session record is the durable authority for title, archive, and
5
+ * other OpenChamber metadata (`opencode-session-record.js`). This JSON file
6
+ * remains the cache and the pre-migration source. A failed or corrupt read is
7
+ * never empty success: the store stays unwritable and does not overwrite
8
+ * unknown disk state or the OpenCode record.
9
9
  *
10
10
  * Writes are a JSON Merge Patch (RFC 7386): nested objects merge key by key and
11
- * a `null` deletes. That is what the old PATCH did, and it is what keeps two
12
- * features writing into the same `openchamber` namespace from erasing each
13
- * other — goal mode saving progress must not drop an assist recap.
11
+ * a `null` deletes. That keeps two features writing into the same `openchamber`
12
+ * namespace from erasing each other — goal mode saving progress must not drop
13
+ * an assist recap. When a record reader and writer are wired, the merged object
14
+ * is written onto the OpenCode session before the local cache commits. A failed
15
+ * record read is not treated as empty metadata.
14
16
  *
15
17
  * Mutations are fully serialized. Committed `entries` update only after a
16
18
  * successful persist so concurrent readers never observe uncommitted drafts.
17
- * A failed or corrupt file read is never empty success: the store stays
18
- * unwritable and reports the error instead of overwriting unknown disk state.
19
19
  */
20
20
 
21
21
  import fsDefault from 'node:fs';
22
22
  import pathDefault from 'node:path';
23
23
 
24
+ import { mergeOwnedMetadata } from './opencode-session-record.js';
25
+
24
26
  const METADATA_FILE_NAME = 'sessions-metadata.json';
25
27
  const HOST_ARCHIVE_KEY = 'archive';
26
28
 
@@ -103,13 +105,22 @@ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive
103
105
  * @param {typeof fsDefault.promises} [options.fsPromises]
104
106
  * @param {typeof pathDefault} [options.path]
105
107
  * @param {() => number} [options.now]
108
+ * @param {(sessionID: string, directory?: string | null) => Promise<object | null>} [options.recordReader]
109
+ * OpenCode session read. Null means the session is gone. Any other failure throws.
110
+ * @param {(sessionID: string, metadata: object, directory?: string | null) => Promise<void>} [options.recordWriter]
111
+ * Writes the full merged metadata onto the OpenCode session record. Must not send title.
106
112
  */
107
113
  export const createSessionMetadataStore = ({
108
114
  dataDir,
109
115
  fsPromises = fsDefault.promises,
110
116
  path = pathDefault,
111
117
  now = Date.now,
118
+ recordReader = null,
119
+ recordWriter = null,
112
120
  }) => {
121
+ if (typeof recordWriter === 'function' && typeof recordReader !== 'function') {
122
+ throw new Error('session record writer requires a record reader');
123
+ }
113
124
  const filePath = path.join(dataDir, METADATA_FILE_NAME);
114
125
 
115
126
  /** sessionID → metadata. Committed only — never holds an in-flight draft. */
@@ -218,11 +229,27 @@ export const createSessionMetadataStore = ({
218
229
  return next;
219
230
  };
220
231
 
221
- const get = async (sessionID) => {
232
+ const readRecordMetadata = async (id, directory) => {
233
+ if (typeof recordReader !== 'function') return undefined;
234
+ try {
235
+ const record = await recordReader(id, directory);
236
+ if (record == null) return null;
237
+ return isPlainObject(record.metadata) ? record.metadata : {};
238
+ } catch (error) {
239
+ if (entries.has(id)) return undefined;
240
+ throw error;
241
+ }
242
+ };
243
+
244
+ const get = async (sessionID, options = {}) => {
222
245
  const id = asNonEmptyString(sessionID);
223
246
  if (!id) return {};
224
247
  await ensureReadable();
225
- return entries.get(id) ?? {};
248
+ const side = entries.get(id);
249
+ const fromRecord = await readRecordMetadata(id, options.directory);
250
+ if (fromRecord === undefined) return side ?? {};
251
+ if (fromRecord === null) return side ?? {};
252
+ return mergeOwnedMetadata(fromRecord, side);
226
253
  };
227
254
 
228
255
  const has = async (sessionID) => {
@@ -243,16 +270,36 @@ export const createSessionMetadataStore = ({
243
270
  */
244
271
  const commitMergedPatch = async (id, previous, patch, options = {}) => {
245
272
  const allowArchive = options.allowArchive === true;
273
+ let base = previous;
274
+ if (typeof recordReader === 'function') {
275
+ let record;
276
+ try {
277
+ record = await recordReader(id, options.directory);
278
+ } catch (error) {
279
+ // A failed read is not an empty record. Do not replace metadata we could not see.
280
+ throw error;
281
+ }
282
+ if (record == null) {
283
+ const error = new Error(`session ${id} was not found`);
284
+ error.status = 404;
285
+ throw error;
286
+ }
287
+ base = mergeOwnedMetadata(record.metadata, previous);
288
+ }
246
289
  const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
247
290
 
248
- let merged = mergeMetadataPatch(previous, effectivePatch);
249
- merged = applyReservedArchiveProtection(previous, patch, merged, { allowArchive });
291
+ let merged = mergeMetadataPatch(base, effectivePatch);
292
+ merged = applyReservedArchiveProtection(base, patch, merged, { allowArchive });
293
+
294
+ if (typeof recordWriter === 'function') {
295
+ await recordWriter(id, merged, options.directory);
296
+ }
250
297
 
251
298
  const nextMap = new Map(entries);
252
299
  if (Object.keys(merged).length === 0) nextMap.delete(id);
253
300
  else nextMap.set(id, merged);
254
301
 
255
- // Persist draft snapshot first; publish to committed entries only on success.
302
+ // OpenCode write already landed. Publish the local cache only after its persist succeeds.
256
303
  await persistMap(nextMap);
257
304
 
258
305
  if (Object.keys(merged).length === 0) entries.delete(id);
@@ -168,6 +168,30 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
168
168
  return next;
169
169
  };
170
170
 
171
+ /**
172
+ * OpenCode record keys win. Side-store keys the record does not have yet are
173
+ * filled in. A missing or empty side row does not wipe the record.
174
+ *
175
+ * @param {unknown} openCodeMetadata
176
+ * @param {unknown} sideMetadata
177
+ */
178
+ export const mergeOwnedMetadata = (openCodeMetadata, sideMetadata) => {
179
+ const openCode = isPlainObject(openCodeMetadata) ? openCodeMetadata : {};
180
+ if (!isPlainObject(sideMetadata) || Object.keys(sideMetadata).length === 0) {
181
+ return { ...openCode };
182
+ }
183
+ return mergeMetadataPatch(sideMetadata, openCode);
184
+ };
185
+
186
+ const projectRecordMetadata = (session, sideMetadata) => {
187
+ const metadata = mergeOwnedMetadata(session.metadata, sideMetadata);
188
+ const hadMetadata = isPlainObject(session.metadata);
189
+ const withRecord = Object.keys(metadata).length === 0 && !hadMetadata
190
+ ? session
191
+ : { ...session, metadata };
192
+ return projectSessionWithHostMetadata(withRecord, null);
193
+ };
194
+
171
195
  /**
172
196
  * @param {object | null | undefined} session
173
197
  * @param {Record<string, object> | null | undefined} storedBySessionId
@@ -178,7 +202,7 @@ export const projectSessionWithStoredMap = (session, storedBySessionId) => {
178
202
  return projectSessionWithHostMetadata(session, null);
179
203
  }
180
204
  const host = storedBySessionId[session.id];
181
- return projectSessionWithHostMetadata(session, isPlainObject(host) ? host : null);
205
+ return projectRecordMetadata(session, isPlainObject(host) ? host : null);
182
206
  };
183
207
 
184
208
  const projectLifecycleInner = (payload, readHostMetadata) => {
@@ -196,7 +220,7 @@ const projectLifecycleInner = (payload, readHostMetadata) => {
196
220
  host = null;
197
221
  }
198
222
  }
199
- const projected = projectSessionWithHostMetadata(info, isPlainObject(host) ? host : null);
223
+ const projected = projectRecordMetadata(info, isPlainObject(host) ? host : null);
200
224
  if (projected === info) return payload;
201
225
 
202
226
  const next = { ...payload };
@@ -122,8 +122,8 @@ const buildTitleSystemPrompt = () => [
122
122
  'Name the main subject of the work.',
123
123
  'Keep that subject across follow-ups, polish, commit, push, tidy, and review.',
124
124
  'Switch only when the user clearly starts a different topic.',
125
- 'One line, ≤50 characters, no explanation.',
126
- 'Prefer ≤6 words or ≤6 CJK characters. No modifiers. No verbs.',
125
+ 'One line, no explanation.',
126
+ 'Hard limit: never exceed 6 CJK characters (or 6 words for English). No modifiers. No verbs.',
127
127
  'Use the language of the user messages.',
128
128
  'Keep technical terms, numbers, filenames, and HTTP codes exact.',
129
129
  'Never include tool names, summarizing, or generating.',