@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.
- package/README.de.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.tr.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/api-docs.html +6 -7
- package/dist/assets/index-BsyL_xmI.js +1348 -0
- package/dist/assets/index-FSP9Hz7V.css +32 -0
- package/dist/index.html +2 -2
- package/dist/modelConstants.js +848 -0
- package/dist-server/server/claude-sdk.js +57 -34
- package/dist-server/server/claude-sdk.js.map +1 -1
- package/dist-server/server/cursor-cli.js +6 -3
- package/dist-server/server/cursor-cli.js.map +1 -1
- package/dist-server/server/gemini-cli.js +3 -1
- package/dist-server/server/gemini-cli.js.map +1 -1
- package/dist-server/server/gemini-response-handler.js +34 -0
- package/dist-server/server/gemini-response-handler.js.map +1 -1
- package/dist-server/server/index.js +234 -67
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/database/index.js +1 -0
- package/dist-server/server/modules/database/index.js.map +1 -1
- package/dist-server/server/modules/projects/services/project-management.service.js +1 -0
- package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
- package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +4 -0
- package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +28 -6
- package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +146 -0
- package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
- package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +84 -0
- package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -39
- package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
- package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js +754 -0
- package/dist-server/server/modules/providers/list/cursor/cursor-models.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +2 -15
- package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/cursor/cursor.provider.js +2 -0
- package/dist-server/server/modules/providers/list/cursor/cursor.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +27 -0
- package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +3 -9
- package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/gemini/gemini.provider.js +2 -0
- package/dist-server/server/modules/providers/list/gemini/gemini.provider.js.map +1 -1
- package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js +92 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-auth.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js +181 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-mcp.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +267 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +115 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +410 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js +62 -0
- package/dist-server/server/modules/providers/list/opencode/opencode-skills.provider.js.map +1 -0
- package/dist-server/server/modules/providers/list/opencode/opencode.provider.js +19 -0
- package/dist-server/server/modules/providers/list/opencode/opencode.provider.js.map +1 -0
- package/dist-server/server/modules/providers/provider.registry.js +2 -0
- package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
- package/dist-server/server/modules/providers/provider.routes.js +42 -1
- package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
- package/dist-server/server/modules/providers/services/mcp.service.js +1 -9
- package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/provider-models.service.js +226 -0
- package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -0
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js +1 -0
- package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js +7 -0
- package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
- package/dist-server/server/modules/providers/shared/base/abstract.provider.js.map +1 -1
- package/dist-server/server/modules/providers/tests/mcp.test.js +73 -6
- package/dist-server/server/modules/providers/tests/mcp.test.js.map +1 -1
- package/dist-server/server/modules/providers/tests/opencode-models.test.js +66 -0
- package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +262 -0
- package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/provider-models.service.test.js +298 -0
- package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -0
- package/dist-server/server/modules/providers/tests/skills.test.js +33 -0
- package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js +12 -1
- package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js +4 -4
- package/dist-server/server/modules/websocket/services/plugin-websocket-proxy.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js +18 -3
- package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/websocket-auth.service.js +6 -2
- package/dist-server/server/modules/websocket/services/websocket-auth.service.js.map +1 -1
- package/dist-server/server/modules/websocket/services/websocket-server.service.js +18 -0
- package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
- package/dist-server/server/openai-codex.js +32 -4
- package/dist-server/server/openai-codex.js.map +1 -1
- package/dist-server/server/opencode-cli.js +287 -0
- package/dist-server/server/opencode-cli.js.map +1 -0
- package/dist-server/server/opencode-cli.test.js +84 -0
- package/dist-server/server/opencode-cli.test.js.map +1 -0
- package/dist-server/server/routes/agent.js +21 -8
- package/dist-server/server/routes/agent.js.map +1 -1
- package/dist-server/server/routes/commands.js +202 -209
- package/dist-server/server/routes/commands.js.map +1 -1
- package/dist-server/server/routes/cursor.js +2 -2
- package/dist-server/server/routes/cursor.js.map +1 -1
- package/dist-server/server/routes/settings.js +0 -10
- package/dist-server/server/routes/settings.js.map +1 -1
- package/dist-server/server/routes/tests/commands.test.js +76 -0
- package/dist-server/server/routes/tests/commands.test.js.map +1 -0
- package/dist-server/server/shared/utils.js +286 -0
- package/dist-server/server/shared/utils.js.map +1 -1
- package/package.json +5 -2
- package/public/api-docs.html +878 -0
- package/public/modelConstants.js +848 -0
- package/server/claude-sdk.js +64 -35
- package/server/cursor-cli.js +6 -3
- package/server/gemini-cli.js +7 -1
- package/server/gemini-response-handler.js +38 -0
- package/server/index.js +259 -75
- package/server/modules/database/index.ts +1 -0
- package/server/modules/projects/services/project-management.service.ts +2 -0
- package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +7 -1
- package/server/modules/providers/README.md +11 -3
- package/server/modules/providers/list/claude/claude-auth.provider.ts +34 -6
- package/server/modules/providers/list/claude/claude-models.provider.ts +196 -0
- package/server/modules/providers/list/claude/claude.provider.ts +3 -0
- package/server/modules/providers/list/codex/codex-models.provider.ts +125 -0
- package/server/modules/providers/list/codex/codex-skills.provider.ts +10 -50
- package/server/modules/providers/list/codex/codex.provider.ts +3 -0
- package/server/modules/providers/list/cursor/cursor-models.provider.ts +820 -0
- package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +7 -20
- package/server/modules/providers/list/cursor/cursor.provider.ts +3 -0
- package/server/modules/providers/list/gemini/gemini-models.provider.ts +42 -0
- package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +3 -10
- package/server/modules/providers/list/gemini/gemini.provider.ts +3 -0
- package/server/modules/providers/list/opencode/opencode-auth.provider.ts +111 -0
- package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +228 -0
- package/server/modules/providers/list/opencode/opencode-models.provider.ts +339 -0
- package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +157 -0
- package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +506 -0
- package/server/modules/providers/list/opencode/opencode-skills.provider.ts +78 -0
- package/server/modules/providers/list/opencode/opencode.provider.ts +27 -0
- package/server/modules/providers/provider.registry.ts +2 -0
- package/server/modules/providers/provider.routes.ts +62 -2
- package/server/modules/providers/services/mcp.service.ts +1 -12
- package/server/modules/providers/services/provider-models.service.ts +358 -0
- package/server/modules/providers/services/session-synchronizer.service.ts +1 -0
- package/server/modules/providers/services/sessions-watcher.service.ts +8 -0
- package/server/modules/providers/shared/base/abstract.provider.ts +2 -0
- package/server/modules/providers/tests/mcp.test.ts +93 -6
- package/server/modules/providers/tests/opencode-models.test.ts +73 -0
- package/server/modules/providers/tests/opencode-sessions.test.ts +334 -0
- package/server/modules/providers/tests/provider-models.service.test.ts +349 -0
- package/server/modules/providers/tests/skills.test.ts +66 -0
- package/server/modules/websocket/services/chat-websocket.service.ts +15 -1
- package/server/modules/websocket/services/plugin-websocket-proxy.service.ts +4 -4
- package/server/modules/websocket/services/shell-websocket.service.ts +22 -2
- package/server/modules/websocket/services/websocket-auth.service.ts +7 -2
- package/server/modules/websocket/services/websocket-server.service.ts +18 -0
- package/server/openai-codex.js +41 -4
- package/server/opencode-cli.js +336 -0
- package/server/opencode-cli.test.js +95 -0
- package/server/routes/agent.js +22 -8
- package/server/routes/commands.js +254 -233
- package/server/routes/cursor.js +2 -2
- package/server/routes/settings.js +1 -10
- package/server/routes/tests/commands.test.js +82 -0
- package/server/shared/interfaces.ts +45 -0
- package/server/shared/types.ts +88 -1
- package/server/shared/utils.ts +384 -0
- package/dist/assets/index-AaSE7-3t.js +0 -1336
- package/dist/assets/index-CKaku72N.css +0 -32
- package/dist-server/shared/modelConstants.js +0 -99
- package/dist-server/shared/modelConstants.js.map +0 -1
- 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 =
|
|
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
|
-
|
|
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;
|