@vrs-soft/wecom-aibot-mcp 3.4.1 → 3.4.3

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.
@@ -208,6 +208,9 @@ let sseConnected = false;
208
208
  let sseAbortController = null;
209
209
  let mcpServer = null;
210
210
  let sseCurrentCcId = undefined;
211
+ // v3.4.3: 暴露给 verify_sse_health 工具,判断"是否假在线"
212
+ let sseLastChunkAt = 0;
213
+ let sseWatchdogActive = false;
211
214
  // 保存首次 enter_headless_mode 的参数,重连时原样复用
212
215
  let sseRobotId = undefined;
213
216
  let sseProjectDir = undefined;
@@ -361,6 +364,7 @@ function connectSSE(ccId) {
361
364
  clearInterval(watchdogTimer);
362
365
  watchdogTimer = null;
363
366
  }
367
+ sseWatchdogActive = false; // v3.4.3
364
368
  };
365
369
  // SSE fetch 配置:添加 keep-alive headers 确保连接稳定
366
370
  fetch(sseUrl, {
@@ -407,6 +411,7 @@ function connectSSE(ccId) {
407
411
  let buffer = '';
408
412
  let messageCount = 0;
409
413
  let lastChunkAt = Date.now();
414
+ sseLastChunkAt = lastChunkAt; // v3.4.3: 暴露到模块作用域
410
415
  let currentEvent = 'message'; // SSE event type,由 `event: xxx` 行设置;空行复位
411
416
  // Watchdog:>45s 没收到任何 chunk(含 daemon 端的 `: heartbeat` 注释)
412
417
  // 视为单向 TCP 死链,主动 abort 让 catch 分支触发 reconnect。
@@ -421,11 +426,13 @@ function connectSSE(ccId) {
421
426
  catch { /* ignore */ }
422
427
  }
423
428
  }, 15000);
429
+ sseWatchdogActive = true; // v3.4.3
424
430
  while (true) {
425
431
  const { done, value } = await reader.read();
426
432
  if (done) {
427
433
  logChannel('SSE stream ended');
428
434
  clearWatchdog();
435
+ sseWatchdogActive = false; // v3.4.3
429
436
  sseConnected = false;
430
437
  // 非主动断开时自动重连
431
438
  if (!sseAbortController?.signal.aborted) {
@@ -435,6 +442,7 @@ function connectSSE(ccId) {
435
442
  break;
436
443
  }
437
444
  lastChunkAt = Date.now();
445
+ sseLastChunkAt = lastChunkAt; // v3.4.3
438
446
  const chunk = decoder.decode(value, { stream: true });
439
447
  logChannel('SSE chunk received', { bytes: chunk.length, preview: chunk.slice(0, 100) });
440
448
  buffer += chunk;
@@ -609,6 +617,108 @@ function registerChannelTools(server) {
609
617
  return forwardToHttpMcp('check_connection', {});
610
618
  });
611
619
  // ============================================
620
+ // 工具 4b: verify_sse_health(v3.4.3)—— 比对本地 SSE 状态 vs daemon 注册表
621
+ // 用于 "CC 自己以为还连着但 daemon 已经看不到" 的 split-brain 诊断
622
+ // ============================================
623
+ server.tool('verify_sse_health', '比对本地 channel-server 的 SSE 状态和远端 daemon 注册表,诊断"假在线/假离线"问题。auto_fix=true 时检测到 split-brain 自动 abort 触发重连。', {
624
+ cc_id: z.string().describe('要检查的 CC 标识(必填,多 CC 部署下唯一确认目标)'),
625
+ auto_fix: z.boolean().optional().default(false).describe('为 true 时若发现 stale_local 自动 abort SSE 触发重连'),
626
+ }, async ({ cc_id, auto_fix }) => {
627
+ const now = Date.now();
628
+ const localIdleMs = sseLastChunkAt ? now - sseLastChunkAt : -1;
629
+ const localView = {
630
+ sseConnected,
631
+ idleMs: localIdleMs,
632
+ watchdogActive: sseWatchdogActive,
633
+ ccId: sseCurrentCcId || null,
634
+ };
635
+ // 调 daemon list_active_ccs 看注册表里有没有 ccId(/state 没暴露 ccIdRegistry,
636
+ // 但 list_active_ccs 走 MCP 转发,返回结构含 lastOnline)
637
+ let remoteView = { reachable: false, error: 'unknown' };
638
+ try {
639
+ const listRes = await forwardToHttpMcp('list_active_ccs', { cc_id });
640
+ const text = listRes?.content?.[0]?.text;
641
+ const data = text ? JSON.parse(text) : {};
642
+ // list_active_ccs.self 是 caller 自己,data.ccs 是其他在线 CC
643
+ // 检查 cc_id 是不是 self(caller 在 registry 自然没问题,复用本地心跳新鲜度)
644
+ // 或者在 ccs 列表里
645
+ const isSelf = data.self === cc_id;
646
+ const otherEntry = (data.ccs || []).find((e) => e?.ccId === cc_id);
647
+ if (isSelf) {
648
+ remoteView = {
649
+ reachable: true,
650
+ inRegistry: true,
651
+ note: 'cc_id is self — daemon 注册表里一定有,参考 local.idleMs 判断新鲜度',
652
+ lastOnlineMs: localIdleMs,
653
+ };
654
+ }
655
+ else if (otherEntry) {
656
+ remoteView = {
657
+ reachable: true,
658
+ inRegistry: true,
659
+ lastOnlineMs: otherEntry.lastOnline ? now - otherEntry.lastOnline : -1,
660
+ robotName: otherEntry.robotName,
661
+ mode: otherEntry.mode,
662
+ };
663
+ }
664
+ else {
665
+ remoteView = {
666
+ reachable: true,
667
+ inRegistry: false,
668
+ knownCcs: [data.self, ...(data.ccs || []).map((e) => e?.ccId)].filter(Boolean),
669
+ };
670
+ }
671
+ }
672
+ catch (e) {
673
+ remoteView = { reachable: false, error: e?.message || String(e) };
674
+ }
675
+ // verdict
676
+ let verdict;
677
+ let message;
678
+ if (!remoteView.reachable) {
679
+ verdict = 'remote_unreachable';
680
+ message = `daemon 不可达:${remoteView.error}。channel-server 本地 sseConnected=${sseConnected},但无法验证 daemon 视角。`;
681
+ }
682
+ else if (!localView.ccId) {
683
+ verdict = 'never_entered';
684
+ message = 'channel-server 这个进程从未调过 enter_headless_mode,没建 SSE。让 LLM 跑一次 enter_headless_mode 即可。';
685
+ }
686
+ else if (!remoteView.inRegistry) {
687
+ verdict = 'stale_remote';
688
+ message = `daemon 注册表里看不到 ${cc_id}(被 stale-clean 掉了,或从未注册)。需要重新调 enter_headless_mode 让 daemon 重新认你。`;
689
+ }
690
+ else if (!sseConnected || (localIdleMs > 0 && localIdleMs > 60000)) {
691
+ verdict = 'stale_local';
692
+ message = `channel-server 本地 SSE 死了(connected=${sseConnected}, idleMs=${localIdleMs})但 daemon 还以为你在线。watchdog 应该 45s 触发但显然没。需要重连。`;
693
+ }
694
+ else if (remoteView.lastOnlineMs > 60000) {
695
+ verdict = 'split_brain';
696
+ message = `本地心跳活(idleMs=${localIdleMs}ms)但 daemon 上 lastOnline 是 ${Math.round(remoteView.lastOnlineMs / 1000)}s 前——路由可能有 bug,但你的 SSE 应该还在工作。`;
697
+ }
698
+ else {
699
+ verdict = 'ok';
700
+ message = `健康:本地 idleMs=${localIdleMs}ms,daemon 上 lastOnline=${Math.round(remoteView.lastOnlineMs / 1000)}s 前。`;
701
+ }
702
+ let autoFixed = false;
703
+ if (auto_fix && (verdict === 'stale_local')) {
704
+ try {
705
+ logger.info('verify_sse_health auto_fix: aborting SSE to trigger reconnect', { ccId: cc_id });
706
+ sseAbortController?.abort();
707
+ autoFixed = true;
708
+ message += ' [auto_fix] 已触发 SSE abort,约 3 秒后 channel-server 自动重连。';
709
+ }
710
+ catch (e) {
711
+ message += ` [auto_fix 失败:${String(e)}]`;
712
+ }
713
+ }
714
+ return {
715
+ content: [{
716
+ type: 'text',
717
+ text: JSON.stringify({ local: localView, remote: remoteView, verdict, message, autoFixed }, null, 2),
718
+ }],
719
+ };
720
+ });
721
+ // ============================================
612
722
  // 工具 4a: CC 间通信 — send_to_cc / list_active_ccs(v2.6.0+)
613
723
  // ============================================
614
724
  server.tool('send_to_cc', '向同一 daemon 上的另一个 CC 发送消息。目标 CC 收到时会作为 <channel source="cc:..."> 推送唤醒。仅支持同 daemon 间互通。支持 attachments 内联小文档(每个 < 16 KB);大文档请改用 upload_document。', {
@@ -1072,6 +1182,18 @@ function registerChannelTools(server) {
1072
1182
  */
1073
1183
  export async function startChannelServer() {
1074
1184
  logChannel('Starting Channel MCP Proxy');
1185
+ // v3.4.2: 启动即装 hook 文件(幂等)。
1186
+ // 防止"已在微信模式 + npx 升级"这种场景:项目 settings.json 已经指向
1187
+ // ~/.wecom-aibot-mcp/permission-hook.mjs,但因为没人重新调 enter_headless_mode,
1188
+ // ensureHookFiles 永远不跑 → 文件不存在 → hook ENOENT 静默放行。
1189
+ // 这里每次 TUI spawn channel-server 都跑一次,确保文件永远是最新版且存在。
1190
+ try {
1191
+ ensureHookFiles();
1192
+ logChannel('Hook 文件已确保安装(启动时幂等检查)');
1193
+ }
1194
+ catch (e) {
1195
+ logChannel('ensureHookFiles 启动时失败', { error: String(e) });
1196
+ }
1075
1197
  // 创建 MCP Server
1076
1198
  mcpServer = new McpServer({
1077
1199
  name: 'wecom-aibot-channel',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
4
4
  "description": "企业微信智能机器人 MCP 客户端 - 连接 wecom-aibot-server daemon",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",