@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 +35 -14
- package/dist/channel-server.js +1 -41
- package/dist/config-wizard.js +31 -382
- package/dist/hooks/permission-hook.d.ts +2 -0
- package/dist/hooks/permission-hook.js +325 -0
- package/dist/hooks/stop-hook.d.ts +2 -0
- package/dist/hooks/stop-hook.js +101 -0
- package/dist/http-server.js +2 -2
- package/dist/platform.d.ts +14 -0
- package/dist/platform.js +107 -0
- package/dist/project-config.js +9 -4
- package/dist/tools/index.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
471
|
-
fs.
|
|
472
|
-
|
|
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] 正在重新安装...');
|
package/dist/channel-server.js
CHANGED
|
@@ -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
|
package/dist/config-wizard.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
230
|
-
fs.
|
|
231
|
-
|
|
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
|
-
//
|
|
432
|
-
function
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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.
|
|
714
|
-
console.log(`[config]
|
|
444
|
+
fs.copyFileSync(src, dest);
|
|
445
|
+
console.log(`[config] ${label} 已写入: ${dest}`);
|
|
446
|
+
return true;
|
|
715
447
|
}
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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,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,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));
|
package/dist/http-server.js
CHANGED
|
@@ -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.
|
|
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;
|
package/dist/platform.js
ADDED
|
@@ -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
|
+
}
|
package/dist/project-config.js
CHANGED
|
@@ -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
|
-
|
|
257
|
-
export const
|
|
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 增加延迟)
|
package/dist/tools/index.js
CHANGED
|
@@ -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.
|
|
256
|
+
script: '~/.wecom-aibot-mcp/permission-hook.js',
|
|
257
257
|
description: '审批请求通过微信发送',
|
|
258
258
|
},
|
|
259
259
|
},
|