@vantran-se/openchamber-web 1.22.1 → 1.23.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 (238) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +11 -1
  3. package/bin/cli.test.js +92 -0
  4. package/bin/lib/cli-startup.js +49 -0
  5. package/bin/lib/commands-startup.js +41 -6
  6. package/dist/assets/{CodeMirrorCommentWidgets-C53Q7pbX.js → CodeMirrorCommentWidgets-DFg2wnpo.js} +1 -1
  7. package/dist/assets/DiffView-3Dn4QHbu.js +4 -0
  8. package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +1 -0
  9. package/dist/assets/FilesView-BdTOpew9.js +17 -0
  10. package/dist/assets/GitView-Dqf9vHJh.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-BKdsjg5B.js} +1 -1
  12. package/dist/assets/LinearIssuesView-CmbeL7I6.js +1 -0
  13. package/dist/assets/MarkdownImageGallery-_5gEVr0X.js +1 -0
  14. package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +12 -0
  15. package/dist/assets/OnboardingScreen-DdP4_nuc.js +1 -0
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-am3SAss2.js} +1 -1
  17. package/dist/assets/PierreDiffViewer-DCsydlKs.js +161 -0
  18. package/dist/assets/PlanView-BoJxdRZe.js +5 -0
  19. package/dist/assets/SettingsView-CcM8pF5O.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DVTdDSol.js} +1 -1
  21. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  22. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-BgfTbDZ4.js} +6 -6
  23. package/dist/assets/ToolPartDiffPreview-C7WLk5i9.js +8 -0
  24. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-DcdgkH-x.js} +1 -1
  25. package/dist/assets/WalkthroughView-BEVUDxuw.js +4 -0
  26. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-C8byMnRh.js} +2 -2
  27. package/dist/assets/contextPanelEmbeddedChat-CirXEPJq.js +1 -0
  28. package/dist/assets/de-C8jV7J9y.js +10 -0
  29. package/dist/assets/es-BI6J1M5R.js +16 -0
  30. package/dist/assets/fr-D6oDFt7E.js +14 -0
  31. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  32. package/dist/assets/{index-DmhujXbT.js → index-BB7ryz8y.js} +1 -1
  33. package/dist/assets/index-BqhiCmYS.js +39 -0
  34. package/dist/assets/index-FqSOBz0J.css +1 -0
  35. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-45ujMPGb.js} +1 -1
  36. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-Cc4KFSYi.js} +1 -1
  37. package/dist/assets/ja-C_4i5EQu.js +16 -0
  38. package/dist/assets/ko-BFbOTpbq.js +16 -0
  39. package/dist/assets/{main-B2a_JfKZ.js → main-C9ETLKFc.js} +2 -2
  40. package/dist/assets/main-CJmb2dF8.js +930 -0
  41. package/dist/assets/markdownCore-DVuZpnli.js +5 -0
  42. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-B4Ytoq2n.js} +2 -2
  43. package/dist/assets/{mobile-QNcFIVCW.js → mobile-BUT6-lo1.js} +2 -2
  44. package/dist/assets/patchFileDiff-8j1apICU.js +13 -0
  45. package/dist/assets/pl-COFvT56l.js +16 -0
  46. package/dist/assets/pt-BR-DtVOO2Lj.js +16 -0
  47. package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +2 -0
  48. package/dist/assets/renderMobileApp-BNgHraq6.js +25 -0
  49. package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +17 -0
  50. package/dist/assets/scheduledTasksApi-GC4epIYA.js +1 -0
  51. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-BJISeZsQ.js} +1 -1
  52. package/dist/assets/surface-BvRzS58U.js +1 -0
  53. package/dist/assets/tr-B8Q0Go2l.js +7 -0
  54. package/dist/assets/uk-Dk4a-Fn-.js +16 -0
  55. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-DpFNefUA.js} +153 -179
  56. package/dist/assets/useGitComparison-Dfxaqd0s.js +1 -0
  57. package/dist/assets/useInlineCommentController-CscsE4t-.js +2 -0
  58. package/dist/assets/useRootScrollLock-BaCvuRlT.js +1 -0
  59. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-DdV8vhHn.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-DzlSIu_W.js} +2 -2
  61. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  62. package/dist/assets/vendor-zod-BPAANqvH.js +39 -0
  63. package/dist/assets/{web-components-BNMwa0qm.js → web-components-CdGfR2XJ.js} +1 -1
  64. package/dist/assets/zh-CN-B_BXxG1Q.js +16 -0
  65. package/dist/assets/zh-TW-DLrl1SPZ.js +16 -0
  66. package/dist/index.html +5 -43
  67. package/dist/mini-chat.html +4 -4
  68. package/dist/mobile.html +4 -4
  69. package/package.json +8 -5
  70. package/server/index.d.ts +15 -0
  71. package/server/index.js +82 -13
  72. package/server/lib/agent-tool/runtime.js +2 -19
  73. package/server/lib/changelog/update-notes.js +82 -0
  74. package/server/lib/changelog/update-notes.test.js +59 -0
  75. package/server/lib/data-dir-migration.js +40 -0
  76. package/server/lib/data-dir-migration.test.js +59 -0
  77. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  78. package/server/lib/dev-tunnel/runtime.js +4 -4
  79. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  80. package/server/lib/event-stream/runtime.test.js +12 -12
  81. package/server/lib/fs/DOCUMENTATION.md +5 -0
  82. package/server/lib/fs/routes.js +63 -21
  83. package/server/lib/fs/routes.test.js +187 -1
  84. package/server/lib/git/DOCUMENTATION.md +32 -6
  85. package/server/lib/git/routes.js +40 -4
  86. package/server/lib/git/service.js +291 -215
  87. package/server/lib/git/service.test.js +593 -25
  88. package/server/lib/inherited-env.js +16 -9
  89. package/server/lib/inherited-env.test.js +8 -9
  90. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  91. package/server/lib/mcp-reconnect/runtime.js +122 -0
  92. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  93. package/server/lib/message-queue/DOCUMENTATION.md +161 -0
  94. package/server/lib/message-queue/runtime.js +870 -0
  95. package/server/lib/message-queue/runtime.test.js +482 -0
  96. package/server/lib/network-defaults.js +23 -0
  97. package/server/lib/network-defaults.test.js +52 -0
  98. package/server/lib/openchamber-sessions/routes.js +102 -0
  99. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  100. package/server/lib/opencode/DOCUMENTATION.md +30 -6
  101. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  102. package/server/lib/opencode/config-paths.test.js +116 -0
  103. package/server/lib/opencode/core-routes.js +1 -0
  104. package/server/lib/opencode/feature-routes-runtime.js +7 -0
  105. package/server/lib/opencode/input-history-scope.js +14 -0
  106. package/server/lib/opencode/managed-plugin-config.js +35 -0
  107. package/server/lib/opencode/openchamber-routes.js +64 -4
  108. package/server/lib/opencode/openchamber-routes.test.js +130 -3
  109. package/server/lib/opencode/plugins.js +2 -2
  110. package/server/lib/opencode/providers.js +44 -2
  111. package/server/lib/opencode/providers.test.js +131 -4
  112. package/server/lib/opencode/routes.js +9 -7
  113. package/server/lib/opencode/server-startup-runtime.js +1 -1
  114. package/server/lib/opencode/settings-files.js +214 -0
  115. package/server/lib/opencode/settings-helpers.js +113 -41
  116. package/server/lib/opencode/settings-helpers.test.js +342 -0
  117. package/server/lib/opencode/settings-registry.json +747 -0
  118. package/server/lib/opencode/settings-runtime.js +141 -20
  119. package/server/lib/opencode/settings-runtime.test.js +191 -4
  120. package/server/lib/opencode/shared.js +4 -1
  121. package/server/lib/opencode/shutdown-runtime.js +2 -0
  122. package/server/lib/opencode/skill-routes.js +2 -1
  123. package/server/lib/opencode/snippets.js +1 -2
  124. package/server/lib/package-manager.js +4 -29
  125. package/server/lib/package-manager.test.js +2 -2
  126. package/server/lib/project-context/DOCUMENTATION.md +25 -1
  127. package/server/lib/project-context/routes.http.test.js +18 -0
  128. package/server/lib/project-context/routes.js +15 -0
  129. package/server/lib/project-context/runtime.js +248 -23
  130. package/server/lib/project-context/runtime.test.js +106 -3
  131. package/server/lib/projects/DOCUMENTATION.md +76 -0
  132. package/server/lib/projects/project-config.js +142 -9
  133. package/server/lib/projects/project-id.js +17 -0
  134. package/server/lib/projects/project-setup.js +409 -0
  135. package/server/lib/projects/project-setup.test.js +557 -0
  136. package/server/lib/projects/routes.js +59 -0
  137. package/server/lib/quota/DOCUMENTATION.md +40 -2
  138. package/server/lib/quota/credentials/providers.js +4 -0
  139. package/server/lib/quota/credentials/store.js +1 -1
  140. package/server/lib/quota/credentials/store.test.js +4 -4
  141. package/server/lib/quota/index.js +1 -0
  142. package/server/lib/quota/providers/cline-pass.js +125 -0
  143. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  144. package/server/lib/quota/providers/exe-dev.js +75 -0
  145. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  146. package/server/lib/quota/providers/hyper.js +118 -0
  147. package/server/lib/quota/providers/hyper.test.js +126 -0
  148. package/server/lib/quota/providers/index.js +22 -0
  149. package/server/lib/quota/providers/neuralwatt.js +3 -6
  150. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  151. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  152. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  153. package/server/lib/quota/providers/opencode-go.js +2 -1
  154. package/server/lib/quota/providers/opencode-go.test.js +31 -2
  155. package/server/lib/quota/providers/openrouter.js +119 -24
  156. package/server/lib/quota/providers/openrouter.test.js +330 -0
  157. package/server/lib/quota/routes.js +2 -0
  158. package/server/lib/quota/utils/auth.js +1 -1
  159. package/server/lib/relay/DOCUMENTATION.md +56 -1
  160. package/server/lib/relay/cross-compat.test.js +26 -0
  161. package/server/lib/relay/downstream-scheduler.js +154 -0
  162. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  163. package/server/lib/relay/e2ee.js +10 -8
  164. package/server/lib/relay/flow-control.test.js +177 -0
  165. package/server/lib/relay/host-client.js +37 -8
  166. package/server/lib/relay/host-client.test.js +9 -1
  167. package/server/lib/relay/tunnel-codec.js +9 -0
  168. package/server/lib/relay/tunnel-host.js +69 -18
  169. package/server/lib/relay/tunnel-host.test.js +9 -1
  170. package/server/lib/security/request-security.js +21 -6
  171. package/server/lib/security/request-security.test.js +42 -8
  172. package/server/lib/session-assist/runtime.js +7 -10
  173. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  174. package/server/lib/session-goal/create.js +3 -1
  175. package/server/lib/session-goal/runtime.js +81 -20
  176. package/server/lib/session-goal/runtime.test.js +426 -0
  177. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  178. package/server/lib/skills-catalog/install.js +3 -2
  179. package/server/lib/small-model/DOCUMENTATION.md +7 -2
  180. package/server/lib/small-model/call.js +17 -6
  181. package/server/lib/small-model/call.test.js +52 -0
  182. package/server/lib/small-model/index.js +7 -11
  183. package/server/lib/small-model/routes.js +2 -1
  184. package/server/lib/small-model/runtime-providers.js +15 -7
  185. package/server/lib/small-model/runtime-providers.test.js +7 -1
  186. package/server/lib/system-prompt/runtime.js +2 -21
  187. package/server/lib/terminal/DOCUMENTATION.md +14 -10
  188. package/server/lib/terminal/runtime.js +190 -31
  189. package/server/lib/terminal/runtime.test.js +571 -37
  190. package/server/lib/terminal/shells.js +17 -0
  191. package/server/lib/terminal/shells.test.js +32 -1
  192. package/server/lib/ui-auth/ui-auth.js +4 -1
  193. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  194. package/server/lib/walkthrough/DOCUMENTATION.md +33 -14
  195. package/server/lib/walkthrough/index.js +1 -0
  196. package/server/lib/walkthrough/model-settings.js +8 -12
  197. package/server/lib/walkthrough/prompt.js +3 -1
  198. package/server/lib/walkthrough/routes.test.js +26 -5
  199. package/server/lib/walkthrough/sources.js +23 -2
  200. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  201. package/dist/assets/DiffView-Dh1ey2x9.js +0 -4
  202. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  203. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  204. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  205. package/dist/assets/MarkdownImageGallery-bA0PS-tn.js +0 -1
  206. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  207. package/dist/assets/OnboardingScreen-D3kYJIb6.js +0 -1
  208. package/dist/assets/PierreDiffViewer-Ct2A13NO.js +0 -157
  209. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  210. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  211. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  212. package/dist/assets/WalkthroughView-AX-u2pFg.js +0 -4
  213. package/dist/assets/contextPanelEmbeddedChat-C9EcRSDF.js +0 -1
  214. package/dist/assets/de-DqVn5vme.js +0 -9
  215. package/dist/assets/es-hj43Z53i.js +0 -15
  216. package/dist/assets/fr-BmkZt2mF.js +0 -13
  217. package/dist/assets/index-L_ncV-6H.css +0 -1
  218. package/dist/assets/index-qES19dlJ.js +0 -7
  219. package/dist/assets/ja-DioMIEGF.js +0 -15
  220. package/dist/assets/ko-BqPgaQJb.js +0 -15
  221. package/dist/assets/main-CYgSu6dx.js +0 -928
  222. package/dist/assets/markdownCore-CWRCqSH2.js +0 -3
  223. package/dist/assets/patchFileDiff-BpMQZnYu.js +0 -10
  224. package/dist/assets/pl-slULBF4R.js +0 -15
  225. package/dist/assets/pt-BR-BqH8fuCH.js +0 -15
  226. package/dist/assets/renderElectronMiniChatApp-D77bmLqo.js +0 -2
  227. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  228. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  229. package/dist/assets/scheduledTasksApi-BSRCtP6T.js +0 -1
  230. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  231. package/dist/assets/uk-BkkfxbOs.js +0 -15
  232. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  233. package/dist/assets/useRootScrollLock-C0N9mCwz.js +0 -1
  234. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  235. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  236. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
  237. package/dist/assets/zh-CN-C6FMdVgv.js +0 -15
  238. package/dist/assets/zh-TW-CdcMjDxv.js +0 -15
@@ -1,4 +1,18 @@
1
1
  import { createProjectIdFromPath } from '../projects/project-id.js';
2
+ import {
3
+ buildPreferencesFields,
4
+ flattenPreferences,
5
+ instancePartOf,
6
+ legacySettingsDocumentOf,
7
+ profilePartOf,
8
+ isDeviceSettingsKey,
9
+ isProfileSettingsKey,
10
+ normalizeSettingsSurface,
11
+ parsePreferencesDocument,
12
+ preferencesFilePathFor,
13
+ seedPreferencesFrom,
14
+ serializePreferencesDocument,
15
+ } from './settings-files.js';
2
16
 
3
17
  const DEFAULT_NOTIFICATION_TEMPLATES = {
4
18
  completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
@@ -48,6 +62,13 @@ export const createSettingsRuntime = (deps) => {
48
62
 
49
63
  let persistSettingsLock = Promise.resolve();
50
64
 
65
+ const PREFERENCES_FILE_PATH = preferencesFilePathFor(SETTINGS_FILE_PATH, path);
66
+ // True while preferences.json exists but cannot be read. Profile writes are
67
+ // refused meanwhile so a corrupt file is never overwritten with a seed or a
68
+ // partial document; clients keep the values they hold.
69
+ let preferencesUnavailable = false;
70
+ let preferencesFailureLogged = false;
71
+
51
72
  // Orphan recovery is a one-shot best-effort scan: when orphans can't be
52
73
  // matched on first pass they stay on disk and every subsequent settings
53
74
  // read would re-scan them. In-process (Electron) this runs in the main
@@ -472,7 +493,7 @@ export const createSettingsRuntime = (deps) => {
472
493
  }
473
494
  };
474
495
 
475
- const readSettingsFromDisk = async () => {
496
+ const readInstanceSettingsFromDisk = async () => {
476
497
  try {
477
498
  const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
478
499
  const parsed = JSON.parse(raw);
@@ -489,6 +510,67 @@ export const createSettingsRuntime = (deps) => {
489
510
  }
490
511
  };
491
512
 
513
+ /**
514
+ * `{ status: 'missing' }` when the file does not exist, `{ status: 'ok',
515
+ * fields }` when it parsed, `{ status: 'failed' }` for anything else. Only
516
+ * "missing" may be seeded; "failed" must leave the file alone.
517
+ */
518
+ const readPreferenceFields = async () => {
519
+ let raw;
520
+ try {
521
+ raw = await fsPromises.readFile(PREFERENCES_FILE_PATH, 'utf8');
522
+ } catch (error) {
523
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
524
+ return { status: 'missing' };
525
+ }
526
+ if (!preferencesFailureLogged) {
527
+ preferencesFailureLogged = true;
528
+ console.warn('Failed to read preferences file:', error);
529
+ }
530
+ return { status: 'failed' };
531
+ }
532
+ const parsed = parsePreferencesDocument(raw);
533
+ if (!parsed.ok) {
534
+ if (!preferencesFailureLogged) {
535
+ preferencesFailureLogged = true;
536
+ console.warn(`Preferences file is unreadable (${parsed.reason}); profile writes are paused until it is fixed or removed.`);
537
+ }
538
+ return { status: 'failed' };
539
+ }
540
+ preferencesFailureLogged = false;
541
+ return { status: 'ok', fields: parsed.fields };
542
+ };
543
+
544
+ const writePreferencesToDisk = async (fields) => {
545
+ await writeJsonFileAtomic(PREFERENCES_FILE_PATH, serializePreferencesDocument(fields));
546
+ };
547
+
548
+ // The merged document every consumer sees: instance facts from settings.json
549
+ // plus the profile from preferences.json. On the first read of an install
550
+ // that predates the split, the profile keys still sitting in settings.json
551
+ // seed preferences.json. settings.json keeps a copy of the profile's base
552
+ // values on every write too, so an older build (which reads only that file)
553
+ // still finds everything where it used to be.
554
+ const readSettingsFromDisk = async ({ surface = null } = {}) => {
555
+ const instance = await readInstanceSettingsFromDisk();
556
+ const preferences = await readPreferenceFields();
557
+ if (preferences.status === 'failed') {
558
+ preferencesUnavailable = true;
559
+ return instance;
560
+ }
561
+ preferencesUnavailable = false;
562
+ if (preferences.status === 'missing') {
563
+ const seeded = seedPreferencesFrom(instance, Date.now());
564
+ try {
565
+ await writePreferencesToDisk(seeded);
566
+ } catch (error) {
567
+ console.warn('Failed to seed preferences file:', error);
568
+ }
569
+ return instance;
570
+ }
571
+ return { ...instance, ...flattenPreferences(preferences.fields, normalizeSettingsSurface(surface)) };
572
+ };
573
+
492
574
  // Strict variant for callers that REGENERATE persisted identity when a key is
493
575
  // absent (relay signing/encryption keys). The lenient reader above maps every
494
576
  // failure — corrupt JSON, EACCES, transient I/O — to `{}`, which such callers
@@ -558,7 +640,7 @@ export const createSettingsRuntime = (deps) => {
558
640
  try {
559
641
  const entries = await fsPromises.readdir(directory, { withFileTypes: true });
560
642
  const cleanupTasks = entries
561
- .filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
643
+ .filter((entry) => entry.isFile() && (entry.name.startsWith('settings.json.tmp-') || entry.name.startsWith('preferences.json.tmp-')))
562
644
  .map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
563
645
  await Promise.all(cleanupTasks);
564
646
  } catch {
@@ -566,27 +648,54 @@ export const createSettingsRuntime = (deps) => {
566
648
  }
567
649
  };
568
650
 
569
- const writeSettingsToDisk = async (settings) => {
570
- const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
571
- await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
572
- if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
573
- // Atomic write: Electron main and ssh-manager read this file via plain
651
+ const writeJsonFileAtomic = async (filePath, text) => {
652
+ const directory = path.dirname(filePath);
653
+ await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 });
654
+ if (process.platform !== 'win32') await fsPromises.chmod(directory, 0o700);
655
+ // Atomic write: Electron main and ssh-manager read these files via plain
574
656
  // readFile + JSON.parse and silently coerce parse errors to {}. A
575
657
  // partial read during a non-atomic writeFile would make their next
576
- // read-modify-write wipe the settings file.
577
- const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
658
+ // read-modify-write wipe the file.
659
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
578
660
  try {
579
- await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
661
+ await fsPromises.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
580
662
  if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
581
- await replaceFile(tmp, SETTINGS_FILE_PATH);
582
- if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
663
+ await replaceFile(tmp, filePath);
664
+ if (process.platform !== 'win32') await fsPromises.chmod(filePath, 0o600);
583
665
  } catch (error) {
584
666
  await fsPromises.rm(tmp, { force: true }).catch(() => {});
585
- console.warn('Failed to write settings file:', error);
667
+ console.warn(`Failed to write ${path.basename(filePath)}:`, error);
586
668
  throw error;
587
669
  }
588
670
  };
589
671
 
672
+ /**
673
+ * Persist a merged document: profile keys go to preferences.json (stamped
674
+ * when their value changed), everything else to settings.json. While
675
+ * preferences.json is unreadable its part is skipped rather than replaced.
676
+ */
677
+ const writeSettingsToDisk = async (settings, { surface = null, changedKeys = null } = {}) => {
678
+ const current = preferencesUnavailable ? { status: 'failed' } : await readPreferenceFields();
679
+ if (current.status === 'failed') {
680
+ // The profile part is not saved; settings.json keeps whatever legacy
681
+ // profile copy it already holds rather than losing it too.
682
+ preferencesUnavailable = true;
683
+ const onDisk = await readInstanceSettingsFromDisk();
684
+ await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify({
685
+ ...instancePartOf(settings),
686
+ ...profilePartOf(onDisk),
687
+ }, null, 2));
688
+ return;
689
+ }
690
+ const previousFields = current.status === 'ok' ? current.fields : {};
691
+ const nextFields = buildPreferencesFields(previousFields, settings, Date.now(), {
692
+ surface: normalizeSettingsSurface(surface),
693
+ changedKeys,
694
+ });
695
+ await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify(legacySettingsDocumentOf(settings, nextFields), null, 2));
696
+ await writePreferencesToDisk(nextFields);
697
+ };
698
+
590
699
  const validateProjectEntries = async (projects) => {
591
700
  if (!Array.isArray(projects)) {
592
701
  return [];
@@ -692,8 +801,8 @@ export const createSettingsRuntime = (deps) => {
692
801
  return { settings, changed: false };
693
802
  }
694
803
 
695
- const defaultLight = 'flexoki-light';
696
- const defaultDark = 'flexoki-dark';
804
+ const defaultLight = 'openchamber-light';
805
+ const defaultDark = 'openchamber-dark';
697
806
 
698
807
  let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
699
808
  let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
@@ -872,7 +981,7 @@ export const createSettingsRuntime = (deps) => {
872
981
 
873
982
  let hasCleanedOrphanedTempFiles = false;
874
983
 
875
- const readSettingsFromDiskMigrated = async () => {
984
+ const readSettingsFromDiskMigrated = async ({ surface = null } = {}) => {
876
985
  if (!hasCleanedOrphanedTempFiles) {
877
986
  hasCleanedOrphanedTempFiles = true;
878
987
  await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
@@ -889,16 +998,28 @@ export const createSettingsRuntime = (deps) => {
889
998
  if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
890
999
  await writeSettingsToDisk(migration8.settings);
891
1000
  }
892
- return migration8.settings;
1001
+ // Migrations run on the base view; a surface asks for its own resolution
1002
+ // of the per-surface keys on top of the migrated files.
1003
+ return normalizeSettingsSurface(surface) ? readSettingsFromDisk({ surface }) : migration8.settings;
893
1004
  };
894
1005
 
895
- const persistSettings = async (changes) => {
1006
+ const persistSettings = async (changes, { surface = null } = {}) => {
896
1007
  persistSettingsLock = persistSettingsLock.then(async () => {
897
1008
  // Log field names only — changes can carry credentials (UI password,
898
1009
  // client tokens, tunnel tokens) that must never reach the log file.
899
1010
  console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
900
- const current = await readSettingsFromDisk();
1011
+ const current = await readSettingsFromDisk({ surface });
901
1012
  const sanitized = sanitizeSettingsUpdate(changes);
1013
+ for (const key of Object.keys(sanitized)) {
1014
+ // Device state belongs to the install in front of the user, never to
1015
+ // the instance; a client that still sends it is simply ignored.
1016
+ if (isDeviceSettingsKey(key)) {
1017
+ delete sanitized[key];
1018
+ } else if (preferencesUnavailable && isProfileSettingsKey(key)) {
1019
+ console.warn(`[persistSettings] Dropping ${key}: preferences file is unreadable`);
1020
+ delete sanitized[key];
1021
+ }
1022
+ }
902
1023
  let next = mergePersistedSettings(current, sanitized);
903
1024
 
904
1025
  const normalizedState = normalizeSettingsPaths(next);
@@ -962,7 +1083,7 @@ export const createSettingsRuntime = (deps) => {
962
1083
  }
963
1084
  }
964
1085
 
965
- await writeSettingsToDisk(next);
1086
+ await writeSettingsToDisk(next, { surface, changedKeys: Object.keys(sanitized) });
966
1087
  return formatSettingsResponse(next);
967
1088
  });
968
1089
 
@@ -6,7 +6,7 @@ import path from 'path';
6
6
  import { createProjectIdFromPath } from '../projects/project-id.js';
7
7
  import { createSettingsRuntime } from './settings-runtime.js';
8
8
 
9
- const createRuntime = async () => {
9
+ const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
10
10
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
11
11
  const settingsFilePath = path.join(tempRoot, 'settings.json');
12
12
  const runtime = createSettingsRuntime({
@@ -16,7 +16,7 @@ const createRuntime = async () => {
16
16
  SETTINGS_FILE_PATH: settingsFilePath,
17
17
  sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
18
18
  sanitizeSettingsUpdate: (settings) => settings,
19
- mergePersistedSettings: (_current, changes) => changes,
19
+ mergePersistedSettings,
20
20
  normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
21
21
  normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
22
22
  formatSettingsResponse: (settings) => settings,
@@ -39,8 +39,37 @@ const createRuntime = async () => {
39
39
  };
40
40
 
41
41
  describe('settings runtime', () => {
42
- it('round-trips shared sidebar preferences through settings.json', async () => {
42
+ it('uses OpenChamber themes when a new install has no theme preferences', async () => {
43
+ const { runtime, cleanup } = await createRuntime();
44
+ try {
45
+ await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
46
+ lightThemeId: 'openchamber-light',
47
+ darkThemeId: 'openchamber-dark',
48
+ });
49
+ } finally {
50
+ await cleanup();
51
+ }
52
+ });
53
+
54
+ it('preserves existing theme preferences during theme migration', async () => {
43
55
  const { runtime, settingsFilePath, cleanup } = await createRuntime();
56
+ try {
57
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify({
58
+ lightThemeId: 'flexoki-light',
59
+ darkThemeId: 'flexoki-dark',
60
+ }), 'utf8');
61
+
62
+ await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({
63
+ lightThemeId: 'flexoki-light',
64
+ darkThemeId: 'flexoki-dark',
65
+ });
66
+ } finally {
67
+ await cleanup();
68
+ }
69
+ });
70
+
71
+ it('round-trips shared sidebar preferences through preferences.json', async () => {
72
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
44
73
  const preferences = {
45
74
  sidebarProjectDisplayMode: 'single',
46
75
  sidebarSessionGroupingMode: 'flat',
@@ -51,7 +80,10 @@ describe('settings runtime', () => {
51
80
  await runtime.persistSettings(preferences);
52
81
 
53
82
  await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
54
- await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
83
+ // Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
84
+ expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
85
+ const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
86
+ expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
55
87
  } finally {
56
88
  await cleanup();
57
89
  }
@@ -219,3 +251,158 @@ describe('settings runtime', () => {
219
251
  }
220
252
  });
221
253
  });
254
+
255
+ describe('settings runtime: preferences.json split', () => {
256
+ const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
257
+
258
+ it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
259
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
260
+ try {
261
+ const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
262
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
263
+
264
+ const merged = await runtime.readSettingsFromDisk();
265
+ expect(merged).toMatchObject(legacy);
266
+
267
+ const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
268
+ expect(preferences.version).toBe(1);
269
+ expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
270
+ expect(preferences.fields.fontSize.value).toBe(110);
271
+ expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
272
+ expect(await readJson(settingsFilePath)).toEqual(legacy);
273
+ } finally {
274
+ await cleanup();
275
+ }
276
+ });
277
+
278
+ it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
279
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
280
+ try {
281
+ await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
282
+
283
+ const settings = await readJson(settingsFilePath);
284
+ expect(settings.desktopLanAccessEnabled).toBe(true);
285
+ // Older builds read only settings.json: the profile's base values stay there as a copy.
286
+ expect(settings.fontSize).toBe(120);
287
+ expect(settings).not.toHaveProperty('mobileKeyboardMode');
288
+
289
+ const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
290
+ expect(preferences.fields.fontSize.value).toBe(120);
291
+ expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
292
+ expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
293
+
294
+ expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
295
+ } finally {
296
+ await cleanup();
297
+ }
298
+ });
299
+
300
+ it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
301
+ const { runtime, tempRoot, cleanup } = await createRuntime();
302
+ try {
303
+ const preferencesPath = path.join(tempRoot, 'preferences.json');
304
+ await runtime.persistSettings({ fontSize: 100, padding: 100 });
305
+ const first = await readJson(preferencesPath);
306
+ await new Promise((resolve) => setTimeout(resolve, 5));
307
+ await runtime.persistSettings({ fontSize: 100, padding: 120 });
308
+ const second = await readJson(preferencesPath);
309
+
310
+ expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
311
+ expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
312
+ expect(second.fields.padding.value).toBe(120);
313
+ } finally {
314
+ await cleanup();
315
+ }
316
+ });
317
+
318
+ it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
319
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
320
+ try {
321
+ const preferencesPath = path.join(tempRoot, 'preferences.json');
322
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
323
+ await fsPromises.writeFile(preferencesPath, '{ not json');
324
+
325
+ expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
326
+
327
+ await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
328
+
329
+ expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
330
+ const settings = await readJson(settingsFilePath);
331
+ expect(settings.desktopKeepAwakeEnabled).toBe(true);
332
+ // The refused profile write must not land in the legacy copy either.
333
+ expect(settings).not.toHaveProperty('fontSize');
334
+ } finally {
335
+ await cleanup();
336
+ }
337
+ });
338
+ });
339
+
340
+ describe('settings runtime: per-surface profile keys', () => {
341
+ const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
342
+ // These sequences persist several times; the default stub replaces the
343
+ // document with the changes, the real merge keeps the current document.
344
+ const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
345
+
346
+ it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
347
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
348
+ try {
349
+ await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
350
+ await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
351
+
352
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
353
+ expect(stored.fields.fontSize.value).toBe(100);
354
+ expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
355
+ // Not per-surface: written to the base regardless of the surface.
356
+ expect(stored.fields.showReasoningTraces.value).toBe(false);
357
+ expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
358
+
359
+ expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
360
+ expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
361
+ expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
362
+ expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
363
+ } finally {
364
+ await cleanup();
365
+ }
366
+ });
367
+
368
+ it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
369
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
370
+ try {
371
+ await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
372
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
373
+ expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
374
+ expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
375
+ expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
376
+ expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
377
+ } finally {
378
+ await cleanup();
379
+ }
380
+ });
381
+
382
+ it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
383
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
384
+ try {
385
+ await runtime.persistSettings({ fontSize: 100 });
386
+ await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
387
+ await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
388
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
389
+ expect(stored.fields.fontSize.value).toBe(100);
390
+ expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
391
+ expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
392
+ } finally {
393
+ await cleanup();
394
+ }
395
+ });
396
+
397
+ it('ignores an unknown surface header value and writes the base', async () => {
398
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
399
+ try {
400
+ await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
401
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
402
+ expect(stored.fields.fontSize.value).toBe(90);
403
+ expect(stored.fields.fontSize.surfaces).toBeUndefined();
404
+ } finally {
405
+ await cleanup();
406
+ }
407
+ });
408
+ });
@@ -6,7 +6,10 @@ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
6
6
 
7
7
  // ============== PATH CONSTANTS ==============
8
8
 
9
- const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
9
+ const OPENCODE_CONFIG_DIR = path.join(
10
+ process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'),
11
+ 'opencode',
12
+ );
10
13
  const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
11
14
  const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
12
15
  const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
@@ -11,6 +11,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
11
11
  sessionAssistRuntime,
12
12
  sessionGoalRuntime,
13
13
  contextObligatoryRuntime,
14
+ messageQueueRuntime,
14
15
  scheduledTasksRuntime,
15
16
  getHealthCheckInterval,
16
17
  clearHealthCheckInterval,
@@ -47,6 +48,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
47
48
  sessionAssistRuntime?.stop?.();
48
49
  sessionGoalRuntime?.stop?.();
49
50
  contextObligatoryRuntime?.stop?.();
51
+ messageQueueRuntime?.stop?.();
50
52
  scheduledTasksRuntime?.stop?.();
51
53
 
52
54
  const healthCheckInterval = getHealthCheckInterval();
@@ -1,5 +1,6 @@
1
1
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
2
  import { buildDeferredRestartResponse } from './config-mutation-response.js';
3
+ import { OPENCODE_CONFIG_DIR } from './shared.js';
3
4
 
4
5
  /**
5
6
  * Matches how OpenCode reads its own boolean env flags: any value other than
@@ -110,7 +111,7 @@ export const registerSkillRoutes = (app, dependencies) => {
110
111
  }
111
112
 
112
113
  const userRoots = [
113
- path.join(home, '.config', 'opencode'),
114
+ OPENCODE_CONFIG_DIR,
114
115
  path.join(home, '.opencode'),
115
116
  path.join(home, '.claude', 'skills'),
116
117
  path.join(home, '.agents', 'skills'),
@@ -1,9 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import yaml from 'yaml';
4
+ import { OPENCODE_CONFIG_DIR } from './shared.js';
5
5
 
6
- const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
7
6
  const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
8
7
  const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
9
8
  const SNIPPET_EXTENSION = '.md';
@@ -4,15 +4,15 @@ import fs from 'fs';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
+ import { fetchUpdateNotes } from './changelog/update-notes.js';
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
11
 
11
12
  const PACKAGE_NAME = '@vantran-se/openchamber-web';
12
13
  const PACKAGE_PATH_SEGMENTS = PACKAGE_NAME.split('/');
13
- const PACKAGE_REGISTRY_URL = 'https://npm.pkg.github.com';
14
+ const PACKAGE_REGISTRY_URL = 'https://registry.npmjs.org';
14
15
  const UPSTREAM_NPM_REGISTRY_URL = 'https://registry.npmjs.org/@openchamber%2Fweb';
15
- const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
16
16
  const FORK_RELEASES_URL = 'https://github.com/vantran-se/openchamber/releases';
17
17
  const FORK_LATEST_RELEASE_API_URL = 'https://api.github.com/repos/vantran-se/openchamber/releases/latest';
18
18
  const UPSTREAM_RELEASES_URL = 'https://github.com/openchamber/openchamber/releases';
@@ -767,34 +767,9 @@ function compareVersions(left, right) {
767
767
  return 0;
768
768
  }
769
769
 
770
- /**
771
- * Fetch changelog notes between versions
772
- */
770
+ /** Release notes between the installed and the offered version, or undefined. */
773
771
  async function fetchChangelogNotes(fromVersion, toVersion) {
774
- try {
775
- const response = await fetch(CHANGELOG_URL, {
776
- signal: AbortSignal.timeout(10000),
777
- });
778
-
779
- if (!response.ok) return undefined;
780
-
781
- const changelog = await response.text();
782
- const sections = changelog.split(/^## /m).slice(1);
783
-
784
- const relevantSections = sections.filter((section) => {
785
- const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
786
- if (!match) return false;
787
- return compareVersions(match[1], fromVersion) > 0 && compareVersions(match[1], toVersion) <= 0;
788
- });
789
-
790
- if (relevantSections.length === 0) return undefined;
791
-
792
- return relevantSections
793
- .map((s) => '## ' + s.trim())
794
- .join('\n\n');
795
- } catch {
796
- return undefined;
797
- }
772
+ return (await fetchUpdateNotes(fromVersion, toVersion, compareVersions)) ?? undefined;
798
773
  }
799
774
 
800
775
  export async function checkForUpdates(options = {}) {
@@ -163,9 +163,9 @@ describe('checkForUpdates', () => {
163
163
  });
164
164
 
165
165
  describe('getUpdateCommand', () => {
166
- it('installs the fork package from GitHub Packages', () => {
166
+ it('installs the public fork package from npm', () => {
167
167
  expect(getUpdateCommand('npm')).toBe(
168
- 'npm install -g @vantran-se/openchamber-web@latest --registry=https://npm.pkg.github.com',
168
+ 'npm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org',
169
169
  );
170
170
  });
171
171
  });
@@ -9,9 +9,10 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
9
9
 
10
10
  | Path | Owner | Contents |
11
11
  |---|---|---|
12
- | `<projectsDir>/<projectId>.json` | shared UI (`packages/ui/src/lib/openchamberConfig.ts`), plus server-owned `version` / `scheduledTasks` | worktree setup, draft starters, project actions |
12
+ | `<projectsDir>/<projectId>.json` | `packages/web/server/lib/projects` (`project-setup.js` for the client-owned keys behind `/api/projects/:projectId/config`; `project-config.js` for `version` / `scheduledTasks`), one write lock for both | worktree setup, draft starters, project actions, scheduled tasks |
13
13
  | `<projectsDir>/<projectId>/context.json` | **this module, exclusively** | notes, todos, plan manifest |
14
14
  | `<projectsDir>/<projectId>/plans/*.md` | **this module, exclusively** | plan bodies |
15
+ | `<repo>/<plansDir>/*.md` | this module (read, edit, delete, move) when the team config names a `plansDir`; the folder is the team's, any tool may write there | shared plan bodies |
15
16
 
16
17
  The split is the point. Both files were previously one, written by the client
17
18
  with a whole-file read-modify-write. Adding a server writer to that file would
@@ -58,6 +59,27 @@ denormalized into the manifest so listing plans costs one read rather than one
58
59
  read per plan; `readPlan` returns the title parsed from the file, which wins if
59
60
  the two ever disagree.
60
61
 
62
+ ## Shared plans
63
+
64
+ Every `.md` file in the repository plans folder is a plan too: `.openchamber/plans`
65
+ by default, or the `plansDir` the team config (`<repo>/.openchamber/project.json`,
66
+ see `packages/web/server/lib/projects`) names instead of it (the custom folder
67
+ replaces the default outright; moving files between the two is the user's job). `readContext` appends them after the personal ones,
68
+ each marked `source: "shared"` (personal ones get `source: "personal"`), and
69
+ reports the folder as `sharedPlansDir`. A shared plan is addressed as
70
+ `shared:<file>` when no manifest entry claims it; its title is parsed from the
71
+ file on every list, and `readPlan` / `updatePlan` / `deletePlan` work on the
72
+ file directly (an update writes the raw document verbatim, so a plan another
73
+ tool wrote keeps its shape). `setPlanPinned` is `404` for such a plan.
74
+
75
+ A plan the user moves there keeps its id: `sharePlan` moves the markdown into
76
+ the folder and keeps the manifest entry with `shared: true` (the flag says
77
+ which folder holds the file), so a session that attached the plan still finds
78
+ it, and the file is listed under that id instead of `shared:<file>`.
79
+ `unsharePlan` moves it back and clears the flag; a plan that only ever lived
80
+ in the team's folder gets a manifest entry (and an id) on the way in. A name
81
+ collision gets a numeric suffix. Sharing is refused only when the checkout cannot be located.
82
+
61
83
  ## Routes
62
84
 
63
85
  | Method | Route | Notes |
@@ -72,6 +94,8 @@ the two ever disagree.
72
94
  | POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
73
95
  | PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
74
96
  | DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
97
+ | POST | `/api/project-context/:projectId/plans/:planId/share` | moves the plan into the shared folder; `400` without one, `404` when unknown |
98
+ | POST | `/api/project-context/:projectId/plans/:planId/unshare` | moves a `shared:` plan back; `404` when unknown |
75
99
 
76
100
  **Body parsing is attached per route.** This server has no global JSON parser:
77
101
  `core-routes` parses only an allowlist of `/api` path prefixes so the generic