@aiworker/web 1.22.2 → 1.23.1

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 (217) hide show
  1. package/bin/cli.js +7 -0
  2. package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  3. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  4. package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  5. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  6. package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
  7. package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
  8. package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
  9. package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
  10. package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
  11. package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
  12. package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
  13. package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
  14. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  15. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  16. package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  17. package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  18. package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
  19. package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
  20. package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  21. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  22. package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  23. package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
  24. package/dist/assets/de-DJsQr-p7.js +10 -0
  25. package/dist/assets/es-BuYMlA9g.js +16 -0
  26. package/dist/assets/fr-ePDay33C.js +14 -0
  27. package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  28. package/dist/assets/index-BD8ILbxF.js +40 -0
  29. package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
  30. package/dist/assets/index-zhgg-SDz.css +1 -0
  31. package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
  32. package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
  33. package/dist/assets/ja-BzR9xOY_.js +16 -0
  34. package/dist/assets/ko-BEjHAm14.js +16 -0
  35. package/dist/assets/main-BydQ1tr_.js +930 -0
  36. package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
  37. package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
  38. package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
  39. package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
  40. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  41. package/dist/assets/pl-rQmpxNIF.js +16 -0
  42. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  43. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  44. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  45. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  46. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  47. package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
  48. package/dist/assets/surface-COKb77Ua.js +1 -0
  49. package/dist/assets/tr-CoAMXPtI.js +7 -0
  50. package/dist/assets/uk-YlAX0rRU.js +16 -0
  51. package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
  52. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  53. package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
  54. package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
  55. package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  57. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  58. package/dist/assets/vendor-zod-B6551dwG.js +39 -0
  59. package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
  60. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  61. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  62. package/dist/index.html +5 -43
  63. package/dist/mini-chat.html +4 -4
  64. package/dist/mobile.html +4 -4
  65. package/package.json +2 -3
  66. package/server/index.d.ts +15 -0
  67. package/server/index.js +48 -22
  68. package/server/lib/agent-memory/runtime.js +7 -2
  69. package/server/lib/agent-memory/runtime.test.js +22 -0
  70. package/server/lib/data-dir-migration.js +40 -0
  71. package/server/lib/data-dir-migration.test.js +59 -0
  72. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  73. package/server/lib/event-stream/global-hub.js +29 -4
  74. package/server/lib/event-stream/global-hub.test.js +22 -0
  75. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  76. package/server/lib/event-stream/protocol.js +21 -7
  77. package/server/lib/event-stream/runtime.js +9 -6
  78. package/server/lib/event-stream/runtime.test.js +72 -12
  79. package/server/lib/fs/DOCUMENTATION.md +3 -0
  80. package/server/lib/fs/routes.js +31 -0
  81. package/server/lib/fs/routes.test.js +110 -1
  82. package/server/lib/git/DOCUMENTATION.md +39 -6
  83. package/server/lib/git/routes.js +45 -12
  84. package/server/lib/git/routes.test.js +78 -0
  85. package/server/lib/git/service.js +343 -202
  86. package/server/lib/git/service.test.js +496 -25
  87. package/server/lib/github/DOCUMENTATION.md +5 -0
  88. package/server/lib/github/routes.js +2 -2
  89. package/server/lib/inherited-env.js +16 -9
  90. package/server/lib/inherited-env.test.js +8 -9
  91. package/server/lib/message-queue/DOCUMENTATION.md +11 -1
  92. package/server/lib/message-queue/runtime.js +13 -0
  93. package/server/lib/message-queue/runtime.test.js +31 -1
  94. package/server/lib/network-defaults.js +23 -0
  95. package/server/lib/network-defaults.test.js +52 -0
  96. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  97. package/server/lib/notifications/emitter-runtime.js +4 -3
  98. package/server/lib/opencode/DOCUMENTATION.md +35 -5
  99. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  100. package/server/lib/opencode/env-runtime.js +5 -1
  101. package/server/lib/opencode/env-runtime.test.js +72 -0
  102. package/server/lib/opencode/feature-routes-runtime.js +17 -1
  103. package/server/lib/opencode/lifecycle.js +4 -3
  104. package/server/lib/opencode/lifecycle.test.js +29 -0
  105. package/server/lib/opencode/managed-plugin-config.js +1 -1
  106. package/server/lib/opencode/openchamber-routes.js +64 -4
  107. package/server/lib/opencode/openchamber-routes.test.js +128 -1
  108. package/server/lib/opencode/proxy.js +33 -0
  109. package/server/lib/opencode/routes.js +5 -3
  110. package/server/lib/opencode/settings-files.js +214 -0
  111. package/server/lib/opencode/settings-helpers.js +88 -41
  112. package/server/lib/opencode/settings-helpers.test.js +176 -1
  113. package/server/lib/opencode/settings-registry.json +750 -0
  114. package/server/lib/opencode/settings-runtime.js +175 -27
  115. package/server/lib/opencode/settings-runtime.test.js +321 -7
  116. package/server/lib/project-context/DOCUMENTATION.md +38 -4
  117. package/server/lib/project-context/routes.http.test.js +18 -0
  118. package/server/lib/project-context/routes.js +15 -0
  119. package/server/lib/project-context/runtime.js +256 -25
  120. package/server/lib/project-context/runtime.test.js +160 -3
  121. package/server/lib/projects/DOCUMENTATION.md +77 -0
  122. package/server/lib/projects/project-config.js +179 -17
  123. package/server/lib/projects/project-config.test.js +97 -0
  124. package/server/lib/projects/project-id.js +43 -0
  125. package/server/lib/projects/project-setup.js +409 -0
  126. package/server/lib/projects/project-setup.test.js +557 -0
  127. package/server/lib/projects/routes.js +59 -0
  128. package/server/lib/quota/DOCUMENTATION.md +31 -1
  129. package/server/lib/quota/providers/cline-pass.js +125 -0
  130. package/server/lib/quota/providers/cline-pass.test.js +103 -0
  131. package/server/lib/quota/providers/index.js +7 -0
  132. package/server/lib/quota/providers/neuralwatt.js +3 -6
  133. package/server/lib/quota/providers/neuralwatt.test.js +7 -7
  134. package/server/lib/quota/providers/ollama-cloud.js +31 -0
  135. package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
  136. package/server/lib/quota/providers/opencode-go.js +1 -1
  137. package/server/lib/quota/providers/opencode-go.test.js +26 -1
  138. package/server/lib/quota/providers/openrouter.js +119 -24
  139. package/server/lib/quota/providers/openrouter.test.js +330 -0
  140. package/server/lib/relay/DOCUMENTATION.md +75 -0
  141. package/server/lib/relay/cross-compat.test.js +26 -0
  142. package/server/lib/relay/downstream-scheduler.js +154 -0
  143. package/server/lib/relay/downstream-scheduler.test.js +162 -0
  144. package/server/lib/relay/e2ee.js +10 -8
  145. package/server/lib/relay/flow-control.test.js +177 -0
  146. package/server/lib/relay/host-client.js +44 -8
  147. package/server/lib/relay/host-client.test.js +14 -0
  148. package/server/lib/relay/tunnel-codec.js +9 -0
  149. package/server/lib/relay/tunnel-host.js +66 -17
  150. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  151. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  152. package/server/lib/scheduled-tasks/runtime.js +12 -2
  153. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  154. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  155. package/server/lib/session-assist/context.js +127 -0
  156. package/server/lib/session-assist/context.test.js +132 -0
  157. package/server/lib/session-assist/prompt.js +54 -0
  158. package/server/lib/session-assist/runtime.js +117 -267
  159. package/server/lib/session-assist/runtime.test.js +203 -0
  160. package/server/lib/session-goal/runtime.js +4 -9
  161. package/server/lib/small-model/DOCUMENTATION.md +3 -2
  162. package/server/lib/small-model/call.js +9 -4
  163. package/server/lib/small-model/call.test.js +29 -0
  164. package/server/lib/small-model/index.js +5 -10
  165. package/server/lib/small-model/runtime-providers.js +15 -7
  166. package/server/lib/small-model/runtime-providers.test.js +7 -1
  167. package/server/lib/terminal/DOCUMENTATION.md +4 -4
  168. package/server/lib/terminal/runtime.js +11 -6
  169. package/server/lib/terminal/runtime.test.js +20 -13
  170. package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
  171. package/server/lib/walkthrough/model-settings.js +8 -12
  172. package/server/lib/walkthrough/pr-source.test.js +21 -0
  173. package/server/lib/walkthrough/prompt.js +3 -1
  174. package/server/lib/walkthrough/pull-request.js +7 -4
  175. package/server/lib/walkthrough/pull-request.test.js +16 -0
  176. package/server/lib/walkthrough/routes.js +19 -0
  177. package/server/lib/walkthrough/routes.test.js +26 -0
  178. package/server/lib/walkthrough/sources.js +36 -4
  179. package/server/opencode-worktree-readiness.test.js +130 -0
  180. package/dist/assets/DiffView-hDBKesA_.js +0 -4
  181. package/dist/assets/FilesView-CdWzeOp9.js +0 -17
  182. package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
  183. package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
  184. package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
  185. package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
  186. package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
  187. package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
  188. package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
  189. package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
  190. package/dist/assets/de-B5JaA3SA.js +0 -10
  191. package/dist/assets/es-EPIH88ii.js +0 -16
  192. package/dist/assets/fr-CAMfxUdZ.js +0 -14
  193. package/dist/assets/index-DF5kMP-i.js +0 -39
  194. package/dist/assets/index-Dl3xbAB_.css +0 -1
  195. package/dist/assets/ja-CifOE1VN.js +0 -16
  196. package/dist/assets/ko-BBDyAyYQ.js +0 -16
  197. package/dist/assets/main-MhSaOZoB.js +0 -930
  198. package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
  199. package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
  200. package/dist/assets/pl-DgEHYfFM.js +0 -16
  201. package/dist/assets/pt-BR-DuWHojUw.js +0 -16
  202. package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
  203. package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
  204. package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
  205. package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
  206. package/dist/assets/tr-D5m2yIWL.js +0 -7
  207. package/dist/assets/uk-D_q60oog.js +0 -16
  208. package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
  209. package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
  210. package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
  211. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  212. package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
  213. package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
  214. package/dist/assets/zh-TW-B9t7rQQO.js +0 -16
  215. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  216. package/server/lib/mcp-reconnect/runtime.js +0 -122
  217. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -1,12 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import crypto from 'crypto';
3
3
  import fsPromises from 'fs/promises';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
- import { createProjectIdFromPath } from '../projects/project-id.js';
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } 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,6 +39,20 @@ const createRuntime = async () => {
39
39
  };
40
40
 
41
41
  describe('settings runtime', () => {
42
+ it('round-trips both archived-only retention states through instance settings', async () => {
43
+ const { runtime, settingsFilePath, cleanup } = await createRuntime();
44
+ try {
45
+ for (const sessionRetentionOnlyArchived of [true, false]) {
46
+ const settings = { sessionRetentionOnlyArchived, sessionRetentionAction: 'delete', autoDeleteAfterDays: 30 };
47
+ await runtime.persistSettings(settings);
48
+ expect(await runtime.readSettingsFromDisk()).toEqual(settings);
49
+ expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(settings);
50
+ }
51
+ } finally {
52
+ await cleanup();
53
+ }
54
+ });
55
+
42
56
  it('uses AiWorker themes when a new install has no theme preferences', async () => {
43
57
  const { runtime, cleanup } = await createRuntime();
44
58
  try {
@@ -68,8 +82,8 @@ describe('settings runtime', () => {
68
82
  }
69
83
  });
70
84
 
71
- it('round-trips shared sidebar preferences through settings.json', async () => {
72
- const { runtime, settingsFilePath, cleanup } = await createRuntime();
85
+ it('round-trips shared sidebar preferences through preferences.json', async () => {
86
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
73
87
  const preferences = {
74
88
  sidebarProjectDisplayMode: 'single',
75
89
  sidebarSessionGroupingMode: 'flat',
@@ -80,7 +94,10 @@ describe('settings runtime', () => {
80
94
  await runtime.persistSettings(preferences);
81
95
 
82
96
  await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
83
- await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
97
+ // Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
98
+ expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
99
+ const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
100
+ expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
84
101
  } finally {
85
102
  await cleanup();
86
103
  }
@@ -142,6 +159,52 @@ describe('settings runtime', () => {
142
159
  }
143
160
  });
144
161
 
162
+ it('migrates a legacy id into the bounded file of a project whose path is too long for a file name', async () => {
163
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
164
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
165
+ try {
166
+ const projectPath = path.join(tempRoot, 'a'.repeat(160));
167
+ const oldProjectId = 'legacy-project-id';
168
+ const newProjectId = createProjectIdFromPath(projectPath);
169
+ expect(newProjectId.length).toBeGreaterThan(200);
170
+ const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
171
+ const boundedPath = path.join(projectsRoot, `${projectConfigFileStemOf(newProjectId)}.json`);
172
+ expect(path.basename(boundedPath).startsWith('path_sha256_')).toBe(true);
173
+
174
+ await fsPromises.mkdir(projectPath, { recursive: true });
175
+ await fsPromises.mkdir(projectsRoot, { recursive: true });
176
+ await fsPromises.writeFile(
177
+ settingsFilePath,
178
+ JSON.stringify({
179
+ projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }],
180
+ activeProjectId: oldProjectId,
181
+ }, null, 2),
182
+ 'utf8',
183
+ );
184
+ await fsPromises.writeFile(
185
+ path.join(projectsRoot, `${oldProjectId}.json`),
186
+ JSON.stringify({ 'setup-worktree': ['bun install'] }, null, 2),
187
+ 'utf8',
188
+ );
189
+
190
+ const settings = await runtime.readSettingsFromDiskMigrated();
191
+
192
+ expect(settings.projects[0].id).toBe(newProjectId);
193
+ const migratedConfig = JSON.parse(await fsPromises.readFile(boundedPath, 'utf8'));
194
+ expect(migratedConfig['setup-worktree']).toEqual(['bun install']);
195
+ await expect(fsPromises.readFile(path.join(projectsRoot, `${oldProjectId}.json`), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
196
+
197
+ // A second pass sees the bounded file beside the settings; it is the
198
+ // project's own file, not a stray from the random-id era.
199
+ await runtime.readSettingsFromDiskMigrated();
200
+ expect(JSON.parse(await fsPromises.readFile(boundedPath, 'utf8'))['setup-worktree']).toEqual(['bun install']);
201
+ expect(warn.mock.calls.some((call) => String(call[0]).includes('orphan'))).toBe(false);
202
+ } finally {
203
+ warn.mockRestore();
204
+ await cleanup();
205
+ }
206
+ });
207
+
145
208
  it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
146
209
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
147
210
  const settingsFilePath = path.join(tempRoot, 'settings.json');
@@ -181,6 +244,102 @@ describe('settings runtime', () => {
181
244
  }
182
245
  });
183
246
 
247
+ it('moves project storage into the bounded folder when the canonical id is too long for a folder name', async () => {
248
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
249
+ try {
250
+ const projectPath = path.join(tempRoot, `${'segment-'.repeat(18)}`, 'demo-repo');
251
+ const oldProjectId = 'legacy-project-id';
252
+ const newProjectId = createProjectIdFromPath(projectPath);
253
+ const stem = projectConfigFileStemOf(newProjectId);
254
+ expect(newProjectId.length).toBeGreaterThan(240);
255
+ expect(stem).not.toBe(newProjectId);
256
+ const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
257
+ const oldStorageDir = path.join(projectsRoot, oldProjectId);
258
+ const newStorageDir = path.join(projectsRoot, stem);
259
+
260
+ await fsPromises.mkdir(projectPath, { recursive: true });
261
+ await fsPromises.mkdir(path.join(oldStorageDir, 'plans'), { recursive: true });
262
+ await fsPromises.writeFile(path.join(oldStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [], todos: [{ id: 't1', text: 'keep', completed: false, createdAt: 1 }], plans: [] }), 'utf8');
263
+ await fsPromises.writeFile(path.join(oldStorageDir, 'plans', 'inside.md'), '# Inside\n', 'utf8');
264
+ await fsPromises.writeFile(path.join(oldStorageDir, 'memory.json'), JSON.stringify({ version: 1, entries: [] }), 'utf8');
265
+ await fsPromises.writeFile(
266
+ settingsFilePath,
267
+ JSON.stringify({
268
+ projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }],
269
+ activeProjectId: oldProjectId,
270
+ }, null, 2),
271
+ 'utf8',
272
+ );
273
+ await fsPromises.writeFile(
274
+ path.join(projectsRoot, `${oldProjectId}.json`),
275
+ JSON.stringify({ projectPlanFiles: [{ id: 'inside', path: path.join(oldStorageDir, 'plans', 'inside.md') }] }, null, 2),
276
+ 'utf8',
277
+ );
278
+
279
+ const settings = await runtime.readSettingsFromDiskMigrated();
280
+
281
+ expect(settings.projects.map((project) => project.id)).toEqual([newProjectId]);
282
+ const migratedConfig = JSON.parse(await fsPromises.readFile(path.join(projectsRoot, `${stem}.json`), 'utf8'));
283
+ expect(migratedConfig.projectPlanFiles).toEqual([{ id: 'inside', path: path.join(newStorageDir, 'plans', 'inside.md') }]);
284
+ expect(JSON.parse(await fsPromises.readFile(path.join(newStorageDir, 'context.json'), 'utf8')).todos).toHaveLength(1);
285
+ await fsPromises.access(path.join(newStorageDir, 'plans', 'inside.md'));
286
+ await fsPromises.access(path.join(newStorageDir, 'memory.json'));
287
+ expect((await fsPromises.readdir(projectsRoot)).sort()).toEqual([stem, `${stem}.json`]);
288
+ } finally {
289
+ await cleanup();
290
+ }
291
+ });
292
+
293
+ it('moves a folder an older build created under a raw id that still fit a folder name into the bounded folder', async () => {
294
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
295
+ try {
296
+ // Long enough for the bounded name, short enough that an older build
297
+ // could still have created `<id>/` on this filesystem. The temp root
298
+ // differs per OS (a few characters on Linux runners, dozens on macOS),
299
+ // so pad to a fixed path length instead of a fixed segment.
300
+ const projectPathLength = 170;
301
+ const segment = 'x'.repeat(Math.max(1, projectPathLength - path.join(tempRoot, 'demo-repo').length - 1));
302
+ const projectPath = path.join(tempRoot, segment, 'demo-repo');
303
+ const projectId = createProjectIdFromPath(projectPath);
304
+ const stem = projectConfigFileStemOf(projectId);
305
+ expect(projectId.length).toBeGreaterThan(200);
306
+ expect(projectId.length).toBeLessThanOrEqual(255);
307
+ expect(stem).not.toBe(projectId);
308
+ const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
309
+ const rawStorageDir = path.join(projectsRoot, projectId);
310
+ const boundedStorageDir = path.join(projectsRoot, stem);
311
+
312
+ await fsPromises.mkdir(projectPath, { recursive: true });
313
+ await fsPromises.mkdir(path.join(rawStorageDir, 'plans'), { recursive: true });
314
+ await fsPromises.writeFile(path.join(rawStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [{ id: 'n1', body: 'old note', createdAt: 1, updatedAt: 1 }], todos: [], plans: [] }), 'utf8');
315
+ await fsPromises.writeFile(path.join(rawStorageDir, 'plans', 'old.md'), '# Old\n', 'utf8');
316
+ await fsPromises.writeFile(path.join(rawStorageDir, 'memory.json'), JSON.stringify({ version: 1, entries: [{ id: 'm1', title: 'Old memory', body: 'x', type: 'fact', createdAt: 1, updatedAt: 1 }] }), 'utf8');
317
+ // The bounded folder already holds newer context: both sides survive.
318
+ await fsPromises.mkdir(boundedStorageDir, { recursive: true });
319
+ await fsPromises.writeFile(path.join(boundedStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [{ id: 'n2', body: 'new note', createdAt: 2, updatedAt: 2 }], todos: [], plans: [] }), 'utf8');
320
+ await fsPromises.writeFile(
321
+ settingsFilePath,
322
+ JSON.stringify({ projects: [{ id: projectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }], activeProjectId: projectId }, null, 2),
323
+ 'utf8',
324
+ );
325
+
326
+ const settings = await runtime.readSettingsFromDiskMigrated();
327
+
328
+ expect(settings.projects.map((project) => project.id)).toEqual([projectId]);
329
+ const context = JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'context.json'), 'utf8'));
330
+ expect(context.notes.map((note) => note.id).sort()).toEqual(['n1', 'n2']);
331
+ await fsPromises.access(path.join(boundedStorageDir, 'plans', 'old.md'));
332
+ expect(JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'memory.json'), 'utf8')).entries).toHaveLength(1);
333
+ await expect(fsPromises.access(rawStorageDir)).rejects.toMatchObject({ code: 'ENOENT' });
334
+
335
+ // A second startup finds nothing left to move.
336
+ await runtime.readSettingsFromDiskMigrated();
337
+ expect(JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'context.json'), 'utf8')).notes).toHaveLength(2);
338
+ } finally {
339
+ await cleanup();
340
+ }
341
+ });
342
+
184
343
  it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
185
344
  const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
186
345
  try {
@@ -248,3 +407,158 @@ describe('settings runtime', () => {
248
407
  }
249
408
  });
250
409
  });
410
+
411
+ describe('settings runtime: preferences.json split', () => {
412
+ const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
413
+
414
+ it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
415
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
416
+ try {
417
+ const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
418
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
419
+
420
+ const merged = await runtime.readSettingsFromDisk();
421
+ expect(merged).toMatchObject(legacy);
422
+
423
+ const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
424
+ expect(preferences.version).toBe(1);
425
+ expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
426
+ expect(preferences.fields.fontSize.value).toBe(110);
427
+ expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
428
+ expect(await readJson(settingsFilePath)).toEqual(legacy);
429
+ } finally {
430
+ await cleanup();
431
+ }
432
+ });
433
+
434
+ it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
435
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
436
+ try {
437
+ await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
438
+
439
+ const settings = await readJson(settingsFilePath);
440
+ expect(settings.desktopLanAccessEnabled).toBe(true);
441
+ // Older builds read only settings.json: the profile's base values stay there as a copy.
442
+ expect(settings.fontSize).toBe(120);
443
+ expect(settings).not.toHaveProperty('mobileKeyboardMode');
444
+
445
+ const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
446
+ expect(preferences.fields.fontSize.value).toBe(120);
447
+ expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
448
+ expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
449
+
450
+ expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
451
+ } finally {
452
+ await cleanup();
453
+ }
454
+ });
455
+
456
+ it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
457
+ const { runtime, tempRoot, cleanup } = await createRuntime();
458
+ try {
459
+ const preferencesPath = path.join(tempRoot, 'preferences.json');
460
+ await runtime.persistSettings({ fontSize: 100, padding: 100 });
461
+ const first = await readJson(preferencesPath);
462
+ await new Promise((resolve) => setTimeout(resolve, 5));
463
+ await runtime.persistSettings({ fontSize: 100, padding: 120 });
464
+ const second = await readJson(preferencesPath);
465
+
466
+ expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
467
+ expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
468
+ expect(second.fields.padding.value).toBe(120);
469
+ } finally {
470
+ await cleanup();
471
+ }
472
+ });
473
+
474
+ it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
475
+ const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
476
+ try {
477
+ const preferencesPath = path.join(tempRoot, 'preferences.json');
478
+ await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
479
+ await fsPromises.writeFile(preferencesPath, '{ not json');
480
+
481
+ expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
482
+
483
+ await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
484
+
485
+ expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
486
+ const settings = await readJson(settingsFilePath);
487
+ expect(settings.desktopKeepAwakeEnabled).toBe(true);
488
+ // The refused profile write must not land in the legacy copy either.
489
+ expect(settings).not.toHaveProperty('fontSize');
490
+ } finally {
491
+ await cleanup();
492
+ }
493
+ });
494
+ });
495
+
496
+ describe('settings runtime: per-surface profile keys', () => {
497
+ const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
498
+ // These sequences persist several times; the default stub replaces the
499
+ // document with the changes, the real merge keeps the current document.
500
+ const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
501
+
502
+ it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
503
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
504
+ try {
505
+ await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
506
+ await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
507
+
508
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
509
+ expect(stored.fields.fontSize.value).toBe(100);
510
+ expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
511
+ // Not per-surface: written to the base regardless of the surface.
512
+ expect(stored.fields.showReasoningTraces.value).toBe(false);
513
+ expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
514
+
515
+ expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
516
+ expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
517
+ expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
518
+ expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
519
+ } finally {
520
+ await cleanup();
521
+ }
522
+ });
523
+
524
+ it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
525
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
526
+ try {
527
+ await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
528
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
529
+ expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
530
+ expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
531
+ expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
532
+ expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
533
+ } finally {
534
+ await cleanup();
535
+ }
536
+ });
537
+
538
+ it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
539
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
540
+ try {
541
+ await runtime.persistSettings({ fontSize: 100 });
542
+ await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
543
+ await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
544
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
545
+ expect(stored.fields.fontSize.value).toBe(100);
546
+ expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
547
+ expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
548
+ } finally {
549
+ await cleanup();
550
+ }
551
+ });
552
+
553
+ it('ignores an unknown surface header value and writes the base', async () => {
554
+ const { runtime, tempRoot, cleanup } = await createMergingRuntime();
555
+ try {
556
+ await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
557
+ const stored = await readJson(path.join(tempRoot, 'preferences.json'));
558
+ expect(stored.fields.fontSize.value).toBe(90);
559
+ expect(stored.fields.fontSize.surfaces).toBeUndefined();
560
+ } finally {
561
+ await cleanup();
562
+ }
563
+ });
564
+ });
@@ -9,9 +9,20 @@ 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
+ | `<projectsDir>/<projectId>/memory.json` | `packages/web/server/lib/agent-memory` | what the agent chose to remember about the project |
16
+ | `<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 |
17
+
18
+ `<projectId>` in these paths is the bounded stem `projectConfigFileStemOf`
19
+ (`packages/web/server/lib/projects/project-id.js`) gives the id: the id itself
20
+ up to 200 characters, `path_sha256_<digest>` beyond that, so a deeply nested
21
+ checkout gets a folder the filesystem accepts. The folder, the config file,
22
+ and the memory file all share that one stem; nothing here composes a path from
23
+ the raw id. The legacy-migration read below looks at the bounded config file
24
+ for the same reason: a read of `<raw id>.json` would fail with ENAMETOOLONG
25
+ and turn an empty project into an error.
15
26
 
16
27
  The split is the point. Both files were previously one, written by the client
17
28
  with a whole-file read-modify-write. Adding a server writer to that file would
@@ -58,6 +69,27 @@ denormalized into the manifest so listing plans costs one read rather than one
58
69
  read per plan; `readPlan` returns the title parsed from the file, which wins if
59
70
  the two ever disagree.
60
71
 
72
+ ## Shared plans
73
+
74
+ Every `.md` file in the repository plans folder is a plan too: `.aiworker/plans`
75
+ by default, or the `plansDir` the team config (`<repo>/.aiworker/project.json`,
76
+ see `packages/web/server/lib/projects`) names instead of it (the custom folder
77
+ replaces the default outright; moving files between the two is the user's job). `readContext` appends them after the personal ones,
78
+ each marked `source: "shared"` (personal ones get `source: "personal"`), and
79
+ reports the folder as `sharedPlansDir`. A shared plan is addressed as
80
+ `shared:<file>` when no manifest entry claims it; its title is parsed from the
81
+ file on every list, and `readPlan` / `updatePlan` / `deletePlan` work on the
82
+ file directly (an update writes the raw document verbatim, so a plan another
83
+ tool wrote keeps its shape). `setPlanPinned` is `404` for such a plan.
84
+
85
+ A plan the user moves there keeps its id: `sharePlan` moves the markdown into
86
+ the folder and keeps the manifest entry with `shared: true` (the flag says
87
+ which folder holds the file), so a session that attached the plan still finds
88
+ it, and the file is listed under that id instead of `shared:<file>`.
89
+ `unsharePlan` moves it back and clears the flag; a plan that only ever lived
90
+ in the team's folder gets a manifest entry (and an id) on the way in. A name
91
+ collision gets a numeric suffix. Sharing is refused only when the checkout cannot be located.
92
+
61
93
  ## Routes
62
94
 
63
95
  | Method | Route | Notes |
@@ -72,6 +104,8 @@ the two ever disagree.
72
104
  | POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
73
105
  | PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
74
106
  | DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
107
+ | POST | `/api/project-context/:projectId/plans/:planId/share` | moves the plan into the shared folder; `400` without one, `404` when unknown |
108
+ | POST | `/api/project-context/:projectId/plans/:planId/unshare` | moves a `shared:` plan back; `404` when unknown |
75
109
 
76
110
  **Body parsing is attached per route.** This server has no global JSON parser:
77
111
  `core-routes` parses only an allowlist of `/api` path prefixes so the generic
@@ -123,9 +157,9 @@ and I/O failures are `500`.
123
157
  ## Legacy migration
124
158
 
125
159
  `projectNotes`, `projectTodos`, and `projectPlanFiles` originally lived in
126
- `<projectId>.json`. On the first read with no `context.json`, those three keys
127
- are moved out and deleted from the client-owned file; every other key is
128
- preserved untouched.
160
+ `<projectId>.json` (the bounded name, see Ownership). On the first read with
161
+ no `context.json`, those three keys are moved out and deleted from the
162
+ client-owned file; every other key is preserved untouched.
129
163
 
130
164
  Plan links carried absolute paths. Migration converts each to a base name. A
131
165
  file already in the plans directory is used in place; one referenced from
@@ -57,6 +57,8 @@ const createApp = (overrides = {}) => {
57
57
  context: emptyContext,
58
58
  }),
59
59
  deletePlan: async () => ({ deleted: true, context: emptyContext }),
60
+ sharePlan: async (_projectId, planId) => (planId === 'p1' ? { plan: { id: 'shared:a.md', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'shared' }, context: emptyContext } : null),
61
+ unsharePlan: async (_projectId, planId) => (planId === 'shared:a.md' ? { plan: { id: 'p2', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'personal' }, context: emptyContext } : null),
60
62
  ...overrides,
61
63
  };
62
64
 
@@ -212,6 +214,22 @@ describe('project context routes over HTTP', () => {
212
214
  .expect(400);
213
215
  });
214
216
 
217
+ it('shares and unshares a plan, and answers 404 for an unknown one', async () => {
218
+ const { app } = createApp();
219
+ const shared = await request(app).post('/api/project-context/proj/plans/p1/share');
220
+ expect(shared.status).toBe(200);
221
+ expect(shared.body.plan.id).toBe('shared:a.md');
222
+ const back = await request(app).post('/api/project-context/proj/plans/shared%3Aa.md/unshare');
223
+ expect(back.status).toBe(200);
224
+ expect(back.body.plan.source).toBe('personal');
225
+ expect((await request(app).post('/api/project-context/proj/plans/nope/share')).status).toBe(404);
226
+ });
227
+
228
+ it('answers 400 when sharing without a shared plans folder', async () => {
229
+ const { app } = createApp({ sharePlan: async () => { throw new Error('shared plans folder is required'); } });
230
+ expect((await request(app).post('/api/project-context/proj/plans/p1/share')).status).toBe(400);
231
+ });
232
+
215
233
  it('returns 404 for an unknown plan', async () => {
216
234
  const { app } = createApp();
217
235
  await request(app).get(`${BASE}/plans/nope`).expect(404);
@@ -220,6 +220,21 @@ export const registerProjectContextRoutes = (app, dependencies) => {
220
220
  }
221
221
  });
222
222
 
223
+ // Moving a plan between the user's folder and the team's shared folder.
224
+ for (const [suffix, method] of [['share', 'sharePlan'], ['unshare', 'unsharePlan']]) {
225
+ app.post(`/api/project-context/:projectId/plans/:planId/${suffix}`, async (req, res) => {
226
+ try {
227
+ const result = await projectContextRuntime[method](req.params.projectId, req.params.planId);
228
+ if (!result) {
229
+ return res.status(404).json({ error: 'Plan not found' });
230
+ }
231
+ return res.json(result);
232
+ } catch (error) {
233
+ return respondWithError(res, error, `Failed to ${suffix} plan`);
234
+ }
235
+ });
236
+ }
237
+
223
238
  app.delete('/api/project-context/:projectId/plans/:planId', async (req, res) => {
224
239
  try {
225
240
  const { deleted, context } = await projectContextRuntime.deletePlan(