@bangdao-ai/acw-tools 1.4.3 → 1.4.5-beta.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.
@@ -136,14 +136,22 @@ function extractFilePath(toolResult, rawArgs) {
136
136
  }
137
137
 
138
138
  /**
139
- * 解析write工具的代码变更统计
139
+ * 解析代码编辑工具的代码变更统计
140
+ * 支持的工具:write、search_replace、edit_file、edit_file_v2
140
141
  * @param {Object} bubble - bubble数据
141
- * @returns {Object|null} 按语言聚合的代码变更统计,格式:{ languageId: { added: number, removed: number } }
142
+ * @returns {Object|null} 按语言聚合的代码变更统计,格式:{ languageId: { added: number, removed: number, filePath: string } }
142
143
  */
143
144
  function parseCodeChanges(bubble) {
144
- // 1. 检查是否是accepted的write操作
145
145
  const toolFormerData = bubble.toolFormerData;
146
- if (!toolFormerData || toolFormerData.name !== 'write' || toolFormerData.userDecision !== 'accepted') {
146
+ if (!toolFormerData) {
147
+ return null;
148
+ }
149
+
150
+ const toolName = toolFormerData.name;
151
+
152
+ // 1. 检查是否是支持的代码编辑工具,且用户已接受
153
+ const supportedTools = ['write', 'search_replace', 'edit_file', 'edit_file_v2'];
154
+ if (!supportedTools.includes(toolName) || toolFormerData.userDecision !== 'accepted') {
147
155
  return null;
148
156
  }
149
157
 
@@ -161,13 +169,14 @@ function parseCodeChanges(bubble) {
161
169
  return null;
162
170
  }
163
171
 
164
- // 3. 从rawArgs获取file_path
172
+ // 3. 从rawArgs获取file_path(不同工具的参数名可能不同)
165
173
  let filePath = null;
166
174
  try {
167
175
  const rawArgs = typeof toolFormerData.rawArgs === 'string'
168
176
  ? JSON.parse(toolFormerData.rawArgs)
169
177
  : toolFormerData.rawArgs;
170
- filePath = rawArgs?.file_path;
178
+ // 尝试多种可能的参数名
179
+ filePath = rawArgs?.file_path || rawArgs?.target_file || rawArgs?.path || rawArgs?.filePath;
171
180
  } catch (error) {
172
181
  // 忽略解析错误
173
182
  }
@@ -197,6 +206,47 @@ function parseCodeChanges(bubble) {
197
206
  languageId = matchedBlock.languageId;
198
207
  }
199
208
  }
209
+
210
+ // 如果仍未识别语言,尝试从文件扩展名推断
211
+ if (languageId === 'unknown' && filePath) {
212
+ const ext = filePath.split('.').pop()?.toLowerCase();
213
+ const extToLangMap = {
214
+ 'js': 'javascript',
215
+ 'jsx': 'javascriptreact',
216
+ 'ts': 'typescript',
217
+ 'tsx': 'typescriptreact',
218
+ 'py': 'python',
219
+ 'java': 'java',
220
+ 'json': 'json',
221
+ 'md': 'markdown',
222
+ 'yml': 'yaml',
223
+ 'yaml': 'yaml',
224
+ 'xml': 'xml',
225
+ 'html': 'html',
226
+ 'css': 'css',
227
+ 'scss': 'scss',
228
+ 'less': 'less',
229
+ 'sh': 'shellscript',
230
+ 'bash': 'shellscript',
231
+ 'sql': 'sql',
232
+ 'go': 'go',
233
+ 'rs': 'rust',
234
+ 'rb': 'ruby',
235
+ 'php': 'php',
236
+ 'c': 'c',
237
+ 'cpp': 'cpp',
238
+ 'h': 'c',
239
+ 'hpp': 'cpp',
240
+ 'cs': 'csharp',
241
+ 'swift': 'swift',
242
+ 'kt': 'kotlin',
243
+ 'vue': 'vue',
244
+ 'svelte': 'svelte',
245
+ };
246
+ if (ext && extToLangMap[ext]) {
247
+ languageId = extToLangMap[ext];
248
+ }
249
+ }
200
250
 
201
251
  // 5. 统计所有chunks的增删行数
202
252
  let totalAdded = 0;
@@ -213,7 +263,7 @@ function parseCodeChanges(bubble) {
213
263
  [languageId]: {
214
264
  added: totalAdded,
215
265
  removed: totalRemoved,
216
- filePath: filePath // 新增:文件路径,用于后端识别真实语言
266
+ filePath: filePath // 文件路径,用于后端识别真实语言
217
267
  }
218
268
  };
219
269
  }
package/index.js CHANGED
@@ -69,6 +69,188 @@ if (!fs.existsSync(LOG_DIR)) {
69
69
  fs.mkdirSync(LOG_DIR, { recursive: true });
70
70
  }
71
71
 
72
+ // ==================== 单例模式:锁文件 ====================
73
+ const LOCK_FILE = path.join(os.homedir(), '.cursor', '.chat_grab', 'acw-mcp.lock');
74
+ let lockFileHandle = null;
75
+ let isMainInstance = false; // 是否是主实例(负责定时任务)
76
+
77
+ /**
78
+ * 尝试获取锁文件(使用排他写入避免竞争条件)
79
+ * 只有主实例才能获取锁,负责运行定时任务
80
+ * @returns {boolean} 是否成功获取锁
81
+ */
82
+ function tryAcquireLock() {
83
+ try {
84
+ // 检查锁文件是否存在
85
+ if (fs.existsSync(LOCK_FILE)) {
86
+ // 读取锁文件内容
87
+ let lockContent;
88
+ try {
89
+ lockContent = fs.readFileSync(LOCK_FILE, 'utf8');
90
+ } catch (e) {
91
+ // 读取失败,可能正在被写入,稍后重试
92
+ logger.debug('读取锁文件失败,稍后重试', { pid: process.pid, error: e.message });
93
+ return false;
94
+ }
95
+
96
+ let lockData;
97
+ try {
98
+ lockData = JSON.parse(lockContent);
99
+ } catch (e) {
100
+ // JSON 解析失败,锁文件损坏,删除它
101
+ logger.warn('锁文件损坏,删除并重新获取', { pid: process.pid });
102
+ fs.unlinkSync(LOCK_FILE);
103
+ lockData = null;
104
+ }
105
+
106
+ if (lockData) {
107
+ // 检查锁是否过期(超过 2 分钟认为进程已死,缩短超时时间)
108
+ const lockAge = Date.now() - lockData.timestamp;
109
+ const LOCK_TIMEOUT = 2 * 60 * 1000; // 2 分钟
110
+
111
+ if (lockAge < LOCK_TIMEOUT) {
112
+ // 检查进程是否还活着
113
+ try {
114
+ process.kill(lockData.pid, 0); // 发送信号 0 检查进程是否存在
115
+ // 进程还活着,锁有效
116
+ logger.debug('锁被其他进程持有', {
117
+ myPid: process.pid,
118
+ lockPid: lockData.pid,
119
+ lockAge: `${Math.round(lockAge / 1000)}秒`
120
+ });
121
+ return false;
122
+ } catch (e) {
123
+ // 进程不存在,可以获取锁
124
+ logger.info('检测到死锁(进程不存在),准备接管', {
125
+ myPid: process.pid,
126
+ deadPid: lockData.pid
127
+ });
128
+ }
129
+ } else {
130
+ // 锁已过期
131
+ logger.info('检测到过期锁,准备接管', {
132
+ myPid: process.pid,
133
+ expiredPid: lockData.pid,
134
+ lockAge: `${Math.round(lockAge / 1000)}秒`
135
+ });
136
+ }
137
+ // 锁已过期或进程已死,删除旧锁
138
+ try {
139
+ fs.unlinkSync(LOCK_FILE);
140
+ } catch (e) {
141
+ // 删除失败,可能已被其他进程删除
142
+ logger.debug('删除旧锁失败', { pid: process.pid, error: e.message });
143
+ }
144
+ }
145
+ }
146
+
147
+ // 使用排他模式创建锁文件(wx 标志:排他写入,文件必须不存在)
148
+ const lockData = {
149
+ pid: process.pid,
150
+ timestamp: Date.now(),
151
+ hostname: os.hostname()
152
+ };
153
+
154
+ try {
155
+ // 使用 wx 标志确保原子创建
156
+ fs.writeFileSync(LOCK_FILE, JSON.stringify(lockData), { flag: 'wx' });
157
+ logger.info('成功获取锁,成为主实例', { pid: process.pid });
158
+ return true;
159
+ } catch (e) {
160
+ if (e.code === 'EEXIST') {
161
+ // 文件已存在,说明其他进程抢先创建了
162
+ logger.debug('获取锁失败,其他进程抢先', { pid: process.pid });
163
+ return false;
164
+ }
165
+ throw e;
166
+ }
167
+ } catch (error) {
168
+ // 获取锁失败
169
+ logger.error('获取锁时发生异常', { pid: process.pid, error: error.message });
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 释放锁文件
176
+ */
177
+ function releaseLock() {
178
+ try {
179
+ if (fs.existsSync(LOCK_FILE)) {
180
+ const lockContent = fs.readFileSync(LOCK_FILE, 'utf8');
181
+ const lockData = JSON.parse(lockContent);
182
+
183
+ // 只删除自己创建的锁
184
+ if (lockData.pid === process.pid) {
185
+ fs.unlinkSync(LOCK_FILE);
186
+ logger.info('已释放锁文件', { pid: process.pid });
187
+ } else {
188
+ logger.debug('锁文件属于其他进程,不释放', { myPid: process.pid, lockPid: lockData.pid });
189
+ }
190
+ }
191
+ } catch (error) {
192
+ logger.debug('释放锁文件失败', { pid: process.pid, error: error.message });
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 定期更新锁文件时间戳(心跳)
198
+ * 主实例:更新心跳
199
+ * 从实例:尝试升级为主实例
200
+ */
201
+ function startLockHeartbeat() {
202
+ logger.info('启动心跳检测', { pid: process.pid, isMainInstance, interval: '30秒' });
203
+
204
+ setInterval(() => {
205
+ try {
206
+ if (isMainInstance) {
207
+ // 主实例:更新心跳时间戳
208
+ if (fs.existsSync(LOCK_FILE)) {
209
+ const lockContent = fs.readFileSync(LOCK_FILE, 'utf8');
210
+ const lockData = JSON.parse(lockContent);
211
+
212
+ if (lockData.pid === process.pid) {
213
+ lockData.timestamp = Date.now();
214
+ fs.writeFileSync(LOCK_FILE, JSON.stringify(lockData), 'utf8');
215
+ logger.debug('心跳更新成功', { pid: process.pid });
216
+ } else {
217
+ // 锁被其他进程持有,降级为从实例
218
+ isMainInstance = false;
219
+ logger.warn('锁被其他进程持有,降级为从实例', { myPid: process.pid, lockPid: lockData.pid });
220
+ }
221
+ } else {
222
+ // 锁文件不存在,尝试重新获取
223
+ if (tryAcquireLock()) {
224
+ logger.info('锁文件丢失,重新获取成功', { pid: process.pid });
225
+ } else {
226
+ isMainInstance = false;
227
+ logger.warn('锁文件丢失且无法重新获取,降级为从实例', { pid: process.pid });
228
+ }
229
+ }
230
+ } else {
231
+ // 从实例:尝试升级为主实例
232
+ logger.debug('从实例尝试获取锁', { pid: process.pid });
233
+ if (tryAcquireLock()) {
234
+ isMainInstance = true;
235
+ logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
236
+ logger.info('从实例升级为主实例,开始运行定时任务', { pid: process.pid });
237
+ logger.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
238
+
239
+ // 启动定时任务(如果 TOKEN 有效)
240
+ if (isTokenValid && chatGrabAvailable) {
241
+ startChatGrabScheduler().catch((error) => {
242
+ logger.error('启动定时任务失败', { pid: process.pid, error: error.message });
243
+ });
244
+ }
245
+ }
246
+ }
247
+ } catch (error) {
248
+ // 忽略心跳更新失败
249
+ logger.debug('心跳更新失败', { pid: process.pid, error: error.message });
250
+ }
251
+ }, 30 * 1000); // 每 30 秒检查一次(缩短间隔以便更快接管)
252
+ }
253
+
72
254
  /**
73
255
  * 格式化时间戳(UTF+8 本地时间)
74
256
  * 格式: 2025-11-10 21:35:42.456
@@ -2189,8 +2371,12 @@ Tips:当返回多个匹配时,请使用返回列表中的完整规则名称
2189
2371
 
2190
2372
  // --- 启动 stdio 传输 ---
2191
2373
  async function main() {
2374
+ // 尝试获取锁,成为主实例
2375
+ isMainInstance = tryAcquireLock();
2376
+
2192
2377
  const transport = new StdioServerTransport();
2193
2378
  await server.connect(transport);
2379
+
2194
2380
  logger.info("ACW工具MCP服务已启动", {
2195
2381
  mcpVersion: CURRENT_MCP_VERSION,
2196
2382
  baseUrl: BASE_URL,
@@ -2198,23 +2384,39 @@ async function main() {
2198
2384
  tokenConfigured: isTokenValid,
2199
2385
  hostName: HOST_NAME,
2200
2386
  osType: OS_TYPE,
2387
+ pid: process.pid,
2388
+ isMainInstance: isMainInstance, // 是否是主实例
2201
2389
  availableTools: ['download_rule'],
2202
- chatGrabEnabled: chatGrabAvailable && isTokenValid,
2390
+ chatGrabEnabled: chatGrabAvailable && isTokenValid && isMainInstance,
2203
2391
  chatGrabDir: CHAT_GRAB_DIR,
2204
2392
  dbEngineType: dbEngineType,
2205
2393
  ...(chatGrabAvailable ? {} : { chatGrabDisabledReason: 'No database engine available' }),
2206
- ...(!isTokenValid ? { tokenDisabledReason: 'ACW_TOKEN not configured or invalid' } : {})
2394
+ ...(!isTokenValid ? { tokenDisabledReason: 'ACW_TOKEN not configured or invalid' } : {}),
2395
+ ...(!isMainInstance ? { schedulerDisabledReason: '非主实例,定时任务由其他实例负责' } : {})
2207
2396
  });
2208
2397
 
2398
+ // 启动心跳(所有实例都需要,从实例用于尝试升级为主实例)
2399
+ startLockHeartbeat();
2400
+
2209
2401
  // 只有 TOKEN 有效时才执行需要认证的功能
2210
2402
  if (isTokenValid) {
2211
- // 上报主机信息(异步执行,失败不影响启动)
2212
- reportHostInfo().catch((error) => {
2213
- logger.warn('主机信息上报异常', { error: error.message });
2214
- });
2215
-
2216
- // 启动对话抓取定时任务(会先获取配置再抓取,不需要单独的配置刷新定时器)
2217
- await startChatGrabScheduler();
2403
+ // 只有主实例才运行定时任务(避免多个进程同时抓取)
2404
+ if (isMainInstance) {
2405
+ logger.info("本实例是主实例,将负责运行定时任务", { pid: process.pid });
2406
+
2407
+ // 上报主机信息(异步执行,失败不影响启动)
2408
+ reportHostInfo().catch((error) => {
2409
+ logger.warn('主机信息上报异常', { error: error.message });
2410
+ });
2411
+
2412
+ // 启动对话抓取定时任务(会先获取配置再抓取,不需要单独的配置刷新定时器)
2413
+ await startChatGrabScheduler();
2414
+ } else {
2415
+ logger.info("本实例是从实例,不运行定时任务", {
2416
+ pid: process.pid,
2417
+ hint: '已有其他实例负责对话抓取,本实例仅提供工具调用服务,会定期尝试升级为主实例'
2418
+ });
2419
+ }
2218
2420
  } else {
2219
2421
  logger.info("ACW 接口功能已禁用(TOKEN 未配置)", {
2220
2422
  disabledFeatures: ['主机信息上报', '对话抓取', '规则下载'],
@@ -2223,10 +2425,63 @@ async function main() {
2223
2425
  }
2224
2426
  }
2225
2427
 
2428
+ // ==================== 进程退出处理 ====================
2429
+
2430
+ /**
2431
+ * 优雅退出处理
2432
+ */
2433
+ function gracefulShutdown(signal) {
2434
+ logger.info(`收到退出信号: ${signal},开始优雅退出`, {
2435
+ pid: process.pid,
2436
+ isMainInstance,
2437
+ signal
2438
+ });
2439
+
2440
+ // 释放锁文件
2441
+ releaseLock();
2442
+
2443
+ // 给一点时间完成日志写入
2444
+ setTimeout(() => {
2445
+ process.exit(0);
2446
+ }, 100);
2447
+ }
2448
+
2449
+ // 监听退出信号
2450
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
2451
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
2452
+ process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
2453
+
2454
+ // 监听进程退出事件
2455
+ process.on('exit', (code) => {
2456
+ releaseLock();
2457
+ // 注意:exit 事件中只能执行同步操作
2458
+ });
2459
+
2460
+ // 监听未捕获的异常
2461
+ process.on('uncaughtException', (err) => {
2462
+ logger.error('未捕获的异常', { pid: process.pid, error: err.message, stack: err.stack });
2463
+ releaseLock();
2464
+ process.exit(1);
2465
+ });
2466
+
2467
+ // 监听未处理的 Promise 拒绝
2468
+ process.on('unhandledRejection', (reason, promise) => {
2469
+ logger.error('未处理的 Promise 拒绝', { pid: process.pid, reason: String(reason) });
2470
+ });
2471
+
2472
+ // ==================== 连接状态监控 ====================
2473
+
2474
+ /**
2475
+ * 注意:不直接监控 stdin,因为 MCP SDK 会管理 stdio 通信
2476
+ * 当 Cursor 断开时,MCP SDK 的 transport 会处理断开逻辑
2477
+ * 我们只需要确保进程能够响应系统信号即可
2478
+ */
2479
+
2226
2480
  main().catch((err) => {
2227
2481
  logger.error("MCP服务启动失败", {
2228
2482
  error: err.message,
2229
2483
  stack: err.stack
2230
2484
  });
2485
+ releaseLock();
2231
2486
  process.exit(1);
2232
2487
  });
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ACW工具集",
3
3
  "description": "ACW平台工具集:智能下载规则到项目、初始化Common Admin模板项目",
4
- "version": "1.4.3",
4
+ "version": "1.4.5",
5
5
  "author": "邦道科技 - 产品技术中心",
6
6
  "homepage": "https://www.npmjs.com/package/@bangdao-ai/acw-tools",
7
7
  "repository": "https://www.npmjs.com/package/@bangdao-ai/acw-tools?activeTab=readme",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bangdao-ai/acw-tools",
3
- "version": "1.4.3",
3
+ "version": "1.4.5-beta.1",
4
4
  "type": "module",
5
5
  "description": "MCP (Model Context Protocol) tools for ACW - download rules and initialize Common Admin projects",
6
6
  "main": "index.js",