@vrs-soft/wecom-aibot-mcp 2.4.7 → 2.4.9
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/channel-server.js +2 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +28 -15
- package/dist/config-wizard.js +124 -69
- package/dist/headless-state.d.ts +1 -1
- package/dist/headless-state.js +8 -75
- package/dist/http-server.js +101 -24
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/message-bus.d.ts +15 -0
- package/dist/message-bus.js +13 -0
- package/dist/project-config.d.ts +12 -4
- package/dist/project-config.js +58 -21
- package/dist/tools/index.js +14 -10
- package/package.json +1 -1
package/dist/channel-server.js
CHANGED
|
@@ -187,7 +187,8 @@ function connectSSE(ccId) {
|
|
|
187
187
|
}
|
|
188
188
|
sseConnected = true;
|
|
189
189
|
sseCurrentCcId = ccId;
|
|
190
|
-
|
|
190
|
+
// SSE URL 添加 ccId 查询参数用于授权验证
|
|
191
|
+
const sseUrl = ccId ? `${MCP_URL}/sse/${ccId}?ccId=${ccId}` : `${MCP_URL}/sse`;
|
|
191
192
|
logChannel('Connecting to SSE', { url: sseUrl, ccId, mcpServerReady: mcpServer ? 'yes' : 'no' });
|
|
192
193
|
sseAbortController = new AbortController();
|
|
193
194
|
// SSE fetch 配置:添加 keep-alive headers 确保连接稳定
|
package/dist/client.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface ApprovalRecord {
|
|
|
13
13
|
keepaliveCount?: number;
|
|
14
14
|
operationHash?: string;
|
|
15
15
|
consumed?: boolean;
|
|
16
|
+
ccId?: string;
|
|
16
17
|
}
|
|
17
18
|
interface MessageRecord {
|
|
18
19
|
seq: number;
|
|
@@ -114,5 +115,4 @@ declare class WecomClient extends EventEmitter {
|
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
export declare function initClient(botId: string, secret: string, targetUserId: string, robotName: string): WecomClient;
|
|
117
|
-
export declare function getClient(): WecomClient;
|
|
118
118
|
export { WecomClient, ApprovalRecord, MessageRecord, PendingMessage, MAX_PENDING_MESSAGES };
|
package/dist/client.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import AiBot from '@wecom/aibot-node-sdk';
|
|
13
13
|
import { EventEmitter } from 'events';
|
|
14
14
|
import { logConnected, logAuthenticated, logDisconnected, logReconnecting, logError, } from './connection-log.js';
|
|
15
|
-
import { publishWecomMessage } from './message-bus.js';
|
|
15
|
+
import { publishWecomMessage, publishApprovalEvent } from './message-bus.js';
|
|
16
16
|
import { hashOperation } from './utils/hash.js';
|
|
17
17
|
import { logger } from './logger.js';
|
|
18
18
|
// 最大待处理消息数量
|
|
@@ -199,9 +199,14 @@ class WecomClient extends EventEmitter {
|
|
|
199
199
|
const cardEvent = event.template_card_event;
|
|
200
200
|
const taskId = cardEvent?.task_id;
|
|
201
201
|
const eventKey = cardEvent?.event_key; // 用户点击的按钮 key
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
// 打印当前 approvals Map 的所有 keys(诊断用)
|
|
203
|
+
const allTaskIds = [...this.approvals.keys()];
|
|
204
|
+
logger.log('wecom', `审批响应详情: taskId=${taskId}, eventKey=${eventKey}`);
|
|
205
|
+
logger.log('wecom', `当前 approvals keys (${allTaskIds.length}个): ${allTaskIds.join(',')}`);
|
|
206
|
+
if (!taskId) {
|
|
207
|
+
logger.log('wecom', `taskId 为空,cardEvent: ${JSON.stringify(cardEvent)}`);
|
|
204
208
|
return;
|
|
209
|
+
}
|
|
205
210
|
logger.log('wecom', `收到审批响应: taskId=${taskId}, key=${eventKey}`);
|
|
206
211
|
const approval = this.approvals.get(taskId);
|
|
207
212
|
if (approval && !approval.resolved) {
|
|
@@ -209,6 +214,15 @@ class WecomClient extends EventEmitter {
|
|
|
209
214
|
approval.result = eventKey;
|
|
210
215
|
approval.timestamp = Date.now();
|
|
211
216
|
this.emit('approval_resolved', { taskId, result: approval.result });
|
|
217
|
+
logger.log('wecom', `审批已解决: taskId=${taskId}, result=${approval.result}`);
|
|
218
|
+
// 发布审批事件到消息总线(用于 SSE 推送)
|
|
219
|
+
publishApprovalEvent({
|
|
220
|
+
robotName: this.robotName,
|
|
221
|
+
taskId,
|
|
222
|
+
result: approval.result,
|
|
223
|
+
ccId: approval.ccId,
|
|
224
|
+
timestamp: Date.now(),
|
|
225
|
+
});
|
|
212
226
|
// 发送确认消息给用户
|
|
213
227
|
const resultText = eventKey === 'allow-once' ? '✅ 已允许(本次)'
|
|
214
228
|
: eventKey === 'allow-always' ? '✅ 已允许(永久)'
|
|
@@ -224,7 +238,7 @@ class WecomClient extends EventEmitter {
|
|
|
224
238
|
logger.log('wecom', `审批已解决,跳过点击: ${taskId}, resolved=${approval.resolved}, result=${approval.result}`);
|
|
225
239
|
}
|
|
226
240
|
else {
|
|
227
|
-
logger.log('wecom', `审批记录不存在: ${taskId}
|
|
241
|
+
logger.log('wecom', `审批记录不存在: ${taskId}, 可能原因: 1) taskId 不匹配 2) 审批已过期清理 3) 审批由其他 client 创建`);
|
|
228
242
|
}
|
|
229
243
|
}
|
|
230
244
|
// 使用 reply 方法回复审批结果(会有引用效果)
|
|
@@ -355,6 +369,7 @@ class WecomClient extends EventEmitter {
|
|
|
355
369
|
toolInput,
|
|
356
370
|
description, // 保存审批请求原文
|
|
357
371
|
operationHash,
|
|
372
|
+
ccId, // 保存 ccId,用于 SSE 推送审批结果
|
|
358
373
|
});
|
|
359
374
|
// 断线时将审批请求加入队列,等待重连后发送
|
|
360
375
|
if (!this.connected) {
|
|
@@ -368,13 +383,14 @@ class WecomClient extends EventEmitter {
|
|
|
368
383
|
// 返回 taskId,审批记录已创建,等待重连后发送
|
|
369
384
|
return taskId;
|
|
370
385
|
}
|
|
371
|
-
//
|
|
386
|
+
// 发送模板卡片(在 description 中显示 taskId 便于用户识别)
|
|
387
|
+
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
372
388
|
await this.wsClient.sendMessage(userId, {
|
|
373
389
|
msgtype: 'template_card',
|
|
374
390
|
template_card: {
|
|
375
391
|
card_type: 'button_interaction',
|
|
376
392
|
main_title: { title },
|
|
377
|
-
sub_title_text:
|
|
393
|
+
sub_title_text: displayDesc,
|
|
378
394
|
button_list: [
|
|
379
395
|
{ text: '允许', key: 'allow-once', style: 1 },
|
|
380
396
|
{ text: '默认', key: 'allow-always', style: 1 },
|
|
@@ -399,13 +415,14 @@ class WecomClient extends EventEmitter {
|
|
|
399
415
|
return false;
|
|
400
416
|
}
|
|
401
417
|
const userId = targetUser || this.targetUserId;
|
|
402
|
-
//
|
|
418
|
+
// 发送模板卡片(在 description 中显示 taskId 便于用户识别)
|
|
419
|
+
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
403
420
|
await this.wsClient.sendMessage(userId, {
|
|
404
421
|
msgtype: 'template_card',
|
|
405
422
|
template_card: {
|
|
406
423
|
card_type: 'button_interaction',
|
|
407
424
|
main_title: { title },
|
|
408
|
-
sub_title_text:
|
|
425
|
+
sub_title_text: displayDesc,
|
|
409
426
|
button_list: [
|
|
410
427
|
{ text: '允许', key: 'allow-once', style: 1 },
|
|
411
428
|
{ text: '默认', key: 'allow-always', style: 1 },
|
|
@@ -646,7 +663,7 @@ class WecomClient extends EventEmitter {
|
|
|
646
663
|
};
|
|
647
664
|
}
|
|
648
665
|
}
|
|
649
|
-
//
|
|
666
|
+
// 单例实例(用于配置验证等场景)
|
|
650
667
|
let instance = null;
|
|
651
668
|
export function initClient(botId, secret, targetUserId, robotName) {
|
|
652
669
|
if (instance) {
|
|
@@ -656,10 +673,6 @@ export function initClient(botId, secret, targetUserId, robotName) {
|
|
|
656
673
|
instance.connect();
|
|
657
674
|
return instance;
|
|
658
675
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
throw new Error('WecomClient 未初始化,请先调用 initClient');
|
|
662
|
-
}
|
|
663
|
-
return instance;
|
|
664
|
-
}
|
|
676
|
+
// 注意:无参数版本的 getClient() 已删除
|
|
677
|
+
// 请使用 connection-manager.ts 的 getClient(robotName) 获取已连接的客户端
|
|
665
678
|
export { WecomClient, MAX_PENDING_MESSAGES };
|
package/dist/config-wizard.js
CHANGED
|
@@ -19,7 +19,7 @@ const SERVER_CONFIG_FILE = path.join(CONFIG_DIR, 'server.json'); // HTTP Server
|
|
|
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
21
|
const HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'permission-hook.sh');
|
|
22
|
-
const
|
|
22
|
+
const STOP_HOOK_SCRIPT_PATH = path.join(CONFIG_DIR, 'stop-hook.sh');
|
|
23
23
|
// Skill 模板路径(包内)- 使用 fileURLToPath 确保跨平台兼容
|
|
24
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
25
|
const __dirname = path.dirname(__filename);
|
|
@@ -448,15 +448,42 @@ case "$TOOL_NAME" in
|
|
|
448
448
|
;;
|
|
449
449
|
esac
|
|
450
450
|
|
|
451
|
-
#
|
|
452
|
-
|
|
453
|
-
|
|
451
|
+
# 通过进程树匹配活跃项目(以 Claude 进程为准,不依赖 pwd)
|
|
452
|
+
ACTIVE_INDEX="$HOME/.wecom-aibot-mcp/active-projects.json"
|
|
453
|
+
log_debug "[$(date)] Checking active-projects index via PID tree (pid=$$, ppid=$PPID)"
|
|
454
454
|
|
|
455
|
-
|
|
455
|
+
if [[ ! -f "$ACTIVE_INDEX" ]]; then
|
|
456
|
+
log_debug "[$(date)] No active-projects index, exit 0"
|
|
457
|
+
exit 0
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
# 沿进程树向上查找,深度 8 层
|
|
461
|
+
PROJECT_DIR=""
|
|
462
|
+
SEARCH_PID=$PPID
|
|
463
|
+
for i in {1..8}; do
|
|
464
|
+
if [[ -z "$SEARCH_PID" ]] || [[ "$SEARCH_PID" -le 1 ]]; then
|
|
465
|
+
break
|
|
466
|
+
fi
|
|
467
|
+
MATCH=$(jq -r --argjson p "$SEARCH_PID" '.[] | select(.pid==$p) | .projectDir' "$ACTIVE_INDEX" 2>/dev/null)
|
|
468
|
+
if [[ -n "$MATCH" ]]; then
|
|
469
|
+
PROJECT_DIR="$MATCH"
|
|
470
|
+
log_debug "[$(date)] Found project via PID $SEARCH_PID (depth $i): $PROJECT_DIR"
|
|
471
|
+
break
|
|
472
|
+
fi
|
|
473
|
+
SEARCH_PID=$(ps -o ppid= -p "$SEARCH_PID" 2>/dev/null | tr -d ' ')
|
|
474
|
+
done
|
|
475
|
+
|
|
476
|
+
if [[ -z "$PROJECT_DIR" ]]; then
|
|
477
|
+
log_debug "[$(date)] No PID match in process tree, exit 0"
|
|
478
|
+
exit 0
|
|
479
|
+
fi
|
|
480
|
+
|
|
481
|
+
CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
|
|
482
|
+
log_debug "[$(date)] Found project: $PROJECT_DIR"
|
|
456
483
|
|
|
457
484
|
# 配置文件不存在,不在微信模式
|
|
458
485
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
459
|
-
log_debug "[$(date)] No config
|
|
486
|
+
log_debug "[$(date)] No wecom-aibot.json config, exit 0"
|
|
460
487
|
exit 0
|
|
461
488
|
fi
|
|
462
489
|
|
|
@@ -468,37 +495,48 @@ if [[ "$WECHAT_MODE" != "true" ]]; then
|
|
|
468
495
|
exit 0
|
|
469
496
|
fi
|
|
470
497
|
|
|
471
|
-
# 确定 MCP Server
|
|
498
|
+
# 确定 MCP Server 地址
|
|
499
|
+
# channel 模式直接使用远程地址,http 模式先试本地再回退远程
|
|
500
|
+
MODE=$(jq -r '.mode // "http"' "$CONFIG_FILE" 2>/dev/null)
|
|
472
501
|
MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
|
|
473
502
|
AUTH_ARGS=()
|
|
474
503
|
|
|
475
|
-
|
|
476
|
-
log_debug "[$(date)] Local health check: $HEALTH"
|
|
477
|
-
if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
|
|
478
|
-
log_debug "[$(date)] Local server not available, trying remote channel config..."
|
|
504
|
+
_try_remote() {
|
|
479
505
|
CLAUDE_JSON="$HOME/.claude.json"
|
|
480
|
-
if [[ -f "$CLAUDE_JSON" ]]; then
|
|
481
|
-
REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
|
|
482
|
-
REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
|
|
483
|
-
if [[ -n "$REMOTE_URL" ]]; then
|
|
484
|
-
REMOTE_HEALTH=$(curl -s -m 5 \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
|
|
485
|
-
log_debug "[$(date)] Remote health check ($REMOTE_URL): $REMOTE_HEALTH"
|
|
486
|
-
if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
|
|
487
|
-
MCP_BASE_URL="$REMOTE_URL"
|
|
488
|
-
[[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
|
|
489
|
-
log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
|
|
490
|
-
else
|
|
491
|
-
log_debug "[$(date)] Remote health check failed, exit 0"
|
|
492
|
-
exit 0
|
|
493
|
-
fi
|
|
494
|
-
else
|
|
495
|
-
log_debug "[$(date)] No remote URL configured, exit 0"
|
|
496
|
-
exit 0
|
|
497
|
-
fi
|
|
498
|
-
else
|
|
506
|
+
if [[ ! -f "$CLAUDE_JSON" ]]; then
|
|
499
507
|
log_debug "[$(date)] No ~/.claude.json found, exit 0"
|
|
500
508
|
exit 0
|
|
501
509
|
fi
|
|
510
|
+
REMOTE_URL=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_URL // empty' "$CLAUDE_JSON" 2>/dev/null)
|
|
511
|
+
REMOTE_TOKEN=$(jq -r '.mcpServers["wecom-aibot-channel"].env.MCP_AUTH_TOKEN // empty' "$CLAUDE_JSON" 2>/dev/null)
|
|
512
|
+
if [[ -z "$REMOTE_URL" ]]; then
|
|
513
|
+
log_debug "[$(date)] No remote URL configured, exit 0"
|
|
514
|
+
exit 0
|
|
515
|
+
fi
|
|
516
|
+
REMOTE_HEALTH=$(curl -s -m 5 \${REMOTE_TOKEN:+-H "Authorization: Bearer $REMOTE_TOKEN"} "$REMOTE_URL/health" 2>/dev/null)
|
|
517
|
+
log_debug "[$(date)] Remote health check ($REMOTE_URL): $REMOTE_HEALTH"
|
|
518
|
+
if echo "$REMOTE_HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
|
|
519
|
+
MCP_BASE_URL="$REMOTE_URL"
|
|
520
|
+
[[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
|
|
521
|
+
log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
|
|
522
|
+
else
|
|
523
|
+
log_debug "[$(date)] Remote health check failed, exit 0"
|
|
524
|
+
exit 0
|
|
525
|
+
fi
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if [[ "$MODE" == "channel" ]]; then
|
|
529
|
+
# channel 模式:直接使用远程地址,跳过本地检查
|
|
530
|
+
log_debug "[$(date)] Channel mode, using remote server directly"
|
|
531
|
+
_try_remote
|
|
532
|
+
else
|
|
533
|
+
# http 模式:本地优先,失败则尝试远程
|
|
534
|
+
HEALTH=$(curl -s -m 2 "$MCP_BASE_URL/health" 2>/dev/null)
|
|
535
|
+
log_debug "[$(date)] Local health check: $HEALTH"
|
|
536
|
+
if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
|
|
537
|
+
log_debug "[$(date)] Local server not available, trying remote channel config..."
|
|
538
|
+
_try_remote
|
|
539
|
+
fi
|
|
502
540
|
fi
|
|
503
541
|
|
|
504
542
|
# 读取当前项目使用的机器人名称和 ccId
|
|
@@ -588,12 +626,14 @@ fi
|
|
|
588
626
|
# 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
|
|
589
627
|
log_debug "[$(date)] Executing smart auto-approval"
|
|
590
628
|
|
|
591
|
-
#
|
|
629
|
+
# 检查是否是删除命令(仅匹配命令行本身,不匹配 heredoc 内容)
|
|
592
630
|
IS_DELETE=0
|
|
593
631
|
if [[ "$TOOL_NAME" == "Bash" ]]; then
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
632
|
+
# 只取命令的第一行(避免 heredoc 内容干扰)
|
|
633
|
+
FIRST_LINE=$(echo "$TOOL_INPUT" | jq -r '.command // empty' | head -1)
|
|
634
|
+
log_debug "[$(date)] Checking delete: FIRST_LINE=$FIRST_LINE"
|
|
635
|
+
if [[ "$FIRST_LINE" == rm\\ * ]] || [[ "$FIRST_LINE" == rm ]] \\
|
|
636
|
+
|| echo "$FIRST_LINE" | grep -qE '(^|[;&|(] *)(rm |rmdir )'; then
|
|
597
637
|
IS_DELETE=1
|
|
598
638
|
fi
|
|
599
639
|
fi
|
|
@@ -615,10 +655,32 @@ IS_IN_PROJECT=0
|
|
|
615
655
|
case "$TOOL_NAME" in
|
|
616
656
|
Bash)
|
|
617
657
|
CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if [[ "$CMD" == *"$PROJECT_DIR"* ]]
|
|
658
|
+
EXEC_CWD=$(pwd)
|
|
659
|
+
log_debug "[$(date)] Bash CMD=$CMD, EXEC_CWD=$EXEC_CWD"
|
|
660
|
+
if [[ "$CMD" == *"$PROJECT_DIR"* ]]; then
|
|
661
|
+
# 明确包含项目路径 → 项目内
|
|
621
662
|
IS_IN_PROJECT=1
|
|
663
|
+
elif echo "$CMD" | grep -qE '(^|[ \t])/[a-zA-Z0-9]'; then
|
|
664
|
+
# 含有绝对路径:过滤掉项目路径和安全系统目录,看是否还有真正的项目外路径
|
|
665
|
+
OUTSIDE=$(echo "$CMD" | grep -oE '(^| )/[a-zA-Z0-9][^ \t>|;&]*' | tr -d ' ' \
|
|
666
|
+
| grep -v "^$PROJECT_DIR" \
|
|
667
|
+
| grep -vE '^(/tmp/|/var/tmp/|/dev/null|/dev/stdin|/dev/stdout|/dev/stderr|/dev/fd/)')
|
|
668
|
+
if [[ -z "$OUTSIDE" ]]; then
|
|
669
|
+
# 绝对路径全是项目内或安全临时目录 → 以执行位置为准
|
|
670
|
+
log_debug "[$(date)] Only safe abs paths, checking EXEC_CWD: $EXEC_CWD"
|
|
671
|
+
if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
|
|
672
|
+
IS_IN_PROJECT=1
|
|
673
|
+
fi
|
|
674
|
+
else
|
|
675
|
+
log_debug "[$(date)] Outside abs path detected: $OUTSIDE"
|
|
676
|
+
IS_IN_PROJECT=0
|
|
677
|
+
fi
|
|
678
|
+
else
|
|
679
|
+
# 无绝对路径(相对路径或纯命令如 npm/git)→ 以执行位置为准
|
|
680
|
+
log_debug "[$(date)] No absolute path, checking EXEC_CWD: $EXEC_CWD"
|
|
681
|
+
if [[ "$EXEC_CWD" == "$PROJECT_DIR"* ]]; then
|
|
682
|
+
IS_IN_PROJECT=1
|
|
683
|
+
fi
|
|
622
684
|
fi
|
|
623
685
|
;;
|
|
624
686
|
Write|Edit)
|
|
@@ -656,19 +718,19 @@ fi
|
|
|
656
718
|
fs.writeFileSync(HOOK_SCRIPT_PATH, script, { mode: 0o755 });
|
|
657
719
|
console.log(`[config] Hook 脚本已写入: ${HOOK_SCRIPT_PATH}`);
|
|
658
720
|
}
|
|
659
|
-
// 生成并写入
|
|
660
|
-
//
|
|
661
|
-
function
|
|
721
|
+
// 生成并写入 Stop hook 脚本
|
|
722
|
+
// HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
723
|
+
function writeStopHookScript() {
|
|
662
724
|
const script = `#!/bin/bash
|
|
663
|
-
# wecom-aibot-mcp
|
|
664
|
-
#
|
|
725
|
+
# wecom-aibot-mcp Stop hook
|
|
726
|
+
# HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
665
727
|
#
|
|
666
728
|
# 固定端口: 18963
|
|
667
729
|
# 检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 和 autoApprove 字段
|
|
668
730
|
|
|
669
731
|
MCP_PORT=18963
|
|
670
732
|
|
|
671
|
-
# 先保存输入(
|
|
733
|
+
# 先保存输入(Stop 事件数据)
|
|
672
734
|
INPUT=$(cat)
|
|
673
735
|
|
|
674
736
|
# 日志输出:--debug 模式下输出到 stderr,否则静默
|
|
@@ -679,7 +741,7 @@ log_debug() {
|
|
|
679
741
|
fi
|
|
680
742
|
}
|
|
681
743
|
|
|
682
|
-
log_debug "[$(date)]
|
|
744
|
+
log_debug "[$(date)] Stop hook called. INPUT: \${INPUT:0:200}"
|
|
683
745
|
|
|
684
746
|
# 检查项目目录的微信模式配置文件
|
|
685
747
|
PROJECT_DIR=$(pwd)
|
|
@@ -687,9 +749,9 @@ CONFIG_FILE="$PROJECT_DIR/.claude/wecom-aibot.json"
|
|
|
687
749
|
|
|
688
750
|
log_debug "[$(date)] Checking config: $CONFIG_FILE"
|
|
689
751
|
|
|
690
|
-
#
|
|
752
|
+
# 配置文件不存在,不在微信模式,允许停止
|
|
691
753
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
692
|
-
log_debug "[$(date)] No config file, exit 0 (allow
|
|
754
|
+
log_debug "[$(date)] No config file, exit 0 (allow stop)"
|
|
693
755
|
exit 0
|
|
694
756
|
fi
|
|
695
757
|
|
|
@@ -697,7 +759,7 @@ fi
|
|
|
697
759
|
WECHAT_MODE=$(jq -r '.wechatMode // false' "$CONFIG_FILE" 2>/dev/null)
|
|
698
760
|
log_debug "[$(date)] wechatMode: $WECHAT_MODE"
|
|
699
761
|
if [[ "$WECHAT_MODE" != "true" ]]; then
|
|
700
|
-
log_debug "[$(date)] wechatMode not true, exit 0 (allow
|
|
762
|
+
log_debug "[$(date)] wechatMode not true, exit 0 (allow stop)"
|
|
701
763
|
exit 0
|
|
702
764
|
fi
|
|
703
765
|
|
|
@@ -705,7 +767,7 @@ fi
|
|
|
705
767
|
AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
|
|
706
768
|
log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
|
|
707
769
|
if [[ "$AUTO_APPROVE" != "true" ]]; then
|
|
708
|
-
log_debug "[$(date)] autoApprove not true, exit 0 (allow
|
|
770
|
+
log_debug "[$(date)] autoApprove not true, exit 0 (allow stop)"
|
|
709
771
|
exit 0
|
|
710
772
|
fi
|
|
711
773
|
|
|
@@ -727,15 +789,15 @@ if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
|
|
|
727
789
|
[[ -n "$REMOTE_TOKEN" ]] && AUTH_ARGS=(-H "Authorization: Bearer $REMOTE_TOKEN")
|
|
728
790
|
log_debug "[$(date)] Using remote server: $MCP_BASE_URL"
|
|
729
791
|
else
|
|
730
|
-
log_debug "[$(date)] MCP Server offline, exit 0 (allow
|
|
792
|
+
log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
|
|
731
793
|
exit 0
|
|
732
794
|
fi
|
|
733
795
|
else
|
|
734
|
-
log_debug "[$(date)] MCP Server offline, exit 0 (allow
|
|
796
|
+
log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
|
|
735
797
|
exit 0
|
|
736
798
|
fi
|
|
737
799
|
else
|
|
738
|
-
log_debug "[$(date)] MCP Server offline, exit 0 (allow
|
|
800
|
+
log_debug "[$(date)] MCP Server offline, exit 0 (allow stop)"
|
|
739
801
|
exit 0
|
|
740
802
|
fi
|
|
741
803
|
fi
|
|
@@ -744,20 +806,20 @@ fi
|
|
|
744
806
|
CC_ID=$(jq -r '.ccId // empty' "$CONFIG_FILE" 2>/dev/null)
|
|
745
807
|
log_debug "[$(date)] ccId: $CC_ID"
|
|
746
808
|
if [[ -z "$CC_ID" ]]; then
|
|
747
|
-
log_debug "[$(date)] No ccId in config, exit 0 (allow
|
|
809
|
+
log_debug "[$(date)] No ccId in config, exit 0 (allow stop)"
|
|
748
810
|
exit 0
|
|
749
811
|
fi
|
|
750
812
|
|
|
751
813
|
# 处于微信模式且 autoApprove 为 true,需要恢复轮询
|
|
752
|
-
# 使用 exit code 2
|
|
753
|
-
log_debug "[$(date)] ✅ WeChat mode active, blocking
|
|
814
|
+
# 使用 exit code 2 阻止停止,并提示 Claude 调用 MCP 工具
|
|
815
|
+
log_debug "[$(date)] ✅ WeChat mode active, blocking stop to resume polling"
|
|
754
816
|
log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
|
|
755
817
|
echo "任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id=\"$CC_ID\", timeout_ms=30000) 恢复微信消息轮询" >&2
|
|
756
818
|
exit 2
|
|
757
819
|
`;
|
|
758
820
|
ensureConfigDir();
|
|
759
|
-
fs.writeFileSync(
|
|
760
|
-
console.log(`[config]
|
|
821
|
+
fs.writeFileSync(STOP_HOOK_SCRIPT_PATH, script, { mode: 0o755 });
|
|
822
|
+
console.log(`[config] Stop Hook 脚本已写入: ${STOP_HOOK_SCRIPT_PATH}`);
|
|
761
823
|
}
|
|
762
824
|
// 写入 MCP Server 配置到 ~/.claude.json
|
|
763
825
|
function writeMcpServerConfig(config, instanceName) {
|
|
@@ -996,7 +1058,7 @@ export function getDocMcpUrl(robotName) {
|
|
|
996
1058
|
error: `有多个机器人配置了文档 MCP URL,请通过 robot_name 参数指定使用哪个机器人。已配置文档能力的机器人: ${robotsWithDoc.map(r => r.name).join(', ')}`,
|
|
997
1059
|
};
|
|
998
1060
|
}
|
|
999
|
-
// 写入 MCP
|
|
1061
|
+
// 写入 MCP 工具权限到 Claude settings
|
|
1000
1062
|
function writeMcpPermissions() {
|
|
1001
1063
|
try {
|
|
1002
1064
|
// 确保目录存在
|
|
@@ -1020,18 +1082,11 @@ function writeMcpPermissions() {
|
|
|
1020
1082
|
if (!existingPerms.has(perm))
|
|
1021
1083
|
settings.permissions.allow.push(perm);
|
|
1022
1084
|
}
|
|
1023
|
-
//
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
if (!settings.hooks['PermissionRequest'])
|
|
1027
|
-
settings.hooks['PermissionRequest'] = [];
|
|
1028
|
-
const hookCommand = HOOK_SCRIPT_PATH;
|
|
1029
|
-
const alreadyRegistered = settings.hooks['PermissionRequest'].some((entry) => entry.hooks?.some?.((h) => h.command === hookCommand));
|
|
1030
|
-
if (!alreadyRegistered) {
|
|
1031
|
-
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: hookCommand }] });
|
|
1032
|
-
}
|
|
1085
|
+
// 注意:PermissionRequest hook 通过项目级 settings.json 配置,不注册全局 hook
|
|
1086
|
+
// HTTP 模式:enter_headless_mode 在项目目录写入 hook
|
|
1087
|
+
// Channel 模式:由 Claude Code 自动审批,不需要 hook
|
|
1033
1088
|
fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
|
1034
|
-
// 确保 hook
|
|
1089
|
+
// 确保 hook 脚本文件存在(供项目级 hook 引用)
|
|
1035
1090
|
writeHookScript();
|
|
1036
1091
|
}
|
|
1037
1092
|
catch (err) {
|
|
@@ -1042,7 +1097,7 @@ function writeMcpPermissions() {
|
|
|
1042
1097
|
// 确保 hook 已安装(幂等,可多次调用)
|
|
1043
1098
|
export function ensureHookInstalled() {
|
|
1044
1099
|
writeMcpPermissions();
|
|
1045
|
-
|
|
1100
|
+
writeStopHookScript();
|
|
1046
1101
|
}
|
|
1047
1102
|
// 确保所有全局配置已写入(强制覆盖,不依赖智能体)
|
|
1048
1103
|
export function ensureGlobalConfigs(mode = 'full', remoteOptions) {
|
package/dist/headless-state.d.ts
CHANGED
package/dist/headless-state.js
CHANGED
|
@@ -17,8 +17,8 @@ const CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
|
|
|
17
17
|
const HEADLESS_INDEX_FILE = path.join(CONFIG_DIR, 'headless-index.json');
|
|
18
18
|
// 项目状态文件路径
|
|
19
19
|
const PROJECT_STATE_FILE = 'headless.json';
|
|
20
|
-
// Hook
|
|
21
|
-
|
|
20
|
+
// 注意:Hook 配置由 tools/index.ts 通过 project-config.ts 的函数统一管理
|
|
21
|
+
// PERMISSION_HOOK_SCRIPT_PATH 定义在 project-config.ts 中
|
|
22
22
|
/**
|
|
23
23
|
* 确保配置目录存在
|
|
24
24
|
*/
|
|
@@ -85,8 +85,7 @@ export function enterHeadlessMode(projectDir, agentName, robotName) {
|
|
|
85
85
|
index.push(projectDir);
|
|
86
86
|
writeHeadlessIndex(index);
|
|
87
87
|
}
|
|
88
|
-
// 3.
|
|
89
|
-
configureProjectHook(projectDir);
|
|
88
|
+
// 3. Hook 配置由 tools/index.ts 的 enter_headless_mode 工具调用 addPermissionHook 管理
|
|
90
89
|
return state;
|
|
91
90
|
}
|
|
92
91
|
/**
|
|
@@ -114,8 +113,7 @@ export function exitHeadlessMode(projectDir) {
|
|
|
114
113
|
const index = readHeadlessIndex();
|
|
115
114
|
const newIndex = index.filter(p => p !== state.projectDir);
|
|
116
115
|
writeHeadlessIndex(newIndex);
|
|
117
|
-
// 3.
|
|
118
|
-
clearProjectHook(state.projectDir);
|
|
116
|
+
// 3. Hook 配置由 tools/index.ts 的 exit_headless_mode 工具调用 removePermissionHook 管理
|
|
119
117
|
return state;
|
|
120
118
|
}
|
|
121
119
|
/**
|
|
@@ -159,77 +157,13 @@ export function isHeadlessMode(projectDir) {
|
|
|
159
157
|
const dir = projectDir || process.cwd();
|
|
160
158
|
return fs.existsSync(getProjectHeadlessFile(dir));
|
|
161
159
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
*/
|
|
165
|
-
function configureProjectHook(projectDir) {
|
|
166
|
-
const settingsDir = path.join(projectDir, '.claude');
|
|
167
|
-
const settingsPath = path.join(settingsDir, 'settings.json');
|
|
168
|
-
// 确保目录存在
|
|
169
|
-
if (!fs.existsSync(settingsDir)) {
|
|
170
|
-
fs.mkdirSync(settingsDir, { recursive: true });
|
|
171
|
-
}
|
|
172
|
-
// 读取现有配置
|
|
173
|
-
let settings = {};
|
|
174
|
-
if (fs.existsSync(settingsPath)) {
|
|
175
|
-
try {
|
|
176
|
-
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
177
|
-
settings = JSON.parse(content);
|
|
178
|
-
}
|
|
179
|
-
catch (err) {
|
|
180
|
-
logger.error(`[headless] 读取 settings.json 失败: ${settingsPath}`, err);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
// 设置 PermissionRequest hook
|
|
184
|
-
if (!settings.hooks) {
|
|
185
|
-
settings.hooks = {};
|
|
186
|
-
}
|
|
187
|
-
settings.hooks['PermissionRequest'] = [
|
|
188
|
-
{
|
|
189
|
-
matcher: '',
|
|
190
|
-
hooks: [
|
|
191
|
-
{
|
|
192
|
-
type: 'command',
|
|
193
|
-
command: HOOK_SCRIPT_PATH,
|
|
194
|
-
timeout: 600,
|
|
195
|
-
},
|
|
196
|
-
],
|
|
197
|
-
},
|
|
198
|
-
];
|
|
199
|
-
// 写入配置
|
|
200
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
201
|
-
logger.log(`[headless] 已配置项目 Hook: ${settingsPath}`);
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* 清除项目级 Hook 配置
|
|
205
|
-
*/
|
|
206
|
-
function clearProjectHook(projectDir) {
|
|
207
|
-
const settingsPath = path.join(projectDir, '.claude', 'settings.json');
|
|
208
|
-
if (!fs.existsSync(settingsPath)) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
try {
|
|
212
|
-
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
213
|
-
const settings = JSON.parse(content);
|
|
214
|
-
if (settings.hooks && settings.hooks['PermissionRequest']) {
|
|
215
|
-
delete settings.hooks['PermissionRequest'];
|
|
216
|
-
// 如果 hooks 对象为空,删除整个 hooks 字段
|
|
217
|
-
if (Object.keys(settings.hooks).length === 0) {
|
|
218
|
-
delete settings.hooks;
|
|
219
|
-
}
|
|
220
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
221
|
-
logger.log(`[headless] 已清除项目 Hook: ${settingsPath}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
logger.error(`[headless] 清除项目 Hook 失败: ${settingsPath}`, err);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
160
|
+
// 注意:configureProjectHook 和 clearProjectHook 已移除
|
|
161
|
+
// Hook 配置由 tools/index.ts 通过 project-config.ts 的函数统一管理
|
|
228
162
|
/**
|
|
229
163
|
* 清理所有孤儿状态文件
|
|
230
164
|
*
|
|
231
165
|
* 扫描全局索引,检查每个项目的状态文件是否存在
|
|
232
|
-
*
|
|
166
|
+
* 如果状态文件不存在,从索引中移除
|
|
233
167
|
*/
|
|
234
168
|
export function cleanupOrphanFiles() {
|
|
235
169
|
ensureConfigDir();
|
|
@@ -241,8 +175,7 @@ export function cleanupOrphanFiles() {
|
|
|
241
175
|
validProjects.push(projectDir);
|
|
242
176
|
}
|
|
243
177
|
else {
|
|
244
|
-
//
|
|
245
|
-
clearProjectHook(projectDir);
|
|
178
|
+
// 状态文件不存在,仅从索引移除,Hook 清理由用户手动处理
|
|
246
179
|
logger.log(`[headless] 清理孤儿项目: ${projectDir}`);
|
|
247
180
|
}
|
|
248
181
|
}
|
package/dist/http-server.js
CHANGED
|
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
23
23
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
24
24
|
import { registerTools } from './tools/index.js';
|
|
25
25
|
import { getClient, getConnectionState, getAllConnectionStates, connectAllRobots } from './connection-manager.js';
|
|
26
|
-
import { subscribeWecomMessage
|
|
26
|
+
import { subscribeWecomMessage } from './message-bus.js';
|
|
27
27
|
import { listAllRobots, VERSION, getAuthToken } from './config-wizard.js';
|
|
28
28
|
import { logger } from './logger.js';
|
|
29
29
|
// ESM 兼容的 __dirname
|
|
@@ -44,10 +44,20 @@ function sanitizeAgentName(agentName) {
|
|
|
44
44
|
// 简化名称:移除特殊字符,限制长度
|
|
45
45
|
return agentName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '').slice(0, 20);
|
|
46
46
|
}
|
|
47
|
-
// ccId 生成器(基于 agentName)-
|
|
47
|
+
// ccId 生成器(基于 agentName)- 自动避免冲突
|
|
48
48
|
export function generateCcId(agentName) {
|
|
49
|
-
const
|
|
50
|
-
|
|
49
|
+
const base = agentName ? sanitizeAgentName(agentName) : 'cc';
|
|
50
|
+
// 无冲突:直接使用
|
|
51
|
+
if (!ccIdRegistry.has(base))
|
|
52
|
+
return base;
|
|
53
|
+
// 有冲突:自动添加数字后缀(-2, -3, ...)
|
|
54
|
+
for (let i = 2; i <= 99; i++) {
|
|
55
|
+
const candidate = `${base}-${i}`;
|
|
56
|
+
if (!ccIdRegistry.has(candidate))
|
|
57
|
+
return candidate;
|
|
58
|
+
}
|
|
59
|
+
// 兜底:使用时间戳保证唯一性
|
|
60
|
+
return `${base}-${Date.now()}`;
|
|
51
61
|
}
|
|
52
62
|
// 推送微信消息到 MCP 客户端(通过 SSE notification)
|
|
53
63
|
export async function pushMessageToSession(robotName, message) {
|
|
@@ -194,6 +204,20 @@ function initMcpServer() {
|
|
|
194
204
|
subscribeWecomMessage((msg) => {
|
|
195
205
|
handleWecomMessage(msg);
|
|
196
206
|
});
|
|
207
|
+
// 定时清理过期审批条目(每 5 分钟清理超过 15 分钟的条目)
|
|
208
|
+
setInterval(() => {
|
|
209
|
+
const cutoff = Date.now() - 15 * 60 * 1000;
|
|
210
|
+
let cleaned = 0;
|
|
211
|
+
for (const [id, entry] of pendingApprovals) {
|
|
212
|
+
if (entry.createdAt < cutoff) {
|
|
213
|
+
pendingApprovals.delete(id);
|
|
214
|
+
cleaned++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (cleaned > 0) {
|
|
218
|
+
logger.log(`[http] 定时清理过期审批: ${cleaned} 条`);
|
|
219
|
+
}
|
|
220
|
+
}, 5 * 60 * 1000);
|
|
197
221
|
}
|
|
198
222
|
// 创建新的 MCP Server 实例
|
|
199
223
|
function createMcpServerInstance() {
|
|
@@ -303,13 +327,14 @@ async function handleWecomMessage(msg) {
|
|
|
303
327
|
logger.log('[http] 无活跃 MCP session,跳过消息处理');
|
|
304
328
|
return;
|
|
305
329
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
330
|
+
// HTTP 模式下检查是否有在线 CC(而非 subscriberCount,后者只对 Channel 模式有效)
|
|
331
|
+
const ccCount = getCCCount();
|
|
332
|
+
logger.log(`[http] 当前在线 CC 数: ${ccCount}`);
|
|
333
|
+
if (ccCount === 0) {
|
|
334
|
+
logger.log('[http] 无在线 CC,跳过消息处理');
|
|
310
335
|
return;
|
|
311
336
|
}
|
|
312
|
-
if (
|
|
337
|
+
if (ccCount === 1) {
|
|
313
338
|
// 只有一个订阅者,直接广播(SSE 检查已在前面完成)
|
|
314
339
|
logger.log('[http] 单订阅者 HTTP 模式,直接广播');
|
|
315
340
|
for (const [sessionId, sessEntry] of transports) {
|
|
@@ -460,7 +485,7 @@ async function sendNoReferencePrompt(msg) {
|
|
|
460
485
|
${onlineList.map(id => `• 【${id}】`).join('\n')}
|
|
461
486
|
|
|
462
487
|
示例:引用【${onlineList[0]}】的消息后回复`;
|
|
463
|
-
await client.sendText(reply);
|
|
488
|
+
await client.sendText(reply, msg.chatid); // 发送到原始会话(群聊或单聊)
|
|
464
489
|
}
|
|
465
490
|
export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
466
491
|
startTime = Date.now();
|
|
@@ -468,7 +493,9 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
|
468
493
|
initMcpServer();
|
|
469
494
|
return new Promise((resolve, reject) => {
|
|
470
495
|
const requestHandler = async (req, res) => {
|
|
471
|
-
|
|
496
|
+
// CORS 设置:HTTPS 模式收紧为 'null',HTTP 本地模式宽松为 '*'
|
|
497
|
+
const isPublicMode = !!httpsConfig;
|
|
498
|
+
res.setHeader('Access-Control-Allow-Origin', isPublicMode ? 'null' : '*');
|
|
472
499
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
473
500
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
|
|
474
501
|
// 必须暴露 Mcp-Session-Id 头,让客户端能看到
|
|
@@ -630,6 +657,23 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
|
630
657
|
res.end(JSON.stringify({ ok: true, ...result }));
|
|
631
658
|
return;
|
|
632
659
|
}
|
|
660
|
+
// ============================================
|
|
661
|
+
// 调试端点统一拦截
|
|
662
|
+
// ============================================
|
|
663
|
+
if (url.startsWith('/debug/')) {
|
|
664
|
+
// 生产环境禁用所有 debug 端点
|
|
665
|
+
if (process.env.NODE_ENV === 'production') {
|
|
666
|
+
res.writeHead(404);
|
|
667
|
+
res.end();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// debug/sampling 额外要求配置了 Auth Token
|
|
671
|
+
if (url === '/debug/sampling' && !getAuthToken()) {
|
|
672
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
673
|
+
res.end(JSON.stringify({ error: 'debug/sampling 需要配置 Auth Token' }));
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
633
677
|
// 临时调试端点:手动进入 headless 模式
|
|
634
678
|
if (req.method === 'POST' && url === '/debug/enter_headless') {
|
|
635
679
|
const ccId = `debug-${Date.now()}`;
|
|
@@ -853,6 +897,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
853
897
|
taskId,
|
|
854
898
|
status: 'pending',
|
|
855
899
|
timestamp: Date.now(),
|
|
900
|
+
createdAt: Date.now(), // 写入时间,用于定时清理
|
|
856
901
|
tool_name,
|
|
857
902
|
tool_input,
|
|
858
903
|
description,
|
|
@@ -878,6 +923,11 @@ function handleApprovalStatus(_req, res, url) {
|
|
|
878
923
|
// 更新审批状态
|
|
879
924
|
if (result !== 'pending') {
|
|
880
925
|
entry.status = result;
|
|
926
|
+
// 延迟 5 分钟删除,给 Hook 最后一次轮询窗口
|
|
927
|
+
setTimeout(() => {
|
|
928
|
+
pendingApprovals.delete(taskId);
|
|
929
|
+
logger.log(`[http] 审批条目已清理: taskId=${taskId}`);
|
|
930
|
+
}, 5 * 60 * 1000);
|
|
881
931
|
}
|
|
882
932
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
883
933
|
res.end(JSON.stringify({ status: result, result }));
|
|
@@ -892,9 +942,10 @@ function handleApprovalStatus(_req, res, url) {
|
|
|
892
942
|
});
|
|
893
943
|
return;
|
|
894
944
|
}
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
res.
|
|
945
|
+
// 未找到 → 返回 404,让 Hook 识别"审批已丢失"并退出
|
|
946
|
+
logger.log(`[http] pendingApprovals 中未找到 taskId=${taskId}`);
|
|
947
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
948
|
+
res.end(JSON.stringify({ error: 'taskId not found', taskId }));
|
|
898
949
|
}
|
|
899
950
|
async function handleApprovalTimeout(req, res, url) {
|
|
900
951
|
const taskId = url.replace('/approval_timeout/', '');
|
|
@@ -917,6 +968,7 @@ async function handleApprovalTimeout(req, res, url) {
|
|
|
917
968
|
const success = client.setApprovalResult(taskId, result, reason);
|
|
918
969
|
if (success) {
|
|
919
970
|
entry.status = result;
|
|
971
|
+
pendingApprovals.delete(taskId); // 处理完立即删除
|
|
920
972
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
921
973
|
res.end(JSON.stringify({ success: true, taskId, result }));
|
|
922
974
|
}
|
|
@@ -944,15 +996,23 @@ function handleHealthCheck(_req, res) {
|
|
|
944
996
|
}, null, 2));
|
|
945
997
|
}
|
|
946
998
|
// SSE 连接处理(Channel 模式)
|
|
947
|
-
function handleSSEConnect(req, res,
|
|
948
|
-
const
|
|
949
|
-
const
|
|
999
|
+
function handleSSEConnect(req, res, _url) {
|
|
1000
|
+
const urlObj = new URL(req.url, 'http://localhost');
|
|
1001
|
+
const targetCcId = decodeURIComponent(urlObj.pathname.replace('/sse/', ''));
|
|
1002
|
+
const requestCcId = urlObj.searchParams.get('ccId'); // 请求方声明的身份
|
|
1003
|
+
const entry = getCCRegistryEntry(targetCcId);
|
|
950
1004
|
if (!entry) {
|
|
951
1005
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
952
|
-
res.end(`CC ${
|
|
1006
|
+
res.end(`CC ${targetCcId} not found`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
// 请求方 ccId 必须与目标 ccId 一致
|
|
1010
|
+
if (requestCcId && requestCcId !== targetCcId) {
|
|
1011
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1012
|
+
res.end(JSON.stringify({ error: `无权订阅 ccId: ${targetCcId}` }));
|
|
953
1013
|
return;
|
|
954
1014
|
}
|
|
955
|
-
const clientId = `${
|
|
1015
|
+
const clientId = `${targetCcId}_${Date.now()}`;
|
|
956
1016
|
// 设置 SSE headers
|
|
957
1017
|
res.writeHead(200, {
|
|
958
1018
|
'Content-Type': 'text/event-stream',
|
|
@@ -963,12 +1023,12 @@ function handleSSEConnect(req, res, url) {
|
|
|
963
1023
|
// 注册 SSE 客户端
|
|
964
1024
|
sseClients.set(clientId, {
|
|
965
1025
|
res,
|
|
966
|
-
ccId,
|
|
1026
|
+
ccId: targetCcId,
|
|
967
1027
|
robotName: entry.robotName,
|
|
968
1028
|
});
|
|
969
|
-
logger.log(`[http] SSE 客户端连接: clientId=${clientId}, ccId=${
|
|
1029
|
+
logger.log(`[http] SSE 客户端连接: clientId=${clientId}, ccId=${targetCcId}, robotName=${entry.robotName}`);
|
|
970
1030
|
// 发送连接确认
|
|
971
|
-
res.write(`event: connected\ndata: {"clientId":"${clientId}","ccId":"${
|
|
1031
|
+
res.write(`event: connected\ndata: {"clientId":"${clientId}","ccId":"${targetCcId}"}\n\n`);
|
|
972
1032
|
// 心跳机制:每 15 秒发送注释行保持连接活跃
|
|
973
1033
|
const heartbeatInterval = setInterval(() => {
|
|
974
1034
|
// SSE 注释行(以冒号开头)会被客户端忽略,但保持连接
|
|
@@ -1021,10 +1081,27 @@ async function handleNotify(req, res) {
|
|
|
1021
1081
|
res.end(JSON.stringify({ error: err.message }));
|
|
1022
1082
|
}
|
|
1023
1083
|
}
|
|
1084
|
+
// push_notification 允许的 method 白名单
|
|
1085
|
+
const PUSH_NOTIFICATION_ALLOWED_METHODS = new Set([
|
|
1086
|
+
'notifications/message',
|
|
1087
|
+
'notifications/progress',
|
|
1088
|
+
'notifications/resources/updated',
|
|
1089
|
+
'notifications/tools/list_changed',
|
|
1090
|
+
]);
|
|
1024
1091
|
async function handlePushNotification(req, res) {
|
|
1025
1092
|
try {
|
|
1026
1093
|
const body = await readRequestBody(req);
|
|
1027
1094
|
const { method, params } = JSON.parse(body);
|
|
1095
|
+
// method 白名单校验
|
|
1096
|
+
const effectiveMethod = method || 'notifications/message';
|
|
1097
|
+
if (!PUSH_NOTIFICATION_ALLOWED_METHODS.has(effectiveMethod)) {
|
|
1098
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1099
|
+
res.end(JSON.stringify({
|
|
1100
|
+
error: `不允许的 method: ${effectiveMethod}`,
|
|
1101
|
+
allowed: Array.from(PUSH_NOTIFICATION_ALLOWED_METHODS),
|
|
1102
|
+
}));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1028
1105
|
if (transports.size === 0) {
|
|
1029
1106
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1030
1107
|
res.end(JSON.stringify({ error: '无活跃 MCP session' }));
|
|
@@ -1035,7 +1112,7 @@ async function handlePushNotification(req, res) {
|
|
|
1035
1112
|
for (const [sessionId, entry] of transports) {
|
|
1036
1113
|
try {
|
|
1037
1114
|
await entry.server.server.notification({
|
|
1038
|
-
method:
|
|
1115
|
+
method: effectiveMethod,
|
|
1039
1116
|
params: params || {}
|
|
1040
1117
|
});
|
|
1041
1118
|
sent++;
|
|
@@ -1045,7 +1122,7 @@ async function handlePushNotification(req, res) {
|
|
|
1045
1122
|
}
|
|
1046
1123
|
}
|
|
1047
1124
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1048
|
-
res.end(JSON.stringify({ success: true, method:
|
|
1125
|
+
res.end(JSON.stringify({ success: true, method: effectiveMethod, sessions: sent }));
|
|
1049
1126
|
}
|
|
1050
1127
|
catch (err) {
|
|
1051
1128
|
logger.error('[http] 推送通知失败:', err);
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { WecomClient, initClient } from './client.js';
|
|
12
12
|
export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, } from './connection-manager.js';
|
|
13
|
-
export { startHttpServer, stopHttpServer, HTTP_PORT,
|
|
13
|
+
export { startHttpServer, stopHttpServer, HTTP_PORT, } from './http-server.js';
|
|
14
14
|
export type { ApprovalRequest } from './http-server.js';
|
|
15
|
+
export { PERMISSION_HOOK_SCRIPT_PATH, STOP_HOOK_SCRIPT_PATH, } from './project-config.js';
|
|
16
|
+
export { PERMISSION_HOOK_SCRIPT_PATH as HOOK_SCRIPT_PATH } from './project-config.js';
|
|
15
17
|
export { registerTools } from './tools/index.js';
|
|
16
18
|
export { listAllRobots, runConfigWizard } from './config-wizard.js';
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,11 @@ export { WecomClient, initClient } from './client.js';
|
|
|
13
13
|
// 连接管理模块
|
|
14
14
|
export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, } from './connection-manager.js';
|
|
15
15
|
// HTTP 服务模块
|
|
16
|
-
export { startHttpServer, stopHttpServer, HTTP_PORT,
|
|
16
|
+
export { startHttpServer, stopHttpServer, HTTP_PORT, } from './http-server.js';
|
|
17
|
+
// Hook 脚本路径(统一从 project-config.ts 导出)
|
|
18
|
+
export { PERMISSION_HOOK_SCRIPT_PATH, STOP_HOOK_SCRIPT_PATH, } from './project-config.js';
|
|
19
|
+
// 向后兼容别名
|
|
20
|
+
export { PERMISSION_HOOK_SCRIPT_PATH as HOOK_SCRIPT_PATH } from './project-config.js';
|
|
17
21
|
// 工具注册
|
|
18
22
|
export { registerTools } from './tools/index.js';
|
|
19
23
|
// 配置向导
|
package/dist/message-bus.d.ts
CHANGED
|
@@ -23,16 +23,31 @@ export interface WecomMessage {
|
|
|
23
23
|
timestamp: number;
|
|
24
24
|
quoteContent?: string;
|
|
25
25
|
}
|
|
26
|
+
export interface ApprovalEvent {
|
|
27
|
+
robotName: string;
|
|
28
|
+
taskId: string;
|
|
29
|
+
result: 'allow-once' | 'allow-always' | 'deny';
|
|
30
|
+
ccId?: string;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
}
|
|
26
33
|
export declare function getSubscriberCount(robotName: string): number;
|
|
27
34
|
declare const wecomMessage$: Subject<WecomMessage>;
|
|
28
35
|
/**
|
|
29
36
|
* 发布微信消息(由 WecomClient 调用)
|
|
30
37
|
*/
|
|
31
38
|
export declare function publishWecomMessage(msg: WecomMessage): void;
|
|
39
|
+
/**
|
|
40
|
+
* 发布审批事件(由 WecomClient 调用)
|
|
41
|
+
*/
|
|
42
|
+
export declare function publishApprovalEvent(event: ApprovalEvent): void;
|
|
32
43
|
/**
|
|
33
44
|
* 订阅所有微信消息
|
|
34
45
|
*/
|
|
35
46
|
export declare function subscribeWecomMessage(callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
|
|
47
|
+
/**
|
|
48
|
+
* 订阅所有审批事件
|
|
49
|
+
*/
|
|
50
|
+
export declare function subscribeApprovalEvent(callback: (event: ApprovalEvent) => void): import("rxjs").Subscription;
|
|
36
51
|
/**
|
|
37
52
|
* 订阅特定机器人的微信消息(带订阅计数)
|
|
38
53
|
*/
|
package/dist/message-bus.js
CHANGED
|
@@ -35,6 +35,7 @@ function decrementSubscriberCount(robotName) {
|
|
|
35
35
|
// 消息总线(RxJS Subject)
|
|
36
36
|
// ============================================
|
|
37
37
|
const wecomMessage$ = new Subject();
|
|
38
|
+
const approvalEvent$ = new Subject();
|
|
38
39
|
// ============================================
|
|
39
40
|
// 发布/订阅接口
|
|
40
41
|
// ============================================
|
|
@@ -44,12 +45,24 @@ const wecomMessage$ = new Subject();
|
|
|
44
45
|
export function publishWecomMessage(msg) {
|
|
45
46
|
wecomMessage$.next(msg);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* 发布审批事件(由 WecomClient 调用)
|
|
50
|
+
*/
|
|
51
|
+
export function publishApprovalEvent(event) {
|
|
52
|
+
approvalEvent$.next(event);
|
|
53
|
+
}
|
|
47
54
|
/**
|
|
48
55
|
* 订阅所有微信消息
|
|
49
56
|
*/
|
|
50
57
|
export function subscribeWecomMessage(callback) {
|
|
51
58
|
return wecomMessage$.subscribe(callback);
|
|
52
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* 订阅所有审批事件
|
|
62
|
+
*/
|
|
63
|
+
export function subscribeApprovalEvent(callback) {
|
|
64
|
+
return approvalEvent$.subscribe(callback);
|
|
65
|
+
}
|
|
53
66
|
/**
|
|
54
67
|
* 订阅特定机器人的微信消息(带订阅计数)
|
|
55
68
|
*/
|
package/dist/project-config.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface WechatModeConfig {
|
|
|
20
20
|
autoApprove?: boolean;
|
|
21
21
|
autoApproveTimeout?: number;
|
|
22
22
|
heartbeatJobId?: string;
|
|
23
|
+
mode?: 'channel' | 'http';
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
26
|
* 获取项目配置文件路径
|
|
@@ -114,6 +115,8 @@ export declare function validateConfig(config: Partial<ProjectConfig>): boolean;
|
|
|
114
115
|
* 获取项目 settings.json 路径
|
|
115
116
|
*/
|
|
116
117
|
export declare function getProjectSettingsPath(projectDir: string): string;
|
|
118
|
+
export declare const PERMISSION_HOOK_SCRIPT_PATH: string;
|
|
119
|
+
export declare const STOP_HOOK_SCRIPT_PATH: string;
|
|
117
120
|
/**
|
|
118
121
|
* 添加 PermissionRequest hook 到项目 settings.json
|
|
119
122
|
*/
|
|
@@ -130,17 +133,22 @@ export declare function removePermissionHook(projectDir: string): {
|
|
|
130
133
|
existed: boolean;
|
|
131
134
|
};
|
|
132
135
|
/**
|
|
133
|
-
* 添加
|
|
136
|
+
* 添加 Stop hook 到项目 settings.json
|
|
137
|
+
* HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
134
138
|
*/
|
|
135
|
-
export declare function
|
|
139
|
+
export declare function addStopHook(projectDir: string): {
|
|
136
140
|
success: boolean;
|
|
137
141
|
path: string;
|
|
138
142
|
};
|
|
139
143
|
/**
|
|
140
|
-
* 删除
|
|
144
|
+
* 删除 Stop hook 从项目 settings.json
|
|
141
145
|
*/
|
|
142
|
-
export declare function
|
|
146
|
+
export declare function removeStopHook(projectDir: string): {
|
|
143
147
|
success: boolean;
|
|
144
148
|
path: string;
|
|
145
149
|
existed: boolean;
|
|
146
150
|
};
|
|
151
|
+
/** 进入微信模式时注册 PID → projectDir */
|
|
152
|
+
export declare function registerActiveProject(claudePid: number, projectDir: string): void;
|
|
153
|
+
/** 退出微信模式时注销 */
|
|
154
|
+
export declare function unregisterActiveProject(projectDir: string): void;
|
package/dist/project-config.js
CHANGED
|
@@ -249,23 +249,26 @@ export function validateConfig(config) {
|
|
|
249
249
|
export function getProjectSettingsPath(projectDir) {
|
|
250
250
|
return path.join(projectDir, '.claude', 'settings.json');
|
|
251
251
|
}
|
|
252
|
+
// ============================================
|
|
253
|
+
// Hook 脚本路径(统一定义)
|
|
254
|
+
// ============================================
|
|
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');
|
|
252
258
|
/**
|
|
253
259
|
* PermissionRequest hook 配置
|
|
254
260
|
*/
|
|
255
261
|
const PERMISSION_HOOK = {
|
|
256
262
|
matcher: '',
|
|
257
|
-
hooks: [{ type: 'command', command:
|
|
263
|
+
hooks: [{ type: 'command', command: PERMISSION_HOOK_SCRIPT_PATH }],
|
|
258
264
|
};
|
|
259
265
|
/**
|
|
260
|
-
*
|
|
266
|
+
* Stop hook 配置
|
|
267
|
+
* 用于 HTTP 模式:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
261
268
|
*/
|
|
262
|
-
const
|
|
263
|
-
/**
|
|
264
|
-
* TaskCompleted hook 配置
|
|
265
|
-
*/
|
|
266
|
-
const TASK_COMPLETED_HOOK = {
|
|
269
|
+
const STOP_HOOK = {
|
|
267
270
|
matcher: '',
|
|
268
|
-
hooks: [{ type: 'command', command:
|
|
271
|
+
hooks: [{ type: 'command', command: STOP_HOOK_SCRIPT_PATH }],
|
|
269
272
|
};
|
|
270
273
|
/**
|
|
271
274
|
* 添加 PermissionRequest hook 到项目 settings.json
|
|
@@ -335,9 +338,10 @@ export function removePermissionHook(projectDir) {
|
|
|
335
338
|
}
|
|
336
339
|
}
|
|
337
340
|
/**
|
|
338
|
-
* 添加
|
|
341
|
+
* 添加 Stop hook 到项目 settings.json
|
|
342
|
+
* HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
339
343
|
*/
|
|
340
|
-
export function
|
|
344
|
+
export function addStopHook(projectDir) {
|
|
341
345
|
const settingsPath = getProjectSettingsPath(projectDir);
|
|
342
346
|
const settingsDir = path.dirname(settingsPath);
|
|
343
347
|
// 确保目录存在
|
|
@@ -355,26 +359,26 @@ export function addTaskCompletedHook(projectDir) {
|
|
|
355
359
|
// ignore
|
|
356
360
|
}
|
|
357
361
|
}
|
|
358
|
-
// 添加 hooks.
|
|
362
|
+
// 添加 hooks.Stop
|
|
359
363
|
if (!settings.hooks) {
|
|
360
364
|
settings.hooks = {};
|
|
361
365
|
}
|
|
362
|
-
settings.hooks.
|
|
366
|
+
settings.hooks.Stop = [STOP_HOOK];
|
|
363
367
|
// 写入配置
|
|
364
368
|
try {
|
|
365
369
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
366
|
-
logger.log(`[project-config] 已添加
|
|
370
|
+
logger.log(`[project-config] 已添加 Stop hook: ${settingsPath}`);
|
|
367
371
|
return { success: true, path: settingsPath };
|
|
368
372
|
}
|
|
369
373
|
catch (err) {
|
|
370
|
-
logger.error(`[project-config] 添加
|
|
374
|
+
logger.error(`[project-config] 添加 Stop hook 失败: ${err}`);
|
|
371
375
|
return { success: false, path: settingsPath };
|
|
372
376
|
}
|
|
373
377
|
}
|
|
374
378
|
/**
|
|
375
|
-
* 删除
|
|
379
|
+
* 删除 Stop hook 从项目 settings.json
|
|
376
380
|
*/
|
|
377
|
-
export function
|
|
381
|
+
export function removeStopHook(projectDir) {
|
|
378
382
|
const settingsPath = getProjectSettingsPath(projectDir);
|
|
379
383
|
if (!fs.existsSync(settingsPath)) {
|
|
380
384
|
return { success: true, path: settingsPath, existed: false };
|
|
@@ -382,22 +386,55 @@ export function removeTaskCompletedHook(projectDir) {
|
|
|
382
386
|
try {
|
|
383
387
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
384
388
|
const settings = JSON.parse(content);
|
|
385
|
-
// 删除 hooks.
|
|
386
|
-
if (settings.hooks && settings.hooks.
|
|
387
|
-
delete settings.hooks.
|
|
389
|
+
// 删除 hooks.Stop
|
|
390
|
+
if (settings.hooks && settings.hooks.Stop) {
|
|
391
|
+
delete settings.hooks.Stop;
|
|
388
392
|
// 如果 hooks 为空,删除整个 hooks 字段
|
|
389
393
|
if (Object.keys(settings.hooks).length === 0) {
|
|
390
394
|
delete settings.hooks;
|
|
391
395
|
}
|
|
392
396
|
// 写入配置
|
|
393
397
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
394
|
-
logger.log(`[project-config] 已删除
|
|
398
|
+
logger.log(`[project-config] 已删除 Stop hook: ${settingsPath}`);
|
|
395
399
|
return { success: true, path: settingsPath, existed: true };
|
|
396
400
|
}
|
|
397
401
|
return { success: true, path: settingsPath, existed: false };
|
|
398
402
|
}
|
|
399
403
|
catch (err) {
|
|
400
|
-
logger.error(`[project-config] 删除
|
|
404
|
+
logger.error(`[project-config] 删除 Stop hook 失败: ${err}`);
|
|
401
405
|
return { success: false, path: settingsPath, existed: false };
|
|
402
406
|
}
|
|
403
407
|
}
|
|
408
|
+
// ============================================================
|
|
409
|
+
// 活跃项目索引(PID → projectDir,供 permission hook 使用)
|
|
410
|
+
// ============================================================
|
|
411
|
+
const ACTIVE_PROJECTS_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'active-projects.json');
|
|
412
|
+
function readActiveProjects() {
|
|
413
|
+
if (!fs.existsSync(ACTIVE_PROJECTS_FILE))
|
|
414
|
+
return [];
|
|
415
|
+
try {
|
|
416
|
+
return JSON.parse(fs.readFileSync(ACTIVE_PROJECTS_FILE, 'utf-8'));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function writeActiveProjects(entries) {
|
|
423
|
+
const dir = path.dirname(ACTIVE_PROJECTS_FILE);
|
|
424
|
+
if (!fs.existsSync(dir))
|
|
425
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
426
|
+
fs.writeFileSync(ACTIVE_PROJECTS_FILE, JSON.stringify(entries, null, 2));
|
|
427
|
+
}
|
|
428
|
+
/** 进入微信模式时注册 PID → projectDir */
|
|
429
|
+
export function registerActiveProject(claudePid, projectDir) {
|
|
430
|
+
const entries = readActiveProjects().filter(e => e.projectDir !== projectDir);
|
|
431
|
+
entries.push({ pid: claudePid, projectDir });
|
|
432
|
+
writeActiveProjects(entries);
|
|
433
|
+
logger.log(`[project-config] 注册活跃项目: pid=${claudePid} projectDir=${projectDir}`);
|
|
434
|
+
}
|
|
435
|
+
/** 退出微信模式时注销 */
|
|
436
|
+
export function unregisterActiveProject(projectDir) {
|
|
437
|
+
const entries = readActiveProjects().filter(e => e.projectDir !== projectDir);
|
|
438
|
+
writeActiveProjects(entries);
|
|
439
|
+
logger.log(`[project-config] 注销活跃项目: ${projectDir}`);
|
|
440
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { callDocTool } from '../doc-proxy.js';
|
|
|
25
25
|
import { connectRobot, disconnectRobot, getClient, getConnectionState, } from '../connection-manager.js';
|
|
26
26
|
import { registerCcId, unregisterCcId, getRobotByCcId, getProjectDirByCcId, generateCcId, } from '../http-server.js';
|
|
27
27
|
import { subscribeWecomMessageByCcId } from '../message-bus.js';
|
|
28
|
-
import { updateWechatModeConfig, loadWechatModeConfig, addPermissionHook, removePermissionHook,
|
|
28
|
+
import { updateWechatModeConfig, loadWechatModeConfig, addPermissionHook, removePermissionHook, addStopHook, removeStopHook, registerActiveProject, unregisterActiveProject } from '../project-config.js';
|
|
29
29
|
import { logger } from '../logger.js';
|
|
30
30
|
// 辅助函数:从 ccId 获取客户端
|
|
31
31
|
async function getConnectedClient(ccId) {
|
|
@@ -431,13 +431,16 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
431
431
|
ccId: finalCcId,
|
|
432
432
|
autoApprove: auto_approve,
|
|
433
433
|
autoApproveTimeout: auto_approve_timeout,
|
|
434
|
+
mode,
|
|
434
435
|
});
|
|
436
|
+
// 注册 PID → projectDir(供 permission hook 通过进程树匹配项目)
|
|
437
|
+
registerActiveProject(process.ppid ?? process.pid, projectDir);
|
|
435
438
|
// 安装 skill 到项目本地(支持远程部署 MCP)
|
|
436
439
|
const skillResult = installSkill(projectDir);
|
|
437
440
|
// 添加 PermissionRequest hook 到项目 settings.json
|
|
438
441
|
const hookResult = addPermissionHook(projectDir);
|
|
439
|
-
// HTTP 模式添加
|
|
440
|
-
const
|
|
442
|
+
// HTTP 模式添加 Stop hook(Channel 模式不需要,消息自动推送)
|
|
443
|
+
const stopHookResult = mode === 'http' ? addStopHook(projectDir) : { success: false, path: '' };
|
|
441
444
|
// 发送确认消息(头部标注来源 ccId 和 mode)
|
|
442
445
|
const modeDesc = mode === 'channel' ? 'Channel模式,消息自动推送' : 'HTTP模式,请定期轮询获取消息';
|
|
443
446
|
await result.client.sendText(`【${finalCcId}】已进入微信模式(${modeDesc}),使用机器人「${selectedRobot.name}」。`);
|
|
@@ -452,11 +455,11 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
452
455
|
agentName: effectiveAgentName, // 返回使用的 agentName
|
|
453
456
|
mode,
|
|
454
457
|
hook: hookResult,
|
|
455
|
-
|
|
458
|
+
stopHook: stopHookResult,
|
|
456
459
|
skill: skillResult, // skill 安装结果(如果 success=false,包含 skillUrl)
|
|
457
|
-
sseEndpoint: mode === 'channel' ? `http://127.0.0.1:18963/sse/${finalCcId}` : undefined,
|
|
460
|
+
sseEndpoint: mode === 'channel' ? `http://127.0.0.1:18963/sse/${finalCcId}?ccId=${finalCcId}` : undefined,
|
|
458
461
|
message: mode === 'channel'
|
|
459
|
-
? `连接 SSE endpoint: http://127.0.0.1:18963/sse/${finalCcId} 接收推送消息`
|
|
462
|
+
? `连接 SSE endpoint: http://127.0.0.1:18963/sse/${finalCcId}?ccId=${finalCcId} 接收推送消息`
|
|
460
463
|
: '已进入微信模式(HTTP)',
|
|
461
464
|
}),
|
|
462
465
|
}],
|
|
@@ -486,13 +489,14 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
486
489
|
}
|
|
487
490
|
// 断开连接
|
|
488
491
|
disconnectRobot(robotName);
|
|
489
|
-
// 更新项目配置文件中的 wechatMode 为 false
|
|
492
|
+
// 更新项目配置文件中的 wechatMode 为 false,注销 PID 索引
|
|
490
493
|
const projectDir = project_dir || process.cwd();
|
|
491
494
|
updateWechatModeConfig(projectDir, { wechatMode: false });
|
|
495
|
+
unregisterActiveProject(projectDir);
|
|
492
496
|
// 删除 PermissionRequest hook 从项目 settings.json
|
|
493
497
|
const hookResult = removePermissionHook(projectDir);
|
|
494
|
-
// 删除
|
|
495
|
-
const
|
|
498
|
+
// 删除 Stop hook 从项目 settings.json
|
|
499
|
+
const stopHookResult = removeStopHook(projectDir);
|
|
496
500
|
return {
|
|
497
501
|
content: [{
|
|
498
502
|
type: 'text',
|
|
@@ -501,7 +505,7 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
501
505
|
headless: false,
|
|
502
506
|
robotName,
|
|
503
507
|
hook: hookResult,
|
|
504
|
-
|
|
508
|
+
stopHook: stopHookResult,
|
|
505
509
|
message: '审批将使用默认 UI',
|
|
506
510
|
}),
|
|
507
511
|
}],
|