@openchambery/web 1.18.1 → 1.18.2-beta.10

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 (126) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-BzRb9sP6.js} +1 -1
  2. package/dist/assets/{AssistantView-f5eK9QED.js → AssistantView-Bcx9ER-E.js} +1 -1
  3. package/dist/assets/{ChatContainer-BvJoxcjJ.js → ChatContainer-DaBnpayh.js} +38 -38
  4. package/dist/assets/{DiagramView-Vu2JnmC-.js → DiagramView-DaZSw3PM.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-oeyB_nIi.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DHaR0peD.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-CSxVZ_eA.js → MarkdownRendererImpl-CoMjP1t6.js} +1 -1
  8. package/dist/assets/{MobileDetailNavigation-3NIRdxpt.js → MobileDetailNavigation-Bsi54FsV.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-B7jqfJ-S.js → MobileShareBridge-BQd5tCJN.js} +1 -1
  10. package/dist/assets/MobileSurface-C0bqzAcb.js +2 -0
  11. package/dist/assets/{MultiRunWindow-DkYTqcPq.js → MultiRunWindow-CJLa67M0.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CCfdEtW6.js → OnboardingScreen-BZHN7C2r.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-DeL1aO59.js → OverlayScrollbar-BLbgO_QF.js} +1 -1
  14. package/dist/assets/SettingsView-pHa9Y38q.js +25 -0
  15. package/dist/assets/{SettingsWindow--nLXOo-L.js → SettingsWindow-q6zT03-N.js} +1 -1
  16. package/dist/assets/{TerminalView-B80SmYHn.js → TerminalView-BFb17E29.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-CtbnU35x.js → ToolOutputDialog-sVkggrhW.js} +1 -1
  18. package/dist/assets/appThemeRegistry-Bh6gmBoH.js +2 -0
  19. package/dist/assets/{button-CDvMVaiO.js → button-DCUMIXDC.js} +1 -1
  20. package/dist/assets/{clipboard-Jdqe05db.js → clipboard-DF5tFLDb.js} +1 -1
  21. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-DNg5bLKV.js} +1 -1
  22. package/dist/assets/{context-menu-D16ocNrk.js → context-menu-VD4qvZ6C.js} +1 -1
  23. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-DSgGxDpI.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-DVIx7jsz.js} +1 -1
  25. package/dist/assets/{diagram-CEon0BQs.js → diagram-cjvFqVa8.js} +1 -1
  26. package/dist/assets/{dialog-BzZ3cKxG.js → dialog-DaSIYgrj.js} +1 -1
  27. package/dist/assets/{es-C25PRSjo.js → es-DCoGZljP.js} +1 -1
  28. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-DXvhIL_U.js} +1 -1
  29. package/dist/assets/{fr-Cv8ZWQdW.js → fr-dkEoZpPz.js} +2 -2
  30. package/dist/assets/{gitApi-B1CIqazO.js → gitApi-cPhxx3NZ.js} +1 -1
  31. package/dist/assets/gitApiHttp-SLrnr8a5.js +2 -0
  32. package/dist/assets/{input-k9DGGyZ6.js → input-QS3IWgY5.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-B9cwRNac.js → insertionBoundaries-XUyczmFs.js} +1 -1
  34. package/dist/assets/{ja-Ue9m9eSe.js → ja-bQwSsvRr.js} +1 -1
  35. package/dist/assets/{ko-DWBUCCV6.js → ko-Kq5C3hsv.js} +1 -1
  36. package/dist/assets/{main-BSVnX01h.js → main-D4CTy7xa.js} +2 -2
  37. package/dist/assets/{main-DzZumJ48.js → main-Mkx9wBnO.js} +23 -23
  38. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-D-OR2e_G.js} +1 -1
  39. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-y_Rf6mLa.js} +1 -1
  40. package/dist/assets/{miniChat-CrEtOV4u.js → miniChat-Bq6uelzq.js} +2 -2
  41. package/dist/assets/mobile-C1L9OCdh.js +2 -0
  42. package/dist/assets/{multirun-G-ZwKVUq.js → multirun-9OIhLOWP.js} +1 -1
  43. package/dist/assets/{persistence-DPNMIbF5.js → persistence-oSFWyKgV.js} +1 -1
  44. package/dist/assets/{pl-DaBaPCyE.js → pl-CYpMAgVn.js} +2 -2
  45. package/dist/assets/projectMeta-BXPWMXjv.js +3 -0
  46. package/dist/assets/{pt-BR-C2cJZ43o.js → pt-BR-MSPTLfTm.js} +1 -1
  47. package/dist/assets/{radio-BtilB34p.js → radio-CqpSp0pL.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-D1HWiWMn.js → renderElectronMiniChatApp-B8cSB7U4.js} +2 -2
  49. package/dist/assets/renderMobileApp-DmBjzy65.js +23 -0
  50. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-n4QAk1lU.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-Bw-RBy5a.js +145 -0
  52. package/dist/assets/{sortable-tabs-strip-BpvOof2G.js → sortable-tabs-strip-B4wI7VAP.js} +1 -1
  53. package/dist/assets/src-BhvNwAr-.css +2 -0
  54. package/dist/assets/{store-SqV3zfE8.js → store-OURVnLSN.js} +2 -2
  55. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-_NnfkxgP.js} +1 -1
  56. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-xEnco9xY.js} +1 -1
  57. package/dist/assets/{uk-DJz_P8OZ.js → uk-cSkRAYFY.js} +1 -1
  58. package/dist/assets/{url-7MqoLs9X.js → url-DIZgk80M.js} +1 -1
  59. package/dist/assets/{useAppFontEffects-BBxqFIta.js → useAppFontEffects-BHoHqFhx.js} +3 -3
  60. package/dist/assets/{useDesktopWindowControlsLayout-D8fjPyqI.js → useDesktopWindowControlsLayout-DwmEdp3O.js} +1 -1
  61. package/dist/assets/{useEffectiveDirectory-C5OxWqUH.js → useEffectiveDirectory-Bjo9xE9q.js} +1 -1
  62. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-B-QHyR3C.js} +1 -1
  63. package/dist/assets/useMobileNavigationStore-SCw9NohC.js +476 -0
  64. package/dist/assets/useSessionAutoCleanup-KwCKRF_d.js +1 -0
  65. package/dist/assets/{useWorkerHighlightedLines-CJozblYF.js → useWorkerHighlightedLines-Cm3UQxhi.js} +1 -1
  66. package/dist/assets/{utils-Co19EJEL.js → utils-Dxsi3J3l.js} +1 -1
  67. package/dist/assets/{zh-CN-DLAKDOM9.js → zh-CN-BKqeT_uz.js} +1 -1
  68. package/dist/assets/{zh-TW-s3Jjx1_n.js → zh-TW-BaNjJ8Y4.js} +1 -1
  69. package/dist/index.html +8 -8
  70. package/dist/mini-chat.html +8 -8
  71. package/dist/mobile.html +8 -8
  72. package/package.json +1 -1
  73. package/server/index.d.ts +6 -0
  74. package/server/index.js +6 -0
  75. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  76. package/server/lib/config-sync/config-sync.test.js +197 -0
  77. package/server/lib/config-sync/constants.js +39 -0
  78. package/server/lib/config-sync/contract.js +130 -0
  79. package/server/lib/config-sync/credential-auth.js +39 -0
  80. package/server/lib/config-sync/engine.js +108 -0
  81. package/server/lib/config-sync/index.js +67 -0
  82. package/server/lib/config-sync/local-backup.js +171 -0
  83. package/server/lib/config-sync/plan.js +318 -0
  84. package/server/lib/config-sync/receiver.js +330 -0
  85. package/server/lib/config-sync/routes.js +141 -0
  86. package/server/lib/config-sync/routes.test.js +154 -0
  87. package/server/lib/config-sync/scripts.js +264 -0
  88. package/server/lib/config-sync/selections.js +112 -0
  89. package/server/lib/config-sync/tar.js +35 -0
  90. package/server/lib/config-sync/target-id.js +24 -0
  91. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  92. package/server/lib/event-stream/diff-summary.js +35 -16
  93. package/server/lib/event-stream/diff-summary.test.js +46 -6
  94. package/server/lib/event-stream/protocol.test.js +7 -7
  95. package/server/lib/git/DOCUMENTATION.md +2 -1
  96. package/server/lib/git/service.js +24 -5
  97. package/server/lib/git/service.test.js +51 -0
  98. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/core-routes.js +1 -0
  101. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  102. package/server/lib/opencode/openchamber-routes.js +7 -0
  103. package/server/lib/opencode/settings-runtime.js +82 -51
  104. package/server/lib/opencode/settings-runtime.test.js +42 -0
  105. package/server/lib/relay/DOCUMENTATION.md +1 -1
  106. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  107. package/server/lib/session-turn-pages/changes.service.js +156 -0
  108. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  109. package/server/lib/session-turn-pages/reconcile.routes.test.js +48 -0
  110. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  111. package/server/lib/session-turn-pages/reconcile.service.test.js +40 -0
  112. package/server/lib/session-turn-pages/routes.js +273 -12
  113. package/server/lib/session-turn-pages/routes.test.js +359 -0
  114. package/server/lib/session-turn-pages/service.js +29 -7
  115. package/server/lib/session-turn-pages/service.test.js +30 -0
  116. package/dist/assets/MobileSurface-BL3hired.js +0 -2
  117. package/dist/assets/SettingsView-CyrIDyDl.js +0 -25
  118. package/dist/assets/appThemeRegistry-D2ZUcQmr.js +0 -2
  119. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  120. package/dist/assets/mobile-DMjO-f5u.js +0 -2
  121. package/dist/assets/projectMeta-owiDYjF9.js +0 -3
  122. package/dist/assets/renderMobileApp-Cqxwb3Ub.js +0 -23
  123. package/dist/assets/runtimeEndpointReset-C0oDuy2R.js +0 -145
  124. package/dist/assets/src-oHrYIqHF.css +0 -2
  125. package/dist/assets/useMobileNavigationStore-U3b_hG4m.js +0 -475
  126. package/dist/assets/useSessionAutoCleanup-CDYDm76L.js +0 -1
@@ -111,7 +111,8 @@ The following functions are internal helpers used by exported functions:
111
111
  - `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
112
112
  - `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
113
113
  - `isClean`: Boolean indicating if working tree is clean.
114
- - `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
114
+ - `oversized`: Boolean. `true` when the change set exceeds the server-owned `GIT_STATUS_DIFF_STATS_MAX_FILES` threshold; `diffStats` are omitted in that case (same omission shape as light mode). Clients must derive deferred / load-on-demand rendering from this flag rather than from their own threshold, so the server can tune the limit without client changes. Light-mode polls do not set this flag.
115
+ - `diffStats`: Object mapping file paths to `{ insertions, deletions }`. Omitted by light mode and by oversized change sets.
115
116
  - `mergeInProgress`: Object with `{ head, message }` if merge in progress.
116
117
  - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
117
118
 
@@ -1964,6 +1964,15 @@ export async function setLocalIdentity(directory, profile) {
1964
1964
  }
1965
1965
  }
1966
1966
 
1967
+ // Change sets larger than this are reported as `oversized` in the getStatus
1968
+ // response and omit diffStats (the same omission shape as light mode). Two
1969
+ // full-tree `git diff --numstat` passes and per-new-file line counting over an
1970
+ // oversized working tree block the server event loop for seconds; regular
1971
+ // repositories never reach this threshold. Clients must derive deferred /
1972
+ // load-on-demand behavior from the `oversized` flag, never from their own copy
1973
+ // of this threshold.
1974
+ const GIT_STATUS_DIFF_STATS_MAX_FILES = 2000;
1975
+
1967
1976
  export async function getStatus(directory, options = {}) {
1968
1977
  const lightMode = options.mode === 'light';
1969
1978
 
@@ -1973,8 +1982,17 @@ export async function getStatus(directory, options = {}) {
1973
1982
  // Use -uall to show all untracked files individually, not just directories
1974
1983
  const status = await git.status(['-uall']);
1975
1984
 
1985
+ // Oversized change sets: skip numstat + new-file stats the same way light
1986
+ // mode does. Two full-tree `git diff --numstat` passes over a huge working
1987
+ // tree take seconds and block the server event loop; regular repositories
1988
+ // stay below the threshold and keep full diffStats. The `oversized` flag in
1989
+ // the response tells clients to defer list rendering; light mode is a
1990
+ // client-requested lightweight poll and does not set the flag.
1991
+ const oversized = (status.files?.length ?? 0) > GIT_STATUS_DIFF_STATS_MAX_FILES;
1992
+ const skipDiffStats = lightMode || oversized;
1993
+
1976
1994
  // Light mode: skip numstat + new-file line counting for faster response
1977
- const [stagedStatsRaw, workingStatsRaw] = lightMode
1995
+ const [stagedStatsRaw, workingStatsRaw] = skipDiffStats
1978
1996
  ? ['', '']
1979
1997
  : await Promise.all([
1980
1998
  git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
@@ -2019,7 +2037,7 @@ export async function getStatus(directory, options = {}) {
2019
2037
  const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
2020
2038
  const newFileStats = [];
2021
2039
 
2022
- if (!lightMode) {
2040
+ if (!skipDiffStats) {
2023
2041
  for (const file of status.files) {
2024
2042
  if (newFileStats.length >= MAX_NEW_FILE_STATS) {
2025
2043
  break;
@@ -2126,7 +2144,7 @@ export async function getStatus(directory, options = {}) {
2126
2144
  // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
2127
2145
  // We still want to show the number of unpublished commits to the user.
2128
2146
  // Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
2129
- if (!lightMode && !tracking && status.current) {
2147
+ if (!skipDiffStats && !tracking && status.current) {
2130
2148
  const baseRef = await selectBaseRefForUnpublished();
2131
2149
  if (baseRef) {
2132
2150
  const countRaw = await git
@@ -2142,7 +2160,7 @@ export async function getStatus(directory, options = {}) {
2142
2160
  }
2143
2161
 
2144
2162
  if (
2145
- !lightMode
2163
+ !skipDiffStats
2146
2164
  && status.current
2147
2165
  && (!tracking || !tracking.startsWith('upstream/'))
2148
2166
  && await hasRemote(git, directoryPath, 'upstream')
@@ -2217,7 +2235,8 @@ export async function getStatus(directory, options = {}) {
2217
2235
  working_dir: f.working_dir,
2218
2236
  })),
2219
2237
  isClean: status.isClean(),
2220
- diffStats: lightMode ? undefined : diffStats,
2238
+ oversized,
2239
+ diffStats: skipDiffStats ? undefined : diffStats,
2221
2240
  mergeInProgress,
2222
2241
  rebaseInProgress,
2223
2242
  };
@@ -282,6 +282,57 @@ describe('getStatus', () => {
282
282
 
283
283
  await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
284
284
  });
285
+
286
+ it('returns diffStats for regular-sized change sets', async () => {
287
+ if (!canRunGit()) return;
288
+
289
+ const repo = createTempDir();
290
+ runGit(repo, ['init', '-b', 'main']);
291
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
292
+ runGit(repo, ['config', 'user.name', 'Test User']);
293
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
294
+ runGit(repo, ['add', 'README.md']);
295
+ runGit(repo, ['commit', '-m', 'Initial commit']);
296
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nMore lines\n');
297
+
298
+ const status = await getStatus(repo);
299
+ expect(status.oversized).toBe(false);
300
+ expect(status.diffStats).toBeDefined();
301
+ expect(status.diffStats['README.md']).toEqual({ insertions: 1, deletions: 0 });
302
+ });
303
+
304
+ it('marks oversized change sets and omits diffStats without changing the rest of the contract', async () => {
305
+ if (!canRunGit()) return;
306
+
307
+ const repo = createTempDir();
308
+ runGit(repo, ['init', '-b', 'main']);
309
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
310
+ runGit(repo, ['config', 'user.name', 'Test User']);
311
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
312
+ runGit(repo, ['add', 'README.md']);
313
+ runGit(repo, ['commit', '-m', 'Initial commit']);
314
+
315
+ // Create one modified tracked file plus enough untracked files to cross
316
+ // the oversized threshold (untracked files are part of status.files).
317
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nChanged\n');
318
+ const oversizeCount = 2001;
319
+ fs.mkdirSync(path.join(repo, 'generated'), { recursive: true });
320
+ for (let i = 0; i < oversizeCount; i += 1) {
321
+ fs.writeFileSync(path.join(repo, 'generated', `file-${i}.txt`), `content ${i}\n`);
322
+ }
323
+
324
+ const status = await getStatus(repo);
325
+ expect(status.files.length).toBeGreaterThan(2000);
326
+ // The server flags the oversized state so clients defer list rendering.
327
+ expect(status.oversized).toBe(true);
328
+ // Same omission shape as light mode: the key is absent, not an empty object.
329
+ expect(status.diffStats).toBeUndefined();
330
+ // Everything else in the contract stays intact.
331
+ expect(status.current).toBe('main');
332
+ expect(status.isClean).toBe(false);
333
+ expect(status.files.some((file) => file.path === 'README.md')).toBe(true);
334
+ expect(status.files.some((file) => file.path.startsWith('generated/'))).toBe(true);
335
+ });
285
336
  });
286
337
 
287
338
  // ---------------------------------------------------------------------------
@@ -28,7 +28,7 @@ This module provides OpenCode server integration utilities for the web server ru
28
28
  - `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
29
29
  - `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
30
30
  - `packages/web/server/lib/conversations/`: combined create-session-and-prompt orchestration (OpenChamber-owned, registered before generic proxy).
31
- - `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`) and anchor reconcile (`GET .../messages/reconcile`); loops official OpenCode `session.messages` for turn pages and head→anchor gap recovery with continuation/budgets/`resetRequired`; registered before generic proxy. See `session-turn-pages/DOCUMENTATION.md`.
31
+ - `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`), anchor reconcile (`GET .../messages/reconcile`), Changes L2/L3 (`GET .../changes`), and exact message L1 projection (`GET /api/session/:sessionID/message/:messageID`); loops official OpenCode `session.messages` for turn pages and head→anchor gap recovery with continuation/budgets/`resetRequired`; all registered before generic proxy. L1 first-packet / SSE / exact-message paths project `summary.diffs` to `diffCount`/`hasDiffs` only. See `session-turn-pages/DOCUMENTATION.md`.
32
32
  - `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
33
33
  - `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: active loopback-port wiring shared by pairing/LAN URLs and the private relay host.
34
34
  - `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
@@ -184,13 +184,16 @@ This module provides OpenCode server integration utilities for the web server ru
184
184
 
185
185
  ## Public exports (settings-runtime.js)
186
186
  - `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns.
187
+ - Optional dependency `runExclusivePersist` / `getRunExclusivePersist`, plus returned `setRunExclusivePersist(fn)`, let an in-process host (Electron) replace the default `persistSettingsLock` with a shared exclusive runner so desktop main/ssh writers and web settings persistence serialize on one chain.
187
188
  - Returned API:
188
189
  - `readSettingsFromDisk()`
189
190
  - `readSettingsFromDiskMigrated()`
190
191
  - `writeSettingsToDisk(settings)`
191
192
  - `persistSettings(changes)`
193
+ - `setRunExclusivePersist(fn)`
192
194
  - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
193
195
  - One-shot compact-chat defaults migration: when disk marker `compactChatDefaultsMigrationVersion` is missing, rewrite legacy/absent `chatRenderMode`/`activityRenderMode`/`showTurnChangedFiles` to `sorted`/`collapsed`/`true` and persist marker `1` (marker stays on disk; response allowlist still hides it). Marker already `1` preserves user values; `persistSettings` runs the same migration before the first write.
196
+ - `startWebUiServer({ settingsPersistLock })` late-binds that shared runner after module load.
194
197
 
195
198
  ## Public exports (settings-helpers.js)
196
199
  - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -30,6 +30,7 @@ export const createBootstrapRuntime = (dependencies) => {
30
30
  getServerId,
31
31
  getServerLabel,
32
32
  readSettingsFromDiskMigrated,
33
+ persistSettings,
33
34
  normalizeTunnelSessionTtlMs,
34
35
  authorizeManagedOpenCodeBridgeRequest,
35
36
  sayTTSCapability,
@@ -147,6 +148,7 @@ export const createBootstrapRuntime = (dependencies) => {
147
148
  __dirname,
148
149
  openchamberDataDir,
149
150
  readSettingsFromDiskMigrated,
151
+ persistSettings,
150
152
  fetchFreeZenModels,
151
153
  getCachedZenModels,
152
154
  sessionIndexService,
@@ -1127,6 +1127,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1127
1127
  } else if (
1128
1128
  (req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/message-queue\/attachments\/uploads\/[^/]+\/?$/))
1129
1129
  || (req.method === 'PUT' && req.path.match(/^\/api\/fs\/prompt-attachments\/[^/]+\/?$/))
1130
+ || (req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/config-sync\/put\/[^/]+\/?$/))
1130
1131
  ) {
1131
1132
  // Binary attachment uploads stream as the request body. Parsing JSON here
1132
1133
  // would buffer the entire payload and block the relay tunnel.
@@ -27,10 +27,19 @@ describe('feature routes runtime composition', () => {
27
27
  const source = await fs.readFile(new URL('./feature-routes-runtime.js', import.meta.url), 'utf8');
28
28
  expect(source).toContain("import { registerSessionTurnPageRoutes } from '../session-turn-pages/routes.js';");
29
29
  expect(source).toMatch(/registerSessionTurnPageRoutes\(app, \{[\s\S]*buildOpenCodeUrl,[\s\S]*getOpenCodeAuthHeaders,/);
30
- // OpenChamber-owned turn-page/reconcile registration comment + call stay before end of feature composition.
30
+ // OpenChamber-owned turn-page/reconcile/changes/exact-message registration stays before end of feature composition.
31
31
  expect(source).toContain('// OpenChamber-owned turn-window messages API — must register before generic proxy.');
32
32
  const turnIdx = source.indexOf('registerSessionTurnPageRoutes(app,');
33
33
  expect(turnIdx).toBeGreaterThan(-1);
34
+ // Exact message + Changes live inside registerSessionTurnPageRoutes (same Host composition Electron reuses).
35
+ const routesSource = await fs.readFile(new URL('../session-turn-pages/routes.js', import.meta.url), 'utf8');
36
+ expect(routesSource).toContain("app.get('/api/session/:sessionID/message/:messageID'");
37
+ expect(routesSource).toContain("app.get('/api/openchamber/sessions/:sessionID/changes'");
38
+ const exactIdx = routesSource.indexOf("app.get('/api/session/:sessionID/message/:messageID'");
39
+ const changesIdx = routesSource.indexOf("app.get('/api/openchamber/sessions/:sessionID/changes'");
40
+ const messagesIdx = routesSource.indexOf("app.get('/api/openchamber/sessions/:sessionID/messages'");
41
+ expect(exactIdx).toBeGreaterThan(messagesIdx);
42
+ expect(changesIdx).toBeGreaterThan(exactIdx);
34
43
  });
35
44
 
36
45
  it('removes broken SSE clients while continuing worktree topology broadcasts', () => {
@@ -1,6 +1,7 @@
1
1
  import { registerSessionIndexRoutes } from '../session-index/routes.js';
2
2
  import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
3
3
  import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
4
+ import { registerConfigSyncRoutes } from '../config-sync/routes.js';
4
5
 
5
6
  export const registerOpenChamberRoutes = (app, dependencies) => {
6
7
  const {
@@ -11,6 +12,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
11
12
  __dirname,
12
13
  openchamberDataDir,
13
14
  readSettingsFromDiskMigrated,
15
+ persistSettings,
14
16
  fetchFreeZenModels,
15
17
  getCachedZenModels,
16
18
  sessionIndexService,
@@ -31,6 +33,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
31
33
  getPairingSession,
32
34
  express,
33
35
  });
36
+ registerConfigSyncRoutes(app, {
37
+ readSettingsFromDiskMigrated,
38
+ persistSettings,
39
+ express,
40
+ });
34
41
 
35
42
  app.get('/api/openchamber/update-check', async (req, res) => {
36
43
  try {
@@ -39,9 +39,41 @@ export const createSettingsRuntime = (deps) => {
39
39
  normalizeStringArray,
40
40
  formatSettingsResponse,
41
41
  resolveDirectoryCandidate,
42
+ /**
43
+ * Optional late-bound exclusive runner (Electron injects after startWebUiServer).
44
+ * May be a function returning the runner, or the runner itself.
45
+ */
46
+ getRunExclusivePersist,
47
+ runExclusivePersist: initialRunExclusivePersist,
42
48
  } = deps;
43
49
 
44
50
  let persistSettingsLock = Promise.resolve();
51
+ let runExclusivePersist = typeof initialRunExclusivePersist === 'function'
52
+ ? initialRunExclusivePersist
53
+ : null;
54
+
55
+ const resolveRunExclusivePersist = () => {
56
+ if (typeof getRunExclusivePersist === 'function') {
57
+ const resolved = getRunExclusivePersist();
58
+ if (typeof resolved === 'function') return resolved;
59
+ }
60
+ return typeof runExclusivePersist === 'function' ? runExclusivePersist : null;
61
+ };
62
+
63
+ const runPersistedWrite = (work) => {
64
+ const external = resolveRunExclusivePersist();
65
+ if (external) {
66
+ return external(work);
67
+ }
68
+ const next = persistSettingsLock.then(async () => work());
69
+ // Keep the chain alive even if one writer throws.
70
+ persistSettingsLock = next.catch(() => {});
71
+ return next;
72
+ };
73
+
74
+ const setRunExclusivePersist = (fn) => {
75
+ runExclusivePersist = typeof fn === 'function' ? fn : null;
76
+ };
45
77
 
46
78
  // Orphan recovery is a one-shot best-effort scan: when orphans can't be
47
79
  // matched on first pass they stay on disk and every subsequent settings
@@ -495,7 +527,7 @@ export const createSettingsRuntime = (deps) => {
495
527
  await fsPromises.rm(tmp, { force: true });
496
528
  };
497
529
 
498
- const writeSettingsToDisk = async (settings) => {
530
+ const writeSettingsToDiskUnlocked = async (settings) => {
499
531
  try {
500
532
  await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
501
533
  // Atomic write: Electron main and ssh-manager read this file via plain
@@ -511,6 +543,8 @@ export const createSettingsRuntime = (deps) => {
511
543
  }
512
544
  };
513
545
 
546
+ const writeSettingsToDisk = async (settings) => runPersistedWrite(() => writeSettingsToDiskUnlocked(settings));
547
+
514
548
  const validateProjectEntries = async (projects) => {
515
549
  if (!Array.isArray(projects)) {
516
550
  return [];
@@ -778,67 +812,63 @@ export const createSettingsRuntime = (deps) => {
778
812
  const migration7 = migrateSettingsRemoveApprovedDirectories(migration6.settings);
779
813
  const migration8 = migrateSettingsCompactChatDefaults(migration7.settings);
780
814
  if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
781
- await writeSettingsToDisk(migration8.settings);
815
+ await runPersistedWrite(() => writeSettingsToDiskUnlocked(migration8.settings));
782
816
  }
783
817
  return migration8.settings;
784
818
  };
785
819
 
786
- const persistSettings = async (changes) => {
787
- persistSettingsLock = persistSettingsLock.then(async () => {
788
- // Log field names only changes can carry credentials (UI password,
789
- // client tokens) that must never reach the log file.
790
- console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
791
- const current = await readSettingsFromDisk();
792
- const sanitized = sanitizeSettingsUpdate(changes);
793
- let next = mergePersistedSettings(current, sanitized);
794
-
795
- const normalizedState = normalizeSettingsPaths(next);
796
- if (normalizedState.changed) {
797
- next = normalizedState.settings;
798
- }
820
+ const persistSettings = async (changes) => runPersistedWrite(async () => {
821
+ // Log field names only — changes can carry credentials (UI password,
822
+ // client tokens) that must never reach the log file.
823
+ console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
824
+ const current = await readSettingsFromDisk();
825
+ const sanitized = sanitizeSettingsUpdate(changes);
826
+ let next = mergePersistedSettings(current, sanitized);
799
827
 
800
- const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
801
- if (deterministicProjectIdMigration.changed) {
802
- next = deterministicProjectIdMigration.settings;
803
- }
828
+ const normalizedState = normalizeSettingsPaths(next);
829
+ if (normalizedState.changed) {
830
+ next = normalizedState.settings;
831
+ }
804
832
 
805
- const approvedDirectoriesMigration = migrateSettingsRemoveApprovedDirectories(next);
806
- if (approvedDirectoriesMigration.changed) {
807
- next = approvedDirectoriesMigration.settings;
808
- }
833
+ const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
834
+ if (deterministicProjectIdMigration.changed) {
835
+ next = deterministicProjectIdMigration.settings;
836
+ }
809
837
 
810
- // Ensure marker + new defaults exist before the first settings write lands.
811
- const compactChatDefaultsMigration = migrateSettingsCompactChatDefaults(next);
812
- if (compactChatDefaultsMigration.changed) {
813
- next = compactChatDefaultsMigration.settings;
814
- }
838
+ const approvedDirectoriesMigration = migrateSettingsRemoveApprovedDirectories(next);
839
+ if (approvedDirectoriesMigration.changed) {
840
+ next = approvedDirectoriesMigration.settings;
841
+ }
815
842
 
816
- // Validating project paths hits the filesystem for every entry, so only
817
- // do it when the incoming update actually touches the projects list —
818
- // not on every theme/window-state/etc. save.
819
- if (Object.prototype.hasOwnProperty.call(sanitized, 'projects') && Array.isArray(next.projects)) {
820
- const validated = await validateProjectEntries(next.projects);
821
- next = { ...next, projects: validated };
822
- }
843
+ // Ensure marker + new defaults exist before the first settings write lands.
844
+ const compactChatDefaultsMigration = migrateSettingsCompactChatDefaults(next);
845
+ if (compactChatDefaultsMigration.changed) {
846
+ next = compactChatDefaultsMigration.settings;
847
+ }
823
848
 
824
- if (Array.isArray(next.projects) && next.projects.length > 0) {
825
- const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
826
- const active = next.projects.find((project) => project.id === activeId) || null;
827
- if (!active) {
828
- console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
829
- next = { ...next, activeProjectId: next.projects[0].id };
830
- }
831
- } else if (next.activeProjectId) {
832
- console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
833
- next = { ...next, activeProjectId: undefined };
834
- }
849
+ // Validating project paths hits the filesystem for every entry, so only
850
+ // do it when the incoming update actually touches the projects list —
851
+ // not on every theme/window-state/etc. save.
852
+ if (Object.prototype.hasOwnProperty.call(sanitized, 'projects') && Array.isArray(next.projects)) {
853
+ const validated = await validateProjectEntries(next.projects);
854
+ next = { ...next, projects: validated };
855
+ }
835
856
 
836
- await writeSettingsToDisk(next);
837
- return formatSettingsResponse(next);
838
- });
857
+ if (Array.isArray(next.projects) && next.projects.length > 0) {
858
+ const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
859
+ const active = next.projects.find((project) => project.id === activeId) || null;
860
+ if (!active) {
861
+ console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
862
+ next = { ...next, activeProjectId: next.projects[0].id };
863
+ }
864
+ } else if (next.activeProjectId) {
865
+ console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
866
+ next = { ...next, activeProjectId: undefined };
867
+ }
839
868
 
840
- return persistSettingsLock;
841
- };
869
+ await writeSettingsToDiskUnlocked(next);
870
+ return formatSettingsResponse(next);
871
+ });
842
872
 
843
873
  return {
844
874
  readSettingsFromDisk,
@@ -846,5 +876,6 @@ export const createSettingsRuntime = (deps) => {
846
876
  readSettingsFromDiskMigrated,
847
877
  writeSettingsToDisk,
848
878
  persistSettings,
879
+ setRunExclusivePersist,
849
880
  };
850
881
  };
@@ -190,6 +190,48 @@ describe('settings runtime', () => {
190
190
  }
191
191
  });
192
192
 
193
+ it('joins an external exclusive persist runner when provided', async () => {
194
+ const { runtime, settingsFilePath, cleanup } = await createRuntime();
195
+ try {
196
+ const order = [];
197
+ let releaseOuter;
198
+ const outerGate = new Promise((resolve) => {
199
+ releaseOuter = resolve;
200
+ });
201
+
202
+ runtime.setRunExclusivePersist(async (work) => {
203
+ order.push('lock-enter');
204
+ const result = await work();
205
+ order.push('lock-exit');
206
+ return result;
207
+ });
208
+
209
+ const pending = (async () => {
210
+ order.push('outer-start');
211
+ await outerGate;
212
+ order.push('outer-before-write');
213
+ await runtime.writeSettingsToDisk({ theme: 'external' });
214
+ order.push('outer-after-write');
215
+ })();
216
+
217
+ // Without awaiting the outer gate, ensure the lock wrapper is the one used.
218
+ releaseOuter();
219
+ await pending;
220
+ expect(order).toEqual([
221
+ 'outer-start',
222
+ 'outer-before-write',
223
+ 'lock-enter',
224
+ 'lock-exit',
225
+ 'outer-after-write',
226
+ ]);
227
+ await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(
228
+ JSON.stringify({ theme: 'external' }, null, 2),
229
+ );
230
+ } finally {
231
+ await cleanup();
232
+ }
233
+ });
234
+
193
235
  it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
194
236
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
195
237
  const settingsFilePath = path.join(tempRoot, 'settings.json');
@@ -50,7 +50,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
50
50
  Private Relay is transparent for allowlisted HTTP paths, so most “works on LAN / Desktop, empty on mobile Relay” bugs are Host routing or client transport-identity mistakes rather than Relay framing bugs:
51
51
 
52
52
  - **Same path, real Host route required.** Clients call the ordinary `/api/...` path through the tunnel. If that route is missing on the Host process currently holding the relay claim (wrong git worktree, stale packaged Desktop build, or a backend that never registered the OpenChamber route before the OpenCode proxy/SPA fallback), the tunnel still returns HTTP 200 with SPA HTML or proxied OpenCode content. Symptom: chat and status work, Provider/model catalog does not.
53
- - **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill).
53
+ - **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill). Config sync (`/api/openchamber/config-sync/*`, ticket 05/06) therefore works over Relay without a tunnel-host allowlist change; identity and credential grants remain endpoint concerns (`relay:<serverId>`, pairing-settings / inbound host grant).
54
54
  - **Transport identity ≠ runtime key.** After LAN⇄relay swaps, UI catalog loaders commit only when `useConfigStore.catalogTransportIdentity` matches `getRuntimeTransportIdentity()`. `runtimeEndpointReset.ts` must write that transport fingerprint on both full endpoint reset and in-place transport reconnect. Writing `runtimeKey` instead leaves Providers empty because the stable device/instance id is shared across LAN and relay.
55
55
  - **Safe catalog projection stays Host-owned.** Provider credentials never cross the browser; Relay only carries the already-allowlisted `GET /api/config/catalog/providers` JSON. Keep Host projection, client parser bounds, and `partial` rules in sync (empty `release_date` is absent, not partial).
56
56
 
@@ -11,6 +11,8 @@ Exposed as:
11
11
 
12
12
  - `GET /api/openchamber/sessions/:sessionID/messages`
13
13
  - `GET /api/openchamber/sessions/:sessionID/messages/reconcile`
14
+ - `GET /api/openchamber/sessions/:sessionID/changes` (L2 file list / L3 single-file patch)
15
+ - `GET /api/session/:sessionID/message/:messageID` (exact message with L1 summary projection; registered before the generic OpenCode proxy)
14
16
 
15
17
  ## Why this exists
16
18
 
@@ -69,9 +71,22 @@ Success `200`:
69
71
  - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
70
72
  - `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
71
73
 
72
- ### Turn-page parts projection
74
+ ### Turn-page payload projection
75
+
76
+ Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus message `summary.diffs` so it does not have to carry the bodies of a long tool-heavy turn, inline attachment data URLs, or full file patches. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
77
+
78
+ ### Changes Host contract (L1 / L2 / L3)
79
+
80
+ | Layer | Surface | Payload |
81
+ |---|---|---|
82
+ | **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to `summary.diffCount: number` + `summary.hasDiffs: boolean` and **`summary.diffs` is removed**. Other lightweight summary fields are preserved. `diffCount` is the original array length; `hasDiffs === diffCount > 0`. L1 never retains a file array. |
83
+ | **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
84
+ | **L3** | Same route with `file=` | Host calls official `session.diff({ sessionID, messageID, directory })`, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match (includes that file's patch). No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. |
85
+
86
+ Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays a raw multi-megabyte `summary.diffs` array through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
87
+
88
+ Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file` length `1..4096` and rejects control characters / NUL. Failures: invalid params → 400, abort → 499, upstream/malformed → 502. Logs carry only safe flags and ids — never paths, patch bodies, or tokens.
73
89
 
74
- Every turn-page response summarizes `tool`, `reasoning`, and `file` parts so it does not have to carry the bodies of a long tool-heavy turn or inline attachment data URLs. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
75
90
 
76
91
  | Kept | Dropped |
77
92
  |---|---|
@@ -85,6 +100,7 @@ Rules:
85
100
  - Slim tool input keeps only short locator and task-identity fields (path, pattern, query, command, `subagent_type`, `description`, skill `name` / `id`). Slim metadata keeps `sessionId`, skill `name`, plus edit `additions`/`deletions`. Projection still drops result bodies, task prompts, patches, and large write payloads.
86
101
  - Assistant `text` stays intact. User rows are no longer wholesale pass-through: their `file` parts are projected; other user parts stay as-is.
87
102
  - `file` projection keeps already-present size/width/height (top-level or `metadata`). `byteSize` is derived from a data URL when present. PNG/GIF/JPEG/WebP headers may fill missing `width`/`height`. Unknown dimensions omit those fields.
103
+ - Message `info.summary.diffs` is L1-projected to `diffCount` / `hasDiffs` (file array removed). Reconcile preserves full parts and applies this same L1 summary projection to response-page byte budgeting and serialization; raw upstream scan budgets retain raw bytes. Clients that need file rows or patches use the Changes L2/L3 API.
88
104
  - A slim `file` part is a reference, not renderable content. Clients must hydrate the full message (`session.message` by `messageID`) before painting or editing the attachment. Do not treat slim file metadata as a complete part.
89
105
  - Records that gain nothing from projection keep their original object reference, so identity-based change detection downstream still works.
90
106
  - Projection applies to every turn-page response, including first packet, prepend, and explicit cursors (`before` present). Reconcile routes return full parts, unchanged.
@@ -242,10 +258,11 @@ When the per-page budget fills before the gap is complete, the host returns `con
242
258
 
243
259
  ## Files
244
260
 
245
- - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
261
+ - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
246
262
  - `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
247
- - `routes.js` — Express registration for both turn-page and reconcile; optional injected `sessionTurnPageService` / `sessionReconcileService` for tests; default wires SDK `session.messages` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
248
- - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` unit contracts
263
+ - `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
264
+ - `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires SDK `session.messages` / `session.message` / `session.diff` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
265
+ - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
249
266
 
250
267
  ## Registration
251
268
 
@@ -257,14 +274,17 @@ registerSessionTurnPageRoutes(app, {
257
274
  });
258
275
  ```
259
276
 
260
- Reconcile is registered on the more-specific path **before** the generic messages path inside `registerSessionTurnPageRoutes`.
277
+ Inside `registerSessionTurnPageRoutes`, registration order is: reconcile turn-page messages → exact `GET /api/session/:sessionID/message/:messageID` → Changes. All of these register before the generic OpenCode proxy in `feature-routes-runtime.js`. Electron reuses this same Host composition in-process.
261
278
 
262
279
  ## Error HTTP map
263
280
 
264
281
  | Condition | Status |
265
282
  |---|---|
266
283
  | Invalid turns / scanLimit / sessionID / cursor | 400 |
284
+ | Invalid messageID / file (Changes / exact message) | 400 |
267
285
  | Invalid anchor / continuation / reconcile params | 400 |
286
+ | Change file not found (`change_not_found`) | 404 |
287
+ | Exact message not found | 404 |
268
288
  | max_scan_pages / max_scan_messages | 413 |
269
289
  | Client abort | 499 |
270
290
  | Server exception (reconcile) | 500 (stack logged; safe client body) |