@vrs-soft/wecom-aibot-mcp 2.4.26 → 3.0.0-rc.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.
package/dist/bin.js CHANGED
@@ -182,23 +182,36 @@ function isServerRunning() {
182
182
  return false;
183
183
  }
184
184
  }
185
- // 通过端口查找进程 PID(fallback,当 PID 文件不存在时)
185
+ // 通过端口查找进程 PID(fallback,当 PID 文件不存在时)。
186
+ // Windows 用 netstat -ano;Unix 优先 lsof,回退 ss。
186
187
  function findPidByPort(port) {
187
- try {
188
- // Linux: ss -tlnp | grep :18963
189
- const output = execSync(`ss -tlnp 2>/dev/null | grep ':${port}'`, { encoding: 'utf-8' });
190
- const match = output.match(/pid=(\d+)/);
191
- if (match)
192
- return parseInt(match[1]);
188
+ if (process.platform === 'win32') {
189
+ try {
190
+ const output = execSync(`netstat -ano -p TCP`, { encoding: 'utf-8' });
191
+ // 行形如: " TCP 127.0.0.1:18963 0.0.0.0:0 LISTENING 1234"
192
+ const re = new RegExp(`^\\s*TCP\\s+\\S+:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`, 'm');
193
+ const m = output.match(re);
194
+ if (m)
195
+ return parseInt(m[1], 10);
196
+ }
197
+ catch { /* ignore */ }
198
+ return null;
193
199
  }
194
- catch { /* ignore */ }
195
200
  try {
196
- // macOS: lsof -ti :18963
201
+ // macOS / Linux 都装了 lsof
197
202
  const output = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf-8' }).trim();
198
203
  if (output)
199
204
  return parseInt(output.split('\n')[0]);
200
205
  }
201
206
  catch { /* ignore */ }
207
+ try {
208
+ // Linux 备选:ss -tlnp
209
+ const output = execSync(`ss -tlnp 2>/dev/null | grep ':${port}'`, { encoding: 'utf-8' });
210
+ const match = output.match(/pid=(\d+)/);
211
+ if (match)
212
+ return parseInt(match[1]);
213
+ }
214
+ catch { /* ignore */ }
202
215
  return null;
203
216
  }
204
217
  // 停止服务
@@ -433,7 +446,13 @@ async function main() {
433
446
  const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
434
447
  const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.local.json');
435
448
  const VERSION_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'version.json');
436
- const HOOK_SCRIPT = path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.sh');
449
+ const HOOK_SCRIPTS = [
450
+ path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.js'),
451
+ path.join(os.homedir(), '.wecom-aibot-mcp', 'stop-hook.js'),
452
+ // 旧版 .sh 残留
453
+ path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.sh'),
454
+ path.join(os.homedir(), '.wecom-aibot-mcp', 'stop-hook.sh'),
455
+ ];
437
456
  // 1. 删除 ~/.claude.json 中的 wecom-aibot 配置
438
457
  if (fs.existsSync(CLAUDE_CONFIG_FILE)) {
439
458
  const content = fs.readFileSync(CLAUDE_CONFIG_FILE, 'utf-8');
@@ -466,10 +485,12 @@ async function main() {
466
485
  fs.unlinkSync(VERSION_FILE);
467
486
  console.log('[mcp] 已删除 ~/.wecom-aibot-mcp/version.json');
468
487
  }
469
- // 4. 删除 hook 脚本
470
- if (fs.existsSync(HOOK_SCRIPT)) {
471
- fs.unlinkSync(HOOK_SCRIPT);
472
- console.log('[mcp] 已删除 ~/.wecom-aibot-mcp/permission-hook.sh');
488
+ // 4. 删除 hook 脚本(含旧版 .sh)
489
+ for (const p of HOOK_SCRIPTS) {
490
+ if (fs.existsSync(p)) {
491
+ fs.unlinkSync(p);
492
+ console.log(`[mcp] 已删除 ${p}`);
493
+ }
473
494
  }
474
495
  // 5. 重新安装全局配置
475
496
  logger.log('\n[mcp] 正在重新安装...');
@@ -12,50 +12,10 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
- import { execSync } from 'child_process';
16
15
  import { VERSION, installSkill } from './config-wizard.js';
17
16
  import { addPermissionHook, registerActiveProject, unregisterActiveProject, updateWechatModeConfig } from './project-config.js';
18
17
  import { logger } from './logger.js';
19
- /**
20
- * 沿进程树向上查找 Claude Code TUI 的 PID。
21
- *
22
- * 背景:本地 dev (`command: "node"`) 时 channel-server 是 Claude TUI 的直接子进程,
23
- * process.ppid = Claude TUI ✓
24
- * 但 npx 部署 (`command: "npx"`) 时多了一层 npx:
25
- * Claude TUI → npx → node bin.js (channel-server)
26
- * process.ppid = npx ❌
27
- * permission-hook.sh 从 hook 自身向上查 active-projects.json 时只能命中 Claude TUI
28
- * 这条祖先链。如果注册的是 npx 的 PID,hook 永远找不到 → 静默 exit 0 → 跳过审批。
29
- *
30
- * 此函数从 startPid 起向上遍历,找到第一个命令名为 "claude" 的进程,返回其 PID。
31
- * 找不到时回退到 startPid(保持旧行为,至少 dev 场景不退化)。
32
- */
33
- function findClaudePid(startPid) {
34
- let pid = startPid;
35
- for (let i = 0; i < 8; i++) {
36
- if (!pid || pid <= 1)
37
- break;
38
- try {
39
- const comm = execSync(`ps -p ${pid} -o comm=`, { stdio: ['ignore', 'pipe', 'ignore'] })
40
- .toString()
41
- .trim();
42
- // ps comm= 返回执行文件 basename。Claude Code TUI 安装名就是 "claude"
43
- if (comm === 'claude' || comm.endsWith('/claude'))
44
- return pid;
45
- const ppidStr = execSync(`ps -p ${pid} -o ppid=`, { stdio: ['ignore', 'pipe', 'ignore'] })
46
- .toString()
47
- .trim();
48
- const ppid = parseInt(ppidStr, 10);
49
- if (!ppid || ppid === pid)
50
- break;
51
- pid = ppid;
52
- }
53
- catch {
54
- break;
55
- }
56
- }
57
- return startPid;
58
- }
18
+ import { findClaudePid } from './platform.js';
59
19
  const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
60
20
  const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
61
21
  // 构建带 auth 的 fetch headers
@@ -18,8 +18,11 @@ const VERSION_FILE = path.join(CONFIG_DIR, 'version.json');
18
18
  const SERVER_CONFIG_FILE = path.join(CONFIG_DIR, 'server.json'); // HTTP Server 配置(auth token 等)
19
19
  const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
20
20
  const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.local.json');
21
- const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
22
- const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
21
+ // v3.0+:hook 改用 Node.js(跨平台)。旧路径仅用于 --upgrade / --uninstall 清理。
22
+ const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.js');
23
+ const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.js');
24
+ const LEGACY_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
25
+ const LEGACY_STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
23
26
  // Skill 模板路径(包内)- 使用 fileURLToPath 确保跨平台兼容
24
27
  const __filename = fileURLToPath(import.meta.url);
25
28
  const __dirname = path.dirname(__filename);
@@ -225,10 +228,12 @@ export function deleteHook() {
225
228
  if (changed) {
226
229
  fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
227
230
  }
228
- // 删除 hook 脚本文件
229
- if (fs.existsSync(HOOK_SCRIPT_PATH)) {
230
- fs.unlinkSync(HOOK_SCRIPT_PATH);
231
- console.log('[config] 已删除 hook 脚本文件');
231
+ // 删除 hook 脚本文件(含旧版 .sh 残留)
232
+ for (const p of [HOOK_SCRIPT_PATH, STOP_HOOK_SCRIPT_PATH, LEGACY_HOOK_SCRIPT_PATH, LEGACY_STOP_HOOK_SCRIPT_PATH]) {
233
+ if (fs.existsSync(p)) {
234
+ fs.unlinkSync(p);
235
+ console.log(`[config] 已删除 hook 文件: ${path.basename(p)}`);
236
+ }
232
237
  }
233
238
  }
234
239
  }
@@ -428,385 +433,29 @@ export function uninstall() {
428
433
  console.log('\n[config] 卸载完成');
429
434
  console.log('[config] 如需重新安装,请运行: npx @vrs-soft/wecom-aibot-mcp\n');
430
435
  }
431
- // 生成并写入 hook 脚本(HTTP Transport 版本)
432
- function writeHookScript() {
433
- const script = `#!/bin/bash
434
- # wecom-aibot-mcp PermissionRequest hook
435
- # HTTP Transport 版本
436
- #
437
- # 固定端口: 18963
438
- # 通过 PID 树查 ~/.wecom-aibot-mcp/active-projects.json 匹配项目,读 wechatMode 开关
439
-
440
- MCP_PORT=18963
441
-
442
- # 先保存输入(只能读一次)
443
- INPUT=$(cat)
444
-
445
- # 日志输出:--debug 模式下输出到 stderr,否则静默
446
- DEBUG_FILE="$HOME/.wecom-aibot-mcp/debug"
447
- log_debug() {
448
- if [[ -f "$DEBUG_FILE" ]]; then
449
- echo "$1" >&2
450
- fi
451
- }
452
-
453
- log_debug "[$(date)] Hook called. TOOL_NAME: $(echo "$INPUT" | jq -r '.tool_name')"
454
-
455
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
456
-
457
- # MCP 工具本身不需要拦截
458
- if [[ "$TOOL_NAME" == mcp__* ]]; then
459
- log_debug "[$(date)] Allowed: MCP tool"
460
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
461
- exit 0
462
- fi
463
-
464
- # 只读工具不需要拦截
465
- case "$TOOL_NAME" in
466
- Read|Glob|Grep|LS|TaskList|TaskGet|TaskOutput|TaskStop|CronList|CronCreate|CronDelete|AskUserQuestion|Skill|ListMcpResourcesTool|EnterPlanMode|ExitPlanMode|WebSearch|WebFetch|NotebookEdit)
467
- log_debug "[$(date)] Allowed: read-only tool"
468
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
469
- exit 0
470
- ;;
471
- esac
472
-
473
- # 通过进程树匹配活跃项目(以 Claude 进程为准,不依赖 pwd)
474
- ACTIVE_INDEX="$HOME/.wecom-aibot-mcp/active-projects.json"
475
- log_debug "[$(date)] Checking active-projects index via PID tree (pid=$$, ppid=$PPID)"
476
-
477
- if [[ ! -f "$ACTIVE_INDEX" ]]; then
478
- log_debug "[$(date)] No active-projects index, exit 0"
479
- exit 0
480
- fi
481
-
482
- # 沿进程树向上查找,深度 8 层
483
- PROJECT_DIR=""
484
- SEARCH_PID=$PPID
485
- for i in {1..8}; do
486
- if [[ -z "$SEARCH_PID" ]] || [[ "$SEARCH_PID" -le 1 ]]; then
487
- break
488
- fi
489
- MATCH=$(jq -r --argjson p "$SEARCH_PID" '.[] | select(.pid==$p) | .projectDir' "$ACTIVE_INDEX" 2>/dev/null)
490
- if [[ -n "$MATCH" ]]; then
491
- PROJECT_DIR="$MATCH"
492
- log_debug "[$(date)] Found project via PID $SEARCH_PID (depth $i): $PROJECT_DIR"
493
- break
494
- fi
495
- SEARCH_PID=$(ps -o ppid= -p "$SEARCH_PID" 2>/dev/null | tr -d ' ')
496
- done
497
-
498
- if [[ -z "$PROJECT_DIR" ]]; then
499
- log_debug "[$(date)] No PID match in process tree, exit 0"
500
- exit 0
501
- fi
502
-
503
- CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
504
- log_debug "[$(date)] Found project: $PROJECT_DIR"
505
-
506
- # 配置文件不存在,不在微信模式
507
- if [[ ! -f "$CONFIG_FILE" ]]; then
508
- log_debug "[$(date)] No wecom-aibot.json config, exit 0"
509
- exit 0
510
- fi
511
-
512
- # 检查 wechatMode 是否为 true(微信模式开关)
513
- WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
514
- log_debug "[$(date)] wechatMode: $WECHAT_MODE"
515
- if [[ "$WECHAT_MODE" != "true" ]]; then
516
- log_debug "[$(date)] wechatMode not true, exit 0"
517
- exit 0
518
- fi
519
-
520
- # 确定 MCP Server 地址
521
- # channel 模式直接使用远程地址,http 模式先试本地再回退远程
522
- MODE=$(jq -r '.mode // "http"' "$CONFIG_FILE" 2>/dev/null)
523
- MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
524
- AUTH_ARGS=()
525
-
526
- _try_remote() {
527
- CLAUDE_JSON="$HOME/.claude.json"
528
- if [[ ! -f "$CLAUDE_JSON" ]]; then
529
- log_debug "[$(date)] No ~/.claude.json found, exit 0"
530
- exit 0
531
- fi
532
- REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
533
- REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
534
- if [[ -z "$REMOTE_URL" ]]; then
535
- log_debug "[$(date)] No remote URL configured, exit 0"
536
- exit 0
537
- fi
538
- REMOTE_HEALTH=$(curl -s -m 5 \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
539
- log_debug "[$(date)] Remote health check ($REMOTE_URL): $REMOTE_HEALTH"
540
- if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
541
- MCP_BASE_URL="$REMOTE_URL"
542
- [[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
543
- log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
544
- else
545
- log_debug "[$(date)] Remote health check failed, exit 0"
546
- exit 0
547
- fi
548
- }
549
-
550
- if [[ "$MODE" == "channel" ]]; then
551
- # channel 模式:直接使用远程地址,跳过本地检查
552
- log_debug "[$(date)] Channel mode, using remote server directly"
553
- _try_remote
554
- else
555
- # http 模式:本地优先,失败则尝试远程
556
- HEALTH=$(curl -s -m 2 "$MCP_BASE_URL/health" 2>/dev/null)
557
- log_debug "[$(date)] Local health check: $HEALTH"
558
- if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
559
- log_debug "[$(date)] Local server not available, trying remote channel config..."
560
- _try_remote
561
- fi
562
- fi
563
-
564
- # 读取当前项目使用的机器人名称和 ccId
565
- ROBOT_NAME=$(jq -r '.robotName // empty' "$CONFIG_FILE" 2>/dev/null)
566
- CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
567
-
568
- # 发送审批请求(使用 pwd 作为 projectDir)
569
- TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
570
- BODY=$(jq -n --arg tool_name "$TOOL_NAME" --argjson tool_input "$TOOL_INPUT" --arg project_dir "$PROJECT_DIR" --arg robot_name "$ROBOT_NAME" --arg cc_id "$CC_ID" \\
571
- '{"tool_name":$tool_name,"tool_input":$tool_input,"projectDir":$project_dir,"robotName":$robot_name,"ccId":$cc_id}')
572
-
573
- log_debug "[$(date)] Sending approval request..."
574
- RESPONSE=$(curl -s -m 10 -X POST "$MCP_BASE_URL/approve" \\
575
- "\${AUTH_ARGS[@]}" \\
576
- -H "Content-Type: application/json" \\
577
- -d "$BODY")
578
-
579
- log_debug "[$(date)] Approval response: $RESPONSE"
580
- TASK_ID=$(echo "$RESPONSE" | jq -r '.taskId // empty')
581
- if [[ -z "$TASK_ID" ]]; then
582
- log_debug "[$(date)] No taskId, exit 0"
583
- exit 0
584
- fi
585
-
586
- log_debug "[$(date)] Waiting for approval, taskId: $TASK_ID"
587
-
588
- # 轮询审批结果(带超时:从配置读取)
589
- AUTO_APPROVE_TIMEOUT=$(jq -r '.autoApproveTimeout // 300' "$CONFIG_FILE" 2>/dev/null)
590
- # 超时时间(秒),转换为轮询次数(每次 sleep 2秒)
591
- # 使用向上取整补偿整数截断:MAX_POLL = ceil(timeout/2) = (timeout+1)/2
592
- MAX_POLL=$(( (AUTO_APPROVE_TIMEOUT + 1) / 2 ))
593
- if [[ $MAX_POLL -lt 1 ]]; then
594
- MAX_POLL=1
595
- fi
596
- POLL_COUNT=0
597
-
598
- log_debug "[$(date)] autoApproveTimeout: $AUTO_APPROVE_TIMEOUT seconds, MAX_POLL: $MAX_POLL (actual wait: ~$((MAX_POLL * 2))s)"
599
-
600
- while [[ $POLL_COUNT -lt $MAX_POLL ]]; do
601
- sleep 2
602
- POLL_COUNT=$((POLL_COUNT + 1))
603
-
604
- STATUS=$(curl -s -m 3 "\${AUTH_ARGS[@]}" "$MCP_BASE_URL/approval_status/$TASK_ID" 2>/dev/null)
605
- RESULT=$(echo "$STATUS" | jq -r '.result // empty')
606
- log_debug "[$(date)] Poll $POLL_COUNT/$MAX_POLL: result=$RESULT"
607
-
608
- if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
609
- log_debug "[$(date)] Approved by user"
610
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
611
- exit 0
612
- elif [[ "$RESULT" == "deny" ]]; then
613
- log_debug "[$(date)] Denied by user"
614
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
615
- exit 0
616
- fi
617
- done
618
-
619
- log_debug "[$(date)] Timeout reached, executing smart auto-approval"
620
-
621
- # 超时处理:必须立即决策,Claude Code 的 hook timeout 会杀掉阻塞进程。
622
- # 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
623
-
624
- # 检查是否是删除命令(仅匹配命令行本身,不匹配 heredoc 内容)
625
- IS_DELETE=0
626
- if [[ "$TOOL_NAME" == "Bash" ]]; then
627
- # 只取命令的第一行(避免 heredoc 内容干扰)
628
- FIRST_LINE=$(echo "$TOOL_INPUT" | jq -r '.command // empty' | head -1)
629
- log_debug "[$(date)] Checking delete: FIRST_LINE=$FIRST_LINE"
630
- if [[ "$FIRST_LINE" == rm\\ * ]] || [[ "$FIRST_LINE" == rm ]] \\
631
- || echo "$FIRST_LINE" | grep -qE '(^|[;&|(] *)(rm |rmdir )'; then
632
- IS_DELETE=1
633
- fi
634
- fi
635
-
636
- log_debug "[$(date)] IS_DELETE: $IS_DELETE"
637
-
638
- # 删除操作 → 永远拒绝
639
- if [[ $IS_DELETE -eq 1 ]]; then
640
- log_debug "[$(date)] Auto-deny: delete operation"
641
- # 通知 MCP Server 发送微信消息
642
- curl -s -m 5 -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"deny","reason":"超时自动拒绝:删除操作需人工确认"}' > /dev/null 2>&1 &
643
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:删除操作需人工确认"}}}'
644
- exit 0
645
- fi
646
-
647
- # 检查操作路径是否在项目内
648
- IS_IN_PROJECT=0
649
-
650
- case "$TOOL_NAME" in
651
- Bash)
652
- CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
653
- EXEC_CWD=$(pwd)
654
- log_debug "[$(date)] Bash CMD=$CMD, EXEC_CWD=$EXEC_CWD"
655
- if [[ "$CMD" == *"$PROJECT_DIR"* ]]; then
656
- # 明确包含项目路径 → 项目内
657
- IS_IN_PROJECT=1
658
- elif echo "$CMD" | grep -qE '(^|[ \t])/[a-zA-Z0-9]'; then
659
- # 含有绝对路径:过滤掉项目路径和安全系统目录,看是否还有真正的项目外路径
660
- OUTSIDE=$(echo "$CMD" | grep -oE '(^| )/[a-zA-Z0-9][^ \t>|;&]*' | tr -d ' ' \
661
- | grep -v "^$PROJECT_DIR" \
662
- | grep -vE '^(/tmp/|/var/tmp/|/dev/null|/dev/stdin|/dev/stdout|/dev/stderr|/dev/fd/)')
663
- if [[ -z "$OUTSIDE" ]]; then
664
- # 绝对路径全是项目内或安全临时目录 → 以执行位置为准
665
- log_debug "[$(date)] Only safe abs paths, checking EXEC_CWD: $EXEC_CWD"
666
- if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
667
- IS_IN_PROJECT=1
668
- fi
669
- else
670
- log_debug "[$(date)] Outside abs path detected: $OUTSIDE"
671
- IS_IN_PROJECT=0
672
- fi
673
- else
674
- # 无绝对路径(相对路径或纯命令如 npm/git)→ 以执行位置为准
675
- log_debug "[$(date)] No absolute path, checking EXEC_CWD: $EXEC_CWD"
676
- if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
677
- IS_IN_PROJECT=1
678
- fi
679
- fi
680
- ;;
681
- Write|Edit)
682
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
683
- if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
684
- IS_IN_PROJECT=1
685
- fi
686
- ;;
687
- *)
688
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // .directory // empty')
689
- if [[ -n "$FILE_PATH" ]]; then
690
- if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
691
- IS_IN_PROJECT=1
692
- fi
693
- fi
694
- ;;
695
- esac
696
-
697
- log_debug "[$(date)] IS_IN_PROJECT: $IS_IN_PROJECT"
698
-
699
- # 根据项目内/外决策
700
- if [[ $IS_IN_PROJECT -eq 1 ]]; then
701
- log_debug "[$(date)] Auto-allow: project operation"
702
- # 通知 MCP Server 发送微信消息
703
- curl -s -m 5 -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"allow-once","reason":"超时自动允许:项目内操作"}' > /dev/null 2>&1 &
704
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","message":"超时自动允许:项目内操作"}}}'
705
- else
706
- log_debug "[$(date)] Auto-deny: outside project"
707
- # 通知 MCP Server 发送微信消息
708
- curl -s -m 5 -X POST "$MCP_BASE_URL/approval_timeout/$TASK_ID" "\${AUTH_ARGS[@]}" -H "Content-Type: application/json" -d '{"result":"deny","reason":"超时自动拒绝:项目外操作需人工确认"}' > /dev/null 2>&1 &
709
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:项目外操作需人工确认"}}}'
710
- fi
711
- `;
436
+ // 拷贝包内编译后的 hook 脚本到 ~/.wecom-aibot-mcp/permission-hook.js
437
+ function copyHook(srcRelative, dest, label) {
438
+ const src = path.join(__dirname, srcRelative);
439
+ if (!fs.existsSync(src)) {
440
+ logger.error(`[config] hook 源文件不存在: ${src}`);
441
+ return false;
442
+ }
712
443
  ensureConfigDir();
713
- fs.writeFileSync(HOOK_SCRIPT_PATH, script, { mode: 0o755 });
714
- console.log(`[config] Hook 脚本已写入: ${HOOK_SCRIPT_PATH}`);
444
+ fs.copyFileSync(src, dest);
445
+ console.log(`[config] ${label} 已写入: ${dest}`);
446
+ return true;
715
447
  }
716
- // 生成并写入 Stop hook 脚本
717
- // HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
718
- function writeStopHookScript() {
719
- const script = `#!/bin/bash
720
- # wecom-aibot-mcp Stop hook
721
- # HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
722
- #
723
- # 固定端口: 18963
724
- # 只检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 字段
725
-
726
- MCP_PORT=18963
727
-
728
- # 先保存输入(Stop 事件数据)
729
- INPUT=$(cat)
730
-
731
- # 日志输出:--debug 模式下输出到 stderr,否则静默
732
- DEBUG_FILE="$HOME/.wecom-aibot-mcp/debug"
733
- log_debug() {
734
- if [[ -f "$DEBUG_FILE" ]]; then
735
- echo "$1" >&2
736
- fi
448
+ // 安装 hook 脚本(v3.0+ 改用 Node.js,跨平台)
449
+ //
450
+ // 升级期同时保留旧 .sh:仍在使用 v2.4.x 项目级 settings.json(hook 命令指向 .sh)
451
+ // CC 在 enter_headless_mode 重新被调用前还得跑 .sh,删早了会让那些 CC 立刻失审批。
452
+ // .sh 真正清理的时机统一放在 --uninstall(deleteHook)。
453
+ function writeHookScript() {
454
+ copyHook(path.join('hooks', 'permission-hook.js'), HOOK_SCRIPT_PATH, 'PermissionRequest hook');
737
455
  }
738
-
739
- log_debug "[$(date)] Stop hook called. INPUT: \${INPUT:0:200}"
740
-
741
- # 检查项目目录的微信模式配置文件
742
- PROJECT_DIR=$(pwd)
743
- CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
744
-
745
- log_debug "[$(date)] Checking config: $CONFIG_FILE"
746
-
747
- # 配置文件不存在,不在微信模式,允许停止
748
- if [[ ! -f "$CONFIG_FILE" ]]; then
749
- log_debug "[$(date)] No config file, exit 0 (allow stop)"
750
- exit 0
751
- fi
752
-
753
- # 检查 wechatMode 是否为 true(微信模式开关)
754
- WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
755
- log_debug "[$(date)] wechatMode: $WECHAT_MODE"
756
- if [[ "$WECHAT_MODE" != "true" ]]; then
757
- log_debug "[$(date)] wechatMode not true, exit 0 (allow stop)"
758
- exit 0
759
- fi
760
-
761
- # 确定 MCP Server 地址(本地优先,失败则尝试远程 channel 配置)
762
- MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
763
- AUTH_ARGS=()
764
-
765
- HEALTH=$(curl -s -m 2 "$MCP_BASE_URL/health" 2>/dev/null)
766
- log_debug "[$(date)] Local health check: $HEALTH"
767
- if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
768
- CLAUDE_JSON="$HOME/.claude.json"
769
- if [[ -f "$CLAUDE_JSON" ]]; then
770
- REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
771
- REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
772
- if [[ -n "$REMOTE_URL" ]]; then
773
- REMOTE_HEALTH=$(curl -s -m 5 \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
774
- if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
775
- MCP_BASE_URL="$REMOTE_URL"
776
- [[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
777
- log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
778
- else
779
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
780
- exit 0
781
- fi
782
- else
783
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
784
- exit 0
785
- fi
786
- else
787
- log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
788
- exit 0
789
- fi
790
- fi
791
-
792
- # 获取 ccId
793
- CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
794
- log_debug "[$(date)] ccId: $CC_ID"
795
- if [[ -z "$CC_ID" ]]; then
796
- log_debug "[$(date)] No ccId in config, exit 0 (allow stop)"
797
- exit 0
798
- fi
799
-
800
- # 处于微信模式,需要恢复轮询
801
- # 使用 exit code 2 阻止停止,并提示 Claude 调用 MCP 工具
802
- log_debug "[$(date)] ✅ WeChat mode active, blocking stop to resume polling"
803
- log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
804
- echo "任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id=\"$CC_ID\", timeout_ms=30000) 恢复微信消息轮询" >&2
805
- exit 2
806
- `;
807
- ensureConfigDir();
808
- fs.writeFileSync(STOP_HOOK_SCRIPT_PATH, script, { mode: 0o755 });
809
- console.log(`[config] Stop Hook 脚本已写入: ${STOP_HOOK_SCRIPT_PATH}`);
456
+ // 安装 Stop hook 脚本(v3.0+ Node.js);旧 .sh 同上策略,--uninstall 时清理
457
+ function writeStopHookScript() {
458
+ copyHook(path.join('hooks', 'stop-hook.js'), STOP_HOOK_SCRIPT_PATH, 'Stop hook');
810
459
  }
811
460
  // 写入 MCP Server 配置到 ~/.claude.json
812
461
  function writeMcpServerConfig(config, instanceName) {
@@ -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));
@@ -31,8 +31,8 @@ const __filename = fileURLToPath(import.meta.url);
31
31
  const __dirname = path.dirname(__filename);
32
32
  // 固定端口
33
33
  export const HTTP_PORT = 18963;
34
- // Hook 脚本路径
35
- export const HOOK_SCRIPT_PATH = path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.sh');
34
+ // Hook 脚本路径(v3.0+ 改用 Node.js 脚本,跨平台)
35
+ export const HOOK_SCRIPT_PATH = path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.js');
36
36
  let httpServer = null;
37
37
  let startTime = 0;
38
38
  // Session ID 生成器(MCP SSE 使用)
@@ -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
+ }
@@ -253,14 +253,19 @@ export function getProjectSettingsPath(projectDir) {
253
253
  // Hook 脚本路径(统一定义)
254
254
  // ============================================
255
255
  const CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
256
- export const PERMISSION_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
257
- export const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
256
+ // v3.0+:hook 改用 Node.js 脚本,跨平台运行
257
+ export const PERMISSION_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.js');
258
+ export const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.js');
259
+ // settings.json 中 hook 命令格式:node "path"(路径 quote 兼容含空格的 Windows 路径)
260
+ function nodeHookCommand(scriptPath) {
261
+ return `node "${scriptPath}"`;
262
+ }
258
263
  /**
259
264
  * PermissionRequest hook 配置
260
265
  */
261
266
  const PERMISSION_HOOK = {
262
267
  matcher: '',
263
- hooks: [{ type: 'command', command: PERMISSION_HOOK_SCRIPT_PATH, timeout: 3600 }],
268
+ hooks: [{ type: 'command', command: nodeHookCommand(PERMISSION_HOOK_SCRIPT_PATH), timeout: 3600 }],
264
269
  };
265
270
  /**
266
271
  * Stop hook 配置
@@ -268,7 +273,7 @@ const PERMISSION_HOOK = {
268
273
  */
269
274
  const STOP_HOOK = {
270
275
  matcher: '',
271
- hooks: [{ type: 'command', command: STOP_HOOK_SCRIPT_PATH }],
276
+ hooks: [{ type: 'command', command: nodeHookCommand(STOP_HOOK_SCRIPT_PATH) }],
272
277
  };
273
278
  /**
274
279
  * 进入微信模式时默认预批的 MCP 工具通配(避免每次都走 hook 增加延迟)
@@ -253,7 +253,7 @@ npx @vrs-soft/wecom-aibot-mcp
253
253
  hooks: {
254
254
  file: '~/.claude/settings.local.json',
255
255
  PermissionRequest: {
256
- script: '~/.wecom-aibot-mcp/permission-hook.sh',
256
+ script: '~/.wecom-aibot-mcp/permission-hook.js',
257
257
  description: '审批请求通过微信发送',
258
258
  },
259
259
  },
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": "3.0.0-rc.0",
4
4
  "description": "企业微信智能机器人 MCP 服务 - Claude Code 审批通道",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",