@vantran-se/openchamber-web 1.22.1 → 1.22.2

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 (163) hide show
  1. package/README.md +2 -3
  2. package/bin/cli.js +4 -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-CV1HNV5l.js} +1 -1
  7. package/dist/assets/{DiffView-Dh1ey2x9.js → DiffView-DkkSa30n.js} +3 -3
  8. package/dist/assets/DirtyBranchSwitchDialog-DW4WaXPc.js +1 -0
  9. package/dist/assets/FilesView-DDu8zDy5.js +17 -0
  10. package/dist/assets/GitView-1Ixo8z-M.js +6 -0
  11. package/dist/assets/{JsonTreeView-Bl0lp9Rs.js → JsonTreeView-DvCLL7dT.js} +1 -1
  12. package/dist/assets/LinearIssuesView-Bs1rZrF2.js +1 -0
  13. package/dist/assets/{MarkdownImageGallery-bA0PS-tn.js → MarkdownImageGallery-DyJUc6h9.js} +1 -1
  14. package/dist/assets/MarkdownRendererImpl-Bdm8vMkS.js +12 -0
  15. package/dist/assets/{OnboardingScreen-D3kYJIb6.js → OnboardingScreen-Dg66B6oB.js} +1 -1
  16. package/dist/assets/{PatchDiff-D6NXAfJk.js → PatchDiff-CAduNjfZ.js} +1 -1
  17. package/dist/assets/{PierreDiffViewer-Ct2A13NO.js → PierreDiffViewer-CTIb1GYD.js} +2 -2
  18. package/dist/assets/PlanView-trDaszfq.js +5 -0
  19. package/dist/assets/SettingsView-DStMfBmy.js +28 -0
  20. package/dist/assets/{SettingsWindow-BiWDBRvb.js → SettingsWindow-DQQFnrSZ.js} +1 -1
  21. package/dist/assets/{ToolOutputDialog-BQexMQIm.js → ToolOutputDialog-SjY2XZlA.js} +6 -6
  22. package/dist/assets/ToolPartDiffPreview-DJdzUQPE.js +8 -0
  23. package/dist/assets/{VirtualizedFileDiff-Dpz2ra8h.js → VirtualizedFileDiff-Iehm4iMH.js} +1 -1
  24. package/dist/assets/{WalkthroughView-AX-u2pFg.js → WalkthroughView-BALp2d9O.js} +2 -2
  25. package/dist/assets/{computeVirtualFileMetrics-DtyozJdt.js → computeVirtualFileMetrics-BKZA43vN.js} +2 -2
  26. package/dist/assets/{contextPanelEmbeddedChat-C9EcRSDF.js → contextPanelEmbeddedChat-D2PM_pEv.js} +1 -1
  27. package/dist/assets/{de-DqVn5vme.js → de-DlSNLAcy.js} +6 -5
  28. package/dist/assets/{es-hj43Z53i.js → es-D1GbowZ3.js} +6 -5
  29. package/dist/assets/{fr-BmkZt2mF.js → fr-BPOLXIAY.js} +6 -5
  30. package/dist/assets/{index-DmhujXbT.js → index-Ce0LR32V.js} +1 -1
  31. package/dist/assets/index-D42fPvt4.css +1 -0
  32. package/dist/assets/index-DlFbCkEy.js +39 -0
  33. package/dist/assets/{isDiffPlainText-XyulMHxx.js → isDiffPlainText-DaB2u_Kv.js} +1 -1
  34. package/dist/assets/{isFilePlainText-B19Ddps1.js → isFilePlainText-yJZa2IAC.js} +1 -1
  35. package/dist/assets/{ja-DioMIEGF.js → ja-Dm2-m9io.js} +7 -6
  36. package/dist/assets/{ko-BqPgaQJb.js → ko-CIXlzQ5-.js} +7 -6
  37. package/dist/assets/main-B7qTsd_w.js +930 -0
  38. package/dist/assets/{main-B2a_JfKZ.js → main-CyQrD9dG.js} +2 -2
  39. package/dist/assets/{markdownCore-CWRCqSH2.js → markdownCore-CeEwGgEK.js} +2 -2
  40. package/dist/assets/{miniChat-B3CSU_Ps.js → miniChat-7cPEx4sV.js} +2 -2
  41. package/dist/assets/{mobile-QNcFIVCW.js → mobile-QtzkPTM8.js} +2 -2
  42. package/dist/assets/{patchFileDiff-BpMQZnYu.js → patchFileDiff-No55fDiD.js} +1 -1
  43. package/dist/assets/pl-B2zmkOpw.js +16 -0
  44. package/dist/assets/{pt-BR-BqH8fuCH.js → pt-BR-C-JE5H6y.js} +6 -5
  45. package/dist/assets/{renderElectronMiniChatApp-D77bmLqo.js → renderElectronMiniChatApp-ePTkBJNS.js} +2 -2
  46. package/dist/assets/renderMobileApp-D9RjB9DX.js +25 -0
  47. package/dist/assets/runtimeEndpointReset-DfJIWwwo.js +17 -0
  48. package/dist/assets/{scheduledTasksApi-BSRCtP6T.js → scheduledTasksApi-DuhdyYEa.js} +1 -1
  49. package/dist/assets/{shikiHighlight-DE-OwqGw.js → shikiHighlight-EHEg58-n.js} +1 -1
  50. package/dist/assets/tr-MJmRIzEY.js +7 -0
  51. package/dist/assets/{uk-BkkfxbOs.js → uk-BmD7eM8k.js} +6 -5
  52. package/dist/assets/{useAppFontEffects-COAeIX-D.js → useAppFontEffects-CuJPKtI1.js} +151 -180
  53. package/dist/assets/useInlineCommentController-DD7_7OP_.js +2 -0
  54. package/dist/assets/{useRootScrollLock-C0N9mCwz.js → useRootScrollLock-DRGAe8PN.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-language-data-BG3v1Ghq.js → vendor-codemirror-language-data-9ZzoH0o0.js} +2 -2
  56. package/dist/assets/vendor-zod-CK21ihK-.js +39 -0
  57. package/dist/assets/{web-components-BNMwa0qm.js → web-components-PttKBrhi.js} +1 -1
  58. package/dist/assets/{zh-CN-C6FMdVgv.js → zh-CN-JJHl4ehT.js} +6 -5
  59. package/dist/assets/{zh-TW-CdcMjDxv.js → zh-TW-DPc4DEEW.js} +6 -5
  60. package/dist/index.html +5 -5
  61. package/dist/mini-chat.html +4 -4
  62. package/dist/mobile.html +4 -4
  63. package/package.json +8 -4
  64. package/server/index.js +38 -5
  65. package/server/lib/agent-tool/runtime.js +2 -19
  66. package/server/lib/changelog/update-notes.js +82 -0
  67. package/server/lib/changelog/update-notes.test.js +59 -0
  68. package/server/lib/dev-tunnel/DOCUMENTATION.md +13 -4
  69. package/server/lib/dev-tunnel/runtime.js +4 -4
  70. package/server/lib/dev-tunnel/tunnel.test.js +20 -0
  71. package/server/lib/fs/DOCUMENTATION.md +2 -0
  72. package/server/lib/fs/routes.js +32 -21
  73. package/server/lib/fs/routes.test.js +77 -0
  74. package/server/lib/git/DOCUMENTATION.md +3 -1
  75. package/server/lib/git/routes.js +16 -0
  76. package/server/lib/git/service.js +107 -17
  77. package/server/lib/git/service.test.js +171 -0
  78. package/server/lib/mcp-reconnect/DOCUMENTATION.md +55 -0
  79. package/server/lib/mcp-reconnect/runtime.js +122 -0
  80. package/server/lib/mcp-reconnect/runtime.test.js +140 -0
  81. package/server/lib/message-queue/DOCUMENTATION.md +158 -0
  82. package/server/lib/message-queue/runtime.js +870 -0
  83. package/server/lib/message-queue/runtime.test.js +482 -0
  84. package/server/lib/openchamber-sessions/routes.js +102 -0
  85. package/server/lib/openchamber-sessions/routes.test.js +88 -0
  86. package/server/lib/opencode/DOCUMENTATION.md +13 -5
  87. package/server/lib/opencode/config-paths.test.js +116 -0
  88. package/server/lib/opencode/core-routes.js +1 -0
  89. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  90. package/server/lib/opencode/input-history-scope.js +14 -0
  91. package/server/lib/opencode/managed-plugin-config.js +35 -0
  92. package/server/lib/opencode/openchamber-routes.test.js +2 -2
  93. package/server/lib/opencode/plugins.js +2 -2
  94. package/server/lib/opencode/providers.js +44 -2
  95. package/server/lib/opencode/providers.test.js +131 -4
  96. package/server/lib/opencode/routes.js +4 -4
  97. package/server/lib/opencode/server-startup-runtime.js +1 -1
  98. package/server/lib/opencode/settings-helpers.js +28 -0
  99. package/server/lib/opencode/settings-helpers.test.js +177 -0
  100. package/server/lib/opencode/settings-runtime.js +2 -2
  101. package/server/lib/opencode/settings-runtime.test.js +29 -0
  102. package/server/lib/opencode/shared.js +4 -1
  103. package/server/lib/opencode/shutdown-runtime.js +2 -0
  104. package/server/lib/opencode/skill-routes.js +2 -1
  105. package/server/lib/opencode/snippets.js +1 -2
  106. package/server/lib/package-manager.js +4 -29
  107. package/server/lib/package-manager.test.js +2 -2
  108. package/server/lib/quota/DOCUMENTATION.md +2 -1
  109. package/server/lib/quota/credentials/providers.js +4 -0
  110. package/server/lib/quota/credentials/store.js +1 -1
  111. package/server/lib/quota/credentials/store.test.js +4 -4
  112. package/server/lib/quota/providers/exe-dev.js +75 -0
  113. package/server/lib/quota/providers/exe-dev.test.js +47 -0
  114. package/server/lib/quota/providers/index.js +7 -0
  115. package/server/lib/quota/providers/opencode-go.js +1 -0
  116. package/server/lib/quota/providers/opencode-go.test.js +5 -1
  117. package/server/lib/quota/routes.js +2 -0
  118. package/server/lib/quota/utils/auth.js +1 -1
  119. package/server/lib/relay/DOCUMENTATION.md +1 -1
  120. package/server/lib/relay/host-client.test.js +9 -1
  121. package/server/lib/relay/tunnel-host.js +3 -1
  122. package/server/lib/relay/tunnel-host.test.js +9 -1
  123. package/server/lib/security/request-security.js +21 -6
  124. package/server/lib/security/request-security.test.js +42 -8
  125. package/server/lib/session-assist/runtime.js +1 -0
  126. package/server/lib/session-goal/DOCUMENTATION.md +20 -7
  127. package/server/lib/session-goal/create.js +3 -1
  128. package/server/lib/session-goal/runtime.js +77 -11
  129. package/server/lib/session-goal/runtime.test.js +426 -0
  130. package/server/lib/skills-catalog/DOCUMENTATION.md +1 -1
  131. package/server/lib/skills-catalog/install.js +3 -2
  132. package/server/lib/small-model/DOCUMENTATION.md +4 -0
  133. package/server/lib/small-model/call.js +8 -2
  134. package/server/lib/small-model/call.test.js +23 -0
  135. package/server/lib/small-model/index.js +2 -1
  136. package/server/lib/small-model/routes.js +2 -1
  137. package/server/lib/system-prompt/runtime.js +2 -21
  138. package/server/lib/terminal/DOCUMENTATION.md +11 -7
  139. package/server/lib/terminal/runtime.js +180 -26
  140. package/server/lib/terminal/runtime.test.js +553 -26
  141. package/server/lib/terminal/shells.js +17 -0
  142. package/server/lib/terminal/shells.test.js +32 -1
  143. package/server/lib/ui-auth/ui-auth.js +4 -1
  144. package/server/lib/ui-auth/ui-auth.test.js +16 -0
  145. package/server/lib/walkthrough/index.js +1 -0
  146. package/server/lib/walkthrough/routes.test.js +26 -5
  147. package/dist/assets/CommitSection-X-f2RgYr.js +0 -1
  148. package/dist/assets/FilesView-3ZYP72ki.js +0 -17
  149. package/dist/assets/GitView-CMuN7z2Z.js +0 -6
  150. package/dist/assets/LinearIssuesView-CQiUDU8b.js +0 -1
  151. package/dist/assets/MarkdownRendererImpl-BGQi2PLq.js +0 -12
  152. package/dist/assets/PlanView-6AUjzNQn.js +0 -5
  153. package/dist/assets/SettingsView-CHI8fFaK.js +0 -28
  154. package/dist/assets/ToolPartDiffPreview-DPAATNn6.js +0 -8
  155. package/dist/assets/index-L_ncV-6H.css +0 -1
  156. package/dist/assets/index-qES19dlJ.js +0 -7
  157. package/dist/assets/main-CYgSu6dx.js +0 -928
  158. package/dist/assets/pl-slULBF4R.js +0 -15
  159. package/dist/assets/renderMobileApp-BQ3Kdm-d.js +0 -25
  160. package/dist/assets/runtimeEndpointReset-CRGn1gVj.js +0 -16
  161. package/dist/assets/tr-CjEbw1Aw.js +0 -6
  162. package/dist/assets/useInlineCommentController-JsBboQwt.js +0 -2
  163. package/dist/assets/vendor-zod-BLfBSgBh.js +0 -39
@@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
17
17
  const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
18
18
  const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
19
19
  const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
20
+ const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
20
21
 
21
22
  let existingSessionMessages = [];
22
23
  let dispatchedUserMessageSeq = 0;
@@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
78
79
  fork: sessionForkMock,
79
80
  messages: sessionMessagesMock,
80
81
  command: sessionCommandMock,
82
+ update: sessionUpdateMock,
81
83
  },
82
84
  command: {
83
85
  list: commandListMock,
@@ -132,6 +134,92 @@ describe('openchamber session routes', () => {
132
134
  sessionCommandMock.mockResolvedValue({ data: {} });
133
135
  commandListMock.mockReset();
134
136
  commandListMock.mockResolvedValue({ data: [] });
137
+ sessionUpdateMock.mockReset();
138
+ sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } }));
139
+ });
140
+
141
+ describe('archiving a batch of sessions', () => {
142
+ it('archives every id against the resolved directory and returns the archived sessions', async () => {
143
+ const { app } = createApp();
144
+ const response = await request(app)
145
+ .post('/api/openchamber/sessions/archive')
146
+ .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
147
+ .expect(200);
148
+
149
+ expect(response.body.directory).toBe('/repo/app');
150
+ expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']);
151
+ expect(response.body.failedIds).toEqual([]);
152
+ expect(sessionUpdateMock).toHaveBeenCalledTimes(2);
153
+ expect(sessionUpdateMock).toHaveBeenCalledWith({
154
+ sessionID: 'ses_a',
155
+ directory: '/repo/app',
156
+ time: { archived: 1700 },
157
+ });
158
+ });
159
+
160
+ it('keeps archiving after a failed session and reports it as failed', async () => {
161
+ sessionUpdateMock.mockImplementation(async ({ sessionID }) => {
162
+ if (sessionID === 'ses_b') throw new Error('session.update failed');
163
+ return { data: { id: sessionID } };
164
+ });
165
+
166
+ const { app } = createApp();
167
+ const response = await request(app)
168
+ .post('/api/openchamber/sessions/archive')
169
+ .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] })
170
+ .expect(200);
171
+
172
+ expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
173
+ expect(response.body.failedIds).toEqual(['ses_b']);
174
+ });
175
+
176
+ it('reports a session the server did not confirm as failed instead of archived', async () => {
177
+ sessionUpdateMock.mockImplementation(async ({ sessionID }) => (
178
+ sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } }
179
+ ));
180
+
181
+ const { app } = createApp();
182
+ const response = await request(app)
183
+ .post('/api/openchamber/sessions/archive')
184
+ .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] })
185
+ .expect(200);
186
+
187
+ expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']);
188
+ expect(response.body.failedIds).toEqual(['ses_b']);
189
+ });
190
+
191
+ it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
192
+ const { app } = createApp();
193
+
194
+ await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400);
195
+ await request(app)
196
+ .post('/api/openchamber/sessions/archive')
197
+ .send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
198
+ .expect(400);
199
+ await request(app)
200
+ .post('/api/openchamber/sessions/archive')
201
+ .send({ directory: '/repo/app', ids: ['ses_a', ''] })
202
+ .expect(400);
203
+ await request(app)
204
+ .post('/api/openchamber/sessions/archive')
205
+ .send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 })
206
+ .expect(400);
207
+
208
+ expect(sessionUpdateMock).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('rejects a directory the runtime does not accept', async () => {
212
+ const { app } = createApp({
213
+ validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }),
214
+ });
215
+
216
+ await request(app)
217
+ .post('/api/openchamber/sessions/archive')
218
+ .send({ directory: '/elsewhere', ids: ['ses_a'] })
219
+ .expect(400);
220
+
221
+ expect(sessionUpdateMock).not.toHaveBeenCalled();
222
+ });
135
223
  });
136
224
 
137
225
  it('creates a session for a directory', async () => {
@@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru
34
34
  - `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
35
35
  - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
36
36
  - `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
37
+ - `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
38
+ - `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
37
39
  - `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
38
40
  - `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
39
41
  - `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
@@ -61,12 +63,12 @@ This module provides OpenCode server integration utilities for the web server ru
61
63
 
62
64
  ## Public exports (providers.js)
63
65
  - `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
64
- - `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
65
- - `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
66
+ - `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. The adapter may be OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages. Existing provider, option, and retained-model fields not managed by the form are preserved; omitted models, headers, and env credentials remain explicit removals. Updating a legacy `providers` entry migrates it to the canonical `provider` key. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
67
+ - `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, adapter allowlist `@ai-sdk/openai-compatible`/`@ai-sdk/openai`/`@ai-sdk/anthropic`, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
66
68
  - `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
67
69
 
68
70
  ## Public exports (shared.js)
69
- - `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
71
+ - `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants rooted at `$XDG_CONFIG_HOME/opencode` when `XDG_CONFIG_HOME` is non-empty, otherwise `~/.config/opencode`. These constants are evaluated when the module loads; no files are migrated. `OPENCODE_CONFIG` remains a separate explicit config-file path and is resolved at call time for the custom config layer; it does not replace the global config directory.
70
72
  - `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
71
73
  - `ensureDirs()`: Creates required OpenCode directories.
72
74
  - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit
130
132
  - `killProcessOnPort(port)`
131
133
 
132
134
  Managed OpenCode launch also merges the environment returned by the agent-tool
133
- runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
135
+ runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
136
+ plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
134
137
  be replaced by injected values. External OpenCode processes receive no
135
138
  OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
136
139
  spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
@@ -208,6 +211,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
208
211
  - `writeSettingsToDisk(settings)`
209
212
  - `persistSettings(changes)`
210
213
  - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
214
+ - Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
211
215
  - Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
212
216
 
213
217
  ## Public exports (settings-helpers.js)
@@ -417,7 +421,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
417
421
 
418
422
  ## Storage and configuration
419
423
  - Provider auth: `~/.local/share/opencode/auth.json`.
420
- - User config: `~/.config/opencode/opencode.json`.
424
+ - User config: `$XDG_CONFIG_HOME/opencode/opencode.json`, falling back to `~/.config/opencode/opencode.json` when unset or blank.
421
425
  - Project config: `<workingDirectory>/.opencode/opencode.json` or `opencode.json`.
422
426
  - Custom config: `OPENCODE_CONFIG` env var path.
423
427
  - Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars.
@@ -429,3 +433,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
429
433
  - Config merging follows priority: custom > project > user.
430
434
  - UI auth uses scrypt for password hashing with constant-time comparison.
431
435
  - Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback.
436
+
437
+ The behavior `GET /api/behavior/agents-md` response includes `path`, the effective
438
+ server-side filename, whether or not the file exists. Settings displays this
439
+ path without deriving a directory from the browser environment.
@@ -0,0 +1,116 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
7
+ const originalOpenCodeConfig = process.env.OPENCODE_CONFIG;
8
+
9
+ afterEach(() => {
10
+ if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
11
+ else process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
12
+ if (originalOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
13
+ else process.env.OPENCODE_CONFIG = originalOpenCodeConfig;
14
+ vi.resetModules();
15
+ });
16
+
17
+ async function loadOpenCodeModules() {
18
+ vi.resetModules();
19
+ return Promise.all([
20
+ import('./shared.js'),
21
+ import('./agents.js'),
22
+ import('./commands.js'),
23
+ import('./skills.js'),
24
+ import('./snippets.js'),
25
+ import('./plugins.js'),
26
+ import('./routes.js'),
27
+ ]);
28
+ }
29
+
30
+ describe('OpenCode global config paths', () => {
31
+ it('derives all shared constants from a non-empty XDG_CONFIG_HOME', async () => {
32
+ const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-xdg-'));
33
+ process.env.XDG_CONFIG_HOME = xdgConfigHome;
34
+
35
+ const [{ OPENCODE_CONFIG_DIR, AGENT_DIR, COMMAND_DIR, SKILL_DIR, CONFIG_FILE }] = await loadOpenCodeModules();
36
+ const configDir = path.join(xdgConfigHome, 'opencode');
37
+ expect(OPENCODE_CONFIG_DIR).toBe(configDir);
38
+ expect(AGENT_DIR).toBe(path.join(configDir, 'agents'));
39
+ expect(COMMAND_DIR).toBe(path.join(configDir, 'commands'));
40
+ expect(SKILL_DIR).toBe(path.join(configDir, 'skills'));
41
+ expect(CONFIG_FILE).toBe(path.join(configDir, 'config.json'));
42
+
43
+ fs.rmSync(xdgConfigHome, { recursive: true, force: true });
44
+ });
45
+
46
+ it.each([undefined, ' '])('falls back to ~/.config/opencode when XDG_CONFIG_HOME is %s', async (xdgConfigHome) => {
47
+ if (xdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
48
+ else process.env.XDG_CONFIG_HOME = xdgConfigHome;
49
+
50
+ const [{ OPENCODE_CONFIG_DIR }] = await loadOpenCodeModules();
51
+ expect(OPENCODE_CONFIG_DIR).toBe(path.join(os.homedir(), '.config', 'opencode'));
52
+ });
53
+
54
+ it('keeps global CRUD below XDG_CONFIG_HOME while project files stay in the project', async () => {
55
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-paths-'));
56
+ const xdgConfigHome = path.join(root, 'xdg');
57
+ const projectDir = path.join(root, 'project');
58
+ process.env.XDG_CONFIG_HOME = xdgConfigHome;
59
+ delete process.env.OPENCODE_CONFIG;
60
+
61
+ const [, agents, commands, skills, snippets, plugins] = await loadOpenCodeModules();
62
+ agents.createAgent('global-agent', { description: 'Global', prompt: 'Global prompt' }, projectDir, 'user');
63
+ commands.createCommand('global-command', { description: 'Global', template: 'Global template' }, projectDir, 'user');
64
+ skills.createSkill('global-skill', { description: 'Global', instructions: 'Global instructions' }, projectDir, 'user');
65
+ snippets.createSnippet('global-snippet', { content: 'Global snippet' }, projectDir, 'global');
66
+ plugins.createPluginEntry({ spec: 'global-plugin', scope: 'user' }, projectDir);
67
+
68
+ const configDir = path.join(xdgConfigHome, 'opencode');
69
+ expect(fs.existsSync(path.join(configDir, 'agents', 'global-agent.md'))).toBe(true);
70
+ expect(fs.existsSync(path.join(configDir, 'commands', 'global-command.md'))).toBe(true);
71
+ expect(fs.existsSync(path.join(configDir, 'skills', 'global-skill', 'SKILL.md'))).toBe(true);
72
+ expect(fs.existsSync(path.join(configDir, 'snippet', 'global-snippet.md'))).toBe(true);
73
+ expect(JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')).plugin).toEqual(['global-plugin']);
74
+
75
+ agents.createAgent('project-agent', { description: 'Project', prompt: 'Project prompt' }, projectDir, 'project');
76
+ commands.createCommand('project-command', { description: 'Project', template: 'Project template' }, projectDir, 'project');
77
+ skills.createSkill('project-skill', { description: 'Project', instructions: 'Project instructions' }, projectDir, 'project');
78
+
79
+ expect(fs.existsSync(path.join(projectDir, '.opencode', 'agents', 'project-agent.md'))).toBe(true);
80
+ expect(fs.existsSync(path.join(projectDir, '.opencode', 'commands', 'project-command.md'))).toBe(true);
81
+ expect(fs.existsSync(path.join(projectDir, '.opencode', 'skills', 'project-skill', 'SKILL.md'))).toBe(true);
82
+
83
+ fs.rmSync(root, { recursive: true, force: true });
84
+ });
85
+
86
+ it('writes global AGENTS.md below XDG_CONFIG_HOME', async () => {
87
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-agents-md-'));
88
+ process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
89
+ delete process.env.OPENCODE_CONFIG;
90
+
91
+ const [, , , , , , routes] = await loadOpenCodeModules();
92
+ const handlers = new Map();
93
+ const app = {
94
+ get(route, ...callbacks) { handlers.set(`GET ${route}`, callbacks.at(-1)); },
95
+ put(route, ...callbacks) { handlers.set(`PUT ${route}`, callbacks.at(-1)); },
96
+ post() {},
97
+ delete() {},
98
+ };
99
+ routes.registerOpenCodeRoutes(app, {});
100
+
101
+ const response = { json: vi.fn(), status: vi.fn(() => response) };
102
+ await handlers.get('GET /api/behavior/agents-md')({}, response);
103
+ expect(response.json).toHaveBeenLastCalledWith({
104
+ content: '', exists: false, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
105
+ });
106
+
107
+ await handlers.get('PUT /api/behavior/agents-md')({ body: { content: 'Global behavior' } }, response);
108
+
109
+ expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
110
+ await handlers.get('GET /api/behavior/agents-md')({}, response);
111
+ expect(response.json).toHaveBeenLastCalledWith({
112
+ content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
113
+ });
114
+ fs.rmSync(root, { recursive: true, force: true });
115
+ });
116
+ });
@@ -1065,6 +1065,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1065
1065
  req.path.startsWith('/api/push') ||
1066
1066
  req.path.startsWith('/api/notifications') ||
1067
1067
  req.path.startsWith('/api/permission-auto-accept') ||
1068
+ req.path.startsWith('/api/message-queue') ||
1068
1069
  req.path.startsWith('/api/provider') ||
1069
1070
  req.path.startsWith('/api/session-folders') ||
1070
1071
  req.path.startsWith('/api/small-model') ||
@@ -13,6 +13,7 @@ import { registerProjectContextRoutes } from '../project-context/routes.js';
13
13
  import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
14
14
  import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
15
15
  import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
16
+ import { registerMessageQueueRoutes } from '../message-queue/runtime.js';
16
17
  import { registerConfigEntityRoutes } from './config-entity-routes.js';
17
18
  import { registerSettingsUtilityRoutes } from './core-routes.js';
18
19
  import { registerProjectIconRoutes } from './project-icon-routes.js';
@@ -97,6 +98,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
97
98
  createFsSearchRuntime,
98
99
  openchamberDataDir,
99
100
  openchamberUserConfigRoot,
101
+ managedChatsRoot,
100
102
  normalizeDirectoryPath,
101
103
  resolveProjectDirectory,
102
104
  resolveOptionalProjectDirectory,
@@ -132,6 +134,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
132
134
  writeSseEvent,
133
135
  emitSessionCreatedEvent,
134
136
  permissionAutoAcceptRuntime,
137
+ messageQueueRuntime,
135
138
  } = routeDependencies;
136
139
 
137
140
  registerSettingsUtilityRoutes(app, {
@@ -141,6 +144,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
141
144
  });
142
145
 
143
146
  registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime);
147
+ registerMessageQueueRoutes(app, messageQueueRuntime);
144
148
 
145
149
  registerOpenCodeRoutes(app, {
146
150
  crypto,
@@ -329,6 +333,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
329
333
  buildAugmentedPath,
330
334
  resolveGitBinaryForSpawn,
331
335
  openchamberUserConfigRoot,
336
+ managedChatsRoot,
332
337
  });
333
338
  };
334
339
 
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_INPUT_HISTORY_SCOPE = 'session';
2
+ export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
3
+ const MIN_INPUT_HISTORY_LIMIT = 1;
4
+ const MAX_INPUT_HISTORY_LIMIT = 100;
5
+
6
+ export const isInputHistoryScope = (value) => (
7
+ value === 'global' || value === 'session'
8
+ );
9
+
10
+ export const isInputHistoryLimit = (value) => (
11
+ Number.isInteger(value)
12
+ && value >= MIN_INPUT_HISTORY_LIMIT
13
+ && value <= MAX_INPUT_HISTORY_LIMIT
14
+ );
@@ -0,0 +1,35 @@
1
+ import { parse as parseJsonc } from 'jsonc-parser';
2
+
3
+ const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
4
+
5
+ /**
6
+ * Append a managed OpenChamber plugin to the `plugin` list of an
7
+ * `OPENCODE_CONFIG_CONTENT` value.
8
+ *
9
+ * Existing entries are preserved; an earlier entry for the same URL is dropped
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
12
+ * merge so they compose in any order.
13
+ *
14
+ * @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
15
+ * @param {string} pluginUrl `file://` URL of the materialized plugin
16
+ * @param {string} purpose short noun phrase for the error message, e.g. "managed tool"
17
+ * @returns {string} the merged config as JSON
18
+ */
19
+ export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => {
20
+ const errors = [];
21
+ const text = (rawConfig ?? '').trim();
22
+ const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {};
23
+ if (errors.length > 0 || !isJsonObject(parsed)) {
24
+ throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`);
25
+ }
26
+ if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
27
+ throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`);
28
+ }
29
+ const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
30
+ parsed.plugin = [
31
+ ...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
32
+ pluginUrl,
33
+ ];
34
+ return JSON.stringify(parsed);
35
+ };
@@ -62,7 +62,7 @@ beforeEach(() => {
62
62
  packageManager: 'npm',
63
63
  });
64
64
  packageManager.getUpdateCommand.mockReturnValue(
65
- 'npm install -g @vantran-se/openchamber-web@latest --registry=https://npm.pkg.github.com',
65
+ 'npm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org',
66
66
  );
67
67
  });
68
68
 
@@ -133,7 +133,7 @@ describe('OpenChamber foreground update route', () => {
133
133
  '--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin',
134
134
  '/bin/sh',
135
135
  '-c',
136
- "set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://npm.pkg.github.com\nsystemctl --user restart 'openchamber@wsl.service'",
136
+ "set -eu\nnpm install -g @vantran-se/openchamber-web@latest --registry=https://registry.npmjs.org\nsystemctl --user restart 'openchamber@wsl.service'",
137
137
  ], {
138
138
  encoding: 'utf8',
139
139
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
- import os from 'os';
3
2
  import path from 'path';
4
3
  import {
5
4
  AGENT_SCOPE,
5
+ OPENCODE_CONFIG_DIR,
6
6
  readConfigFile,
7
7
  readConfigLayer,
8
8
  writeConfig,
@@ -71,7 +71,7 @@ function getActiveOpencodeConfigDir() {
71
71
  if (customConfigPath) {
72
72
  return path.dirname(path.resolve(customConfigPath));
73
73
  }
74
- return path.join(os.homedir(), '.config', 'opencode');
74
+ return OPENCODE_CONFIG_DIR;
75
75
  }
76
76
 
77
77
  function getActiveUserConfigPaths() {
@@ -152,6 +152,37 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
152
152
  return { ok: true, value: { providerId, config: normalized } };
153
153
  }
154
154
 
155
+ function mergeCustomProviderConfig(existingValue, normalizedConfig) {
156
+ const existing = isPlainObject(existingValue) ? existingValue : {};
157
+ const existingOptions = isPlainObject(existing.options) ? existing.options : {};
158
+ const normalizedOptions = isPlainObject(normalizedConfig.options) ? normalizedConfig.options : {};
159
+ const mergedOptions = { ...existingOptions, ...normalizedOptions };
160
+ if (!Object.prototype.hasOwnProperty.call(normalizedOptions, 'headers')) {
161
+ delete mergedOptions.headers;
162
+ }
163
+
164
+ const existingModels = isPlainObject(existing.models) ? existing.models : {};
165
+ const normalizedModels = isPlainObject(normalizedConfig.models) ? normalizedConfig.models : {};
166
+ const mergedModels = Object.fromEntries(
167
+ Object.entries(normalizedModels).map(([modelId, normalizedModel]) => {
168
+ const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {};
169
+ const nextModel = isPlainObject(normalizedModel) ? normalizedModel : {};
170
+ return [modelId, { ...existingModel, ...nextModel }];
171
+ }),
172
+ );
173
+
174
+ const merged = {
175
+ ...existing,
176
+ ...normalizedConfig,
177
+ options: mergedOptions,
178
+ models: mergedModels,
179
+ };
180
+ if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) {
181
+ delete merged.env;
182
+ }
183
+ return merged;
184
+ }
185
+
155
186
  /**
156
187
  * Persist (create or update) a custom provider block in OpenCode user/project/custom config.
157
188
  * Does not write secrets — API keys remain in auth.json via the OpenCode auth API.
@@ -183,8 +214,19 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
183
214
 
184
215
  const targetConfig = getConfigForPath(layers, targetPath);
185
216
  const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
186
- providerConfig[validated.value.providerId] = validated.value.config;
217
+ const providersAlias = isPlainObject(targetConfig.providers) ? { ...targetConfig.providers } : {};
218
+ const existingProvider = providerConfig[validated.value.providerId] ?? providersAlias[validated.value.providerId];
219
+ const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config);
220
+ providerConfig[validated.value.providerId] = mergedConfig;
187
221
  targetConfig.provider = providerConfig;
222
+ if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) {
223
+ delete providersAlias[validated.value.providerId];
224
+ if (Object.keys(providersAlias).length === 0) {
225
+ delete targetConfig.providers;
226
+ } else {
227
+ targetConfig.providers = providersAlias;
228
+ }
229
+ }
188
230
 
189
231
  if (Array.isArray(targetConfig.disabled_providers)) {
190
232
  targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
@@ -198,7 +240,7 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use
198
240
  return {
199
241
  providerId: validated.value.providerId,
200
242
  path: writePath,
201
- config: validated.value.config,
243
+ config: mergedConfig,
202
244
  };
203
245
  }
204
246
 
@@ -9,6 +9,7 @@ import {
9
9
  getProviderSources,
10
10
  removeProviderConfig,
11
11
  } from './providers.js';
12
+ import { OPENCODE_CONFIG_DIR } from './shared.js';
12
13
 
13
14
  let projectDir;
14
15
 
@@ -162,6 +163,132 @@ describe('custom provider config persistence', () => {
162
163
  expect(written.disabled_providers).toEqual(['other']);
163
164
  });
164
165
 
166
+ test('upsertProviderConfig preserves unmanaged provider and model metadata', () => {
167
+ const configPath = path.join(projectDir, 'opencode.json');
168
+ writeJson(configPath, {
169
+ provider: {
170
+ 'campus-llm': {
171
+ npm: '@ai-sdk/openai-compatible',
172
+ name: 'Old',
173
+ customProviderField: { owner: 'user' },
174
+ env: ['OLD_KEY'],
175
+ options: {
176
+ baseURL: 'https://old.example.edu/v1',
177
+ headers: { 'X-Old': '1' },
178
+ timeout: 45_000,
179
+ },
180
+ models: {
181
+ retained: {
182
+ name: 'Old retained name',
183
+ reasoning: true,
184
+ attachment: true,
185
+ tool_call: true,
186
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
187
+ limit: { context: 1_050_000, input: 922_000, output: 128_000 },
188
+ options: { instructions: 'Keep this instruction' },
189
+ variants: {
190
+ low: { reasoningEffort: 'low' },
191
+ high: { reasoningEffort: 'high' },
192
+ },
193
+ customModelField: { source: 'manual' },
194
+ },
195
+ removed: {
196
+ name: 'Remove me',
197
+ reasoning: true,
198
+ },
199
+ },
200
+ },
201
+ },
202
+ });
203
+
204
+ upsertProviderConfig('campus-llm', {
205
+ name: 'Campus LLM',
206
+ options: { baseURL: 'https://new.example.edu/v1' },
207
+ models: {
208
+ retained: { name: 'Retained model' },
209
+ added: { name: 'Added model' },
210
+ },
211
+ }, projectDir, 'project', { hasStoredAuth: true });
212
+
213
+ const written = readJson(configPath).provider['campus-llm'];
214
+ expect(written).toEqual({
215
+ npm: '@ai-sdk/openai-compatible',
216
+ name: 'Campus LLM',
217
+ customProviderField: { owner: 'user' },
218
+ options: {
219
+ baseURL: 'https://new.example.edu/v1',
220
+ timeout: 45_000,
221
+ },
222
+ models: {
223
+ retained: {
224
+ name: 'Retained model',
225
+ reasoning: true,
226
+ attachment: true,
227
+ tool_call: true,
228
+ modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
229
+ limit: { context: 1_050_000, input: 922_000, output: 128_000 },
230
+ options: { instructions: 'Keep this instruction' },
231
+ variants: {
232
+ low: { reasoningEffort: 'low' },
233
+ high: { reasoningEffort: 'high' },
234
+ },
235
+ customModelField: { source: 'manual' },
236
+ },
237
+ added: { name: 'Added model' },
238
+ },
239
+ });
240
+ });
241
+
242
+ test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => {
243
+ const configPath = path.join(projectDir, 'opencode.json');
244
+ writeJson(configPath, {
245
+ providers: {
246
+ legacy: {
247
+ name: 'Legacy provider',
248
+ options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 },
249
+ models: { model: { name: 'Old model', reasoning: true } },
250
+ },
251
+ },
252
+ });
253
+
254
+ upsertProviderConfig('legacy', {
255
+ name: 'Updated provider',
256
+ options: { baseURL: 'https://new.example.com/v1' },
257
+ models: { model: { name: 'Updated model' } },
258
+ }, projectDir, 'project', { hasStoredAuth: true });
259
+
260
+ const written = readJson(configPath);
261
+ expect(written.providers).toBeUndefined();
262
+ expect(written.provider.legacy).toEqual({
263
+ npm: '@ai-sdk/openai-compatible',
264
+ name: 'Updated provider',
265
+ options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 },
266
+ models: { model: { name: 'Updated model', reasoning: true } },
267
+ });
268
+ });
269
+
270
+ test('migrating one legacy providers entry keeps the other legacy entries', () => {
271
+ const configPath = path.join(projectDir, 'opencode.json');
272
+ writeJson(configPath, {
273
+ providers: {
274
+ legacy: { name: 'Legacy provider', options: { baseURL: 'https://old.example.com/v1' }, models: { model: { name: 'Old model' } } },
275
+ untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
276
+ },
277
+ });
278
+
279
+ upsertProviderConfig('legacy', {
280
+ name: 'Updated provider',
281
+ options: { baseURL: 'https://new.example.com/v1' },
282
+ models: { model: { name: 'Updated model' } },
283
+ }, projectDir, 'project', { hasStoredAuth: true });
284
+
285
+ const written = readJson(configPath);
286
+ expect(written.providers).toEqual({
287
+ untouched: { name: 'Untouched', options: { baseURL: 'https://other.example.com/v1' }, models: { model: { name: 'Other model' } } },
288
+ });
289
+ expect(written.provider.legacy.name).toBe('Updated provider');
290
+ });
291
+
165
292
  test('upsert then remove restores absence', () => {
166
293
  upsertProviderConfig('temp-provider', {
167
294
  name: 'Temp',
@@ -230,8 +357,8 @@ describe('custom provider config persistence', () => {
230
357
  expect(sources.sources.custom.exists).toBe(false);
231
358
 
232
359
  for (const userPath of [
233
- path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
234
- path.join(os.homedir(), '.config', 'opencode', 'config.json'),
360
+ path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
361
+ path.join(OPENCODE_CONFIG_DIR, 'config.json'),
235
362
  ]) {
236
363
  if (!fs.existsSync(userPath)) continue;
237
364
  const userConfig = readJson(userPath);
@@ -269,8 +396,8 @@ describe('custom provider config persistence', () => {
269
396
  expect(sources.sources.project.exists).toBe(false);
270
397
 
271
398
  for (const userPath of [
272
- path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
273
- path.join(os.homedir(), '.config', 'opencode', 'config.json'),
399
+ path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
400
+ path.join(OPENCODE_CONFIG_DIR, 'config.json'),
274
401
  ]) {
275
402
  if (!fs.existsSync(userPath)) continue;
276
403
  const userConfig = readJson(userPath);