@vrs-soft/wecom-aibot-mcp 2.4.26 → 2.6.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.
@@ -225,6 +225,16 @@ function connectSSE(ccId) {
225
225
  const sseUrl = ccId ? `${MCP_URL}/sse/${ccId}?ccId=${ccId}` : `${MCP_URL}/sse`;
226
226
  logger.info('Connecting to SSE', { url: sseUrl, ccId, mcpServerReady: mcpServer ? 'yes' : 'no' });
227
227
  sseAbortController = new AbortController();
228
+ // Watchdog:每 15s 检查最后一次收到 chunk 的时间,>45s 无数据则主动 abort 触发重连。
229
+ // 修复 daemon 端 SSE keep-alive 单向失效问题(NAT 在 client→daemon 方向闭合时
230
+ // daemon 写心跳失败把 entry 清掉,但 channel-server 的 fetch read 永不返回)。
231
+ let watchdogTimer = null;
232
+ const clearWatchdog = () => {
233
+ if (watchdogTimer) {
234
+ clearInterval(watchdogTimer);
235
+ watchdogTimer = null;
236
+ }
237
+ };
228
238
  // SSE fetch 配置:添加 keep-alive headers 确保连接稳定
229
239
  fetch(sseUrl, {
230
240
  method: 'GET',
@@ -269,15 +279,26 @@ function connectSSE(ccId) {
269
279
  const decoder = new TextDecoder();
270
280
  let buffer = '';
271
281
  let messageCount = 0;
272
- // 添加心跳监控
273
- const heartbeatInterval = setInterval(() => {
274
- logChannel('SSE heartbeat', { connected: sseConnected, messages: messageCount });
275
- }, 30000);
282
+ let lastChunkAt = Date.now();
283
+ let currentEvent = 'message'; // SSE event type,由 `event: xxx` 行设置;空行复位
284
+ // Watchdog:>45s 没收到任何 chunk(含 daemon 端的 `: heartbeat` 注释)
285
+ // 视为单向 TCP 死链,主动 abort 让 catch 分支触发 reconnect。
286
+ watchdogTimer = setInterval(() => {
287
+ const idleMs = Date.now() - lastChunkAt;
288
+ logChannel('SSE watchdog', { connected: sseConnected, messages: messageCount, idleMs });
289
+ if (idleMs > 45000) {
290
+ logger.info('SSE 心跳超时(>45s 无数据),主动 abort 触发重连', { ccId, idleMs });
291
+ try {
292
+ sseAbortController?.abort();
293
+ }
294
+ catch { /* ignore */ }
295
+ }
296
+ }, 15000);
276
297
  while (true) {
277
298
  const { done, value } = await reader.read();
278
299
  if (done) {
279
300
  logChannel('SSE stream ended');
280
- clearInterval(heartbeatInterval);
301
+ clearWatchdog();
281
302
  sseConnected = false;
282
303
  // 非主动断开时自动重连
283
304
  if (!sseAbortController?.signal.aborted) {
@@ -286,6 +307,7 @@ function connectSSE(ccId) {
286
307
  }
287
308
  break;
288
309
  }
310
+ lastChunkAt = Date.now();
289
311
  const chunk = decoder.decode(value, { stream: true });
290
312
  logChannel('SSE chunk received', { bytes: chunk.length, preview: chunk.slice(0, 100) });
291
313
  buffer += chunk;
@@ -296,28 +318,49 @@ function connectSSE(ccId) {
296
318
  logChannel('SSE line', { line: line.slice(0, 80) });
297
319
  if (line.startsWith('data: ')) {
298
320
  const data = line.slice(6);
299
- logChannel('📩 SSE MESSAGE RECEIVED', { data: data.slice(0, 100) });
321
+ logChannel('📩 SSE MESSAGE RECEIVED', { data: data.slice(0, 100), event: currentEvent });
300
322
  try {
301
323
  const msg = JSON.parse(data);
302
324
  messageCount++;
303
- logChannel('✅ 消息解析成功', { messageNumber: messageCount, msg });
304
- // 推送 notifications/claude/channel 唤醒 Claude agent
325
+ logChannel('✅ 消息解析成功', { messageNumber: messageCount, event: currentEvent, msg });
305
326
  if (mcpServer) {
306
- // content 成为 <channel> 标签正文,meta 成为标签属性(只允许字母/数字/下划线)
307
- const message = msg.message || {};
308
- const notification = {
309
- method: 'notifications/claude/channel',
310
- params: {
311
- content: message.content || JSON.stringify(msg),
312
- meta: {
313
- from: message.from || '',
314
- chatid: message.chatid || '',
315
- chattype: message.chattype || 'single',
316
- cc_id: msg.ccId || '',
317
- quote_content: message.quoteContent || '',
327
+ let notification;
328
+ if (currentEvent === 'cc_message') {
329
+ // CC 间消息:用 cc:<fromCc> 作为 source 前缀,便于 agent 区分非 wecom 来源
330
+ notification = {
331
+ method: 'notifications/claude/channel',
332
+ params: {
333
+ content: msg.content || '',
334
+ meta: {
335
+ source: `cc:${msg.fromCc || ''}`,
336
+ from_cc: msg.fromCc || '',
337
+ to_cc: msg.toCc || '',
338
+ chattype: 'cc',
339
+ cc_id: msg.toCc || '',
340
+ kind: msg.kind || 'notify',
341
+ reply_to: msg.replyTo || '',
342
+ msg_id: msg.msgId || '',
343
+ },
344
+ },
345
+ };
346
+ }
347
+ else {
348
+ // 默认 wecom 消息(event: message 或无 event 头)
349
+ const message = msg.message || {};
350
+ notification = {
351
+ method: 'notifications/claude/channel',
352
+ params: {
353
+ content: message.content || JSON.stringify(msg),
354
+ meta: {
355
+ from: message.from || '',
356
+ chatid: message.chatid || '',
357
+ chattype: message.chattype || 'single',
358
+ cc_id: msg.ccId || '',
359
+ quote_content: message.quoteContent || '',
360
+ },
318
361
  },
319
- },
320
- };
362
+ };
363
+ }
321
364
  logChannel('📤 发送 notification', { notification });
322
365
  try {
323
366
  mcpServer.server.notification(notification);
@@ -336,10 +379,12 @@ function connectSSE(ccId) {
336
379
  }
337
380
  }
338
381
  else if (line.startsWith('event: ')) {
339
- logChannel('SSE event type', { type: line.slice(7) });
382
+ currentEvent = line.slice(7).trim();
383
+ logChannel('SSE event type', { type: currentEvent });
340
384
  }
341
385
  else if (line === '') {
342
- // 事件分隔符,忽略
386
+ // 事件分隔符:复位 event type 到默认 'message'
387
+ currentEvent = 'message';
343
388
  }
344
389
  else if (line.startsWith(':')) {
345
390
  // SSE 注释(如 ": heartbeat"),忽略,不要写回 buffer
@@ -350,13 +395,18 @@ function connectSSE(ccId) {
350
395
  }
351
396
  }
352
397
  }
353
- clearInterval(heartbeatInterval);
398
+ clearWatchdog();
354
399
  }).catch((err) => {
400
+ clearWatchdog();
355
401
  logger.error('SSE error', { error: String(err) });
356
402
  sseConnected = false;
357
- // 非主动断开时自动重连
358
- if (!sseAbortController?.signal.aborted) {
359
- logger.info('SSE 出错,3 秒后重连', { ccId });
403
+ // watchdog abort 或网络异常都走这里:触发 reconnect
404
+ // 注意 abort() 后 signal.aborted=true,但这是 watchdog 自己造成的,仍需要重连
405
+ const isWatchdogAbort = sseAbortController?.signal.aborted && String(err).includes('aborted');
406
+ if (!sseAbortController?.signal.aborted || isWatchdogAbort) {
407
+ logger.info('SSE 出错,3 秒后重连', { ccId, watchdogAbort: isWatchdogAbort });
408
+ // watchdog abort 后需要新建 controller,否则下次 connectSSE 会立即被 abort 状态干扰
409
+ sseAbortController = null;
360
410
  setTimeout(() => { httpSessionId = null; connectSSE(ccId); }, 3000);
361
411
  }
362
412
  });
@@ -402,6 +452,19 @@ function registerChannelTools(server) {
402
452
  return forwardToHttpMcp('check_connection', {});
403
453
  });
404
454
  // ============================================
455
+ // 工具 4a: CC 间通信 — send_to_cc / list_active_ccs(v2.6.0+)
456
+ // ============================================
457
+ server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通。', {
458
+ cc_id: z.string().describe('自己的 CC 标识'),
459
+ to_cc: z.string().describe('目标 CC 标识'),
460
+ content: z.string().describe('消息内容(支持 Markdown)'),
461
+ kind: z.enum(['request', 'reply', 'notify']).optional().default('notify').describe('消息语义'),
462
+ reply_to: z.string().optional().describe('可选:关联的请求 msgId'),
463
+ }, async (params) => forwardToHttpMcp('send_to_cc', params));
464
+ server.tool('list_active_ccs', '列出同一 daemon 上当前在线的所有 CC', {
465
+ cc_id: z.string().describe('自己的 CC 标识'),
466
+ }, async (params) => forwardToHttpMcp('list_active_ccs', params));
467
+ // ============================================
405
468
  // 工具 4: 获取待处理消息
406
469
  // ============================================
407
470
  server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
package/dist/client.d.ts CHANGED
@@ -46,6 +46,9 @@ declare class WecomClient extends EventEmitter {
46
46
  private reconnectAttempt;
47
47
  private lastDisconnectTime;
48
48
  private disconnectNotifyCount;
49
+ private daemonReconnectTimer;
50
+ private daemonReconnectAttempts;
51
+ private intentionallyDisconnected;
49
52
  constructor(botId: string, secret: string, targetUserId: string, robotName: string);
50
53
  getAuthUrl(): string;
51
54
  private setupEventHandlers;
@@ -54,6 +57,8 @@ declare class WecomClient extends EventEmitter {
54
57
  private replyApprovalResult;
55
58
  connect(): void;
56
59
  disconnect(): void;
60
+ private scheduleDaemonReconnect;
61
+ private clearDaemonReconnect;
57
62
  isConnected(): boolean;
58
63
  getDefaultTargetUser(): string;
59
64
  verifyTargetUser(userId?: string): Promise<{
package/dist/client.js CHANGED
@@ -56,6 +56,11 @@ class WecomClient extends EventEmitter {
56
56
  reconnectAttempt = 0; // 重连尝试次数
57
57
  lastDisconnectTime = 0; // 最后断线时间
58
58
  disconnectNotifyCount = 0; // 断线通知次数(最多1次)
59
+ // daemon-level 重连兜底:SDK 内部的 reconnect 偶尔会因被服务端踢断("New connection established")
60
+ // 而沉默卡住。下面这套是 daemon 层的 safety net,指数退避 5s/10s/30s/60s,最多 100 次。
61
+ daemonReconnectTimer = null;
62
+ daemonReconnectAttempts = 0;
63
+ intentionallyDisconnected = false;
59
64
  constructor(botId, secret, targetUserId, robotName) {
60
65
  super();
61
66
  this.botId = botId;
@@ -87,6 +92,7 @@ class WecomClient extends EventEmitter {
87
92
  this.wasReconnecting = false;
88
93
  this.reconnectAttempt = 0;
89
94
  this.disconnectNotifyCount = 0; // 重连成功后重置断线通知计数
95
+ this.clearDaemonReconnect(); // 重连成功后清理 daemon-level safety net
90
96
  logAuthenticated();
91
97
  // 重连成功后发送通知
92
98
  if (wasReconnecting) {
@@ -109,6 +115,10 @@ class WecomClient extends EventEmitter {
109
115
  logger.error('wecom', `发送断线通知失败: ${err}`);
110
116
  });
111
117
  }
118
+ // 兜底重连:SDK 重连失败时由 daemon 层接手
119
+ if (!this.intentionallyDisconnected) {
120
+ this.scheduleDaemonReconnect();
121
+ }
112
122
  });
113
123
  this.wsClient.on('reconnecting', (attempt) => {
114
124
  this.reconnectAttempt = attempt;
@@ -285,12 +295,51 @@ class WecomClient extends EventEmitter {
285
295
  }
286
296
  // 连接
287
297
  connect() {
298
+ this.intentionallyDisconnected = false;
288
299
  this.wsClient.connect();
289
300
  }
290
301
  // 断开
291
302
  disconnect() {
303
+ this.intentionallyDisconnected = true;
304
+ this.clearDaemonReconnect();
292
305
  this.wsClient.disconnect();
293
306
  }
307
+ // daemon-level 重连:SDK 内部 reconnect 卡住时由这里接手
308
+ // 指数退避:5s → 10s → 30s → 60s(封顶);最多 100 次
309
+ scheduleDaemonReconnect() {
310
+ if (this.daemonReconnectTimer)
311
+ return; // 已经在等待
312
+ if (this.daemonReconnectAttempts >= 100) {
313
+ logger.error('wecom', `[${this.robotName}] daemon-level reconnect 100 次仍未成功,放弃`);
314
+ return;
315
+ }
316
+ const backoff = [5000, 10000, 30000, 60000];
317
+ const delay = backoff[Math.min(this.daemonReconnectAttempts, backoff.length - 1)];
318
+ this.daemonReconnectTimer = setTimeout(() => {
319
+ this.daemonReconnectTimer = null;
320
+ if (this.connected || this.intentionallyDisconnected) {
321
+ // 已经恢复或已显式断开,不需要重连
322
+ return;
323
+ }
324
+ this.daemonReconnectAttempts++;
325
+ logger.info('wecom', `[${this.robotName}] daemon-level reconnect attempt ${this.daemonReconnectAttempts}`);
326
+ try {
327
+ this.wsClient.connect();
328
+ }
329
+ catch (err) {
330
+ logger.error('wecom', `[${this.robotName}] daemon-level reconnect failed: ${err.message}`);
331
+ }
332
+ // 无论成功失败都排下一轮,若已连上下次 timeout 触发时 connected=true 会直接 return
333
+ this.scheduleDaemonReconnect();
334
+ }, delay);
335
+ }
336
+ clearDaemonReconnect() {
337
+ if (this.daemonReconnectTimer) {
338
+ clearTimeout(this.daemonReconnectTimer);
339
+ this.daemonReconnectTimer = null;
340
+ }
341
+ this.daemonReconnectAttempts = 0;
342
+ }
294
343
  // 检查连接状态
295
344
  isConnected() {
296
345
  return this.connected;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wecom-aibot-mcp PermissionRequest hook (Node.js, 跨平台)
4
+ *
5
+ * 由 Claude Code 在工具调用前 stdin 喂入 JSON:
6
+ * { tool_name, tool_input, ... }
7
+ * 我们 stdout 输出:
8
+ * { hookSpecificOutput: { hookEventName, decision: { behavior, message? } } }
9
+ *
10
+ * 决策逻辑:
11
+ * 1. MCP 工具 / 只读工具 → 直接 allow
12
+ * 2. 沿进程树向上查 active-projects.json,未匹配 → exit 0(不拦截)
13
+ * 3. 项目无 .claude/wecom-aibot.json 或 wechatMode!=true → exit 0
14
+ * 4. 探测本地 daemon /health;channel 模式或本地不通则尝试远程
15
+ * 5. POST /approve 拿 taskId,轮询 /approval_status/:taskId
16
+ * 6. 超时(autoApproveTimeout)→ 智能策略:
17
+ * - rm 命令 → 拒
18
+ * - 项目内 → 允许,项目外 → 拒
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as os from 'os';
23
+ import { execSync } from 'child_process';
24
+ const MCP_PORT = 18963;
25
+ const HOME = os.homedir();
26
+ const ACTIVE_INDEX = path.join(HOME, '.wecom-aibot-mcp', 'active-projects.json');
27
+ const DEBUG_FILE = path.join(HOME, '.wecom-aibot-mcp', 'debug');
28
+ const CLAUDE_JSON = path.join(HOME, '.claude.json');
29
+ const IS_WIN = process.platform === 'win32';
30
+ function log(msg) {
31
+ if (fs.existsSync(DEBUG_FILE)) {
32
+ process.stderr.write(`[${new Date().toISOString()}] ${msg}\n`);
33
+ }
34
+ }
35
+ function emit(decision) {
36
+ const out = {
37
+ hookSpecificOutput: {
38
+ hookEventName: 'PermissionRequest',
39
+ decision,
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(out) + '\n');
43
+ // 显式退出,避免事件循环里残留的 timer / fetch 让进程多挂几秒
44
+ process.exit(0);
45
+ }
46
+ function readStdinSync() {
47
+ // hook 进程很短命,同步读 stdin 完整内容
48
+ const chunks = [];
49
+ try {
50
+ const buf = Buffer.alloc(65536);
51
+ while (true) {
52
+ const n = fs.readSync(0, buf, 0, buf.length, null);
53
+ if (!n)
54
+ break;
55
+ chunks.push(Buffer.from(buf.subarray(0, n)));
56
+ }
57
+ }
58
+ catch {
59
+ // EAGAIN / closed
60
+ }
61
+ return Buffer.concat(chunks).toString('utf-8');
62
+ }
63
+ function getParentPid(pid) {
64
+ if (!pid || pid <= 1)
65
+ return 0;
66
+ try {
67
+ if (IS_WIN) {
68
+ const out = execSync(`wmic process where ProcessId=${pid} get ParentProcessId /value`, {
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ }).toString();
71
+ const m = out.match(/ParentProcessId=(\d+)/);
72
+ return m ? parseInt(m[1], 10) : 0;
73
+ }
74
+ return parseInt(execSync(`ps -o ppid= -p ${pid}`, {
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ }).toString().trim(), 10) || 0;
77
+ }
78
+ catch {
79
+ return 0;
80
+ }
81
+ }
82
+ function findProjectFromActiveIndex(startPid) {
83
+ if (!fs.existsSync(ACTIVE_INDEX)) {
84
+ log('No active-projects index');
85
+ return null;
86
+ }
87
+ let entries = [];
88
+ try {
89
+ entries = JSON.parse(fs.readFileSync(ACTIVE_INDEX, 'utf-8'));
90
+ if (!Array.isArray(entries))
91
+ return null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ let pid = startPid;
97
+ for (let i = 0; i < 8; i++) {
98
+ if (!pid || pid <= 1)
99
+ break;
100
+ const hit = entries.find((e) => e?.pid === pid);
101
+ if (hit?.projectDir) {
102
+ log(`Matched PID ${pid} -> ${hit.projectDir}`);
103
+ return hit.projectDir;
104
+ }
105
+ const parent = getParentPid(pid);
106
+ if (!parent || parent === pid)
107
+ break;
108
+ pid = parent;
109
+ }
110
+ return null;
111
+ }
112
+ async function fetchJson(url, init) {
113
+ const ctrl = new AbortController();
114
+ const t = setTimeout(() => ctrl.abort(), init?.timeoutMs ?? 5000);
115
+ try {
116
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
117
+ if (!res.ok)
118
+ return null;
119
+ return await res.json().catch(() => null);
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ finally {
125
+ clearTimeout(t);
126
+ }
127
+ }
128
+ async function probeLocal() {
129
+ const baseUrl = `http://127.0.0.1:${MCP_PORT}`;
130
+ const data = await fetchJson(`${baseUrl}/health`, { timeoutMs: 2000 });
131
+ return data?.status === 'ok' ? { baseUrl } : null;
132
+ }
133
+ async function probeRemote() {
134
+ if (!fs.existsSync(CLAUDE_JSON))
135
+ return null;
136
+ let claudeConfig;
137
+ try {
138
+ claudeConfig = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf-8'));
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ const channel = claudeConfig?.mcpServers?.['wecom-aibot-channel'];
144
+ const remoteUrl = channel?.env?.MCP_URL;
145
+ const remoteToken = channel?.env?.MCP_AUTH_TOKEN;
146
+ if (!remoteUrl)
147
+ return null;
148
+ const baseUrl = remoteUrl.replace(/\/+$/, '');
149
+ const headers = {};
150
+ if (remoteToken)
151
+ headers['Authorization'] = `Bearer ${remoteToken}`;
152
+ const data = await fetchJson(`${baseUrl}/health`, { timeoutMs: 5000, headers });
153
+ if (data?.status !== 'ok')
154
+ return null;
155
+ return remoteToken ? { baseUrl, authHeader: `Bearer ${remoteToken}` } : { baseUrl };
156
+ }
157
+ function authedHeaders(ep, extra) {
158
+ const h = { ...(extra || {}) };
159
+ if (ep.authHeader)
160
+ h['Authorization'] = ep.authHeader;
161
+ return h;
162
+ }
163
+ function extractStringField(obj, ...keys) {
164
+ for (const k of keys) {
165
+ if (typeof obj?.[k] === 'string' && obj[k])
166
+ return obj[k];
167
+ }
168
+ return '';
169
+ }
170
+ function isInProject(toolName, toolInput, projectDir) {
171
+ if (toolName === 'Bash') {
172
+ const cmd = toolInput?.command || '';
173
+ if (cmd.includes(projectDir))
174
+ return true;
175
+ const absPaths = cmd.match(/(^|[ \t])(\/[A-Za-z0-9][^ \t>|;&]*|[A-Za-z]:\\[^ \t>|;&]+)/g) || [];
176
+ const safe = (p) => p.startsWith(projectDir) ||
177
+ /^(\/tmp\/|\/var\/tmp\/|\/dev\/null|\/dev\/std|\/dev\/fd\/)/.test(p) ||
178
+ /^[A-Za-z]:\\(Users\\[^\\]+\\AppData\\Local\\Temp\\|Windows\\Temp\\)/i.test(p);
179
+ const outside = absPaths.map(s => s.trim()).filter(p => !safe(p));
180
+ if (absPaths.length > 0 && outside.length > 0)
181
+ return false;
182
+ // 无可疑外部路径 → 以 cwd 为准
183
+ return process.cwd().startsWith(projectDir);
184
+ }
185
+ if (toolName === 'Write' || toolName === 'Edit') {
186
+ const fp = extractStringField(toolInput, 'file_path');
187
+ return !fp || fp.startsWith(projectDir) || !path.isAbsolute(fp);
188
+ }
189
+ const fp = extractStringField(toolInput, 'file_path', 'path', 'directory');
190
+ if (!fp)
191
+ return true; // 无路径信息时倾向放行
192
+ return fp.startsWith(projectDir) || !path.isAbsolute(fp);
193
+ }
194
+ function isDeleteCommand(toolName, toolInput) {
195
+ if (toolName !== 'Bash')
196
+ return false;
197
+ const cmd = (toolInput?.command || '').toString();
198
+ const firstLine = cmd.split('\n')[0] || '';
199
+ return /(^|[;&|(]\s*)(rm\s|rmdir\s|del\s|Remove-Item\s)/i.test(firstLine);
200
+ }
201
+ async function notifyTimeout(ep, taskId, result, reason) {
202
+ await fetchJson(`${ep.baseUrl}/approval_timeout/${taskId}`, {
203
+ method: 'POST',
204
+ timeoutMs: 5000,
205
+ headers: authedHeaders(ep, { 'Content-Type': 'application/json' }),
206
+ body: JSON.stringify({ result, reason }),
207
+ });
208
+ }
209
+ async function main() {
210
+ const raw = readStdinSync();
211
+ let input = {};
212
+ try {
213
+ input = raw ? JSON.parse(raw) : {};
214
+ }
215
+ catch {
216
+ log('stdin not JSON, exit 0');
217
+ process.exit(0);
218
+ }
219
+ const toolName = input?.tool_name || '';
220
+ log(`tool_name=${toolName}`);
221
+ // MCP 工具:放行
222
+ if (toolName.startsWith('mcp__')) {
223
+ emit({ behavior: 'allow' });
224
+ return;
225
+ }
226
+ // 只读工具:放行
227
+ const readOnly = new Set([
228
+ 'Read', 'Glob', 'Grep', 'LS', 'TaskList', 'TaskGet', 'TaskOutput', 'TaskStop',
229
+ 'CronList', 'CronCreate', 'CronDelete', 'AskUserQuestion', 'Skill',
230
+ 'ListMcpResourcesTool', 'EnterPlanMode', 'ExitPlanMode',
231
+ 'WebSearch', 'WebFetch', 'NotebookEdit',
232
+ ]);
233
+ if (readOnly.has(toolName)) {
234
+ emit({ behavior: 'allow' });
235
+ return;
236
+ }
237
+ // 沿进程树查 active-projects.json
238
+ const projectDir = findProjectFromActiveIndex(process.ppid || process.pid);
239
+ if (!projectDir) {
240
+ log('No active project match');
241
+ process.exit(0);
242
+ }
243
+ const configFile = path.join(projectDir, '.claude', 'wecom-aibot.json');
244
+ if (!fs.existsSync(configFile)) {
245
+ log('No wecom-aibot.json in project');
246
+ process.exit(0);
247
+ }
248
+ let cfg;
249
+ try {
250
+ cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
251
+ }
252
+ catch {
253
+ process.exit(0);
254
+ }
255
+ if (cfg?.wechatMode !== true) {
256
+ log('wechatMode not true');
257
+ process.exit(0);
258
+ }
259
+ const mode = cfg?.mode === 'channel' ? 'channel' : 'http';
260
+ const endpoint = mode === 'channel'
261
+ ? (await probeRemote())
262
+ : ((await probeLocal()) || (await probeRemote()));
263
+ if (!endpoint) {
264
+ log('No reachable MCP server');
265
+ process.exit(0);
266
+ }
267
+ // 提交审批
268
+ const body = {
269
+ tool_name: toolName,
270
+ tool_input: input?.tool_input ?? {},
271
+ projectDir,
272
+ robotName: cfg?.robotName || '',
273
+ ccId: cfg?.ccId || '',
274
+ };
275
+ const approveRes = await fetchJson(`${endpoint.baseUrl}/approve`, {
276
+ method: 'POST',
277
+ timeoutMs: 10000,
278
+ headers: authedHeaders(endpoint, { 'Content-Type': 'application/json' }),
279
+ body: JSON.stringify(body),
280
+ });
281
+ const taskId = approveRes?.taskId || '';
282
+ if (!taskId) {
283
+ log('No taskId returned');
284
+ process.exit(0);
285
+ }
286
+ // 轮询
287
+ const timeoutSec = Number(cfg?.autoApproveTimeout) || 300;
288
+ const maxPoll = Math.max(1, Math.ceil(timeoutSec / 2));
289
+ log(`Polling taskId=${taskId} maxPoll=${maxPoll}`);
290
+ for (let i = 0; i < maxPoll; i++) {
291
+ await new Promise(r => setTimeout(r, 2000));
292
+ const data = await fetchJson(`${endpoint.baseUrl}/approval_status/${taskId}`, {
293
+ timeoutMs: 3000,
294
+ headers: authedHeaders(endpoint),
295
+ });
296
+ const result = data?.result;
297
+ if (result === 'allow-once' || result === 'allow-always') {
298
+ emit({ behavior: 'allow' });
299
+ return;
300
+ }
301
+ if (result === 'deny') {
302
+ emit({ behavior: 'deny', message: '用户拒绝' });
303
+ return;
304
+ }
305
+ }
306
+ // 超时智能决策
307
+ const toolInput = input?.tool_input ?? {};
308
+ if (isDeleteCommand(toolName, toolInput)) {
309
+ await notifyTimeout(endpoint, taskId, 'deny', '超时自动拒绝:删除操作需人工确认');
310
+ emit({ behavior: 'deny', message: '超时自动拒绝:删除操作需人工确认' });
311
+ return;
312
+ }
313
+ if (isInProject(toolName, toolInput, projectDir)) {
314
+ await notifyTimeout(endpoint, taskId, 'allow-once', '超时自动允许:项目内操作');
315
+ emit({ behavior: 'allow', message: '超时自动允许:项目内操作' });
316
+ return;
317
+ }
318
+ await notifyTimeout(endpoint, taskId, 'deny', '超时自动拒绝:项目外操作需人工确认');
319
+ emit({ behavior: 'deny', message: '超时自动拒绝:项目外操作需人工确认' });
320
+ }
321
+ main().catch(err => {
322
+ log(`hook error: ${err?.message || err}`);
323
+ // 任何错误都让 Claude 继续(exit 0 表示不干预)
324
+ process.exit(0);
325
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wecom-aibot-mcp Stop hook (Node.js, 跨平台)
4
+ *
5
+ * Claude 准备停止时触发。如果当前项目处于微信模式,输出 exit code 2 阻止停止,
6
+ * 同时通过 stderr 提示 Claude 调用 get_pending_messages 恢复轮询。
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ const MCP_PORT = 18963;
12
+ const HOME = os.homedir();
13
+ const DEBUG_FILE = path.join(HOME, '.wecom-aibot-mcp', 'debug');
14
+ const CLAUDE_JSON = path.join(HOME, '.claude.json');
15
+ function log(msg) {
16
+ if (fs.existsSync(DEBUG_FILE)) {
17
+ process.stderr.write(`[${new Date().toISOString()}] [stop] ${msg}\n`);
18
+ }
19
+ }
20
+ function readStdinSync() {
21
+ const chunks = [];
22
+ try {
23
+ const buf = Buffer.alloc(65536);
24
+ while (true) {
25
+ const n = fs.readSync(0, buf, 0, buf.length, null);
26
+ if (!n)
27
+ break;
28
+ chunks.push(Buffer.from(buf.subarray(0, n)));
29
+ }
30
+ }
31
+ catch { /* ignore */ }
32
+ return Buffer.concat(chunks).toString('utf-8');
33
+ }
34
+ async function fetchHealth(url, headers, timeoutMs = 2000) {
35
+ const ctrl = new AbortController();
36
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
37
+ try {
38
+ const res = await fetch(`${url}/health`, { signal: ctrl.signal, headers });
39
+ if (!res.ok)
40
+ return false;
41
+ const data = await res.json().catch(() => null);
42
+ return data?.status === 'ok';
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ finally {
48
+ clearTimeout(t);
49
+ }
50
+ }
51
+ async function main() {
52
+ // 消费 stdin(Claude 会传 stop 事件 JSON,我们不需要内容)
53
+ readStdinSync();
54
+ const projectDir = process.cwd();
55
+ const configFile = path.join(projectDir, '.claude', 'wecom-aibot.json');
56
+ if (!fs.existsSync(configFile)) {
57
+ log('no config, allow stop');
58
+ process.exit(0);
59
+ }
60
+ let cfg;
61
+ try {
62
+ cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
63
+ }
64
+ catch {
65
+ process.exit(0);
66
+ }
67
+ if (cfg?.wechatMode !== true) {
68
+ log('wechatMode not true, allow stop');
69
+ process.exit(0);
70
+ }
71
+ const ccId = cfg?.ccId || '';
72
+ if (!ccId) {
73
+ log('no ccId, allow stop');
74
+ process.exit(0);
75
+ }
76
+ // 探测 daemon 是否在线,离线就允许停止
77
+ let alive = await fetchHealth(`http://127.0.0.1:${MCP_PORT}`);
78
+ if (!alive && fs.existsSync(CLAUDE_JSON)) {
79
+ try {
80
+ const claudeConfig = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf-8'));
81
+ const channel = claudeConfig?.mcpServers?.['wecom-aibot-channel'];
82
+ const remoteUrl = channel?.env?.MCP_URL;
83
+ const token = channel?.env?.MCP_AUTH_TOKEN;
84
+ if (remoteUrl) {
85
+ const headers = {};
86
+ if (token)
87
+ headers['Authorization'] = `Bearer ${token}`;
88
+ alive = await fetchHealth(remoteUrl.replace(/\/+$/, ''), headers, 5000);
89
+ }
90
+ }
91
+ catch { /* ignore */ }
92
+ }
93
+ if (!alive) {
94
+ log('MCP server offline, allow stop');
95
+ process.exit(0);
96
+ }
97
+ log(`WeChat mode active, blocking stop for ccId=${ccId}`);
98
+ process.stderr.write(`任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id="${ccId}", timeout_ms=30000) 恢复微信消息轮询\n`);
99
+ process.exit(2);
100
+ }
101
+ main().catch(() => process.exit(0));
@@ -57,6 +57,7 @@ export declare function clearCcIdRegistry(): {
57
57
  entries: string[];
58
58
  };
59
59
  export declare function getRobotByCcId(ccId: string): string | null;
60
+ export declare function touchCcId(ccId: string): void;
60
61
  export declare function getProjectDirByCcId(ccId: string): string | null;
61
62
  export declare function getCCRegistryEntry(ccId: string): CCRegistryEntry | null;
62
63
  export declare function getCCCount(): number;
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
23
23
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
24
24
  import { registerTools } from './tools/index.js';
25
25
  import { getClient, getConnectionState, getAllConnectionStates, connectAllRobots } from './connection-manager.js';
26
- import { subscribeWecomMessage } from './message-bus.js';
26
+ import { subscribeWecomMessage, subscribeCcMessageByTarget } from './message-bus.js';
27
27
  import { listAllRobots, VERSION, getAuthToken } from './config-wizard.js';
28
28
  import { logger } from './logger.js';
29
29
  // ESM 兼容的 __dirname
@@ -171,7 +171,19 @@ export function clearCcIdRegistry() {
171
171
  return { cleared: entries.length, entries };
172
172
  }
173
173
  export function getRobotByCcId(ccId) {
174
- return ccIdRegistry.get(ccId)?.robotName || null;
174
+ const entry = ccIdRegistry.get(ccId);
175
+ if (!entry)
176
+ return null;
177
+ // 任何 lookup 都视为 CC 还活着,刷新 lastOnline 防止 30 分钟 idle 被 stale-cleanup
178
+ // 否则 channel 模式下 SSE 长连接还活、但 ccIdRegistry 被清,send_message 立刻报「未在微信模式」
179
+ entry.lastOnline = Date.now();
180
+ return entry.robotName;
181
+ }
182
+ // 显式刷新 ccId 的 lastOnline(SSE 心跳等场景调用)
183
+ export function touchCcId(ccId) {
184
+ const entry = ccIdRegistry.get(ccId);
185
+ if (entry)
186
+ entry.lastOnline = Date.now();
175
187
  }
176
188
  export function getProjectDirByCcId(ccId) {
177
189
  return ccIdRegistry.get(ccId)?.projectDir || null;
@@ -661,6 +673,41 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
661
673
  res.end(JSON.stringify({ ok: true, ...result }));
662
674
  return;
663
675
  }
676
+ // DELETE /admin/ccid/:id - 注销单个 ccId 并断开它的所有 SSE 客户端连接
677
+ // 让对应 channel-server 在 3s 内自动 re-call enter_headless_mode → connectRobot 重建链路
678
+ // 必须配置全局 auth token 才能用(避免裸暴露)
679
+ const ccidAdminMatch = url.match(/^\/admin\/ccid\/(.+)$/);
680
+ if (req.method === 'DELETE' && ccidAdminMatch) {
681
+ if (!getAuthToken()) {
682
+ res.writeHead(403, { 'Content-Type': 'application/json' });
683
+ res.end(JSON.stringify({ error: '/admin/ccid 需要先配置 Auth Token(--set-token)' }));
684
+ return;
685
+ }
686
+ const targetCcId = decodeURIComponent(ccidAdminMatch[1]);
687
+ const entry = ccIdRegistry.get(targetCcId);
688
+ if (!entry) {
689
+ res.writeHead(404, { 'Content-Type': 'application/json' });
690
+ res.end(JSON.stringify({ error: 'ccId not found', ccId: targetCcId }));
691
+ return;
692
+ }
693
+ // 关闭并清理所有匹配该 ccId 的 SSE 客户端
694
+ const closedClients = [];
695
+ for (const [clientId, client] of sseClients) {
696
+ if (client.ccId === targetCcId) {
697
+ try {
698
+ client.res.end();
699
+ }
700
+ catch { /* ignore */ }
701
+ sseClients.delete(clientId);
702
+ closedClients.push(clientId);
703
+ }
704
+ }
705
+ unregisterCcId(targetCcId);
706
+ logger.info('ccId admin unregister', { ccId: targetCcId, robot: entry.robotName, closedSse: closedClients.length });
707
+ res.writeHead(200, { 'Content-Type': 'application/json' });
708
+ res.end(JSON.stringify({ ok: true, ccId: targetCcId, robot: entry.robotName, closedSseClients: closedClients.length }));
709
+ return;
710
+ }
664
711
  // ============================================
665
712
  // 调试端点统一拦截
666
713
  // ============================================
@@ -1121,15 +1168,28 @@ function handleSSEConnect(req, res, _url) {
1121
1168
  logger.log(`[http] SSE 客户端连接: clientId=${clientId}, ccId=${targetCcId}, robotName=${entry.robotName}`);
1122
1169
  // 发送连接确认
1123
1170
  res.write(`event: connected\ndata: {"clientId":"${clientId}","ccId":"${targetCcId}"}\n\n`);
1124
- // 心跳机制:每 15 秒发送注释行保持连接活跃
1171
+ // 心跳机制:每 15 秒发送注释行保持连接活跃 + 刷新 ccIdRegistry 的 lastOnline
1172
+ // 避免 channel 模式 CC 长时间 idle 时 SSE 还活但 ccIdRegistry 被 30 分钟 stale-cleanup
1125
1173
  const heartbeatInterval = setInterval(() => {
1126
- // SSE 注释行(以冒号开头)会被客户端忽略,但保持连接
1127
1174
  res.write(': heartbeat\n\n');
1175
+ touchCcId(targetCcId);
1128
1176
  logger.log(`[http] SSE 心跳发送: clientId=${clientId}`);
1129
1177
  }, 15000);
1178
+ // 订阅发往当前 ccId 的 CC 间消息,转发为 cc_message SSE event
1179
+ const ccSub = subscribeCcMessageByTarget(targetCcId, (msg) => {
1180
+ try {
1181
+ const data = JSON.stringify(msg);
1182
+ res.write(`event: cc_message\ndata: ${data}\n\n`);
1183
+ logger.info('cc_message pushed', { from: msg.fromCc, to: msg.toCc, msgId: msg.msgId, kind: msg.kind });
1184
+ }
1185
+ catch (err) {
1186
+ logger.error(`[http] cc_message 推送失败 clientId=${clientId}:`, err);
1187
+ }
1188
+ });
1130
1189
  // 处理客户端断开
1131
1190
  req.on('close', () => {
1132
1191
  clearInterval(heartbeatInterval);
1192
+ ccSub.unsubscribe();
1133
1193
  sseClients.delete(clientId);
1134
1194
  logger.log(`[http] SSE 客户端断开: clientId=${clientId}`);
1135
1195
  });
@@ -23,6 +23,16 @@ export interface WecomMessage {
23
23
  timestamp: number;
24
24
  quoteContent?: string;
25
25
  }
26
+ export interface CcMessage {
27
+ msgId: string;
28
+ fromCc: string;
29
+ toCc: string;
30
+ content: string;
31
+ kind: 'request' | 'reply' | 'notify';
32
+ replyTo?: string;
33
+ hopCount: number;
34
+ timestamp: number;
35
+ }
26
36
  export interface ApprovalEvent {
27
37
  robotName: string;
28
38
  taskId: string;
@@ -32,6 +42,7 @@ export interface ApprovalEvent {
32
42
  }
33
43
  export declare function getSubscriberCount(robotName: string): number;
34
44
  declare const wecomMessage$: Subject<WecomMessage>;
45
+ declare const ccMessage$: Subject<CcMessage>;
35
46
  /**
36
47
  * 发布微信消息(由 WecomClient 调用)
37
48
  */
@@ -57,4 +68,7 @@ export declare function subscribeWecomMessageByRobot(robotName: string, callback
57
68
  * 用于多 CC 共用一个机器人场景的过滤
58
69
  */
59
70
  export declare function subscribeWecomMessageByCcId(robotName: string, ccId: string, callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
60
- export { wecomMessage$ };
71
+ export declare function publishCcMessage(msg: CcMessage): void;
72
+ /** 订阅发往指定 ccId 的所有 CC 间消息 */
73
+ export declare function subscribeCcMessageByTarget(toCc: string, callback: (msg: CcMessage) => void): import("rxjs").Subscription;
74
+ export { wecomMessage$, ccMessage$ };
@@ -36,6 +36,7 @@ function decrementSubscriberCount(robotName) {
36
36
  // ============================================
37
37
  const wecomMessage$ = new Subject();
38
38
  const approvalEvent$ = new Subject();
39
+ const ccMessage$ = new Subject();
39
40
  // ============================================
40
41
  // 发布/订阅接口
41
42
  // ============================================
@@ -99,5 +100,15 @@ function isMessageForCcId(msg, ccId) {
99
100
  // 在多 CC 场景下,需要用户指定
100
101
  return false;
101
102
  }
103
+ // ============================================
104
+ // CC 间消息(v2.6.0+)
105
+ // ============================================
106
+ export function publishCcMessage(msg) {
107
+ ccMessage$.next(msg);
108
+ }
109
+ /** 订阅发往指定 ccId 的所有 CC 间消息 */
110
+ export function subscribeCcMessageByTarget(toCc, callback) {
111
+ return ccMessage$.pipe(filter(m => m.toCc === toCc)).subscribe(callback);
112
+ }
102
113
  // 导出 Observable(供高级用法)
103
- export { wecomMessage$ };
114
+ export { wecomMessage$, ccMessage$ };
@@ -0,0 +1,14 @@
1
+ /** 通过 fetch /health 探测 daemon 是否在该端口监听(跨平台) */
2
+ export declare function isDaemonAlive(port: number, timeoutMs?: number): Promise<boolean>;
3
+ /** 取指定 PID 的父进程 PID;不存在返回 0 */
4
+ export declare function getParentPid(pid: number): number;
5
+ /** 取指定 PID 的可执行文件名(comm 字段,如 "claude" / "node") */
6
+ export declare function getProcessName(pid: number): string;
7
+ /**
8
+ * 沿进程树向上查找 Claude Code 进程的 PID。
9
+ * 用于 channel-server 注册 active-projects 时定位真正的 TUI 进程
10
+ * (npx 安装下 process.ppid 是 npx 不是 claude)。
11
+ */
12
+ export declare function findClaudePid(startPid: number, maxDepth?: number): number;
13
+ /** 进程是否还在(process.kill(pid, 0) 在 Win/Unix 都可用) */
14
+ export declare function isProcessAlive(pid: number): boolean;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 跨平台辅助函数(Windows / macOS / Linux)
3
+ *
4
+ * 用于替代 ps / lsof / ss / kill 等 Unix 专属命令,
5
+ * 让 daemon 启停、Claude 进程树查找在 Windows 也能工作。
6
+ */
7
+ import { execSync } from 'child_process';
8
+ const IS_WIN = process.platform === 'win32';
9
+ /** 通过 fetch /health 探测 daemon 是否在该端口监听(跨平台) */
10
+ export async function isDaemonAlive(port, timeoutMs = 1500) {
11
+ try {
12
+ const ctrl = new AbortController();
13
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
14
+ const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: ctrl.signal });
15
+ clearTimeout(t);
16
+ if (!res.ok)
17
+ return false;
18
+ const data = await res.json().catch(() => ({}));
19
+ return data?.status === 'ok';
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /** 取指定 PID 的父进程 PID;不存在返回 0 */
26
+ export function getParentPid(pid) {
27
+ if (!pid || pid <= 1)
28
+ return 0;
29
+ try {
30
+ if (IS_WIN) {
31
+ // 输出形如:
32
+ // ParentProcessId
33
+ // 1234
34
+ const out = execSync(`wmic process where ProcessId=${pid} get ParentProcessId /value`, {
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).toString();
37
+ const m = out.match(/ParentProcessId=(\d+)/);
38
+ return m ? parseInt(m[1], 10) : 0;
39
+ }
40
+ else {
41
+ const out = execSync(`ps -o ppid= -p ${pid}`, {
42
+ stdio: ['ignore', 'pipe', 'ignore'],
43
+ }).toString().trim();
44
+ return parseInt(out, 10) || 0;
45
+ }
46
+ }
47
+ catch {
48
+ return 0;
49
+ }
50
+ }
51
+ /** 取指定 PID 的可执行文件名(comm 字段,如 "claude" / "node") */
52
+ export function getProcessName(pid) {
53
+ if (!pid || pid <= 1)
54
+ return '';
55
+ try {
56
+ if (IS_WIN) {
57
+ // wmic process where ProcessId=N get Name /value -> Name=node.exe
58
+ const out = execSync(`wmic process where ProcessId=${pid} get Name /value`, {
59
+ stdio: ['ignore', 'pipe', 'ignore'],
60
+ }).toString();
61
+ const m = out.match(/Name=(.+?)\s*$/m);
62
+ return (m ? m[1] : '').trim();
63
+ }
64
+ else {
65
+ return execSync(`ps -p ${pid} -o comm=`, {
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ }).toString().trim();
68
+ }
69
+ }
70
+ catch {
71
+ return '';
72
+ }
73
+ }
74
+ /**
75
+ * 沿进程树向上查找 Claude Code 进程的 PID。
76
+ * 用于 channel-server 注册 active-projects 时定位真正的 TUI 进程
77
+ * (npx 安装下 process.ppid 是 npx 不是 claude)。
78
+ */
79
+ export function findClaudePid(startPid, maxDepth = 8) {
80
+ let pid = startPid;
81
+ for (let i = 0; i < maxDepth; i++) {
82
+ if (!pid || pid <= 1)
83
+ break;
84
+ const name = getProcessName(pid).toLowerCase();
85
+ // Win 上是 "claude.exe";Unix 上可能是 "claude" 或绝对路径末尾 "/claude"
86
+ if (name === 'claude' || name === 'claude.exe' || name.endsWith('/claude') || name.endsWith('\\claude.exe')) {
87
+ return pid;
88
+ }
89
+ const parent = getParentPid(pid);
90
+ if (!parent || parent === pid)
91
+ break;
92
+ pid = parent;
93
+ }
94
+ return startPid;
95
+ }
96
+ /** 进程是否还在(process.kill(pid, 0) 在 Win/Unix 都可用) */
97
+ export function isProcessAlive(pid) {
98
+ if (!pid)
99
+ return false;
100
+ try {
101
+ process.kill(pid, 0);
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
@@ -2,9 +2,10 @@
2
2
  import { z } from 'zod';
3
3
  import { listAllRobots, getDocMcpUrl, installSkill, VERSION } from '../config-wizard.js';
4
4
  import { callDocTool } from '../doc-proxy.js';
5
- import { connectRobot, disconnectRobot, getClient, getConnectionState, } from '../connection-manager.js';
6
- import { registerCcId, unregisterCcId, getRobotByCcId, getProjectDirByCcId, generateCcId, } from '../http-server.js';
7
- import { subscribeWecomMessageByCcId } from '../message-bus.js';
5
+ import { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, } from '../connection-manager.js';
6
+ import { registerCcId, unregisterCcId, getRobotByCcId, getProjectDirByCcId, generateCcId, getOnlineCcIds, getCCRegistryEntry, } from '../http-server.js';
7
+ import { subscribeWecomMessageByCcId, publishCcMessage } from '../message-bus.js';
8
+ import { randomBytes } from 'crypto';
8
9
  import { updateWechatModeConfig, loadWechatModeConfig, addPermissionHook, removePermissionHook, addStopHook, removeStopHook, registerActiveProject, unregisterActiveProject } from '../project-config.js';
9
10
  import { logger } from '../logger.js';
10
11
  // 辅助函数:从 ccId 获取客户端
@@ -75,9 +76,44 @@ export function registerTools(server) {
75
76
  };
76
77
  });
77
78
  // ============================================
78
- // 工具 4: 检查连接状态
79
+ // 工具 4: 检查连接状态(per-CC)
79
80
  // ============================================
80
- server.tool('check_connection', '检查当前 WebSocket 连接状态', {}, async () => {
81
+ server.tool('check_connection', '检查当前 WebSocket 连接状态。建议传入 cc_id 以获取本 CC 对应 robot 的状态;不传则返回任一活跃连接(v3 起将下线无参版本)', {
82
+ cc_id: z.string().optional().describe('CC 唯一标识。传入后返回本 CC 对应 robot 的连接状态;不传则降级为旧行为'),
83
+ }, async ({ cc_id }) => {
84
+ if (cc_id) {
85
+ const robotName = getRobotByCcId(cc_id);
86
+ if (!robotName) {
87
+ return {
88
+ content: [{
89
+ type: 'text',
90
+ text: JSON.stringify({ connected: false, ccId: cc_id, error: '未注册的 ccId 或 robot 未连接' }),
91
+ }],
92
+ };
93
+ }
94
+ const all = getAllConnectionStates();
95
+ const entry = all.find(s => s.robotName === robotName);
96
+ if (!entry) {
97
+ return {
98
+ content: [{
99
+ type: 'text',
100
+ text: JSON.stringify({ connected: false, ccId: cc_id, robotName, error: '该 robot 不在 connectionPool 中' }),
101
+ }],
102
+ };
103
+ }
104
+ return {
105
+ content: [{
106
+ type: 'text',
107
+ text: JSON.stringify({
108
+ connected: entry.connected,
109
+ ccId: cc_id,
110
+ robotName: entry.robotName,
111
+ connectedAt: entry.connectedAt,
112
+ }),
113
+ }],
114
+ };
115
+ }
116
+ // 旧行为:返回 connectionPool 中任一活跃连接,并提示 deprecation
81
117
  const state = getConnectionState();
82
118
  return {
83
119
  content: [{
@@ -86,11 +122,74 @@ export function registerTools(server) {
86
122
  connected: state.connected,
87
123
  robotName: state.robotName,
88
124
  connectedAt: state.connectedAt,
125
+ warning: '请传入 cc_id 获取该 CC 对应 robot 的状态,无参版本将在 v3.0 移除',
89
126
  }),
90
127
  }],
91
128
  };
92
129
  });
93
130
  // ============================================
131
+ // 工具 5a: CC 间通信 — 向另一个 CC 发消息(v2.6.0+,单 daemon 范围内)
132
+ // ============================================
133
+ server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通,跨 daemon 不通。', {
134
+ cc_id: z.string().describe('自己的 CC 标识(必填)'),
135
+ to_cc: z.string().describe('目标 CC 标识'),
136
+ content: z.string().describe('消息内容(支持 Markdown)'),
137
+ kind: z.enum(['request', 'reply', 'notify']).optional().default('notify').describe('消息语义:request 期待回复 / reply 是对前一条 request 的回复 / notify 单向通知'),
138
+ reply_to: z.string().optional().describe('可选:关联的请求 msgId(用于追踪请求-响应)'),
139
+ }, async ({ cc_id, to_cc, content, kind = 'notify', reply_to }) => {
140
+ if (cc_id === to_cc) {
141
+ return { content: [{ type: 'text', text: JSON.stringify({ delivered: false, reason: 'cannot send to self' }) }] };
142
+ }
143
+ const targetEntry = getCCRegistryEntry(to_cc);
144
+ if (!targetEntry) {
145
+ return { content: [{ type: 'text', text: JSON.stringify({ delivered: false, reason: 'target offline', to_cc }) }] };
146
+ }
147
+ const msgId = `cc_${Date.now()}_${randomBytes(4).toString('hex')}`;
148
+ publishCcMessage({
149
+ msgId,
150
+ fromCc: cc_id,
151
+ toCc: to_cc,
152
+ content,
153
+ kind,
154
+ replyTo: reply_to,
155
+ hopCount: 0,
156
+ timestamp: Date.now(),
157
+ });
158
+ return {
159
+ content: [{
160
+ type: 'text',
161
+ text: JSON.stringify({ delivered: true, msgId, to_cc, kind }),
162
+ }],
163
+ };
164
+ });
165
+ // ============================================
166
+ // 工具 5b: CC 间通信 — 列出当前 daemon 上在线的 CC
167
+ // ============================================
168
+ server.tool('list_active_ccs', '列出同一 daemon 上当前在线的所有 CC,用于决定 send_to_cc 的目标', {
169
+ cc_id: z.string().describe('自己的 CC 标识(用于过滤输出,不返回 self)'),
170
+ }, async ({ cc_id }) => {
171
+ const onlineIds = getOnlineCcIds();
172
+ const others = onlineIds
173
+ .filter(id => id !== cc_id)
174
+ .map(id => {
175
+ const entry = getCCRegistryEntry(id);
176
+ return entry ? {
177
+ ccId: id,
178
+ robotName: entry.robotName,
179
+ agentName: entry.agentName,
180
+ mode: entry.mode,
181
+ lastOnline: entry.lastOnline,
182
+ } : null;
183
+ })
184
+ .filter(Boolean);
185
+ return {
186
+ content: [{
187
+ type: 'text',
188
+ text: JSON.stringify({ self: cc_id, ccs: others }),
189
+ }],
190
+ };
191
+ });
192
+ // ============================================
94
193
  // 工具 6: 获取待处理消息
95
194
  // ============================================
96
195
  server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "2.4.26",
3
+ "version": "2.6.0",
4
4
  "description": "企业微信智能机器人 MCP 服务 - Claude Code 审批通道",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",