@aiworker/web 1.23.0 → 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 (152) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  2. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  3. package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  4. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  5. package/dist/assets/{GitView-BXsS3jG6.js → GitView-B_x52MHY.js} +4 -4
  6. package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-cmAzUTiw.js} +1 -1
  7. package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-Dehh5afm.js} +1 -1
  8. package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-DgNOqqhE.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DusmMZ_p.js} +2 -2
  10. package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-B8fm1H4m.js} +1 -1
  11. package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-BVr7gJU0.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-DzLt-nel.js} +1 -1
  13. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  14. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  15. package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-cTl_eLIH.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-C_3iYNJQ.js} +2 -2
  18. package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  19. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  20. package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  21. package/dist/assets/de-DJsQr-p7.js +10 -0
  22. package/dist/assets/es-BuYMlA9g.js +16 -0
  23. package/dist/assets/fr-ePDay33C.js +14 -0
  24. package/dist/assets/index-BD8ILbxF.js +40 -0
  25. package/dist/assets/{index-jXSK5RrB.js → index-n6O3hklM.js} +1 -1
  26. package/dist/assets/index-zhgg-SDz.css +1 -0
  27. package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-GMdPz-71.js} +1 -1
  28. package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-BVuH1zH9.js} +1 -1
  29. package/dist/assets/ja-BzR9xOY_.js +16 -0
  30. package/dist/assets/{ko-idhXGF8j.js → ko-BEjHAm14.js} +3 -3
  31. package/dist/assets/main-BydQ1tr_.js +930 -0
  32. package/dist/assets/{main-CzdPuL0s.js → main-D3ZDQglc.js} +2 -2
  33. package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-Cb2MdZnV.js} +3 -3
  34. package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-d01k801U.js} +2 -2
  35. package/dist/assets/{mobile-mvxTo055.js → mobile-D7VV-Ufz.js} +2 -2
  36. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  37. package/dist/assets/{pl-CGZWUqZJ.js → pl-rQmpxNIF.js} +3 -3
  38. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  39. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  40. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  41. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  42. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  43. package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-BzXUjlby.js} +1 -1
  44. package/dist/assets/surface-COKb77Ua.js +1 -0
  45. package/dist/assets/tr-CoAMXPtI.js +7 -0
  46. package/dist/assets/uk-YlAX0rRU.js +16 -0
  47. package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-Cwyhjfl5.js} +153 -155
  48. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  49. package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-DDkpBEU8.js} +2 -2
  50. package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-CwjoWuC4.js} +1 -1
  51. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  53. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  54. package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
  55. package/dist/assets/{web-components-z7hfnS8n.js → web-components-LV3vjTxy.js} +1 -1
  56. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  57. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  58. package/dist/index.html +4 -4
  59. package/dist/mini-chat.html +4 -4
  60. package/dist/mobile.html +4 -4
  61. package/package.json +1 -1
  62. package/server/index.js +3 -13
  63. package/server/lib/agent-memory/runtime.js +7 -2
  64. package/server/lib/agent-memory/runtime.test.js +22 -0
  65. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  66. package/server/lib/event-stream/global-hub.js +29 -4
  67. package/server/lib/event-stream/global-hub.test.js +22 -0
  68. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  69. package/server/lib/event-stream/protocol.js +21 -7
  70. package/server/lib/event-stream/runtime.js +9 -6
  71. package/server/lib/event-stream/runtime.test.js +60 -0
  72. package/server/lib/git/DOCUMENTATION.md +10 -1
  73. package/server/lib/git/routes.js +21 -8
  74. package/server/lib/git/routes.test.js +78 -0
  75. package/server/lib/git/service.js +159 -4
  76. package/server/lib/git/service.test.js +74 -0
  77. package/server/lib/github/DOCUMENTATION.md +5 -0
  78. package/server/lib/github/routes.js +2 -2
  79. package/server/lib/message-queue/DOCUMENTATION.md +7 -0
  80. package/server/lib/message-queue/runtime.js +13 -0
  81. package/server/lib/message-queue/runtime.test.js +31 -1
  82. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  83. package/server/lib/notifications/emitter-runtime.js +4 -3
  84. package/server/lib/opencode/DOCUMENTATION.md +18 -4
  85. package/server/lib/opencode/env-runtime.js +5 -1
  86. package/server/lib/opencode/env-runtime.test.js +72 -0
  87. package/server/lib/opencode/feature-routes-runtime.js +15 -1
  88. package/server/lib/opencode/lifecycle.js +4 -3
  89. package/server/lib/opencode/lifecycle.test.js +29 -0
  90. package/server/lib/opencode/managed-plugin-config.js +1 -1
  91. package/server/lib/opencode/proxy.js +33 -0
  92. package/server/lib/opencode/settings-helpers.js +3 -0
  93. package/server/lib/opencode/settings-helpers.test.js +11 -1
  94. package/server/lib/opencode/settings-registry.json +3 -0
  95. package/server/lib/opencode/settings-runtime.js +36 -9
  96. package/server/lib/opencode/settings-runtime.test.js +158 -2
  97. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  98. package/server/lib/project-context/runtime.js +8 -2
  99. package/server/lib/project-context/runtime.test.js +54 -0
  100. package/server/lib/projects/DOCUMENTATION.md +2 -1
  101. package/server/lib/projects/project-config.js +38 -9
  102. package/server/lib/projects/project-config.test.js +97 -0
  103. package/server/lib/projects/project-id.js +26 -0
  104. package/server/lib/relay/DOCUMENTATION.md +20 -0
  105. package/server/lib/relay/host-client.js +7 -0
  106. package/server/lib/relay/host-client.test.js +14 -0
  107. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  108. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  109. package/server/lib/scheduled-tasks/runtime.js +12 -2
  110. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  111. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  112. package/server/lib/session-assist/context.js +127 -0
  113. package/server/lib/session-assist/context.test.js +132 -0
  114. package/server/lib/session-assist/prompt.js +54 -0
  115. package/server/lib/session-assist/runtime.js +111 -257
  116. package/server/lib/session-assist/runtime.test.js +203 -0
  117. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  118. package/server/lib/walkthrough/pr-source.test.js +21 -0
  119. package/server/lib/walkthrough/pull-request.js +7 -4
  120. package/server/lib/walkthrough/pull-request.test.js +16 -0
  121. package/server/lib/walkthrough/routes.js +19 -0
  122. package/server/lib/walkthrough/routes.test.js +26 -0
  123. package/server/lib/walkthrough/sources.js +13 -2
  124. package/server/opencode-worktree-readiness.test.js +130 -0
  125. package/dist/assets/DiffView-DXjVsEOw.js +0 -4
  126. package/dist/assets/FilesView-CJdG0-wk.js +0 -17
  127. package/dist/assets/PlanView-CJTmOF6I.js +0 -5
  128. package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
  129. package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
  130. package/dist/assets/de-DCrsQ5zS.js +0 -10
  131. package/dist/assets/es-Dwv4YfH6.js +0 -16
  132. package/dist/assets/fr-BCWg4Cnx.js +0 -14
  133. package/dist/assets/index-B68h5S5B.css +0 -1
  134. package/dist/assets/index-DVwMarHI.js +0 -39
  135. package/dist/assets/ja-Bruw3RYC.js +0 -16
  136. package/dist/assets/main-BkyaQhJX.js +0 -930
  137. package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
  138. package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
  139. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
  140. package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
  141. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
  142. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
  143. package/dist/assets/surface-oMvhsog3.js +0 -1
  144. package/dist/assets/tr-BK_KiO2V.js +0 -7
  145. package/dist/assets/uk-CG74xV8N.js +0 -16
  146. package/dist/assets/useGitComparison-DH220WmO.js +0 -1
  147. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  148. package/dist/assets/zh-CN-T4AXofZI.js +0 -16
  149. package/dist/assets/zh-TW-Dte40sxp.js +0 -16
  150. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  151. package/server/lib/mcp-reconnect/runtime.js +0 -122
  152. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -14,6 +14,7 @@ const originalResourcesPath = process.resourcesPath;
14
14
  const originalWslBinary = process.env.WSL_BINARY;
15
15
  const originalAiWorkerWslBinary = process.env.AIWORKER_WSL_BINARY;
16
16
  const originalPlatform = process.platform;
17
+ const originalRuntime = process.env.AIWORKER_RUNTIME;
17
18
  const tempDirs = [];
18
19
  const itIf = (condition) => condition ? it : it.skip;
19
20
 
@@ -30,6 +31,8 @@ const setPlatform = (platform) => {
30
31
  };
31
32
 
32
33
  afterEach(() => {
34
+ if (originalRuntime === undefined) delete process.env.AIWORKER_RUNTIME;
35
+ else process.env.AIWORKER_RUNTIME = originalRuntime;
33
36
  Object.defineProperty(process, 'platform', {
34
37
  value: originalPlatform,
35
38
  });
@@ -117,7 +120,76 @@ const createRuntime = (settings, options = {}) => {
117
120
  return { runtime, state };
118
121
  };
119
122
 
123
+ const createBundledCli = () => {
124
+ const resourcesPath = createTempDir('openchamber-resources-');
125
+ const directory = path.join(resourcesPath, 'opencode-cli');
126
+ fs.mkdirSync(directory);
127
+ const binary = path.join(directory, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
128
+ fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
129
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath });
130
+ return binary;
131
+ };
132
+
120
133
  describe('OpenCode env runtime', () => {
134
+ it.each(['linux', 'darwin', 'win32'])('keeps automatic desktop bundled resolution local across relaunch on %s', (platform) => {
135
+ setPlatform(platform);
136
+ process.env.AIWORKER_RUNTIME = 'desktop';
137
+ delete process.env.OPENCODE_BINARY;
138
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
139
+
140
+ const oldBinary = createBundledCli();
141
+ const oldRuntime = createRuntime({});
142
+ expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
143
+ expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
144
+ expect(oldRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
145
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
146
+ expect(process.env.PATH.split(path.delimiter)[0]).toBe(path.dirname(oldBinary));
147
+
148
+ // The old binary remains executable and its directory is still in PATH.
149
+ const newBinary = createBundledCli();
150
+ const newRuntime = createRuntime({});
151
+ expect(newRuntime.runtime.ensureOpencodeCliEnv()).toBe(newBinary);
152
+ expect(newRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
153
+ expect(newRuntime.runtime.isBundledOpenCodeCliPath(newBinary)).toBe(true);
154
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
155
+ });
156
+
157
+ it.each(['web', 'ssh-remote'])('preserves automatic binary export outside desktop in %s', (runtimeName) => {
158
+ process.env.AIWORKER_RUNTIME = runtimeName;
159
+ delete process.env.OPENCODE_BINARY;
160
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
161
+ const binary = createBundledCli();
162
+ const { runtime } = createRuntime({});
163
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
164
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
165
+ });
166
+
167
+ it.each(['env', 'settings'])('preserves explicit desktop %s selection even when it points at a bundled CLI', async (source) => {
168
+ process.env.AIWORKER_RUNTIME = 'desktop';
169
+ delete process.env.OPENCODE_BINARY;
170
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
171
+ const binary = createBundledCli();
172
+ if (source === 'env') process.env.OPENCODE_BINARY = binary;
173
+ const { runtime, state } = createRuntime(source === 'settings' ? { opencodeBinary: binary } : {});
174
+ await runtime.applyOpencodeBinaryFromSettings({ strict: true });
175
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
176
+ expect(state.resolvedOpencodeBinarySource).toBe(source);
177
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
178
+ });
179
+
180
+ it('preserves automatic PATH binary export for desktop without a bundled CLI', () => {
181
+ process.env.AIWORKER_RUNTIME = 'desktop';
182
+ delete process.env.OPENCODE_BINARY;
183
+ delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
184
+ const binary = createBundledCli();
185
+ Object.defineProperty(process, 'resourcesPath', { configurable: true, value: undefined });
186
+ process.env.PATH = path.dirname(binary);
187
+ const { runtime, state } = createRuntime({});
188
+ expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
189
+ expect(state.resolvedOpencodeBinarySource).toBe('path');
190
+ expect(process.env.OPENCODE_BINARY).toBe(binary);
191
+ });
192
+
121
193
  it('searches an explicit PATH without mutating the process environment', () => {
122
194
  const defaultDir = createTempDir('openchamber-default-path-');
123
195
  const explicitDir = createTempDir('openchamber-explicit-path-');
@@ -307,7 +307,21 @@ export const createFeatureRoutesRuntime = (dependencies) => {
307
307
  registerSessionGoalRoutes(app);
308
308
  registerGitHubRoutes(app);
309
309
  registerLinearRoutes(app);
310
- registerGitRoutes(app);
310
+ registerGitRoutes(app, {
311
+ emitWorktreeChanged: ({ directories, at }) => {
312
+ const clients = getAiWorkerEventClients();
313
+ for (const client of clients) {
314
+ try {
315
+ writeSseEvent(client, {
316
+ type: 'aiworker:worktree-changed',
317
+ properties: { directories, at },
318
+ });
319
+ } catch {
320
+ clients.delete(client);
321
+ }
322
+ }
323
+ },
324
+ });
311
325
  registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
312
326
  registerMagicPromptRoutes(app, {
313
327
  fsPromises,
@@ -377,8 +377,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
377
377
  return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured';
378
378
  };
379
379
 
380
- const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
381
- let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
380
+ const createManagedOpenCodeServerProcess = async ({ resolvedBinary, hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
381
+ let binary = (resolvedBinary || process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
382
382
  const sourceBinary = binary;
383
383
  let args = ['serve', '--hostname', hostname, '--port', String(port)];
384
384
  let launchWrapperType = null;
@@ -694,7 +694,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
694
694
  );
695
695
 
696
696
  await applyOpencodeBinaryFromSettings({ strict: true });
697
- ensureOpencodeCliEnv();
697
+ const resolvedBinary = ensureOpencodeCliEnv();
698
698
  recordStartupPerformance('opencode.binary.ready', {
699
699
  attempt,
700
700
  durationMs: performance.now() - phaseStartedAt,
@@ -721,6 +721,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
721
721
 
722
722
  try {
723
723
  const serverInstance = await createManagedOpenCodeServerProcess({
724
+ resolvedBinary,
724
725
  hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
725
726
  port: spawnPort,
726
727
  timeout: 30000,
@@ -126,6 +126,35 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
126
126
  };
127
127
 
128
128
  describe('OpenCode lifecycle', () => {
129
+ it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
130
+ delete process.env.OPENCODE_BINARY;
131
+ const binaries = ['/bundle one/opencode-cli/opencode', '/bundle two/opencode-cli/opencode'];
132
+ const ensureOpencodeCliEnv = vi.fn()
133
+ .mockReturnValueOnce(binaries[0])
134
+ .mockReturnValueOnce(binaries[1]);
135
+ spawnMock.mockImplementation(() => {
136
+ const child = createMockChild();
137
+ queueMicrotask(() => child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'));
138
+ return child;
139
+ });
140
+ globalThis.fetch = vi.fn(async () => ({ ok: false }));
141
+ const runtime = createRuntime({ ensureOpencodeCliEnv });
142
+ const firstServer = await runtime.startOpenCode();
143
+ runtime.testState.openCodeProcess = firstServer;
144
+ try {
145
+ await runtime.restartOpenCode();
146
+ expect(firstServer.signalCode).toBe('SIGTERM');
147
+ expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(binaries);
148
+ expect(runtime.testState.lastOpenCodeLaunchDiagnostics.sourceBinary).toBe(binaries[1]);
149
+ expect(process.env.OPENCODE_BINARY).toBeUndefined();
150
+ for (const [, , options] of spawnMock.mock.calls) {
151
+ expect(options.env).not.toHaveProperty('OPENCODE_BINARY');
152
+ }
153
+ } finally {
154
+ await runtime.testState.openCodeProcess.close();
155
+ }
156
+ });
157
+
129
158
  it('records an authoritative ready terminal event for external startup', async () => {
130
159
  globalThis.fetch = vi.fn(async () => ({
131
160
  ok: true,
@@ -8,7 +8,7 @@ const isJsonObject = (value) => value !== null && value !== undefined && Object.
8
8
  *
9
9
  * Existing entries are preserved; an earlier entry for the same URL is dropped
10
10
  * so a restart never registers the same plugin twice. Every managed plugin
11
- * (agent tools, system prompt optimizer, MCP reconnect) goes through this one
11
+ * (agent tools, system prompt optimizer) goes through this one
12
12
  * merge so they compose in any order.
13
13
  *
14
14
  * @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
@@ -11,6 +11,7 @@ import {
11
11
  import { createRealpathCache } from '../path-realpath-cache.js';
12
12
  import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js';
13
13
  import { recordStartupPerformance } from './startup-performance.js';
14
+ import { getWorktreeBootstrapStatus } from '../git/service.js';
14
15
 
15
16
  const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
16
17
 
@@ -286,6 +287,8 @@ export const registerOpenCodeProxy = (app, deps) => {
286
287
  SSE_HEARTBEAT_INTERVAL_MS = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS,
287
288
  SSE_UPSTREAM_STALL_TIMEOUT_MS = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
288
289
  getSseUpstreamStallTimeoutMs = () => SSE_UPSTREAM_STALL_TIMEOUT_MS,
290
+ readWorktreeBootstrapStatus = getWorktreeBootstrapStatus,
291
+ WORKTREE_READY_TIMEOUT_MS = 5 * 60 * 1000,
289
292
  } = deps;
290
293
 
291
294
  if (app.get('opencodeProxyConfigured')) {
@@ -775,6 +778,36 @@ export const registerOpenCodeProxy = (app, deps) => {
775
778
  }
776
779
  });
777
780
 
781
+ // Any directory-scoped read can initialize OpenCode's cached project/config,
782
+ // before session.create runs. Hold all upstream requests until Git population
783
+ // finishes, independently of the user's optional setup-script wait.
784
+ app.use('/api', async (req, res, next) => {
785
+ normalizeForwardedDirectoryHeaders(req.headers);
786
+ const url = new URL(req.url, 'http://localhost');
787
+ const directory = url.searchParams.get('directory') || req.get('x-opencode-directory');
788
+ if (!directory) return next();
789
+
790
+ const deadline = Date.now() + WORKTREE_READY_TIMEOUT_MS;
791
+ try {
792
+ while (!res.destroyed && !res.writableEnded && !req.aborted) {
793
+ const status = await readWorktreeBootstrapStatus(directory);
794
+ if (res.destroyed || res.writableEnded || req.aborted) return;
795
+ if (status.status === 'failed') {
796
+ return res.status(503).json({ error: status.error || 'Worktree bootstrap failed' });
797
+ }
798
+ if (status.status === 'ready' || status.phase === 'git-ready' || status.phase === 'setup-ready') {
799
+ return next();
800
+ }
801
+ if (Date.now() >= deadline) {
802
+ return res.status(503).json({ error: 'Timed out waiting for worktree checkout' });
803
+ }
804
+ await sleep(75);
805
+ }
806
+ } catch (error) {
807
+ next(error);
808
+ }
809
+ });
810
+
778
811
  // Windows: session merge for cross-directory session listing
779
812
  if (process.platform === 'win32') {
780
813
  app.get('/api/session', async (req, res, next) => {
@@ -473,6 +473,9 @@ export const createSettingsHelpers = (dependencies) => {
473
473
  if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') {
474
474
  result.sessionRetentionAction = candidate.sessionRetentionAction;
475
475
  }
476
+ if (typeof candidate.sessionRetentionOnlyArchived === 'boolean') {
477
+ result.sessionRetentionOnlyArchived = candidate.sessionRetentionOnlyArchived;
478
+ }
476
479
  if (candidate.tunnelBootstrapTtlMs === null) {
477
480
  result.tunnelBootstrapTtlMs = null;
478
481
  } else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
@@ -690,6 +690,14 @@ describe('settings helpers', () => {
690
690
  });
691
691
 
692
692
  describe('session retention settings persistence', () => {
693
+ it('round-trips archived-only retention and rejects non-boolean values', () => {
694
+ const helpers = createTestHelpersWithRealSanitizers();
695
+ for (const sessionRetentionOnlyArchived of [true, false]) {
696
+ expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived })).toEqual({ sessionRetentionOnlyArchived });
697
+ }
698
+ expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived: 'true' })).toEqual({});
699
+ expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived: null })).toEqual({});
700
+ });
693
701
  it('round-trips sessionRetentionAction archive and delete through the sanitizer', () => {
694
702
  const helpers = createTestHelpersWithRealSanitizers();
695
703
 
@@ -714,6 +722,7 @@ describe('settings helpers', () => {
714
722
  autoDeleteEnabled: true,
715
723
  autoDeleteAfterDays: 60,
716
724
  sessionRetentionAction: 'delete',
725
+ sessionRetentionOnlyArchived: true,
717
726
  };
718
727
 
719
728
  const sanitized = helpers.sanitizeSettingsUpdate(payload);
@@ -721,6 +730,7 @@ describe('settings helpers', () => {
721
730
  expect(sanitized.autoDeleteEnabled).toBe(true);
722
731
  expect(sanitized.autoDeleteAfterDays).toBe(60);
723
732
  expect(sanitized.sessionRetentionAction).toBe('delete');
733
+ expect(sanitized.sessionRetentionOnlyArchived).toBe(true);
724
734
  });
725
735
  });
726
736
  });
@@ -746,7 +756,7 @@ describe('settings registry gate', () => {
746
756
  desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
747
757
  defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
748
758
  agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
749
- autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
759
+ autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionOnlyArchived: false, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
750
760
  openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
751
761
  tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
752
762
  managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
@@ -136,6 +136,9 @@
136
136
  "autoDeleteAfterDays": {
137
137
  "scope": "instance"
138
138
  },
139
+ "sessionRetentionOnlyArchived": {
140
+ "scope": "instance"
141
+ },
139
142
  "sessionRetentionAction": {
140
143
  "scope": "instance"
141
144
  },
@@ -1,4 +1,4 @@
1
- import { createProjectIdFromPath } from '../projects/project-id.js';
1
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
2
2
  import {
3
3
  buildPreferencesFields,
4
4
  flattenPreferences,
@@ -288,10 +288,10 @@ export const createSettingsRuntime = (deps) => {
288
288
  return;
289
289
  }
290
290
 
291
- const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${oldId}.json`);
292
- const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${newId}.json`);
293
- const oldStorageDir = path.join(PROJECTS_ROOT_DIR, oldId);
294
- const newStorageDir = path.join(PROJECTS_ROOT_DIR, newId);
291
+ const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${projectConfigFileStemOf(oldId)}.json`);
292
+ const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${projectConfigFileStemOf(newId)}.json`);
293
+ const oldStorageDir = path.join(PROJECTS_ROOT_DIR, projectConfigFileStemOf(oldId));
294
+ const newStorageDir = path.join(PROJECTS_ROOT_DIR, projectConfigFileStemOf(newId));
295
295
 
296
296
  const [oldConfig, newConfig] = await Promise.all([
297
297
  readJsonFile(oldConfigPath),
@@ -308,6 +308,28 @@ export const createSettingsRuntime = (deps) => {
308
308
  await fsPromises.rm(oldConfigPath, { force: true });
309
309
  };
310
310
 
311
+ /**
312
+ * A build before the bounded folder name created `<projectId>/` for an id
313
+ * the filesystem still accepted (201 to 255 characters; longer ids never
314
+ * got a folder). Only the bounded folder is read now, so that folder's
315
+ * notes, plans, and memory are moved over once. A raw name the filesystem
316
+ * cannot hold (ENAMETOOLONG) means no such folder ever existed.
317
+ */
318
+ const migrateRawIdStorageFolder = async (projectId) => {
319
+ const stem = projectConfigFileStemOf(projectId);
320
+ if (stem === projectId) return;
321
+ const rawStorageDir = path.join(PROJECTS_ROOT_DIR, projectId);
322
+ try {
323
+ await fsPromises.stat(rawStorageDir);
324
+ } catch (error) {
325
+ if (error?.code === 'ENOENT' || error?.code === 'ENAMETOOLONG') return;
326
+ throw error;
327
+ }
328
+ const boundedStorageDir = path.join(PROJECTS_ROOT_DIR, stem);
329
+ await mergeProjectContextFiles(rawStorageDir, boundedStorageDir);
330
+ await moveDirectoryContents(rawStorageDir, boundedStorageDir);
331
+ };
332
+
311
333
  const migrateSettingsToDeterministicProjectIds = async (current) => {
312
334
  const settings = current && typeof current === 'object' ? current : {};
313
335
  const projects = sanitizeProjects(settings.projects) || [];
@@ -328,6 +350,7 @@ export const createSettingsRuntime = (deps) => {
328
350
  await migrateProjectScopedStorage({ oldId: project.id, newId: nextId, projectPath: project.path });
329
351
  await migrateProjectIconFiles({ oldId: project.id, newId: nextId });
330
352
  }
353
+ await migrateRawIdStorageFolder(nextId);
331
354
  nextProjects.push({ ...project, id: nextId });
332
355
  }
333
356
 
@@ -468,19 +491,23 @@ export const createSettingsRuntime = (deps) => {
468
491
  for (const [projectId, orphansForProject] of matches.entries()) {
469
492
  const project = canonicalProjects.find((p) => p.id === projectId);
470
493
  if (!project) continue;
471
- const targetPath = path.join(PROJECTS_ROOT_DIR, `${project.id}.json`);
494
+ const targetStem = projectConfigFileStemOf(project.id);
495
+ const targetPath = path.join(PROJECTS_ROOT_DIR, `${targetStem}.json`);
496
+ const targetStorageDir = path.join(PROJECTS_ROOT_DIR, targetStem);
472
497
 
473
498
  for (const orphan of orphansForProject) {
474
499
  const targetExisting = (await readJsonFile(targetPath)) || {};
500
+ // An orphan is named by the file found on disk, so its folder is the raw name.
501
+ const orphanStorageDir = path.join(PROJECTS_ROOT_DIR, orphan.orphanId);
475
502
  const merged = mergeProjectConfigData({
476
503
  oldConfig: orphan.content,
477
504
  newConfig: targetExisting,
478
- oldStorageDir: path.join(PROJECTS_ROOT_DIR, orphan.orphanId),
479
- newStorageDir: path.join(PROJECTS_ROOT_DIR, project.id),
505
+ oldStorageDir: orphanStorageDir,
506
+ newStorageDir: targetStorageDir,
480
507
  projectPath: project.path,
481
508
  });
482
509
  await writeJsonFile(targetPath, merged);
483
- await moveDirectoryContents(path.join(PROJECTS_ROOT_DIR, orphan.orphanId), path.join(PROJECTS_ROOT_DIR, project.id));
510
+ await moveDirectoryContents(orphanStorageDir, targetStorageDir);
484
511
  await fsPromises.rm(orphan.filePath, { force: true });
485
512
  orphansConsumed.add(orphan.orphanId);
486
513
  console.log(`[projects] Recovered orphan ${orphan.orphanId} -> ${project.id} (${project.path})`);
@@ -1,9 +1,9 @@
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
9
  const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
@@ -39,6 +39,20 @@ const createRuntime = async ({ mergePersistedSettings = (_current, changes) => c
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 {
@@ -145,6 +159,52 @@ describe('settings runtime', () => {
145
159
  }
146
160
  });
147
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
+
148
208
  it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
149
209
  const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
150
210
  const settingsFilePath = path.join(tempRoot, 'settings.json');
@@ -184,6 +244,102 @@ describe('settings runtime', () => {
184
244
  }
185
245
  });
186
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
+
187
343
  it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
188
344
  const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
189
345
  try {
@@ -12,8 +12,18 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
12
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 |
15
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 |
16
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.
26
+
17
27
  The split is the point. Both files were previously one, written by the client
18
28
  with a whole-file read-modify-write. Adding a server writer to that file would
19
29
  have made unrelated features (project actions, draft starters) clobber notes
@@ -147,9 +157,9 @@ and I/O failures are `500`.
147
157
  ## Legacy migration
148
158
 
149
159
  `projectNotes`, `projectTodos`, and `projectPlanFiles` originally lived in
150
- `<projectId>.json`. On the first read with no `context.json`, those three keys
151
- are moved out and deleted from the client-owned file; every other key is
152
- 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.
153
163
 
154
164
  Plan links carried absolute paths. Migration converts each to a base name. A
155
165
  file already in the plans directory is used in place; one referenced from
@@ -12,6 +12,8 @@
12
12
  * directory never invalidates a reference.
13
13
  */
14
14
 
15
+ import { projectConfigFileStemOf } from '../projects/project-id.js';
16
+
15
17
  const PROJECT_CONTEXT_VERSION = 2;
16
18
  const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
17
19
  const PROJECT_NOTE_MAX_ITEMS = 200;
@@ -221,10 +223,14 @@ export const createProjectContextRuntime = (deps) => {
221
223
  return value;
222
224
  };
223
225
 
224
- const storageDirFor = (projectId) => path.join(projectsDirPath, sanitizeProjectId(projectId));
226
+ // The folder and the client-owned config file share one bounded stem
227
+ // (`projectConfigFileStemOf`): an id too long for a file name would
228
+ // otherwise fail every read with ENAMETOOLONG instead of reading as empty.
229
+ const storageStemFor = (projectId) => projectConfigFileStemOf(sanitizeProjectId(projectId));
230
+ const storageDirFor = (projectId) => path.join(projectsDirPath, storageStemFor(projectId));
225
231
  const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
226
232
  const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
227
- const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${sanitizeProjectId(projectId)}.json`);
233
+ const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${storageStemFor(projectId)}.json`);
228
234
 
229
235
  const readJson = async (filePath) => {
230
236
  let raw;