@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21

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 (148) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
  2. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
  3. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
  4. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
  10. package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
  11. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
  14. package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
  15. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
  16. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
  19. package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
  20. package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
  21. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
  22. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
  23. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
  24. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
  26. package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
  27. package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
  28. package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
  29. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
  30. package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
  31. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
  32. package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
  33. package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
  34. package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
  35. package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
  36. package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
  37. package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
  38. package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
  39. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
  41. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
  42. package/dist/assets/mobile-C4bTWKry.js +2 -0
  43. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
  44. package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
  45. package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
  46. package/dist/assets/projectMeta-BunN9pH5.js +3 -0
  47. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
  48. package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
  49. package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
  50. package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
  51. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
  54. package/dist/assets/src-8kQFmtjw.css +2 -0
  55. package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
  56. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
  57. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
  58. package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
  59. package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
  60. package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
  61. package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
  62. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
  63. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
  65. package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
  66. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
  67. package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
  68. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
  69. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
  70. package/dist/index.html +8 -8
  71. package/dist/mini-chat.html +8 -8
  72. package/dist/mobile.html +8 -8
  73. package/package.json +1 -1
  74. package/server/index.d.ts +6 -0
  75. package/server/index.js +13 -0
  76. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  77. package/server/lib/config-sync/config-sync.test.js +197 -0
  78. package/server/lib/config-sync/constants.js +39 -0
  79. package/server/lib/config-sync/contract.js +130 -0
  80. package/server/lib/config-sync/credential-auth.js +39 -0
  81. package/server/lib/config-sync/engine.js +108 -0
  82. package/server/lib/config-sync/index.js +67 -0
  83. package/server/lib/config-sync/local-backup.js +171 -0
  84. package/server/lib/config-sync/plan.js +318 -0
  85. package/server/lib/config-sync/receiver.js +330 -0
  86. package/server/lib/config-sync/routes.js +141 -0
  87. package/server/lib/config-sync/routes.test.js +154 -0
  88. package/server/lib/config-sync/scripts.js +264 -0
  89. package/server/lib/config-sync/selections.js +112 -0
  90. package/server/lib/config-sync/tar.js +35 -0
  91. package/server/lib/config-sync/target-id.js +24 -0
  92. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  93. package/server/lib/event-stream/diff-summary.js +39 -11
  94. package/server/lib/event-stream/diff-summary.test.js +73 -6
  95. package/server/lib/event-stream/protocol.test.js +70 -7
  96. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  97. package/server/lib/notifications/runtime.js +68 -27
  98. package/server/lib/notifications/runtime.test.js +176 -0
  99. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  100. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  101. package/server/lib/opencode/core-routes.js +1 -0
  102. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  103. package/server/lib/opencode/openchamber-routes.js +7 -0
  104. package/server/lib/opencode/settings-runtime.js +82 -51
  105. package/server/lib/opencode/settings-runtime.test.js +42 -0
  106. package/server/lib/relay/DOCUMENTATION.md +1 -1
  107. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  108. package/server/lib/session-index/routes.js +34 -0
  109. package/server/lib/session-index/routes.test.js +76 -0
  110. package/server/lib/session-index/service.js +53 -4
  111. package/server/lib/session-index/service.test.js +72 -2
  112. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  113. package/server/lib/session-title/runtime.js +8 -8
  114. package/server/lib/session-title/runtime.test.js +231 -0
  115. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  116. package/server/lib/session-turn-pages/changes.service.js +156 -0
  117. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  118. package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
  119. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  120. package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
  121. package/server/lib/session-turn-pages/routes.js +273 -12
  122. package/server/lib/session-turn-pages/routes.test.js +387 -0
  123. package/server/lib/session-turn-pages/service.js +31 -7
  124. package/server/lib/session-turn-pages/service.test.js +40 -0
  125. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  126. package/server/lib/small-model/catalog.js +10 -2
  127. package/server/lib/small-model/catalog.test.js +3 -1
  128. package/server/lib/small-model/index.js +52 -28
  129. package/server/lib/small-model/opencode-session.js +339 -0
  130. package/server/lib/small-model/opencode-session.test.js +177 -0
  131. package/server/lib/small-model/resolve.js +116 -5
  132. package/server/lib/small-model/resolve.test.js +76 -10
  133. package/server/lib/small-model/summary-settings.test.js +60 -3
  134. package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
  135. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  136. package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
  137. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  138. package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
  139. package/dist/assets/mobile-BktO3_4R.js +0 -2
  140. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  141. package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
  142. package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
  143. package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
  144. package/dist/assets/src-oHrYIqHF.css +0 -2
  145. package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
  147. package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
  148. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -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
 
@@ -13,14 +13,20 @@ newest 20 root-session summaries per runtime and directory. It never stores
13
13
  messages, attachments, permissions, provider data, or model metadata.
14
14
  Sessions titled `smartfetch-secondary` are temporary SmartFetch model calls;
15
15
  the index excludes them from every snapshot and clears prior summaries when a
16
- matching session update arrives. System-owned sessions are excluded by
17
- authoritative metadata only: a non-empty
18
- `metadata.openchamber.assistant.assistantID` or
19
- `metadata.openchamber.scheduledTask.taskID`. Title prefixes are human labels and
20
- never participate in this filter. Metadata is the ownership/isolation signal;
21
- `time.archived` is archive state; titles are for recognition. The index does
16
+ matching session update arrives. System-owned sessions are excluded by
17
+ authoritative metadata only: a non-empty
18
+ `metadata.openchamber.assistant.assistantID`,
19
+ `metadata.openchamber.scheduledTask.taskID`, or
20
+ `metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
21
+ never participate in this filter. Metadata is the ownership/isolation signal;
22
+ `time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
23
+ (persisted as `pinned_at`, rescued across directory rebuilds, never overwritten
24
+ by live OpenCode upserts); titles are for recognition. The index does
22
25
  not cache system sessions, so cold-start and live upserts stay aligned with
23
26
  ordinary sidebar lists.
27
+ Pin and unpin use `POST` / `DELETE`
28
+ `/api/openchamber/session-index/session/:id/pin`, update `pinned_at`, then
29
+ `publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
24
30
 
25
31
  The server-side global OpenCode event subscriber writes session summary events
26
32
  directly into this index. User `message.updated` events and `session.idle`
@@ -62,4 +62,38 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
62
62
  }
63
63
  res.json({ available: true, session: hit });
64
64
  });
65
+
66
+ app.post('/api/openchamber/session-index/session/:id/pin', (req, res) => {
67
+ if (!sessionIndexService) return unsupported(res);
68
+ if (typeof sessionIndexService.setPinned !== 'function') {
69
+ return res.status(501).json({ error: 'Session pin is unavailable' });
70
+ }
71
+ const sessionId = req.params.id;
72
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
73
+ return res.status(400).json({ error: 'sessionId is required' });
74
+ }
75
+ const changed = sessionIndexService.setPinned(sessionId.trim());
76
+ if (!changed) {
77
+ return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
78
+ }
79
+ sessionIndexSyncRuntime?.publishChange?.();
80
+ res.status(204).end();
81
+ });
82
+
83
+ app.delete('/api/openchamber/session-index/session/:id/pin', (req, res) => {
84
+ if (!sessionIndexService) return unsupported(res);
85
+ if (typeof sessionIndexService.clearPinned !== 'function') {
86
+ return res.status(501).json({ error: 'Session unpin is unavailable' });
87
+ }
88
+ const sessionId = req.params.id;
89
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
90
+ return res.status(400).json({ error: 'sessionId is required' });
91
+ }
92
+ const changed = sessionIndexService.clearPinned(sessionId.trim());
93
+ if (!changed) {
94
+ return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
95
+ }
96
+ sessionIndexSyncRuntime?.publishChange?.();
97
+ res.status(204).end();
98
+ });
65
99
  };
@@ -137,4 +137,80 @@ describe('session index routes', () => {
137
137
  expect(res.statusCode).toBe(404);
138
138
  expect(res.body).toMatchObject({ error: 'session_not_found' });
139
139
  });
140
+
141
+ it('pins a session and publishes a session-index change', () => {
142
+ const { app, route } = registry();
143
+ const setPinned = vi.fn(() => true);
144
+ const publishChange = vi.fn();
145
+ registerSessionIndexRoutes(app, {
146
+ sessionIndexService: {
147
+ snapshot: () => ({ directories: [] }),
148
+ setPinned,
149
+ },
150
+ sessionIndexSyncRuntime: { publishChange },
151
+ });
152
+ const res = response();
153
+
154
+ route('POST', '/api/openchamber/session-index/session/:id/pin')(
155
+ { params: { id: 'ses_abc' } },
156
+ res,
157
+ );
158
+
159
+ expect(setPinned).toHaveBeenCalledWith('ses_abc');
160
+ expect(publishChange).toHaveBeenCalledTimes(1);
161
+ expect(res.statusCode).toBe(204);
162
+ });
163
+
164
+ it('unpins a session and publishes a session-index change', () => {
165
+ const { app, route } = registry();
166
+ const clearPinned = vi.fn(() => true);
167
+ const publishChange = vi.fn();
168
+ registerSessionIndexRoutes(app, {
169
+ sessionIndexService: {
170
+ snapshot: () => ({ directories: [] }),
171
+ clearPinned,
172
+ },
173
+ sessionIndexSyncRuntime: { publishChange },
174
+ });
175
+ const res = response();
176
+
177
+ route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
178
+ { params: { id: 'ses_abc' } },
179
+ res,
180
+ );
181
+
182
+ expect(clearPinned).toHaveBeenCalledWith('ses_abc');
183
+ expect(publishChange).toHaveBeenCalledTimes(1);
184
+ expect(res.statusCode).toBe(204);
185
+ });
186
+
187
+ it('returns 404 for pin and unpin when the session is missing', () => {
188
+ const { app, route } = registry();
189
+ const publishChange = vi.fn();
190
+ registerSessionIndexRoutes(app, {
191
+ sessionIndexService: {
192
+ snapshot: () => ({ directories: [] }),
193
+ setPinned: () => false,
194
+ clearPinned: () => false,
195
+ },
196
+ sessionIndexSyncRuntime: { publishChange },
197
+ });
198
+
199
+ const pinRes = response();
200
+ route('POST', '/api/openchamber/session-index/session/:id/pin')(
201
+ { params: { id: 'ses_missing' } },
202
+ pinRes,
203
+ );
204
+ expect(pinRes.statusCode).toBe(404);
205
+ expect(pinRes.body).toMatchObject({ error: 'session_not_found' });
206
+
207
+ const unpinRes = response();
208
+ route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
209
+ { params: { id: 'ses_missing' } },
210
+ unpinRes,
211
+ );
212
+ expect(unpinRes.statusCode).toBe(404);
213
+ expect(unpinRes.body).toMatchObject({ error: 'session_not_found' });
214
+ expect(publishChange).not.toHaveBeenCalled();
215
+ });
140
216
  });
@@ -15,6 +15,7 @@ const isSystemSession = (session) => {
15
15
  if (!openchamber || typeof openchamber !== 'object') return false;
16
16
  if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
17
17
  if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
18
+ if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
18
19
  return false;
19
20
  };
20
21
 
@@ -116,6 +117,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
116
117
  updated_at INTEGER NOT NULL,
117
118
  activity_updated_at INTEGER NOT NULL,
118
119
  archived_at INTEGER NOT NULL DEFAULT 0,
120
+ pinned_at INTEGER,
119
121
  status TEXT,
120
122
  status_changed_at INTEGER NOT NULL DEFAULT 0,
121
123
  parent_id TEXT,
@@ -139,6 +141,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
139
141
  CREATE INDEX IF NOT EXISTS session_child_parent
140
142
  ON session_child(runtime_key, directory, parent_id);
141
143
  `);
144
+ const sessionSummaryColumns = new Set(
145
+ db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
146
+ );
147
+ if (!sessionSummaryColumns.has('pinned_at')) {
148
+ db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
149
+ }
142
150
  db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
143
151
  .run('schema_version', String(SCHEMA_VERSION));
144
152
 
@@ -165,7 +173,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
165
173
  const deleteDirectoryRows = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND directory = ?');
166
174
  const existingSummaryState = db.prepare(`
167
175
  SELECT session_id, has_children AS hasChildren, activity_updated_at AS activityUpdatedAt,
168
- status, status_changed_at AS statusChangedAt
176
+ status, status_changed_at AS statusChangedAt, pinned_at AS pinnedAt
169
177
  FROM session_summary WHERE runtime_key = ? AND directory = ?
170
178
  `);
171
179
  const readSummary = db.prepare(`
@@ -183,8 +191,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
183
191
  LIMIT 1 OFFSET ?
184
192
  `);
185
193
  const insertSummary = db.prepare(`
186
- INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
187
- VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
194
+ INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, pinned_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
195
+ VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @pinnedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
188
196
  `);
189
197
  const upsertSummary = db.prepare(`
190
198
  INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
@@ -259,6 +267,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
259
267
  insertSummary.run({
260
268
  ...summary,
261
269
  activityUpdatedAt: Math.max(summary.activityUpdatedAt, toTimestamp(previous?.activityUpdatedAt)),
270
+ pinnedAt: previous?.pinnedAt != null ? toTimestamp(previous.pinnedAt) : null,
262
271
  status: typeof previous?.status === 'string' ? previous.status : null,
263
272
  statusChangedAt: toTimestamp(previous?.statusChangedAt),
264
273
  hasChildren: toBooleanInt(summary.hasChildren ?? Boolean(previous?.hasChildren)),
@@ -364,6 +373,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
364
373
  sessions: db.prepare(`
365
374
  SELECT session_id AS id, title, created_at AS createdAt, updated_at AS updatedAt,
366
375
  activity_updated_at AS activityUpdatedAt, archived_at AS archivedAt,
376
+ pinned_at AS pinnedAt,
367
377
  status, status_changed_at AS statusChangedAt, parent_id AS parentID,
368
378
  has_children AS hasChildren, assistant_id AS assistantID, assistant_name AS assistantName
369
379
  FROM session_summary WHERE runtime_key = ? AND directory = ?
@@ -373,7 +383,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
373
383
  id: session.id,
374
384
  title: session.title,
375
385
  directory: directory.directory,
376
- time: { created: session.createdAt, updated: session.updatedAt, ...(session.archivedAt ? { archived: session.archivedAt } : {}) },
386
+ time: {
387
+ created: session.createdAt,
388
+ updated: session.updatedAt,
389
+ ...(session.archivedAt ? { archived: session.archivedAt } : {}),
390
+ ...(session.pinnedAt ? { pinned: session.pinnedAt } : {}),
391
+ },
377
392
  metadata: {
378
393
  openchamber: {
379
394
  titleRefresh: { activityUpdatedAt: session.activityUpdatedAt },
@@ -465,6 +480,38 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
465
480
  return result.changes > 0;
466
481
  };
467
482
 
483
+ const summaryExists = db.prepare(`
484
+ SELECT 1 AS present FROM session_summary
485
+ WHERE runtime_key = ? AND session_id = ?
486
+ LIMIT 1
487
+ `);
488
+
489
+ const setPinned = (sessionID, pinnedAt = Date.now()) => {
490
+ if (typeof sessionID !== 'string' || !sessionID) return false;
491
+ const timestamp = toTimestamp(pinnedAt);
492
+ if (!timestamp) return false;
493
+ const key = runtimeKey();
494
+ if (!summaryExists.get(key, sessionID)) return false;
495
+ db.prepare(`
496
+ UPDATE session_summary
497
+ SET pinned_at = ?
498
+ WHERE runtime_key = ? AND session_id = ?
499
+ `).run(timestamp, key, sessionID);
500
+ return true;
501
+ };
502
+
503
+ const clearPinned = (sessionID) => {
504
+ if (typeof sessionID !== 'string' || !sessionID) return false;
505
+ const key = runtimeKey();
506
+ if (!summaryExists.get(key, sessionID)) return false;
507
+ db.prepare(`
508
+ UPDATE session_summary
509
+ SET pinned_at = NULL
510
+ WHERE runtime_key = ? AND session_id = ?
511
+ `).run(key, sessionID);
512
+ return true;
513
+ };
514
+
468
515
  const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
469
516
  const normalizedDirectory = normalizeDirectory(directory);
470
517
  if (!normalizedDirectory || typeof parentSessionID !== 'string' || !parentSessionID) return false;
@@ -487,6 +534,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
487
534
  upsertAndReportChange,
488
535
  touchActivity,
489
536
  updateStatus,
537
+ setPinned,
538
+ clearPinned,
490
539
  remove,
491
540
  findBySessionId,
492
541
  replaceChildSessions,
@@ -68,7 +68,7 @@ describe('Electron session index', () => {
68
68
  service.close();
69
69
  });
70
70
 
71
- it('excludes Assistant and Scheduled system sessions from ordinary sidebar summaries', () => {
71
+ it('excludes Assistant, Scheduled, and smallModel system sessions from ordinary sidebar summaries', () => {
72
72
  const runtimeRef = { value: 'http://runtime-a.test' };
73
73
  const service = createService(runtimeRef);
74
74
  const assistantSession = {
@@ -79,11 +79,15 @@ describe('Electron session index', () => {
79
79
  ...session('ses_scheduled', 99),
80
80
  metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
81
81
  };
82
+ const smallModelSession = {
83
+ ...session('ses_small_model', 97),
84
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
85
+ };
82
86
  const ordinary = session('ses_ordinary', 98);
83
87
 
84
88
  service.replaceDirectory({
85
89
  directory: '/repo',
86
- sessions: [assistantSession, scheduledSession, ordinary],
90
+ sessions: [assistantSession, scheduledSession, smallModelSession, ordinary],
87
91
  cursor: null,
88
92
  hasMore: false,
89
93
  });
@@ -91,6 +95,7 @@ describe('Electron session index', () => {
91
95
 
92
96
  service.upsert(assistantSession);
93
97
  service.upsert(scheduledSession);
98
+ service.upsert(smallModelSession);
94
99
  expect(service.snapshot().directories[0].sessions.map((item) => item.id)).toEqual(['ses_ordinary']);
95
100
  service.close();
96
101
  });
@@ -318,4 +323,69 @@ describe('Electron session index', () => {
318
323
  expect(rebuilt.snapshot().directories).toEqual([]);
319
324
  rebuilt.close();
320
325
  });
326
+
327
+ it('exposes time.pinned in snapshots and clears it on unpin', () => {
328
+ const runtimeRef = { value: 'http://runtime-a.test' };
329
+ const service = createService(runtimeRef);
330
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
331
+
332
+ expect(service.setPinned('ses_a', 55)).toBe(true);
333
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
334
+ created: 9,
335
+ updated: 10,
336
+ pinned: 55,
337
+ });
338
+
339
+ expect(service.clearPinned('ses_a')).toBe(true);
340
+ expect(service.snapshot().directories[0].sessions[0].time).toEqual({
341
+ created: 9,
342
+ updated: 10,
343
+ });
344
+ service.close();
345
+ });
346
+
347
+ it('rescues pinned_at across replaceDirectory rebuilds', () => {
348
+ const runtimeRef = { value: 'http://runtime-a.test' };
349
+ const service = createService(runtimeRef);
350
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
351
+ expect(service.setPinned('ses_a', 77)).toBe(true);
352
+
353
+ service.replaceDirectory({
354
+ directory: '/repo',
355
+ sessions: [session('ses_a', 20)],
356
+ cursor: null,
357
+ hasMore: false,
358
+ now: 2000,
359
+ });
360
+
361
+ expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
362
+ updated: 20,
363
+ pinned: 77,
364
+ });
365
+ service.close();
366
+ });
367
+
368
+ it('does not let live upsert overwrite pinned_at', () => {
369
+ const runtimeRef = { value: 'http://runtime-a.test' };
370
+ const service = createService(runtimeRef);
371
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
372
+ expect(service.setPinned('ses_a', 88)).toBe(true);
373
+
374
+ service.upsert(session('ses_a', 30));
375
+
376
+ expect(service.snapshot().directories[0].sessions[0].time.pinned).toBe(88);
377
+ service.close();
378
+ });
379
+
380
+ it('removes pinned rows when an archived upsert deletes the session', () => {
381
+ const runtimeRef = { value: 'http://runtime-a.test' };
382
+ const service = createService(runtimeRef);
383
+ service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
384
+ expect(service.setPinned('ses_a', 99)).toBe(true);
385
+
386
+ expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
387
+ expect(service.snapshot().directories[0].sessions).toEqual([]);
388
+ expect(service.clearPinned('ses_a')).toBe(false);
389
+ service.close();
390
+ });
321
391
  });