@adversity/coding-tool-x 2.2.0

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 (125) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/LICENSE +21 -0
  3. package/README.md +404 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/index-D1AYlFLZ.js +3220 -0
  6. package/dist/web/assets/index-aL3cKxSK.css +41 -0
  7. package/dist/web/favicon.ico +0 -0
  8. package/dist/web/index.html +14 -0
  9. package/dist/web/logo.png +0 -0
  10. package/docs/CHANGELOG.md +582 -0
  11. package/docs/DIRECTORY_MIGRATION.md +112 -0
  12. package/docs/PROJECT_STRUCTURE.md +396 -0
  13. package/docs/bannel.png +0 -0
  14. package/docs/home.png +0 -0
  15. package/docs/logo.png +0 -0
  16. package/docs/multi-channel-load-balancing.md +249 -0
  17. package/package.json +73 -0
  18. package/src/commands/channels.js +504 -0
  19. package/src/commands/cli-type.js +99 -0
  20. package/src/commands/daemon.js +286 -0
  21. package/src/commands/doctor.js +332 -0
  22. package/src/commands/list.js +222 -0
  23. package/src/commands/logs.js +259 -0
  24. package/src/commands/port-config.js +115 -0
  25. package/src/commands/proxy-control.js +258 -0
  26. package/src/commands/proxy.js +152 -0
  27. package/src/commands/resume.js +137 -0
  28. package/src/commands/search.js +190 -0
  29. package/src/commands/stats.js +224 -0
  30. package/src/commands/switch.js +48 -0
  31. package/src/commands/toggle-proxy.js +222 -0
  32. package/src/commands/ui.js +92 -0
  33. package/src/commands/workspace.js +454 -0
  34. package/src/config/default.js +40 -0
  35. package/src/config/loader.js +75 -0
  36. package/src/config/paths.js +121 -0
  37. package/src/index.js +373 -0
  38. package/src/reset-config.js +92 -0
  39. package/src/server/api/agents.js +248 -0
  40. package/src/server/api/aliases.js +36 -0
  41. package/src/server/api/channels.js +258 -0
  42. package/src/server/api/claude-hooks.js +480 -0
  43. package/src/server/api/codex-channels.js +312 -0
  44. package/src/server/api/codex-projects.js +91 -0
  45. package/src/server/api/codex-proxy.js +182 -0
  46. package/src/server/api/codex-sessions.js +491 -0
  47. package/src/server/api/codex-statistics.js +57 -0
  48. package/src/server/api/commands.js +245 -0
  49. package/src/server/api/config-templates.js +182 -0
  50. package/src/server/api/config.js +147 -0
  51. package/src/server/api/convert.js +127 -0
  52. package/src/server/api/dashboard.js +125 -0
  53. package/src/server/api/env.js +144 -0
  54. package/src/server/api/favorites.js +77 -0
  55. package/src/server/api/gemini-channels.js +261 -0
  56. package/src/server/api/gemini-projects.js +91 -0
  57. package/src/server/api/gemini-proxy.js +160 -0
  58. package/src/server/api/gemini-sessions.js +397 -0
  59. package/src/server/api/gemini-statistics.js +57 -0
  60. package/src/server/api/health-check.js +118 -0
  61. package/src/server/api/mcp.js +336 -0
  62. package/src/server/api/pm2-autostart.js +269 -0
  63. package/src/server/api/projects.js +124 -0
  64. package/src/server/api/prompts.js +279 -0
  65. package/src/server/api/proxy.js +235 -0
  66. package/src/server/api/rules.js +271 -0
  67. package/src/server/api/sessions.js +595 -0
  68. package/src/server/api/settings.js +61 -0
  69. package/src/server/api/skills.js +305 -0
  70. package/src/server/api/statistics.js +91 -0
  71. package/src/server/api/terminal.js +202 -0
  72. package/src/server/api/ui-config.js +64 -0
  73. package/src/server/api/workspaces.js +407 -0
  74. package/src/server/codex-proxy-server.js +538 -0
  75. package/src/server/dev-server.js +26 -0
  76. package/src/server/gemini-proxy-server.js +518 -0
  77. package/src/server/index.js +305 -0
  78. package/src/server/proxy-server.js +469 -0
  79. package/src/server/services/agents-service.js +354 -0
  80. package/src/server/services/alias.js +71 -0
  81. package/src/server/services/channel-health.js +234 -0
  82. package/src/server/services/channel-scheduler.js +234 -0
  83. package/src/server/services/channels.js +347 -0
  84. package/src/server/services/codex-channels.js +625 -0
  85. package/src/server/services/codex-config.js +90 -0
  86. package/src/server/services/codex-parser.js +322 -0
  87. package/src/server/services/codex-sessions.js +665 -0
  88. package/src/server/services/codex-settings-manager.js +397 -0
  89. package/src/server/services/codex-speed-test-template.json +24 -0
  90. package/src/server/services/codex-statistics-service.js +255 -0
  91. package/src/server/services/commands-service.js +360 -0
  92. package/src/server/services/config-templates-service.js +732 -0
  93. package/src/server/services/env-checker.js +307 -0
  94. package/src/server/services/env-manager.js +300 -0
  95. package/src/server/services/favorites.js +163 -0
  96. package/src/server/services/gemini-channels.js +333 -0
  97. package/src/server/services/gemini-config.js +73 -0
  98. package/src/server/services/gemini-sessions.js +689 -0
  99. package/src/server/services/gemini-settings-manager.js +263 -0
  100. package/src/server/services/gemini-statistics-service.js +253 -0
  101. package/src/server/services/health-check.js +399 -0
  102. package/src/server/services/mcp-service.js +1188 -0
  103. package/src/server/services/prompts-service.js +492 -0
  104. package/src/server/services/proxy-runtime.js +79 -0
  105. package/src/server/services/pty-manager.js +435 -0
  106. package/src/server/services/rules-service.js +401 -0
  107. package/src/server/services/session-cache.js +127 -0
  108. package/src/server/services/session-converter.js +577 -0
  109. package/src/server/services/sessions.js +757 -0
  110. package/src/server/services/settings-manager.js +163 -0
  111. package/src/server/services/skill-service.js +965 -0
  112. package/src/server/services/speed-test.js +545 -0
  113. package/src/server/services/statistics-service.js +386 -0
  114. package/src/server/services/terminal-commands.js +155 -0
  115. package/src/server/services/terminal-config.js +140 -0
  116. package/src/server/services/terminal-detector.js +306 -0
  117. package/src/server/services/ui-config.js +130 -0
  118. package/src/server/services/workspace-service.js +662 -0
  119. package/src/server/utils/pricing.js +41 -0
  120. package/src/server/websocket-server.js +557 -0
  121. package/src/ui/menu.js +129 -0
  122. package/src/ui/prompts.js +100 -0
  123. package/src/utils/format.js +43 -0
  124. package/src/utils/port-helper.js +94 -0
  125. package/src/utils/session.js +239 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Rules 服务
3
+ *
4
+ * 管理 Claude Code 规则文件的 CRUD 操作
5
+ * 规则目录:
6
+ * - 用户级: ~/.claude/rules/
7
+ * - 项目级: .claude/rules/
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ // 规则目录路径
15
+ const USER_RULES_DIR = path.join(os.homedir(), '.claude', 'rules');
16
+
17
+ /**
18
+ * 确保目录存在
19
+ */
20
+ function ensureDir(dirPath) {
21
+ if (!fs.existsSync(dirPath)) {
22
+ fs.mkdirSync(dirPath, { recursive: true });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 解析 YAML frontmatter
28
+ */
29
+ function parseFrontmatter(content) {
30
+ const result = {
31
+ frontmatter: {},
32
+ body: content
33
+ };
34
+
35
+ // 移除 BOM
36
+ content = content.trim().replace(/^\uFEFF/, '');
37
+
38
+ // 解析 YAML frontmatter
39
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
40
+ if (!match) {
41
+ return result;
42
+ }
43
+
44
+ const frontmatterText = match[1];
45
+ result.body = match[2].trim();
46
+
47
+ // 简单解析 YAML(支持基本字段)
48
+ const lines = frontmatterText.split('\n');
49
+ for (const line of lines) {
50
+ const colonIndex = line.indexOf(':');
51
+ if (colonIndex === -1) continue;
52
+
53
+ const key = line.slice(0, colonIndex).trim();
54
+ let value = line.slice(colonIndex + 1).trim();
55
+
56
+ // 去除引号
57
+ if ((value.startsWith('"') && value.endsWith('"')) ||
58
+ (value.startsWith("'") && value.endsWith("'"))) {
59
+ value = value.slice(1, -1);
60
+ }
61
+
62
+ result.frontmatter[key] = value;
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * 生成 frontmatter 字符串
70
+ */
71
+ function generateFrontmatter(data) {
72
+ const lines = ['---'];
73
+
74
+ if (data.paths) {
75
+ lines.push(`paths: ${data.paths}`);
76
+ }
77
+
78
+ lines.push('---');
79
+ return lines.join('\n');
80
+ }
81
+
82
+ /**
83
+ * 递归扫描目录获取规则文件
84
+ */
85
+ function scanRulesDir(dir, basePath, scope) {
86
+ const rules = [];
87
+
88
+ if (!fs.existsSync(dir)) {
89
+ return rules;
90
+ }
91
+
92
+ try {
93
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
94
+
95
+ for (const entry of entries) {
96
+ const fullPath = path.join(dir, entry.name);
97
+
98
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
99
+ // 递归扫描子目录
100
+ const subRules = scanRulesDir(fullPath, basePath, scope);
101
+ rules.push(...subRules);
102
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
103
+ // 解析规则文件
104
+ try {
105
+ const content = fs.readFileSync(fullPath, 'utf-8');
106
+ const { frontmatter, body } = parseFrontmatter(content);
107
+
108
+ // 计算相对路径
109
+ const relativePath = path.relative(basePath, fullPath);
110
+ const fileName = entry.name.replace(/\.md$/, '');
111
+ const directory = path.dirname(relativePath);
112
+
113
+ rules.push({
114
+ name: fileName,
115
+ fileName,
116
+ directory: directory === '.' ? null : directory,
117
+ scope,
118
+ path: relativePath,
119
+ fullPath,
120
+ paths: frontmatter.paths || '', // 条件规则的路径模式
121
+ body,
122
+ fullContent: content,
123
+ updatedAt: fs.statSync(fullPath).mtime.getTime()
124
+ });
125
+ } catch (err) {
126
+ console.warn(`[RulesService] Failed to parse ${fullPath}:`, err.message);
127
+ }
128
+ }
129
+ }
130
+ } catch (err) {
131
+ console.error(`[RulesService] Failed to scan ${dir}:`, err.message);
132
+ }
133
+
134
+ return rules;
135
+ }
136
+
137
+ /**
138
+ * Rules 服务类
139
+ */
140
+ class RulesService {
141
+ constructor() {
142
+ this.userRulesDir = USER_RULES_DIR;
143
+ ensureDir(this.userRulesDir);
144
+ }
145
+
146
+ /**
147
+ * 获取所有规则列表
148
+ * @param {string} projectPath - 项目路径(可选,用于获取项目级规则)
149
+ */
150
+ listRules(projectPath = null) {
151
+ const rules = [];
152
+
153
+ // 获取用户级规则
154
+ const userRules = scanRulesDir(this.userRulesDir, this.userRulesDir, 'user');
155
+ rules.push(...userRules);
156
+
157
+ // 获取项目级规则(如果提供了项目路径)
158
+ if (projectPath) {
159
+ const projectRulesDir = path.join(projectPath, '.claude', 'rules');
160
+ const projectRules = scanRulesDir(projectRulesDir, projectRulesDir, 'project');
161
+ rules.push(...projectRules);
162
+ }
163
+
164
+ // 按路径排序
165
+ rules.sort((a, b) => a.path.toLowerCase().localeCompare(b.path.toLowerCase()));
166
+
167
+ return {
168
+ rules,
169
+ total: rules.length,
170
+ userCount: userRules.length,
171
+ projectCount: rules.length - userRules.length
172
+ };
173
+ }
174
+
175
+ /**
176
+ * 获取单个规则详情
177
+ */
178
+ getRule(relativePath, scope, projectPath = null) {
179
+ const baseDir = scope === 'user'
180
+ ? this.userRulesDir
181
+ : path.join(projectPath, '.claude', 'rules');
182
+
183
+ // 确保路径以 .md 结尾
184
+ const filePath = relativePath.endsWith('.md')
185
+ ? path.join(baseDir, relativePath)
186
+ : path.join(baseDir, `${relativePath}.md`);
187
+
188
+ if (!fs.existsSync(filePath)) {
189
+ return null;
190
+ }
191
+
192
+ const content = fs.readFileSync(filePath, 'utf-8');
193
+ const { frontmatter, body } = parseFrontmatter(content);
194
+
195
+ const actualRelativePath = path.relative(baseDir, filePath);
196
+ const fileName = path.basename(filePath, '.md');
197
+ const directory = path.dirname(actualRelativePath);
198
+
199
+ return {
200
+ name: fileName,
201
+ fileName,
202
+ directory: directory === '.' ? null : directory,
203
+ scope,
204
+ path: actualRelativePath,
205
+ fullPath: filePath,
206
+ paths: frontmatter.paths || '',
207
+ body,
208
+ fullContent: content,
209
+ updatedAt: fs.statSync(filePath).mtime.getTime()
210
+ };
211
+ }
212
+
213
+ /**
214
+ * 创建规则
215
+ */
216
+ createRule({ fileName, scope, projectPath, directory, paths, body }) {
217
+ if (!fileName || !fileName.trim()) {
218
+ throw new Error('规则文件名不能为空');
219
+ }
220
+
221
+ // 验证文件名:只允许字母、数字、横杠、下划线
222
+ if (!/^[a-zA-Z0-9_-]+$/.test(fileName)) {
223
+ throw new Error('规则文件名只能包含字母、数字、横杠和下划线');
224
+ }
225
+
226
+ const baseDir = scope === 'user'
227
+ ? this.userRulesDir
228
+ : path.join(projectPath, '.claude', 'rules');
229
+
230
+ const targetDir = directory ? path.join(baseDir, directory) : baseDir;
231
+ ensureDir(targetDir);
232
+
233
+ const filePath = path.join(targetDir, `${fileName}.md`);
234
+
235
+ // 检查是否已存在
236
+ if (fs.existsSync(filePath)) {
237
+ throw new Error(`规则 "${fileName}" 已存在`);
238
+ }
239
+
240
+ // 生成文件内容
241
+ let content = '';
242
+ if (paths) {
243
+ content = generateFrontmatter({ paths }) + '\n\n';
244
+ }
245
+ content += body || '';
246
+
247
+ fs.writeFileSync(filePath, content, 'utf-8');
248
+
249
+ const relativePath = directory
250
+ ? path.join(directory, `${fileName}.md`)
251
+ : `${fileName}.md`;
252
+
253
+ return this.getRule(relativePath, scope, projectPath);
254
+ }
255
+
256
+ /**
257
+ * 更新规则
258
+ */
259
+ updateRule({ relativePath, scope, projectPath, paths, body }) {
260
+ const baseDir = scope === 'user'
261
+ ? this.userRulesDir
262
+ : path.join(projectPath, '.claude', 'rules');
263
+
264
+ const filePath = path.join(baseDir, relativePath);
265
+
266
+ if (!fs.existsSync(filePath)) {
267
+ throw new Error(`规则 "${relativePath}" 不存在`);
268
+ }
269
+
270
+ // 生成文件内容
271
+ let content = '';
272
+ if (paths) {
273
+ content = generateFrontmatter({ paths }) + '\n\n';
274
+ }
275
+ content += body || '';
276
+
277
+ fs.writeFileSync(filePath, content, 'utf-8');
278
+
279
+ return this.getRule(relativePath, scope, projectPath);
280
+ }
281
+
282
+ /**
283
+ * 删除规则
284
+ */
285
+ deleteRule(relativePath, scope, projectPath = null) {
286
+ const baseDir = scope === 'user'
287
+ ? this.userRulesDir
288
+ : path.join(projectPath, '.claude', 'rules');
289
+
290
+ const filePath = path.join(baseDir, relativePath);
291
+
292
+ if (!fs.existsSync(filePath)) {
293
+ return { success: false, message: '规则不存在' };
294
+ }
295
+
296
+ fs.unlinkSync(filePath);
297
+
298
+ // 如果目录为空,删除目录
299
+ const directory = path.dirname(relativePath);
300
+ if (directory && directory !== '.') {
301
+ const dirPath = path.join(baseDir, directory);
302
+ try {
303
+ const remaining = fs.readdirSync(dirPath);
304
+ if (remaining.length === 0) {
305
+ fs.rmdirSync(dirPath);
306
+ }
307
+ } catch (err) {
308
+ // 忽略删除目录错误
309
+ }
310
+ }
311
+
312
+ return { success: true, message: '规则已删除' };
313
+ }
314
+
315
+ /**
316
+ * 获取目录结构(树形)
317
+ */
318
+ getDirectoryTree(projectPath = null) {
319
+ const tree = {
320
+ user: this.buildTree(this.userRulesDir),
321
+ project: null
322
+ };
323
+
324
+ if (projectPath) {
325
+ const projectRulesDir = path.join(projectPath, '.claude', 'rules');
326
+ tree.project = this.buildTree(projectRulesDir);
327
+ }
328
+
329
+ return tree;
330
+ }
331
+
332
+ /**
333
+ * 构建目录树
334
+ */
335
+ buildTree(dir) {
336
+ if (!fs.existsSync(dir)) {
337
+ return { directories: [], files: [] };
338
+ }
339
+
340
+ const result = {
341
+ directories: [],
342
+ files: []
343
+ };
344
+
345
+ try {
346
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
347
+
348
+ for (const entry of entries) {
349
+ if (entry.name.startsWith('.')) continue;
350
+
351
+ if (entry.isDirectory()) {
352
+ result.directories.push({
353
+ name: entry.name,
354
+ children: this.buildTree(path.join(dir, entry.name))
355
+ });
356
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
357
+ result.files.push(entry.name.replace(/\.md$/, ''));
358
+ }
359
+ }
360
+ } catch (err) {
361
+ console.error(`[RulesService] Failed to build tree for ${dir}:`, err.message);
362
+ }
363
+
364
+ return result;
365
+ }
366
+
367
+ /**
368
+ * 获取统计信息
369
+ */
370
+ getStats(projectPath = null) {
371
+ const { rules, userCount, projectCount } = this.listRules(projectPath);
372
+
373
+ // 按目录分组
374
+ const directories = {};
375
+ let conditionalCount = 0;
376
+
377
+ for (const rule of rules) {
378
+ const dir = rule.directory || '(root)';
379
+ if (!directories[dir]) {
380
+ directories[dir] = 0;
381
+ }
382
+ directories[dir]++;
383
+
384
+ if (rule.paths) {
385
+ conditionalCount++;
386
+ }
387
+ }
388
+
389
+ return {
390
+ total: rules.length,
391
+ userCount,
392
+ projectCount,
393
+ conditionalCount,
394
+ directories
395
+ };
396
+ }
397
+ }
398
+
399
+ module.exports = {
400
+ RulesService
401
+ };
@@ -0,0 +1,127 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const PROJECTS_CACHE_TTL = 30 * 1000; // 30s
6
+ const projectsCache = new Map();
7
+
8
+ const HAS_MESSAGES_CACHE_LIMIT = 50000;
9
+ const hasMessagesCache = new Map();
10
+ let hasMessagesPersisted = {};
11
+ let hasMessagesPersistTimer = null;
12
+
13
+ function getCcToolDir() {
14
+ return path.join(os.homedir(), '.claude', 'cc-tool');
15
+ }
16
+
17
+ function ensureDirExists(dir) {
18
+ if (!fs.existsSync(dir)) {
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ }
21
+ }
22
+
23
+ function getProjectsCacheKey(config) {
24
+ return config?.projectsDir || '__default__';
25
+ }
26
+
27
+ function getCachedProjects(config) {
28
+ const cacheEntry = projectsCache.get(getProjectsCacheKey(config));
29
+ if (!cacheEntry) return null;
30
+ if ((Date.now() - cacheEntry.timestamp) > PROJECTS_CACHE_TTL) {
31
+ projectsCache.delete(getProjectsCacheKey(config));
32
+ return null;
33
+ }
34
+ return cacheEntry.data;
35
+ }
36
+
37
+ function setCachedProjects(config, data) {
38
+ projectsCache.set(getProjectsCacheKey(config), {
39
+ data,
40
+ timestamp: Date.now()
41
+ });
42
+ }
43
+
44
+ function invalidateProjectsCache(configOrPath) {
45
+ if (!configOrPath) {
46
+ projectsCache.clear();
47
+ return;
48
+ }
49
+ const key = typeof configOrPath === 'string'
50
+ ? configOrPath
51
+ : getProjectsCacheKey(configOrPath);
52
+ projectsCache.delete(key);
53
+ }
54
+
55
+ const hasMessagesCacheFile = path.join(getCcToolDir(), 'session-has-cache.json');
56
+ loadHasMessagesCacheFromDisk();
57
+
58
+ function loadHasMessagesCacheFromDisk() {
59
+ try {
60
+ if (!fs.existsSync(hasMessagesCacheFile)) {
61
+ hasMessagesPersisted = {};
62
+ return;
63
+ }
64
+ const raw = fs.readFileSync(hasMessagesCacheFile, 'utf8');
65
+ const parsed = JSON.parse(raw) || {};
66
+ hasMessagesPersisted = parsed;
67
+ Object.entries(parsed).forEach(([filePath, entry]) => {
68
+ if (!entry || typeof entry !== 'object') return;
69
+ const { size, mtimeMs, value } = entry;
70
+ if (typeof size === 'number' && typeof mtimeMs === 'number' && typeof value === 'boolean') {
71
+ hasMessagesCache.set(`${filePath}:${size}:${mtimeMs}`, value);
72
+ }
73
+ });
74
+ } catch (err) {
75
+ hasMessagesPersisted = {};
76
+ }
77
+ }
78
+
79
+ function checkHasMessagesCache(filePath, stats) {
80
+ if (!filePath || !stats) return undefined;
81
+ const cacheKey = `${filePath}:${stats.size}:${stats.mtimeMs}`;
82
+ if (!hasMessagesCache.has(cacheKey)) {
83
+ return undefined;
84
+ }
85
+ return hasMessagesCache.get(cacheKey);
86
+ }
87
+
88
+ function rememberHasMessages(filePath, stats, value) {
89
+ if (!filePath || !stats) return;
90
+ const cacheKey = `${filePath}:${stats.size}:${stats.mtimeMs}`;
91
+ if (hasMessagesCache.size >= HAS_MESSAGES_CACHE_LIMIT) {
92
+ const firstKey = hasMessagesCache.keys().next().value;
93
+ if (firstKey) {
94
+ hasMessagesCache.delete(firstKey);
95
+ }
96
+ }
97
+ hasMessagesCache.set(cacheKey, value);
98
+
99
+ hasMessagesPersisted[filePath] = {
100
+ size: stats.size,
101
+ mtimeMs: stats.mtimeMs,
102
+ value
103
+ };
104
+ schedulePersistHasMessagesCache();
105
+ }
106
+
107
+ function schedulePersistHasMessagesCache() {
108
+ if (hasMessagesPersistTimer) return;
109
+ hasMessagesPersistTimer = setTimeout(() => {
110
+ try {
111
+ ensureDirExists(path.dirname(hasMessagesCacheFile));
112
+ fs.writeFileSync(hasMessagesCacheFile, JSON.stringify(hasMessagesPersisted, null, 2), 'utf8');
113
+ } catch (err) {
114
+ // ignore persistence errors
115
+ } finally {
116
+ hasMessagesPersistTimer = null;
117
+ }
118
+ }, 1000);
119
+ }
120
+
121
+ module.exports = {
122
+ getCachedProjects,
123
+ setCachedProjects,
124
+ invalidateProjectsCache,
125
+ checkHasMessagesCache,
126
+ rememberHasMessages
127
+ };