@cloudcli-ai/cloudcli 1.32.0 → 1.33.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 (180) hide show
  1. package/README.de.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.tr.md +1 -1
  6. package/README.zh-CN.md +1 -1
  7. package/dist/api-docs.html +6 -7
  8. package/dist/assets/index-BsyL_xmI.js +1348 -0
  9. package/dist/assets/index-FSP9Hz7V.css +32 -0
  10. package/dist/index.html +2 -2
  11. package/dist/modelConstants.js +848 -0
  12. package/dist-server/server/claude-sdk.js +57 -34
  13. package/dist-server/server/claude-sdk.js.map +1 -1
  14. package/dist-server/server/cursor-cli.js +6 -3
  15. package/dist-server/server/cursor-cli.js.map +1 -1
  16. package/dist-server/server/gemini-cli.js +3 -1
  17. package/dist-server/server/gemini-cli.js.map +1 -1
  18. package/dist-server/server/gemini-response-handler.js +34 -0
  19. package/dist-server/server/gemini-response-handler.js.map +1 -1
  20. package/dist-server/server/index.js +234 -67
  21. package/dist-server/server/index.js.map +1 -1
  22. package/dist-server/server/modules/database/index.js +1 -0
  23. package/dist-server/server/modules/database/index.js.map +1 -1
  24. package/dist-server/server/modules/projects/services/project-management.service.js +1 -0
  25. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  26. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +4 -0
  27. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  28. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +28 -6
  29. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  30. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +146 -0
  31. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
  32. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  33. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  34. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
  35. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
  36. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
  37. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  38. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  39. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  40. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
  41. package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
  42. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
  43. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  44. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
  45. package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
  46. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
  47. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
  48. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
  49. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  50. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
  51. package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
  52. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
  53. package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
  54. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
  55. package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
  56. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
  57. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
  58. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
  59. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
  60. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
  61. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
  62. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
  63. package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
  64. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
  65. package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
  66. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  67. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  68. package/dist-server/server/modules/providers/provider.routes.js +42 -1
  69. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  70. package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
  71. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  72. package/dist-server/server/modules/providers/services/provider-models.service.js +226 -0
  73. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
  74. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
  75. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  76. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
  77. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  78. package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
  79. package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
  80. package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
  81. package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
  82. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
  83. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +262 -0
  84. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
  85. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +298 -0
  86. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
  87. package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
  88. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  89. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +12 -1
  90. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  91. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js +4 -4
  92. package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js.map +1 -1
  93. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +18 -3
  94. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  95. package/dist-server/server/modules/websocket/services/websocket-auth.service.js +6 -2
  96. package/dist-server/server/modules/websocket/services/websocket-auth.service.js.map +1 -1
  97. package/dist-server/server/modules/websocket/services/websocket-server.service.js +18 -0
  98. package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
  99. package/dist-server/server/openai-codex.js +32 -4
  100. package/dist-server/server/openai-codex.js.map +1 -1
  101. package/dist-server/server/opencode-cli.js +287 -0
  102. package/dist-server/server/opencode-cli.js.map +1 -0
  103. package/dist-server/server/opencode-cli.test.js +84 -0
  104. package/dist-server/server/opencode-cli.test.js.map +1 -0
  105. package/dist-server/server/routes/agent.js +21 -8
  106. package/dist-server/server/routes/agent.js.map +1 -1
  107. package/dist-server/server/routes/commands.js +202 -209
  108. package/dist-server/server/routes/commands.js.map +1 -1
  109. package/dist-server/server/routes/cursor.js +2 -2
  110. package/dist-server/server/routes/cursor.js.map +1 -1
  111. package/dist-server/server/routes/settings.js +0 -10
  112. package/dist-server/server/routes/settings.js.map +1 -1
  113. package/dist-server/server/routes/tests/commands.test.js +76 -0
  114. package/dist-server/server/routes/tests/commands.test.js.map +1 -0
  115. package/dist-server/server/shared/utils.js +286 -0
  116. package/dist-server/server/shared/utils.js.map +1 -1
  117. package/package.json +5 -2
  118. package/public/api-docs.html +878 -0
  119. package/public/modelConstants.js +848 -0
  120. package/server/claude-sdk.js +64 -35
  121. package/server/cursor-cli.js +6 -3
  122. package/server/gemini-cli.js +7 -1
  123. package/server/gemini-response-handler.js +38 -0
  124. package/server/index.js +259 -75
  125. package/server/modules/database/index.ts +1 -0
  126. package/server/modules/projects/services/project-management.service.ts +2 -0
  127. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
  128. package/server/modules/providers/README.md +11 -3
  129. package/server/modules/providers/list/claude/claude-auth.provider.ts +34 -6
  130. package/server/modules/providers/list/claude/claude-models.provider.ts +196 -0
  131. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  132. package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
  133. package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
  134. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  135. package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
  136. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
  137. package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
  138. package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
  139. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
  140. package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
  141. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
  142. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
  143. package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
  144. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +157 -0
  145. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
  146. package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
  147. package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
  148. package/server/modules/providers/provider.registry.ts +2 -0
  149. package/server/modules/providers/provider.routes.ts +62 -2
  150. package/server/modules/providers/services/mcp.service.ts +1 -12
  151. package/server/modules/providers/services/provider-models.service.ts +358 -0
  152. package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
  153. package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
  154. package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
  155. package/server/modules/providers/tests/mcp.test.ts +93 -6
  156. package/server/modules/providers/tests/opencode-models.test.ts +73 -0
  157. package/server/modules/providers/tests/opencode-sessions.test.ts +334 -0
  158. package/server/modules/providers/tests/provider-models.service.test.ts +349 -0
  159. package/server/modules/providers/tests/skills.test.ts +66 -0
  160. package/server/modules/websocket/services/chat-websocket.service.ts +15 -1
  161. package/server/modules/websocket/services/plugin-websocket-proxy.service.ts +4 -4
  162. package/server/modules/websocket/services/shell-websocket.service.ts +22 -2
  163. package/server/modules/websocket/services/websocket-auth.service.ts +7 -2
  164. package/server/modules/websocket/services/websocket-server.service.ts +18 -0
  165. package/server/openai-codex.js +41 -4
  166. package/server/opencode-cli.js +336 -0
  167. package/server/opencode-cli.test.js +95 -0
  168. package/server/routes/agent.js +22 -8
  169. package/server/routes/commands.js +254 -233
  170. package/server/routes/cursor.js +2 -2
  171. package/server/routes/settings.js +1 -10
  172. package/server/routes/tests/commands.test.js +82 -0
  173. package/server/shared/interfaces.ts +45 -0
  174. package/server/shared/types.ts +88 -1
  175. package/server/shared/utils.ts +384 -0
  176. package/dist/assets/index-AaSE7-3t.js +0 -1336
  177. package/dist/assets/index-CKaku72N.css +0 -32
  178. package/dist-server/shared/modelConstants.js +0 -99
  179. package/dist-server/shared/modelConstants.js.map +0 -1
  180. package/shared/modelConstants.js +0 -107
@@ -0,0 +1,349 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ createProviderModelsService,
9
+ PROVIDER_MODELS_CACHE_TTL_MS,
10
+ } from '@/modules/providers/services/provider-models.service.js';
11
+ import type {
12
+ ProviderChangeActiveModelInput,
13
+ LLMProvider,
14
+ ProviderCurrentActiveModel,
15
+ ProviderModelsDefinition,
16
+ ProviderSessionActiveModelChange,
17
+ } from '@/shared/types.js';
18
+ import { writeProviderSessionActiveModelChange } from '@/shared/utils.js';
19
+
20
+ const createModels = (value: string): ProviderModelsDefinition => ({
21
+ OPTIONS: [{ value, label: value }],
22
+ DEFAULT: value,
23
+ });
24
+
25
+ const createCurrentActiveModel = (model: string): ProviderCurrentActiveModel => ({
26
+ model,
27
+ });
28
+
29
+ const createSessionActiveModelChange = (
30
+ provider: LLMProvider,
31
+ input: ProviderChangeActiveModelInput,
32
+ ): ProviderSessionActiveModelChange => ({
33
+ provider,
34
+ sessionId: input.sessionId,
35
+ supported: true,
36
+ changed: true,
37
+ model: input.model,
38
+ });
39
+
40
+ const createEphemeralCachePath = (): string => path.join(
41
+ os.tmpdir(),
42
+ `provider-model-cache-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
43
+ );
44
+
45
+ test('provider models service delegates to the resolved provider model adapter', async () => {
46
+ const calls: LLMProvider[] = [];
47
+ const service = createProviderModelsService({
48
+ cachePath: createEphemeralCachePath(),
49
+ resolveProvider: (provider) => {
50
+ calls.push(provider);
51
+ return {
52
+ models: {
53
+ getSupportedModels: async () => createModels(`${provider}-models`),
54
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
55
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
56
+ },
57
+ };
58
+ },
59
+ });
60
+
61
+ const models = await service.getProviderModels('codex', { bypassCache: true });
62
+
63
+ assert.deepEqual(calls, ['codex']);
64
+ assert.equal(models.models.DEFAULT, 'codex-models');
65
+ assert.equal(models.cache.source, 'fresh');
66
+ });
67
+
68
+ test('provider models service returns each provider adapter result without rewriting it', async () => {
69
+ const expectedModels: ProviderModelsDefinition = {
70
+ OPTIONS: [
71
+ { value: 'cursor-a', label: 'Cursor A' },
72
+ { value: 'cursor-b', label: 'Cursor B' },
73
+ ],
74
+ DEFAULT: 'cursor-b',
75
+ };
76
+
77
+ const service = createProviderModelsService({
78
+ cachePath: createEphemeralCachePath(),
79
+ resolveProvider: () => ({
80
+ models: {
81
+ getSupportedModels: async () => expectedModels,
82
+ getCurrentActiveModel: async () => createCurrentActiveModel('cursor-active'),
83
+ changeActiveModel: async (input) => createSessionActiveModelChange('cursor', input),
84
+ },
85
+ }),
86
+ });
87
+
88
+ const models = await service.getProviderModels('cursor', { bypassCache: true });
89
+
90
+ assert.deepEqual(models.models, expectedModels);
91
+ });
92
+
93
+ test('provider models are cached for the three-day ttl', async () => {
94
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-ttl-'));
95
+ let currentTime = 1_000;
96
+ let loadCount = 0;
97
+
98
+ try {
99
+ const service = createProviderModelsService({
100
+ cachePath: path.join(tempRoot, 'models-cache.json'),
101
+ now: () => currentTime,
102
+ resolveProvider: (provider) => ({
103
+ models: {
104
+ getSupportedModels: async () => {
105
+ loadCount += 1;
106
+ return createModels(`${provider}-${loadCount}`);
107
+ },
108
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
109
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
110
+ },
111
+ }),
112
+ });
113
+
114
+ const first = await service.getProviderModels('codex');
115
+ const cached = await service.getProviderModels('codex');
116
+ assert.equal(loadCount, 1);
117
+ assert.equal(cached.models.DEFAULT, first.models.DEFAULT);
118
+ assert.equal(cached.cache.source, 'memory');
119
+
120
+ currentTime += PROVIDER_MODELS_CACHE_TTL_MS - 1;
121
+ await service.getProviderModels('codex');
122
+ assert.equal(loadCount, 1);
123
+
124
+ currentTime += 2;
125
+ const refreshed = await service.getProviderModels('codex');
126
+ assert.equal(loadCount, 2);
127
+ assert.equal(refreshed.models.DEFAULT, 'codex-2');
128
+ } finally {
129
+ await rm(tempRoot, { recursive: true, force: true });
130
+ }
131
+ });
132
+
133
+ test('claude provider models are always loaded directly from the provider', async () => {
134
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
135
+ let loadCount = 0;
136
+
137
+ try {
138
+ const service = createProviderModelsService({
139
+ cachePath: path.join(tempRoot, 'models-cache.json'),
140
+ resolveProvider: (provider) => ({
141
+ models: {
142
+ getSupportedModels: async () => {
143
+ loadCount += 1;
144
+ return createModels(`${provider}-${loadCount}`);
145
+ },
146
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
147
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
148
+ },
149
+ }),
150
+ });
151
+
152
+ const first = await service.getProviderModels('claude');
153
+ const second = await service.getProviderModels('claude');
154
+
155
+ assert.equal(loadCount, 2);
156
+ assert.equal(first.models.DEFAULT, 'claude-1');
157
+ assert.equal(second.models.DEFAULT, 'claude-2');
158
+ assert.equal(second.cache.source, 'fresh');
159
+ } finally {
160
+ await rm(tempRoot, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test('provider model cache is persisted across service instances', async () => {
165
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
166
+ const cachePath = path.join(tempRoot, 'models-cache.json');
167
+
168
+ try {
169
+ const writer = createProviderModelsService({
170
+ cachePath,
171
+ resolveProvider: () => ({
172
+ models: {
173
+ getSupportedModels: async () => createModels('gemini-cached'),
174
+ getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
175
+ changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
176
+ },
177
+ }),
178
+ });
179
+ await writer.getProviderModels('gemini');
180
+
181
+ const reader = createProviderModelsService({
182
+ cachePath,
183
+ resolveProvider: () => ({
184
+ models: {
185
+ getSupportedModels: async () => {
186
+ throw new Error('loader should not be called for persisted cache hits');
187
+ },
188
+ getCurrentActiveModel: async () => createCurrentActiveModel('gemini-active'),
189
+ changeActiveModel: async (input) => createSessionActiveModelChange('gemini', input),
190
+ },
191
+ }),
192
+ });
193
+ const models = await reader.getProviderModels('gemini');
194
+ assert.equal(models.models.DEFAULT, 'gemini-cached');
195
+ assert.equal(models.cache.source, 'disk');
196
+ } finally {
197
+ await rm(tempRoot, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ test('concurrent provider model requests share one load operation', async () => {
202
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-pending-'));
203
+ let loadCount = 0;
204
+
205
+ try {
206
+ const service = createProviderModelsService({
207
+ cachePath: path.join(tempRoot, 'models-cache.json'),
208
+ resolveProvider: () => ({
209
+ models: {
210
+ getSupportedModels: async () => {
211
+ loadCount += 1;
212
+ await new Promise((resolve) => setTimeout(resolve, 20));
213
+ return createModels('claude-cached');
214
+ },
215
+ getCurrentActiveModel: async () => createCurrentActiveModel('claude-active'),
216
+ changeActiveModel: async (input) => createSessionActiveModelChange('claude', input),
217
+ },
218
+ }),
219
+ });
220
+
221
+ const [first, second] = await Promise.all([
222
+ service.getProviderModels('claude'),
223
+ service.getProviderModels('claude'),
224
+ ]);
225
+
226
+ assert.equal(loadCount, 1);
227
+ assert.equal(first.models.DEFAULT, 'claude-cached');
228
+ assert.equal(second.models.DEFAULT, 'claude-cached');
229
+ } finally {
230
+ await rm(tempRoot, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test('bypassCache forces a fresh provider fetch and updates cache metadata', async () => {
235
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-refresh-'));
236
+ let currentTime = 1_000;
237
+ let loadCount = 0;
238
+
239
+ try {
240
+ const service = createProviderModelsService({
241
+ cachePath: path.join(tempRoot, 'models-cache.json'),
242
+ now: () => currentTime,
243
+ resolveProvider: (provider) => ({
244
+ models: {
245
+ getSupportedModels: async () => {
246
+ loadCount += 1;
247
+ return createModels(`${provider}-${loadCount}`);
248
+ },
249
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active-${loadCount}`),
250
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
251
+ },
252
+ }),
253
+ });
254
+
255
+ const first = await service.getProviderModels('claude');
256
+ currentTime += 50;
257
+ const refreshed = await service.getProviderModels('claude', { bypassCache: true });
258
+
259
+ assert.equal(first.models.DEFAULT, 'claude-1');
260
+ assert.equal(refreshed.models.DEFAULT, 'claude-2');
261
+ assert.equal(refreshed.cache.source, 'fresh');
262
+ assert.notEqual(refreshed.cache.updatedAt, first.cache.updatedAt);
263
+ assert.equal(loadCount, 2);
264
+ } finally {
265
+ await rm(tempRoot, { recursive: true, force: true });
266
+ }
267
+ });
268
+
269
+ test('provider models service delegates current active model lookups to the provider adapter', async () => {
270
+ const calls: Array<{ provider: LLMProvider; sessionId?: string }> = [];
271
+ const service = createProviderModelsService({
272
+ resolveProvider: (provider) => ({
273
+ models: {
274
+ getSupportedModels: async () => createModels(`${provider}-models`),
275
+ getCurrentActiveModel: async (sessionId) => {
276
+ calls.push({ provider, sessionId });
277
+ return createCurrentActiveModel(`${provider}-${sessionId}`);
278
+ },
279
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
280
+ },
281
+ }),
282
+ });
283
+
284
+ const activeModel = await service.getCurrentActiveModel('opencode', 'session-123');
285
+
286
+ assert.deepEqual(calls, [{ provider: 'opencode', sessionId: 'session-123' }]);
287
+ assert.equal(activeModel.model, 'opencode-session-123');
288
+ });
289
+
290
+ test('provider models service delegates active model change requests to the provider adapter', async () => {
291
+ const calls: Array<{ provider: LLMProvider; input: ProviderChangeActiveModelInput }> = [];
292
+ const service = createProviderModelsService({
293
+ resolveProvider: (provider) => ({
294
+ models: {
295
+ getSupportedModels: async () => createModels(`${provider}-models`),
296
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
297
+ changeActiveModel: async (input) => {
298
+ calls.push({ provider, input });
299
+ return createSessionActiveModelChange(provider, input);
300
+ },
301
+ },
302
+ }),
303
+ });
304
+
305
+ const changedModel = await service.changeActiveModel('claude', {
306
+ sessionId: 'session-123',
307
+ model: 'opus',
308
+ });
309
+
310
+ assert.deepEqual(calls, [{
311
+ provider: 'claude',
312
+ input: {
313
+ sessionId: 'session-123',
314
+ model: 'opus',
315
+ },
316
+ }]);
317
+ assert.equal(changedModel.changed, true);
318
+ assert.equal(changedModel.model, 'opus');
319
+ });
320
+
321
+ test('resolveResumeModel prefers a stored changed model over the requested one', async () => {
322
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-change-'));
323
+ const activeModelChangesPath = path.join(tempRoot, 'session-model-changes.json');
324
+
325
+ try {
326
+ const service = createProviderModelsService({
327
+ activeModelChangesPath,
328
+ resolveProvider: (provider) => ({
329
+ models: {
330
+ getSupportedModels: async () => createModels(`${provider}-models`),
331
+ getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
332
+ changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
333
+ },
334
+ }),
335
+ });
336
+
337
+ await writeProviderSessionActiveModelChange('cursor', {
338
+ sessionId: 'session-456',
339
+ model: 'composer-2',
340
+ }, {
341
+ filePath: activeModelChangesPath,
342
+ });
343
+
344
+ const model = await service.resolveResumeModel('cursor', 'session-456', 'composer-2-fast');
345
+ assert.equal(model, 'composer-2');
346
+ } finally {
347
+ await rm(tempRoot, { recursive: true, force: true });
348
+ }
349
+ });
@@ -377,6 +377,72 @@ test('providerSkillsService lists codex repository, user, and system skills', {
377
377
  }
378
378
  });
379
379
 
380
+ /**
381
+ * This test covers OpenCode skill lookup across cwd-to-git-root project folders
382
+ * plus the global OpenCode/Claude/Agents compatibility locations.
383
+ */
384
+ test('providerSkillsService lists opencode project and user compatibility skills', { concurrency: false }, async () => {
385
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-skills-opencode-'));
386
+ const repoRoot = path.join(tempRoot, 'repo');
387
+ const workspacePath = path.join(repoRoot, 'packages', 'app');
388
+ await fs.mkdir(path.join(repoRoot, '.git'), { recursive: true });
389
+ await fs.mkdir(workspacePath, { recursive: true });
390
+
391
+ const restoreHomeDir = patchHomeDir(tempRoot);
392
+ try {
393
+ await writeSkill(
394
+ path.join(workspacePath, '.opencode', 'skills'),
395
+ 'opencode-cwd-dir',
396
+ 'opencode-cwd',
397
+ 'OpenCode cwd skill',
398
+ );
399
+ await writeSkill(
400
+ path.join(repoRoot, 'packages', '.claude', 'skills'),
401
+ 'opencode-claude-parent-dir',
402
+ 'opencode-claude-parent',
403
+ 'OpenCode Claude parent skill',
404
+ );
405
+ await writeSkill(
406
+ path.join(repoRoot, '.agents', 'skills'),
407
+ 'opencode-agents-root-dir',
408
+ 'opencode-agents-root',
409
+ 'OpenCode Agents root skill',
410
+ );
411
+ await writeSkill(
412
+ path.join(tempRoot, '.config', 'opencode', 'skills'),
413
+ 'opencode-user-dir',
414
+ 'opencode-user',
415
+ 'OpenCode user skill',
416
+ );
417
+ await writeSkill(
418
+ path.join(tempRoot, '.claude', 'skills'),
419
+ 'opencode-claude-user-dir',
420
+ 'opencode-claude-user',
421
+ 'OpenCode Claude user skill',
422
+ );
423
+ await writeSkill(
424
+ path.join(tempRoot, '.agents', 'skills'),
425
+ 'opencode-agents-user-dir',
426
+ 'opencode-agents-user',
427
+ 'OpenCode Agents user skill',
428
+ );
429
+
430
+ const skills = await providerSkillsService.listProviderSkills('opencode', { workspacePath });
431
+ const byName = new Map(skills.map((skill) => [skill.name, skill]));
432
+
433
+ assert.equal(byName.get('opencode-cwd')?.scope, 'project');
434
+ assert.equal(byName.get('opencode-claude-parent')?.scope, 'project');
435
+ assert.equal(byName.get('opencode-agents-root')?.scope, 'project');
436
+ assert.equal(byName.get('opencode-user')?.scope, 'user');
437
+ assert.equal(byName.get('opencode-claude-user')?.scope, 'user');
438
+ assert.equal(byName.get('opencode-agents-user')?.scope, 'user');
439
+ assert.equal(byName.get('opencode-cwd')?.command, '/opencode-cwd');
440
+ } finally {
441
+ restoreHomeDir();
442
+ await fs.rm(tempRoot, { recursive: true, force: true });
443
+ }
444
+ });
445
+
380
446
  /**
381
447
  * This test covers Gemini and Cursor skill directory rules, including shared
382
448
  * `.agents/skills` project support.
@@ -29,10 +29,12 @@ type ChatWebSocketDependencies = {
29
29
  spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
30
30
  queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
31
31
  spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
32
+ spawnOpenCode: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
32
33
  abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
33
34
  abortCursorSession: (sessionId: string) => boolean;
34
35
  abortCodexSession: (sessionId: string) => boolean;
35
36
  abortGeminiSession: (sessionId: string) => boolean;
37
+ abortOpenCodeSession: (sessionId: string) => boolean;
36
38
  resolveToolApproval: (
37
39
  requestId: string,
38
40
  payload: {
@@ -46,19 +48,21 @@ type ChatWebSocketDependencies = {
46
48
  isCursorSessionActive: (sessionId: string) => boolean;
47
49
  isCodexSessionActive: (sessionId: string) => boolean;
48
50
  isGeminiSessionActive: (sessionId: string) => boolean;
51
+ isOpenCodeSessionActive: (sessionId: string) => boolean;
49
52
  reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
50
53
  getPendingApprovalsForSession: (sessionId: string) => unknown[];
51
54
  getActiveClaudeSDKSessions: () => unknown;
52
55
  getActiveCursorSessions: () => unknown;
53
56
  getActiveCodexSessions: () => unknown;
54
57
  getActiveGeminiSessions: () => unknown;
58
+ getActiveOpenCodeSessions: () => unknown;
55
59
  };
56
60
 
57
61
  /**
58
62
  * Normalizes potentially invalid provider names coming from websocket payloads.
59
63
  */
60
64
  function readProvider(value: unknown): LLMProvider {
61
- if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
65
+ if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini' || value === 'opencode') {
62
66
  return value;
63
67
  }
64
68
 
@@ -134,6 +138,11 @@ export function handleChatConnection(
134
138
  return;
135
139
  }
136
140
 
141
+ if (messageType === 'opencode-command') {
142
+ await dependencies.spawnOpenCode(data.command ?? '', data.options, writer);
143
+ return;
144
+ }
145
+
137
146
  if (messageType === 'cursor-resume') {
138
147
  await dependencies.spawnCursor(
139
148
  '',
@@ -158,6 +167,8 @@ export function handleChatConnection(
158
167
  success = dependencies.abortCodexSession(sessionId);
159
168
  } else if (provider === 'gemini') {
160
169
  success = dependencies.abortGeminiSession(sessionId);
170
+ } else if (provider === 'opencode') {
171
+ success = dependencies.abortOpenCodeSession(sessionId);
161
172
  } else {
162
173
  success = await dependencies.abortClaudeSDKSession(sessionId);
163
174
  }
@@ -214,6 +225,8 @@ export function handleChatConnection(
214
225
  isActive = dependencies.isCodexSessionActive(sessionId);
215
226
  } else if (provider === 'gemini') {
216
227
  isActive = dependencies.isGeminiSessionActive(sessionId);
228
+ } else if (provider === 'opencode') {
229
+ isActive = dependencies.isOpenCodeSessionActive(sessionId);
217
230
  } else {
218
231
  isActive = dependencies.isClaudeSDKSessionActive(sessionId);
219
232
  if (isActive) {
@@ -251,6 +264,7 @@ export function handleChatConnection(
251
264
  cursor: dependencies.getActiveCursorSessions(),
252
265
  codex: dependencies.getActiveCodexSessions(),
253
266
  gemini: dependencies.getActiveGeminiSessions(),
267
+ opencode: dependencies.getActiveOpenCodeSessions(),
254
268
  },
255
269
  });
256
270
  }
@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
26
26
  console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
27
27
  });
28
28
 
29
- upstream.on('message', (data) => {
29
+ upstream.on('message', (data, isBinary) => {
30
30
  if (clientWs.readyState === WebSocket.OPEN) {
31
- clientWs.send(data);
31
+ clientWs.send(data, { binary: isBinary });
32
32
  }
33
33
  });
34
34
 
35
- clientWs.on('message', (data) => {
35
+ clientWs.on('message', (data, isBinary) => {
36
36
  if (upstream.readyState === WebSocket.OPEN) {
37
- upstream.send(data);
37
+ upstream.send(data, { binary: isBinary });
38
38
  }
39
39
  });
40
40
 
@@ -18,6 +18,7 @@ type ShellIncomingMessage = {
18
18
  provider?: string;
19
19
  initialCommand?: string;
20
20
  isPlainShell?: boolean;
21
+ forceRestart?: boolean;
21
22
  };
22
23
 
23
24
  type PtySessionEntry = {
@@ -136,6 +137,13 @@ function buildShellCommand(
136
137
  return command;
137
138
  }
138
139
 
140
+ if (provider === 'opencode') {
141
+ if (hasSession && sessionId) {
142
+ return `opencode --session "${sessionId}"`;
143
+ }
144
+ return initialCommand || 'opencode';
145
+ }
146
+
139
147
  const command = initialCommand || 'claude';
140
148
  if (hasSession && sessionId) {
141
149
  if (os.platform() === 'win32') {
@@ -173,6 +181,7 @@ export function handleShellConnection(
173
181
  const hasSession = readBoolean(data.hasSession);
174
182
  const provider = readString(data.provider, 'claude');
175
183
  const initialCommand = readString(data.initialCommand);
184
+ const forceRestart = readBoolean(data.forceRestart);
176
185
  const isPlainShell =
177
186
  readBoolean(data.isPlainShell) ||
178
187
  (!!initialCommand && !hasSession) ||
@@ -193,7 +202,7 @@ export function handleShellConnection(
193
202
  : '';
194
203
  ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
195
204
 
196
- if (isLoginCommand) {
205
+ if (isLoginCommand || forceRestart) {
197
206
  const oldSession = ptySessionsMap.get(ptySessionKey);
198
207
  if (oldSession) {
199
208
  if (oldSession.timeoutId) {
@@ -204,7 +213,8 @@ export function handleShellConnection(
204
213
  }
205
214
  }
206
215
 
207
- const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
216
+ const existingSession =
217
+ isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
208
218
  if (existingSession) {
209
219
  shellProcess = existingSession.pty;
210
220
  if (existingSession.timeoutId) {
@@ -361,6 +371,10 @@ export function handleShellConnection(
361
371
  }
362
372
 
363
373
  const session = ptySessionsMap.get(ptySessionKey);
374
+ if (session && session.pty !== shellProcess) {
375
+ return;
376
+ }
377
+
364
378
  if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
365
379
  session.ws.send(
366
380
  JSON.stringify({
@@ -389,6 +403,8 @@ export function handleShellConnection(
389
403
  ? 'Codex'
390
404
  : provider === 'gemini'
391
405
  ? 'Gemini'
406
+ : provider === 'opencode'
407
+ ? 'OpenCode'
392
408
  : 'Claude';
393
409
  welcomeMsg = hasSession
394
410
  ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
@@ -442,6 +458,10 @@ export function handleShellConnection(
442
458
 
443
459
  session.ws = null;
444
460
  session.timeoutId = setTimeout(() => {
461
+ if (ptySessionsMap.get(ptySessionKey as string) !== session) {
462
+ return;
463
+ }
464
+
445
465
  session.pty.kill();
446
466
  ptySessionsMap.delete(ptySessionKey as string);
447
467
  }, PTY_SESSION_TIMEOUT);
@@ -20,7 +20,13 @@ export function verifyWebSocketClient(
20
20
  dependencies: WebSocketAuthDependencies
21
21
  ): boolean {
22
22
  const request = info.req as AuthenticatedWebSocketRequest;
23
- console.log('WebSocket connection attempt to:', request.url);
23
+ const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
24
+ const loggedUrl = new URL(upgradeUrl);
25
+ if (loggedUrl.searchParams.has('token')) {
26
+ loggedUrl.searchParams.set('token', 'REDACTED');
27
+ }
28
+
29
+ console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
24
30
 
25
31
  // Platform mode: use the first DB user and skip token checks.
26
32
  if (dependencies.isPlatform) {
@@ -36,7 +42,6 @@ export function verifyWebSocketClient(
36
42
  }
37
43
 
38
44
  // OSS mode: read JWT from query string first, then Authorization header.
39
- const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
40
45
  const token =
41
46
  upgradeUrl.searchParams.get('token') ??
42
47
  request.headers.authorization?.split(' ')[1] ??
@@ -31,6 +31,24 @@ export function createWebSocketServer(
31
31
  });
32
32
 
33
33
  wss.on('connection', (ws, request) => {
34
+ // Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
35
+ // AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
36
+ // are silently torn down even when the UI is active, causing repeated
37
+ // reconnect cycles. ws library heartbeat is opt-in.
38
+ const HEARTBEAT_INTERVAL_MS = 30_000;
39
+ const heartbeat = setInterval(() => {
40
+ if (ws.readyState === ws.OPEN) {
41
+ try {
42
+ ws.ping();
43
+ } catch {
44
+ // socket may have been closed concurrently — interval will be cleared below
45
+ }
46
+ }
47
+ }, HEARTBEAT_INTERVAL_MS);
48
+ const stopHeartbeat = () => clearInterval(heartbeat);
49
+ ws.on('close', stopHeartbeat);
50
+ ws.on('error', stopHeartbeat);
51
+
34
52
  const incomingRequest = request as AuthenticatedWebSocketRequest;
35
53
  const url = incomingRequest.url ?? '/';
36
54
  const pathname = new URL(url, 'http://localhost').pathname;