@openchambery/web 2.0.0-beta.8 → 2.0.0

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 (237) hide show
  1. package/README.md +14 -0
  2. package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-C1MGZ7qC.js} +1 -1
  3. package/dist/assets/AssistantView-vqCFvVo0.js +3 -0
  4. package/dist/assets/{DiagramView-BBPsLqLq.js → DiagramView-CYO5iVVm.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-BMSk_tY-.js} +1 -1
  6. package/dist/assets/Icon-BdzPRCSy.js +1 -0
  7. package/dist/assets/MarkdownRenderer-FMynjkGR.js +2 -0
  8. package/dist/assets/{MarkdownRendererImpl-CD0RYNWX.js → MarkdownRendererImpl-DoWvw5jj.js} +3 -3
  9. package/dist/assets/{MarkstreamRendererImpl-CECVYTH6.js → MarkstreamRendererImpl-DoeYajmw.js} +1 -1
  10. package/dist/assets/{MobileDetailNavigation-DOwW-F0i.js → MobileDetailNavigation-BzBz-co_.js} +1 -1
  11. package/dist/assets/{MobileShareBridge-d0hQ2FSQ.js → MobileShareBridge-D1Uoj6l_.js} +3 -3
  12. package/dist/assets/MobileSurface-8YpZ51Qo.js +2 -0
  13. package/dist/assets/{MultiRunWindow-DOdFEgMg.js → MultiRunWindow-BxcYUPta.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-yRdQTR1M.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-S-7EePsU.js → OverlayScrollbar-BUjtqnxn.js} +1 -1
  16. package/dist/assets/SettingsView-D7KrvqvH.js +25 -0
  17. package/dist/assets/{SettingsWindow-vKn-MWGU.js → SettingsWindow-DX2w-cDc.js} +1 -1
  18. package/dist/assets/{TerminalView-BgPjY0CO.js → TerminalView-CiUcCQPm.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-DtT3wbie.js → ToolOutputDialog-DQvXtK71.js} +1 -1
  20. package/dist/assets/appThemeRegistry-BXC6cIDE.js +1 -0
  21. package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
  22. package/dist/assets/{button-DIIrsMjW.js → button-CUwjpv-i.js} +1 -1
  23. package/dist/assets/{collapsible-CnvUeYst.js → collapsible-paItOH3A.js} +1 -1
  24. package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-K6zNumqN.js} +1 -1
  25. package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-DuftEj8B.js} +1 -1
  26. package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
  27. package/dist/assets/{diagram-rvn7_qBM.js → diagram-D6nY5hbD.js} +1 -1
  28. package/dist/assets/dialog-PjKWayIU.js +1 -0
  29. package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
  30. package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
  31. package/dist/assets/fileMentionTouchSelection-78v5mNTE.js +2 -0
  32. package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-DuF89nFO.js} +1 -1
  33. package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
  34. package/dist/assets/{gitApi-D9ygh31C.js → gitApi-eoKSJcvW.js} +4 -4
  35. package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
  36. package/dist/assets/{input-BVu-iykl.js → input-BeVw4D0W.js} +1 -1
  37. package/dist/assets/insertionBoundaries-BI3RHOpf.js +1 -0
  38. package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
  39. package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
  40. package/dist/assets/main-CQpUPjPK.js +169 -0
  41. package/dist/assets/{main-B97oNWoh.js → main-MN0YKJ0_.js} +2 -2
  42. package/dist/assets/main-qSeeJRW1.css +1 -0
  43. package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-CGCWmHgq.js} +1 -1
  44. package/dist/assets/{mcpQueries-zC4-V5b8.js → mcpQueries-X424Iq2z.js} +1 -1
  45. package/dist/assets/miniChat-DFIl76ku.js +2 -0
  46. package/dist/assets/mobile-C3fWXGQJ.js +2 -0
  47. package/dist/assets/multirun-DuSX3YRs.js +3 -0
  48. package/dist/assets/persistence-Dnj1QOdX.js +3 -0
  49. package/dist/assets/pl-B714sdql.js +15 -0
  50. package/dist/assets/platform-jCZ3KfMQ.js +6 -0
  51. package/dist/assets/projectMeta-Cn08pTNi.js +1 -0
  52. package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
  53. package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-CRPdctIn.js +2 -0
  55. package/dist/assets/renderMobileApp-CVURQbeh.js +28 -0
  56. package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
  57. package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
  58. package/dist/assets/runtimeConfig-xivE1wxC.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-B8DzKzwg.js +148 -0
  60. package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
  61. package/dist/assets/sessionChangeSummary-yBsR2pyd.js +2 -0
  62. package/dist/assets/{sessionLookup-DcyGP3xg.js → sessionLookup-1Z1FWZRB.js} +1 -1
  63. package/dist/assets/{sortable-tabs-strip-BsObV7Pl.js → sortable-tabs-strip-D611LPyd.js} +1 -1
  64. package/dist/assets/src-TBM7scxy.css +2 -0
  65. package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
  66. package/dist/assets/{terminalApi-Dhqqp4xi.js → terminalApi-BZW_rAc3.js} +1 -1
  67. package/dist/assets/{textarea-BoXpPjTd.js → textarea-QtZjf4L8.js} +1 -1
  68. package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-DDNnmcXI.js} +1 -1
  69. package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
  70. package/dist/assets/{url-CGbJbiJq.js → url-DNRMq_0R.js} +1 -1
  71. package/dist/assets/{useAppFontEffects-BauzQXF0.js → useAppFontEffects-DJy8P5By.js} +51 -51
  72. package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
  73. package/dist/assets/useDesktopWindowControlsLayout-DlwSomVK.js +1 -0
  74. package/dist/assets/{useEffectiveDirectory-qDoROBC_.js → useEffectiveDirectory-eJorVTT9.js} +1 -1
  75. package/dist/assets/{useFontPreferences-Xw1vSkmC.js → useFontPreferences-Bjgt_lzl.js} +1 -1
  76. package/dist/assets/useMobileNavigationStore-A_ZNTfpl.js +415 -0
  77. package/dist/assets/useSessionAutoCleanup-CqC3dEou.js +2 -0
  78. package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-Dfx6TKvi.js} +1 -1
  79. package/dist/assets/{useWorkerHighlightedLines-D8luxgem.js → useWorkerHighlightedLines-B0vSL6jr.js} +1 -1
  80. package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
  81. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
  82. package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
  83. package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
  84. package/dist/index.html +13 -13
  85. package/dist/mini-chat.html +13 -13
  86. package/dist/mobile.html +13 -13
  87. package/package.json +3 -3
  88. package/server/index.js +8 -20
  89. package/server/lib/client-auth/remote-clients.js +15 -0
  90. package/server/lib/client-auth/remote-clients.test.js +42 -1
  91. package/server/lib/git/DOCUMENTATION.md +1 -0
  92. package/server/lib/git/routes.js +7 -4
  93. package/server/lib/git/routes.test.js +34 -0
  94. package/server/lib/git/service.js +3 -1
  95. package/server/lib/llm/DOCUMENTATION.md +6 -4
  96. package/server/lib/llm/generate.js +8 -9
  97. package/server/lib/llm/generate.test.js +33 -6
  98. package/server/lib/message-queue/service.js +1 -1
  99. package/server/lib/opencode/DOCUMENTATION.md +33 -22
  100. package/server/lib/opencode/agents.js +13 -22
  101. package/server/lib/opencode/auth-state-runtime.js +3 -3
  102. package/server/lib/opencode/auth-state-runtime.test.js +59 -0
  103. package/server/lib/opencode/auth.d.ts +13 -0
  104. package/server/lib/opencode/auth.js +74 -64
  105. package/server/lib/opencode/auth.test.js +57 -0
  106. package/server/lib/opencode/commands.js +1 -1
  107. package/server/lib/opencode/config-entities-layering.test.js +72 -0
  108. package/server/lib/opencode/config-entity-routes.js +10 -123
  109. package/server/lib/opencode/config-entity-routes.test.js +23 -11
  110. package/server/lib/opencode/config-files.d.ts +17 -0
  111. package/server/lib/opencode/config-files.js +187 -0
  112. package/server/lib/opencode/config-files.test.js +201 -0
  113. package/server/lib/opencode/core-routes.js +17 -1
  114. package/server/lib/opencode/core-routes.test.js +43 -15
  115. package/server/lib/opencode/credential-boundary.d.ts +1 -0
  116. package/server/lib/opencode/credential-boundary.js +11 -0
  117. package/server/lib/opencode/ensure-cli.js +4 -1
  118. package/server/lib/opencode/env-config.js +4 -1
  119. package/server/lib/opencode/env-config.test.js +28 -0
  120. package/server/lib/opencode/env-runtime.test.js +18 -1
  121. package/server/lib/opencode/feature-routes-runtime.js +9 -7
  122. package/server/lib/opencode/hmr-state-runtime.js +4 -4
  123. package/server/lib/opencode/lifecycle.js +1 -49
  124. package/server/lib/opencode/lifecycle.test.js +30 -4
  125. package/server/lib/opencode/location-release.d.ts +8 -0
  126. package/server/lib/opencode/location-release.js +32 -0
  127. package/server/lib/opencode/location-release.test.js +43 -0
  128. package/server/lib/opencode/mcp.js +1 -1
  129. package/server/lib/opencode/opencode2-pin.js +2 -1
  130. package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
  131. package/server/lib/opencode/plugin-routes.test.js +4 -2
  132. package/server/lib/opencode/plugins.js +12 -60
  133. package/server/lib/opencode/plugins.test.js +5 -3
  134. package/server/lib/opencode/providers.js +6 -3
  135. package/server/lib/opencode/proxy.js +7 -0
  136. package/server/lib/opencode/proxy.test.js +13 -0
  137. package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
  138. package/server/lib/opencode/routes.js +10 -8
  139. package/server/lib/opencode/routes.test.js +51 -10
  140. package/server/lib/opencode/runtime-contract.js +3 -2
  141. package/server/lib/opencode/runtime-contract.test.js +12 -12
  142. package/server/lib/opencode/settings-normalization-runtime.js +3 -1
  143. package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
  144. package/server/lib/opencode/settings-runtime.test.js +58 -1
  145. package/server/lib/opencode/shared.js +6 -175
  146. package/server/lib/opencode/update-discovery.js +39 -0
  147. package/server/lib/opencode/update-discovery.test.js +39 -0
  148. package/server/lib/opencode/upgrade-screen.js +4 -11
  149. package/server/lib/opencode/upgrade-screen.test.js +25 -25
  150. package/server/lib/opencode/version-alignment.test.js +40 -0
  151. package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
  152. package/server/lib/question-auto-delegate/core.d.ts +5 -1
  153. package/server/lib/question-auto-delegate/core.js +21 -13
  154. package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
  155. package/server/lib/question-auto-delegate/form-io.js +39 -0
  156. package/server/lib/question-auto-delegate/runtime.js +4 -24
  157. package/server/lib/question-auto-delegate/runtime.test.js +138 -0
  158. package/server/lib/quota/DOCUMENTATION.md +9 -0
  159. package/server/lib/quota/provider-credentials.test.js +29 -0
  160. package/server/lib/quota/providers/claude.js +4 -4
  161. package/server/lib/quota/providers/codex.js +4 -4
  162. package/server/lib/quota/providers/codex.test.js +1 -1
  163. package/server/lib/quota/providers/copilot.js +5 -5
  164. package/server/lib/quota/providers/google/auth.js +3 -3
  165. package/server/lib/quota/providers/google/index.js +2 -2
  166. package/server/lib/quota/providers/index.js +3 -7
  167. package/server/lib/quota/providers/index.test.js +2 -2
  168. package/server/lib/quota/providers/kimi.js +4 -4
  169. package/server/lib/quota/providers/minimax-shared.js +4 -4
  170. package/server/lib/quota/providers/nanogpt.js +4 -4
  171. package/server/lib/quota/providers/openai.js +4 -4
  172. package/server/lib/quota/providers/openrouter.js +4 -4
  173. package/server/lib/quota/providers/wafer.js +4 -4
  174. package/server/lib/quota/providers/zai.js +4 -4
  175. package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
  176. package/server/lib/quota/routes.js +1 -1
  177. package/server/lib/relay/DOCUMENTATION.md +3 -1
  178. package/server/lib/relay/service.js +1 -0
  179. package/server/lib/relay/service.test.js +13 -0
  180. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
  181. package/server/lib/scheduled-tasks/runtime.js +12 -4
  182. package/server/lib/scheduled-tasks/runtime.test.js +81 -6
  183. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  184. package/server/lib/session-goal/runtime.js +4 -4
  185. package/server/lib/session-goal/runtime.test.js +32 -3
  186. package/server/lib/session-index/DOCUMENTATION.md +7 -0
  187. package/server/lib/session-index/event-ingest.js +13 -0
  188. package/server/lib/session-index/event-ingest.test.js +7 -0
  189. package/server/lib/session-index/service.js +45 -0
  190. package/server/lib/session-index/service.test.js +47 -0
  191. package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
  192. package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
  193. package/server/lib/session-metadata/session-projection.d.ts +1 -0
  194. package/server/lib/session-metadata/session-projection.js +23 -0
  195. package/server/lib/session-metadata/session-projection.test.js +35 -0
  196. package/server/lib/session-title/DOCUMENTATION.md +52 -14
  197. package/server/lib/session-title/native-runtime.test.js +59 -5
  198. package/server/lib/session-title/runtime.js +63 -17
  199. package/server/lib/session-title/session-access.js +108 -3
  200. package/server/lib/session-title/session-access.test.js +125 -7
  201. package/server/lib/tts/DOCUMENTATION.md +7 -5
  202. package/server/lib/tts/credential-source.test.js +23 -0
  203. package/server/lib/tts/routes.js +2 -2
  204. package/server/lib/tts/service.js +16 -37
  205. package/dist/assets/AssistantView-CxAzKd78.js +0 -3
  206. package/dist/assets/Icon-B8fSTLdP.js +0 -1
  207. package/dist/assets/MarkdownRenderer-yq2DPZUG.js +0 -2
  208. package/dist/assets/MobileSurface-qalyCN7X.js +0 -2
  209. package/dist/assets/SettingsView-DVxMbeKC.js +0 -25
  210. package/dist/assets/appThemeRegistry-DC5NRV_i.js +0 -1
  211. package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
  212. package/dist/assets/dialog-CQ63sc9E.js +0 -1
  213. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
  214. package/dist/assets/fileMentionTouchSelection-B8Blr6EI.js +0 -3
  215. package/dist/assets/insertionBoundaries-DYu8VdgE.js +0 -1
  216. package/dist/assets/main-D2BzpnUt.css +0 -1
  217. package/dist/assets/main-SXMSe99Y.js +0 -169
  218. package/dist/assets/miniChat-CIi4PmQO.js +0 -2
  219. package/dist/assets/mobile-BK0Ov-k5.js +0 -2
  220. package/dist/assets/multirun-UIu_EbC2.js +0 -4
  221. package/dist/assets/persistence-D0y7226-.js +0 -3
  222. package/dist/assets/pl-BCf5uLlK.js +0 -15
  223. package/dist/assets/platform-D_c-T5nO.js +0 -6
  224. package/dist/assets/projectMeta-BPi16qGY.js +0 -3
  225. package/dist/assets/renderElectronMiniChatApp-CnTCJpBd.js +0 -2
  226. package/dist/assets/renderMobileApp-BFDrOvfv.js +0 -28
  227. package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
  228. package/dist/assets/runtimeConfig-023C-il6.js +0 -2
  229. package/dist/assets/runtimeEndpointReset-Bdx3CutT.js +0 -149
  230. package/dist/assets/sessionChangeSummary-B8xrgW5-.js +0 -2
  231. package/dist/assets/src-lS1keDVV.css +0 -2
  232. package/dist/assets/useDesktopWindowControlsLayout-kZ4wtPnO.js +0 -1
  233. package/dist/assets/useMobileNavigationStore-BDXu5FSG.js +0 -414
  234. package/dist/assets/useSessionAutoCleanup-BHloMhRp.js +0 -2
  235. package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
  236. package/server/lib/opencode/snippets.js +0 -242
  237. package/server/lib/opencode/snippets.test.js +0 -97
@@ -1108,6 +1108,78 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1108
1108
  expect(client.prompt).not.toHaveBeenCalled();
1109
1109
  });
1110
1110
 
1111
+ it.each([
1112
+ ['succeeded', 'success', null],
1113
+ ['failed', 'error', 'session execution failed'],
1114
+ ['interrupted', 'error', 'session execution interrupted'],
1115
+ ])('settles native idle outcome %s instead of timing out', async (outcome, status, error) => {
1116
+ vi.useFakeTimers();
1117
+ try {
1118
+ const history = createHistoryStore();
1119
+ const client = createSuccessfulClient({
1120
+ messageListImpl: async () => ({
1121
+ data: [
1122
+ { id: 'msg_idle', type: 'idle', outcome, time: { created: 1_200 } },
1123
+ outcome === 'succeeded' ? erroredAssistant() : completedAssistant(),
1124
+ ],
1125
+ }),
1126
+ });
1127
+ const updateState = vi.fn(async (_projectID, _taskID, state) => ({
1128
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1129
+ }));
1130
+ const runtime = createRuntime(updateState, {
1131
+ runHistoryStore: history,
1132
+ waitForOpenCodeReady: vi.fn(async () => {}),
1133
+ maxRunDurationMs: 2_500,
1134
+ });
1135
+ await runtime.syncProject('project-1');
1136
+ const pending = runtime.runNow('project-1', 'task-1');
1137
+ await vi.advanceTimersByTimeAsync(2_500);
1138
+ const result = await pending;
1139
+ expect(result.status).toBe(status);
1140
+ expect(result.error ?? null).toBe(error);
1141
+ expect(history.finishRun).toHaveBeenCalledWith(
1142
+ expect.any(String), expect.objectContaining({ status }),
1143
+ );
1144
+ expect(client.interrupt).not.toHaveBeenCalled();
1145
+ expect(client.messageList).toHaveBeenCalledTimes(1);
1146
+ expect(history.finishRun.mock.calls[0][1].durationMs).toBeLessThan(2_500);
1147
+ runtime.stop();
1148
+ } finally {
1149
+ vi.useRealTimers();
1150
+ }
1151
+ });
1152
+
1153
+ it('does not settle an older idle record while the session is active', async () => {
1154
+ vi.useFakeTimers();
1155
+ try {
1156
+ const history = createHistoryStore();
1157
+ let busy = true;
1158
+ const client = createSuccessfulClient({
1159
+ activeImpl: async () => busy ? { ses_1: { type: 'running' } } : {},
1160
+ messageListImpl: async () => ({
1161
+ data: [{ id: 'msg_idle', type: 'idle', outcome: 'succeeded', time: { created: 1_200 } }],
1162
+ }),
1163
+ });
1164
+ const runtime = createRuntime(vi.fn(async () => ({ task: scheduledTask })), {
1165
+ runHistoryStore: history,
1166
+ waitForOpenCodeReady: vi.fn(async () => {}),
1167
+ maxRunDurationMs: 5_000,
1168
+ });
1169
+ await runtime.syncProject('project-1');
1170
+ const pending = runtime.runNow('project-1', 'task-1');
1171
+ await vi.advanceTimersByTimeAsync(1_000);
1172
+ expect(history.finishRun).not.toHaveBeenCalled();
1173
+ expect(client.messageList).not.toHaveBeenCalled();
1174
+ busy = false;
1175
+ await vi.advanceTimersByTimeAsync(1_000);
1176
+ expect((await pending).status).toBe('success');
1177
+ runtime.stop();
1178
+ } finally {
1179
+ vi.useRealTimers();
1180
+ }
1181
+ });
1182
+
1111
1183
  it('ordinary non-goal runs still create a session and settle on the assistant tail', async () => {
1112
1184
  const history = createHistoryStore();
1113
1185
  const client = createSuccessfulClient({ sessionID: 'ses_plain' });
@@ -1182,16 +1254,17 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1182
1254
  );
1183
1255
  });
1184
1256
 
1185
- it('does not succeed when the latest message is model-switched', async () => {
1257
+ it.each([
1258
+ { id: 'msg_switch', type: 'model-switched', model: { providerID: 'openai', id: 'gpt-4.1' } },
1259
+ { id: 'msg_idle', type: 'idle', outcome: 'unknown' },
1260
+ ])('does not succeed on an unrecognized terminal: $type', async (tail) => {
1186
1261
  vi.useFakeTimers();
1187
1262
  const history = createHistoryStore();
1188
1263
  createSuccessfulClient({
1189
1264
  messageListImpl: async () => ({
1190
1265
  data: [{
1191
- id: 'msg_switch',
1192
- type: 'model-switched',
1266
+ ...tail,
1193
1267
  time: { created: Date.now() },
1194
- model: { providerID: 'openai', id: 'gpt-4.1' },
1195
1268
  }],
1196
1269
  }),
1197
1270
  });
@@ -1442,7 +1515,7 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1442
1515
  vi.unstubAllGlobals();
1443
1516
  });
1444
1517
 
1445
- it('corrects lastStatus error → success on idle after a continuation, without rewriting history', async () => {
1518
+ it.each(['assistant', 'idle'])('corrects lastStatus error → success from %s after a continuation, without rewriting history', async (tailType) => {
1446
1519
  const history = createHistoryStore();
1447
1520
  const client = createSuccessfulClient({ settlement: 'assistant-error' });
1448
1521
  const fetchMock = vi.fn(async (url) => {
@@ -1468,7 +1541,9 @@ describe('scheduled-tasks run history and session lifecycle', () => {
1468
1541
  expect(history.finishRun).toHaveBeenCalledTimes(1);
1469
1542
 
1470
1543
  client.messageList.mockImplementation(async () => ({
1471
- data: [completedAssistant('msg_ok')],
1544
+ data: [tailType === 'idle'
1545
+ ? { id: 'msg_idle', type: 'idle', outcome: 'succeeded', time: { created: 1_200 } }
1546
+ : completedAssistant('msg_ok')],
1472
1547
  }));
1473
1548
 
1474
1549
  await runtime.observeSessionEvent({
@@ -186,6 +186,17 @@ before touching the filesystem). Rationale: metadata rides every
186
186
 
187
187
  ## Flow
188
188
 
189
+ Recent-message reads request the newest 40 records with V2 `order: desc`, then
190
+ reverse that page into chronological order for tail checks, audit selection and
191
+ accounting. The SDK and HTTP fallback use the same ordering contract; `asc`
192
+ without a cursor would read the oldest page and strand long sessions after
193
+ compaction. Message IDs are not used to reorder the fetched page.
194
+
195
+ UI goal removal sends the Merge Patch tombstone `openchamber.goal: null`.
196
+ Omitting the key preserves the stored goal. Objective-file cleanup and stopping
197
+ the active turn follow a successful metadata write; failed writes retain the
198
+ goal and its objective for retry.
199
+
189
200
  1. `createSessionGoalRuntime` subscribes to the global SSE hub (it needs
190
201
  the envelope's `directory`).
191
202
  2. `session.status: idle` arms a 15s per-session timer; `busy`/`retry` clears
@@ -722,20 +722,20 @@ export const createSessionGoalRuntime = ({
722
722
  const client = openCodeClient();
723
723
  if (typeof client?.message?.list === 'function') {
724
724
  const listed = await client.message.list(
725
- { sessionID: sessionId, limit: MESSAGE_FETCH_LIMIT, order: 'asc' },
725
+ { sessionID: sessionId, limit: MESSAGE_FETCH_LIMIT, order: 'desc' },
726
726
  directoryRequestOptions(directory),
727
727
  );
728
728
  const projected = projectGoalMessages(listed, sessionId);
729
- if (projected) return projected;
729
+ if (projected) return projected.reverse();
730
730
  }
731
731
  } catch {
732
732
  // Fall through to controlled HTTP seam for tests / older fixtures.
733
733
  }
734
734
  const raw = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
735
735
  directory,
736
- query: { limit: String(MESSAGE_FETCH_LIMIT) },
736
+ query: { limit: String(MESSAGE_FETCH_LIMIT), order: 'desc' },
737
737
  }).catch(() => null);
738
- return projectGoalMessages(raw, sessionId);
738
+ return projectGoalMessages(raw, sessionId)?.reverse() ?? null;
739
739
  };
740
740
 
741
741
  /**
@@ -136,7 +136,7 @@ const makeFetch = ({ messages, liveStatus }) => {
136
136
  }
137
137
  if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
138
138
  // Official v2 SessionMessagesResponse shape.
139
- return jsonResponse({ data: messages, cursor: {} });
139
+ return jsonResponse({ data: new URL(url).searchParams.get('order') === 'desc' ? [...messages].reverse() : messages, cursor: {} });
140
140
  }
141
141
  if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
142
142
  if (init.method === 'PATCH') {
@@ -263,6 +263,33 @@ describe('session-goal v2 message projection helpers', () => {
263
263
  });
264
264
 
265
265
  describe('session-goal continuation delivery', () => {
266
+ it.each([false, true])('continues after a completed compaction beyond the first message page (HTTP fallback: %s)', async (fallback) => {
267
+ const messages = Array.from({ length: 42 }, (_, index) => ({
268
+ id: `msg_${String(index).padStart(3, '0')}`,
269
+ type: index === 41 ? 'compaction' : index % 2 ? 'user' : 'assistant',
270
+ time: index === 41 ? { created: index + 1 } : { created: index + 1, completed: index + 2 },
271
+ model: { providerID: 'p', id: 'm' },
272
+ agent: 'build',
273
+ ...(index === 41 ? { status: 'completed', summary: 'Work remains' } : { text: 'Working' }),
274
+ }));
275
+ const fixture = makeFetch({ messages, liveStatus: 'idle' });
276
+ const runtime = makeRuntime();
277
+ try {
278
+ await withFetch(async (input, init) => {
279
+ const url = new URL(String(input));
280
+ if (pathEndsWith(url.pathname, '/session/ses-goal/message')) {
281
+ if (fallback && url.pathname.startsWith('/api/')) return jsonResponse({ error: 'unavailable' }, 503);
282
+ const ordered = url.searchParams.get('order') === 'desc' ? [...messages].reverse() : messages;
283
+ return jsonResponse({ data: ordered.slice(0, Number(url.searchParams.get('limit') || 40)), cursor: {} });
284
+ }
285
+ return fixture.fetch(input, init);
286
+ }, async () => runtime.runTick('ses-goal', '/repo'));
287
+ expect(fixture.calls.prompt).toBe(1);
288
+ } finally {
289
+ runtime.stop();
290
+ }
291
+ });
292
+
266
293
  it('dispatches v2 session.prompt with stable continuation id and metadata', async () => {
267
294
  const promptBodies = [];
268
295
  const messages = [
@@ -291,7 +318,7 @@ describe('session-goal continuation delivery', () => {
291
318
  return jsonResponse({ data: {} });
292
319
  }
293
320
  if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
294
- return jsonResponse({ data: messages, cursor: {} });
321
+ return jsonResponse({ data: new URL(url).searchParams.get('order') === 'desc' ? [...messages].reverse() : messages, cursor: {} });
295
322
  }
296
323
  if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
297
324
  if (init.method === 'PATCH' && init.body) {
@@ -984,7 +1011,9 @@ describe('session-goal runtime — pause / audit / dispatch boundary', () => {
984
1011
  const body = Array.isArray(messages)
985
1012
  ? { data: messages, cursor: {} }
986
1013
  : messages;
987
- return jsonResponse(body);
1014
+ return jsonResponse(new URL(String(input)).searchParams.get('order') === 'desc'
1015
+ ? { ...body, data: [...body.data].reverse() }
1016
+ : body);
988
1017
  }
989
1018
  if (pathEndsWith(reqPath, '/session/ses-goal')) {
990
1019
  if (init.method === 'PATCH') {
@@ -1,5 +1,12 @@
1
1
  # Session Index Runtime
2
2
 
3
+ Native `session.renamed` updates the existing runtime-scoped summary's title and
4
+ update clock directly, publishing through the normal index revision path. It
5
+ preserves activity ordering, pins and execution status; older/duplicate events
6
+ and unknown sessions are no-ops. Partial rename payloads never fabricate rows.
7
+
8
+ Native `session.moved` uses `data.location.directory`, not the old event-envelope directory. The transactional `move` operation retains summary activity/status and independent pin membership, maintains the newest-20 destination bound, and installs a runtime-scoped event-clock fence against delayed source-directory upserts/pages. Duplicate/older moves are no-ops. Unknown sessions are not fabricated from the partial move payload.
9
+
3
10
  Every OpenChamber Web Server enables `session-index.sqlite` in its data
4
11
  directory by default. Electron injects its user-data path explicitly.
5
12
  `sessionIndexDbPath` and `OPENCHAMBER_SESSION_INDEX_DB_PATH` override that
@@ -36,6 +36,19 @@ export const applySessionIndexEvent = (service, event, observedAt = Date.now(),
36
36
 
37
37
  const baseType = normalizeSessionEventType(payload.type);
38
38
 
39
+ if (baseType === 'session.renamed') {
40
+ if (isHostReady && !isHostReady()) return false;
41
+ const data = payload.data ?? properties;
42
+ return service.rename(data.sessionID, data.title, payload.created ?? properties.eventCreated ?? observedAt);
43
+ }
44
+
45
+ if (baseType === 'session.moved') {
46
+ const data = payload.data ?? properties;
47
+ return typeof data.sessionID === 'string' && typeof data.location?.directory === 'string'
48
+ ? service.move(data.sessionID, data.location.directory, typeof payload.created === 'number' ? payload.created : observedAt, payload.durable?.seq)
49
+ : false;
50
+ }
51
+
39
52
  if (baseType === 'session.created' || baseType === 'session.updated') {
40
53
  // Until Host metadata is ready, do not write session lifecycle into the
41
54
  // index (would bake unprojected upstream archive). Transcript paths use
@@ -3,6 +3,13 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { applySessionIndexEvent } from './event-ingest.js';
4
4
 
5
5
  describe('session index event ingest', () => {
6
+ it('takes moved ownership from native data rather than the old event envelope directory', () => {
7
+ const service = { move: vi.fn(() => true) };
8
+ expect(applySessionIndexEvent(service, { directory: '/old', payload: {
9
+ type: 'session.moved', created: 20, data: { sessionID: 'ses_move', location: { directory: '/new' }, projectID: 'project' },
10
+ } }, 30)).toBe(true);
11
+ expect(service.move).toHaveBeenCalledWith('ses_move', '/new', 20, undefined);
12
+ });
6
13
  it('writes session summaries, user activity, and status to the service', () => {
7
14
  const service = {
8
15
  upsertAndReportChange: vi.fn(() => true),
@@ -277,6 +277,30 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
277
277
  WHERE runtime_key = ? AND session_id = ?
278
278
  `);
279
279
 
280
+ const movedLocations = new Map();
281
+ const isStaleLocation = (session, directory) => {
282
+ const moved = movedLocations.get(`${runtimeKey()}:${session?.id}`);
283
+ return moved && moved.directory !== normalizeDirectory(directory) && toTimestamp(session?.time?.updated) <= moved.at;
284
+ };
285
+ const move = db.transaction((sessionID, directory, at = Date.now(), sequence) => {
286
+ directory = normalizeDirectory(directory);
287
+ if (!sessionID || !directory) return false;
288
+ const key = runtimeKey();
289
+ const fenceKey = `${key}:${sessionID}`;
290
+ const previous = movedLocations.get(fenceKey);
291
+ if (previous && (sequence !== undefined && previous.sequence !== undefined
292
+ ? sequence <= previous.sequence
293
+ : at < previous.at || (at === previous.at && previous.directory === directory))) return false;
294
+ movedLocations.set(fenceKey, { directory, at, sequence });
295
+ touchExistingDirectory.run({ runtimeKey: key, directory, lastAccessedAt: at });
296
+ const roots = db.prepare('UPDATE OR REPLACE session_summary SET directory = ? WHERE runtime_key = ? AND session_id = ? AND directory != ?').run(directory, key, sessionID, directory);
297
+ const children = db.prepare('UPDATE OR REPLACE session_child SET directory = ? WHERE runtime_key = ? AND session_id = ? AND directory != ?').run(directory, key, sessionID, directory);
298
+ db.prepare(`DELETE FROM session_summary WHERE runtime_key = ? AND directory = ? AND session_id IN (
299
+ SELECT session_id FROM session_summary WHERE runtime_key = ? AND directory = ? ORDER BY activity_updated_at DESC, session_id DESC LIMIT -1 OFFSET ?
300
+ )`).run(key, directory, key, directory, MAX_ROOT_SESSIONS);
301
+ return roots.changes > 0 || children.changes > 0;
302
+ });
303
+
280
304
  const replaceDirectoryRows = ({ directory, sessions, cursor, hasMore, fullSync = true, now = Date.now() }) => {
281
305
  const normalizedDirectory = normalizeDirectory(directory);
282
306
  if (!normalizedDirectory) throw new Error('directory is required');
@@ -289,6 +313,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
289
313
  // stale unarchived cache cannot resurrect them.
290
314
  const summaries = Array.isArray(sessions)
291
315
  ? sessions
316
+ .filter((session) => !isStaleLocation(session, normalizedDirectory))
292
317
  .map((session) => toSummary(session, normalizedDirectory))
293
318
  .filter((summary) => summary && !summary.archivedAt)
294
319
  .slice(0, MAX_ROOT_SESSIONS)
@@ -328,6 +353,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
328
353
  });
329
354
 
330
355
  const upsertMutation = db.transaction((session, now = Date.now(), options = {}) => {
356
+ if (isStaleLocation(session, session?.directory ?? session?.location?.directory)) return { accepted: false, changed: false };
331
357
  if (!isVisibleSession(session)) {
332
358
  const changed = remove(session?.id);
333
359
  return { accepted: changed, changed };
@@ -517,6 +543,23 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
517
543
  return result.changes > 0;
518
544
  };
519
545
 
546
+ const rename = (sessionID, title, observedAt) => {
547
+ if (typeof sessionID !== 'string' || !sessionID || typeof title !== 'string') return false;
548
+ const timestamp = toTimestamp(observedAt);
549
+ if (!timestamp) return false;
550
+ const key = runtimeKey();
551
+ if (HIDDEN_SESSION_TITLES.has(title)) {
552
+ const current = db.prepare('SELECT updated_at FROM session_summary WHERE runtime_key = ? AND session_id = ?').get(key, sessionID);
553
+ return current && current.updated_at <= timestamp ? remove(sessionID) : false;
554
+ }
555
+ const result = db.prepare(`
556
+ UPDATE session_summary SET title = ?, updated_at = ?
557
+ WHERE runtime_key = ? AND session_id = ? AND updated_at <= ?
558
+ AND (title != ? OR updated_at != ?)
559
+ `).run(title, timestamp, key, sessionID, timestamp, title, timestamp);
560
+ return result.changes > 0;
561
+ };
562
+
520
563
  const updateStatus = (sessionID, status, observedAt) => {
521
564
  if (typeof sessionID !== 'string' || !sessionID) return false;
522
565
  if (status !== 'busy' && status !== 'retry' && status !== 'idle') return false;
@@ -570,6 +613,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
570
613
  upsertAndReportChange,
571
614
  touchActivity,
572
615
  updateStatus,
616
+ rename,
617
+ move,
573
618
  setPinned,
574
619
  clearPinned,
575
620
  remove,
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { afterEach, describe, expect, it } from 'vitest';
6
6
 
7
7
  import { createSessionIndexService } from './service.js';
8
+ import { applySessionIndexEvent } from './event-ingest.js';
8
9
 
9
10
  const tempDirectories = [];
10
11
 
@@ -31,6 +32,52 @@ afterEach(() => {
31
32
  });
32
33
 
33
34
  describe('Electron session index', () => {
35
+ it('ingests native renames without changing activity, status, pins or unrelated sessions', () => {
36
+ const runtimeRef = { value: 'http://runtime-a.test' };
37
+ const service = createService(runtimeRef);
38
+ service.upsert(session('ses_title', 10));
39
+ service.upsert(session('ses_other', 15));
40
+ service.setPinned('ses_title', 12);
41
+ service.updateStatus('ses_title', 'busy', 13);
42
+ const before = service.snapshot();
43
+ const rename = (title, created, sessionID = 'ses_title') => applySessionIndexEvent(service, {
44
+ payload: { type: 'session.renamed', created, data: { sessionID, title } },
45
+ });
46
+ expect(rename('Generated title', 20)).toBe(true);
47
+ const after = service.snapshot();
48
+ expect(after.directories[0].sessions.map((row) => row.id)).toEqual(before.directories[0].sessions.map((row) => row.id));
49
+ expect(service.findBySessionId('ses_title')).toMatchObject({ title: 'Generated title' });
50
+ expect(after.pinnedSessionIds).toEqual(before.pinnedSessionIds);
51
+ expect(after.directories[0].sessions.find((row) => row.id === 'ses_title').metadata).toEqual(
52
+ before.directories[0].sessions.find((row) => row.id === 'ses_title').metadata,
53
+ );
54
+ expect(rename('Generated title', 20)).toBe(false);
55
+ expect(rename('Stale title', 19)).toBe(false);
56
+ expect(rename('Unknown', 30, 'ses_missing')).toBe(false);
57
+ runtimeRef.value = 'http://runtime-b.test';
58
+ expect(rename('Other runtime', 30)).toBe(false);
59
+ service.close();
60
+ });
61
+ it('moves one authoritative identity, preserving pin/status and fencing delayed old-directory pages', () => {
62
+ const runtimeRef = { value: 'http://runtime-a.test' };
63
+ const service = createService(runtimeRef);
64
+ service.upsert(session('ses_move', 10, '/old'));
65
+ service.setPinned('ses_move', 12);
66
+ service.updateStatus('ses_move', 'retry', 15);
67
+ expect(service.move('ses_move', '/new', 20)).toBe(true);
68
+ expect(service.move('ses_move', '/new', 20)).toBe(false);
69
+ expect(service.move('ses_move', '/old', 19)).toBe(false);
70
+ service.replaceDirectory({ directory: '/old', sessions: [session('ses_move', 10, '/old')], now: 30 });
71
+ expect(service.upsertAndReportChange(session('ses_move', 10, '/old'), 40)).toBe(false);
72
+ const rows = service.snapshot().directories.flatMap((item) => item.sessions);
73
+ expect(rows).toHaveLength(1);
74
+ expect(rows[0]).toMatchObject({ id: 'ses_move', directory: '/new', metadata: { openchamber: { sessionStatus: { type: 'retry' } } } });
75
+ expect(service.snapshot().pinnedSessionIds).toContain('ses_move');
76
+ runtimeRef.value = 'http://runtime-b.test';
77
+ expect(service.upsert(session('ses_move', 1, '/old'))).toBe(true);
78
+ expect(service.findBySessionId('ses_move').directory).toBe('/old');
79
+ service.close();
80
+ });
34
81
  it('stores one bounded root-session page transactionally', () => {
35
82
  const runtimeRef = { value: 'http://runtime-a.test' };
36
83
  const service = createService(runtimeRef);
@@ -46,6 +46,12 @@ to match a narrower upstream metadata shape.
46
46
 
47
47
  ## Contracts
48
48
 
49
+ - `openchamber.titleAuthority` is the durable OpenChamber title choice; see
50
+ `../session-title/DOCUMENTATION.md`. Projection applies a valid same-session
51
+ authority to list/detail, full session events, and native `session.renamed`
52
+ events (including replay). Inherited fork authority is ignored. Missing or
53
+ malformed authority preserves upstream titles and does not lock legacy rows.
54
+
49
55
  - Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
50
56
  - Mutations are fully serialized (read-merge-write + persist) so concurrent writers cannot lose neighbors.
51
57
  - Committed `entries` update **only after** successful persist — concurrent readers never observe uncommitted drafts. Persist failure keeps the last committed snapshot and surfaces the error.
@@ -53,6 +53,29 @@ describe('mergeMetadataPatch', () => {
53
53
  });
54
54
 
55
55
  describe('createSessionMetadataStore', () => {
56
+ it('round-trips title authority through the OpenCode record and restart without losing neighboring metadata', async () => {
57
+ const dataDir = makeDataDir();
58
+ const record = { id: 'ses_1', title: 'Native', metadata: { plugin: { retained: true } } };
59
+ const authority = { sessionID: 'ses_1', title: 'Chosen', revision: 'r1', source: 'summary' };
60
+ const deps = {
61
+ dataDir,
62
+ recordReader: async () => structuredClone(record),
63
+ recordWriter: async (_id, metadata) => { record.metadata = structuredClone(metadata); },
64
+ };
65
+ const store = createSessionMetadataStore(deps);
66
+ await store.mutateSessionMetadata('ses_1', () => ({ ok: true, patch: {
67
+ openchamber: { titleAuthority: authority },
68
+ } }));
69
+ expect(record.metadata).toEqual({ plugin: { retained: true }, openchamber: { titleAuthority: authority } });
70
+ const reopened = createSessionMetadataStore(deps);
71
+ expect((await reopened.get('ses_1')).openchamber.titleAuthority).toEqual(authority);
72
+ expect((await reopened.getAll()).ses_1.openchamber.titleAuthority).toEqual(authority);
73
+ const stale = await reopened.mutateSessionMetadata('ses_1', (metadata) => ({
74
+ ok: metadata.openchamber.titleAuthority.revision === 'old', patch: { openchamber: { titleAuthority: null } },
75
+ }));
76
+ expect(stale.committed).toBe(false);
77
+ expect(record.metadata.openchamber.titleAuthority).toEqual(authority);
78
+ });
56
79
  it('starts empty when the file does not exist', async () => {
57
80
  const store = createSessionMetadataStore({ dataDir: makeDataDir() });
58
81
  await expect(store.getAll()).resolves.toEqual({});
@@ -39,3 +39,4 @@ export function projectSessionLifecyclePayload(
39
39
  readHostMetadata: (sessionId: string) => unknown,
40
40
  options?: { hostReady?: boolean },
41
41
  ): unknown;
42
+ export function readSessionTitleAuthority(sessionID: string, metadata: unknown): { sessionID: string; title: string; revision: string; source?: string } | null;
@@ -13,6 +13,14 @@
13
13
 
14
14
  const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
15
 
16
+ export const readSessionTitleAuthority = (sessionID, metadata) => {
17
+ const authority = metadata?.openchamber?.titleAuthority;
18
+ return typeof sessionID === 'string' && sessionID.length > 0 && authority?.sessionID === sessionID
19
+ && typeof authority.title === 'string' && authority.title.trim()
20
+ && typeof authority.revision === 'string' && authority.revision
21
+ ? authority : null;
22
+ };
23
+
16
24
  /** RFC 7386 merge (local copy — avoid circular import with the metadata store). */
17
25
  const mergeMetadataPatch = (current, patch) => {
18
26
  const base = isPlainObject(current) ? { ...current } : {};
@@ -33,6 +41,7 @@ const SESSION_LIFECYCLE_BASE = new Set([
33
41
  'session.created',
34
42
  'session.updated',
35
43
  'session.deleted',
44
+ 'session.renamed',
36
45
  ]);
37
46
 
38
47
  /**
@@ -156,8 +165,10 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
156
165
  else timeBase.archived = projectedArchived;
157
166
 
158
167
  const directory = resolveSessionDirectory(session);
168
+ const titleAuthority = readSessionTitleAuthority(session.id, metadata);
159
169
  const next = {
160
170
  ...session,
171
+ ...(titleAuthority ? { title: titleAuthority.title } : {}),
161
172
  metadata: Object.keys(metadata).length > 0 ? metadata : (session.metadata ?? metadata),
162
173
  time: timeBase,
163
174
  ...(directory ? { directory } : {}),
@@ -207,6 +218,15 @@ export const projectSessionWithStoredMap = (session, storedBySessionId) => {
207
218
 
208
219
  const projectLifecycleInner = (payload, readHostMetadata) => {
209
220
  const baseType = normalizeSessionEventType(payload.type);
221
+ if (baseType === 'session.renamed') {
222
+ const data = payload.data ?? payload.properties;
223
+ if (typeof data?.sessionID !== 'string') return payload;
224
+ const authority = readSessionTitleAuthority(data?.sessionID, readHostMetadata?.(data?.sessionID));
225
+ if (!authority || data.title === authority.title) return payload;
226
+ return payload.data
227
+ ? { ...payload, data: { ...data, title: authority.title } }
228
+ : { ...payload, properties: { ...data, title: authority.title } };
229
+ }
210
230
  if (baseType !== 'session.created' && baseType !== 'session.updated') return payload;
211
231
 
212
232
  const info = extractSessionInfoFromPayload(payload);
@@ -267,6 +287,9 @@ export const projectSessionLifecyclePayload = (payload, readHostMetadata, option
267
287
  }
268
288
 
269
289
  const type = typeof payload.type === 'string' ? payload.type : '';
290
+ if (type === 'session.renamed') {
291
+ return options.hostReady === false ? null : projectLifecycleInner(payload, readHostMetadata);
292
+ }
270
293
  if (options.hostReady === false && isSessionLifecycleEventType(type)) {
271
294
  return null;
272
295
  }
@@ -5,9 +5,44 @@ import {
5
5
  normalizeSessionEventType,
6
6
  projectSessionLifecyclePayload,
7
7
  resolveSessionDirectory,
8
+ projectSessionWithHostMetadata,
9
+ readSessionTitleAuthority,
8
10
  } from './session-projection.js';
9
11
 
10
12
  describe('session projection event shapes', () => {
13
+ const titleMetadata = { openchamber: { titleAuthority: {
14
+ sessionID: 'ses_owned', title: 'Chosen title', revision: 'r1', source: 'summary',
15
+ } } };
16
+
17
+ it('keeps the owned title in snapshots and native rename events regardless of upstream clocks', () => {
18
+ const upstream = { id: 'ses_owned', title: 'Native title', time: { updated: 20 } };
19
+ expect(projectSessionWithHostMetadata(upstream, titleMetadata).title).toBe('Chosen title');
20
+ for (const created of [1, 20, 10000]) {
21
+ const event = { type: 'session.renamed', created, data: { sessionID: 'ses_owned', title: 'Native title' } };
22
+ expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toEqual({
23
+ ...event, data: { ...event.data, title: 'Chosen title' },
24
+ });
25
+ expect(event.data.title).toBe('Native title');
26
+ }
27
+ expect(projectSessionWithHostMetadata({ ...upstream, id: 'ses_fork' }, titleMetadata).title).toBe('Native title');
28
+ });
29
+
30
+ it('suppresses native rename while metadata is unknown, including wrapped replay', () => {
31
+ const payload = { directory: '/repo', payload: {
32
+ type: 'session.renamed', data: { sessionID: 'ses_owned', title: 'Native' },
33
+ } };
34
+ expect(projectSessionLifecyclePayload(payload, () => null, { hostReady: false })).toBeNull();
35
+ expect(projectSessionLifecyclePayload(payload, () => titleMetadata).payload.data.title).toBe('Chosen title');
36
+ });
37
+
38
+ it('ignores malformed or absent authority and rename identities', () => {
39
+ for (const metadata of [undefined, {}, { openchamber: { titleAuthority: {} } }]) {
40
+ expect(readSessionTitleAuthority(undefined, metadata)).toBeNull();
41
+ expect(readSessionTitleAuthority('ses_owned', metadata)).toBeNull();
42
+ }
43
+ const event = { type: 'session.renamed', data: {} };
44
+ expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toBe(event);
45
+ });
11
46
  it('normalizes version suffixes', () => {
12
47
  expect(normalizeSessionEventType('session.updated.v2')).toBe('session.updated');
13
48
  expect(normalizeSessionEventType('session.created')).toBe('session.created');