@vrs-soft/wecom-aibot-mcp 2.4.25 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wecom-aibot-mcp PermissionRequest hook (Node.js, 跨平台)
4
+ *
5
+ * 由 Claude Code 在工具调用前 stdin 喂入 JSON:
6
+ * { tool_name, tool_input, ... }
7
+ * 我们 stdout 输出:
8
+ * { hookSpecificOutput: { hookEventName, decision: { behavior, message? } } }
9
+ *
10
+ * 决策逻辑:
11
+ * 1. MCP 工具 / 只读工具 → 直接 allow
12
+ * 2. 沿进程树向上查 active-projects.json,未匹配 → exit 0(不拦截)
13
+ * 3. 项目无 .claude/wecom-aibot.json 或 wechatMode!=true → exit 0
14
+ * 4. 探测本地 daemon /health;channel 模式或本地不通则尝试远程
15
+ * 5. POST /approve 拿 taskId,轮询 /approval_status/:taskId
16
+ * 6. 超时(autoApproveTimeout)→ 智能策略:
17
+ * - rm 命令 → 拒
18
+ * - 项目内 → 允许,项目外 → 拒
19
+ */
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+ import * as os from 'os';
23
+ import { execSync } from 'child_process';
24
+ const MCP_PORT = 18963;
25
+ const HOME = os.homedir();
26
+ const ACTIVE_INDEX = path.join(HOME, '.wecom-aibot-mcp', 'active-projects.json');
27
+ const DEBUG_FILE = path.join(HOME, '.wecom-aibot-mcp', 'debug');
28
+ const CLAUDE_JSON = path.join(HOME, '.claude.json');
29
+ const IS_WIN = process.platform === 'win32';
30
+ function log(msg) {
31
+ if (fs.existsSync(DEBUG_FILE)) {
32
+ process.stderr.write(`[${new Date().toISOString()}] ${msg}\n`);
33
+ }
34
+ }
35
+ function emit(decision) {
36
+ const out = {
37
+ hookSpecificOutput: {
38
+ hookEventName: 'PermissionRequest',
39
+ decision,
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(out) + '\n');
43
+ // 显式退出,避免事件循环里残留的 timer / fetch 让进程多挂几秒
44
+ process.exit(0);
45
+ }
46
+ function readStdinSync() {
47
+ // hook 进程很短命,同步读 stdin 完整内容
48
+ const chunks = [];
49
+ try {
50
+ const buf = Buffer.alloc(65536);
51
+ while (true) {
52
+ const n = fs.readSync(0, buf, 0, buf.length, null);
53
+ if (!n)
54
+ break;
55
+ chunks.push(Buffer.from(buf.subarray(0, n)));
56
+ }
57
+ }
58
+ catch {
59
+ // EAGAIN / closed
60
+ }
61
+ return Buffer.concat(chunks).toString('utf-8');
62
+ }
63
+ function getParentPid(pid) {
64
+ if (!pid || pid <= 1)
65
+ return 0;
66
+ try {
67
+ if (IS_WIN) {
68
+ const out = execSync(`wmic process where ProcessId=${pid} get ParentProcessId /value`, {
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ }).toString();
71
+ const m = out.match(/ParentProcessId=(\d+)/);
72
+ return m ? parseInt(m[1], 10) : 0;
73
+ }
74
+ return parseInt(execSync(`ps -o ppid= -p ${pid}`, {
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ }).toString().trim(), 10) || 0;
77
+ }
78
+ catch {
79
+ return 0;
80
+ }
81
+ }
82
+ function findProjectFromActiveIndex(startPid) {
83
+ if (!fs.existsSync(ACTIVE_INDEX)) {
84
+ log('No active-projects index');
85
+ return null;
86
+ }
87
+ let entries = [];
88
+ try {
89
+ entries = JSON.parse(fs.readFileSync(ACTIVE_INDEX, 'utf-8'));
90
+ if (!Array.isArray(entries))
91
+ return null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ let pid = startPid;
97
+ for (let i = 0; i < 8; i++) {
98
+ if (!pid || pid <= 1)
99
+ break;
100
+ const hit = entries.find((e) => e?.pid === pid);
101
+ if (hit?.projectDir) {
102
+ log(`Matched PID ${pid} -> ${hit.projectDir}`);
103
+ return hit.projectDir;
104
+ }
105
+ const parent = getParentPid(pid);
106
+ if (!parent || parent === pid)
107
+ break;
108
+ pid = parent;
109
+ }
110
+ return null;
111
+ }
112
+ async function fetchJson(url, init) {
113
+ const ctrl = new AbortController();
114
+ const t = setTimeout(() => ctrl.abort(), init?.timeoutMs ?? 5000);
115
+ try {
116
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
117
+ if (!res.ok)
118
+ return null;
119
+ return await res.json().catch(() => null);
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ finally {
125
+ clearTimeout(t);
126
+ }
127
+ }
128
+ async function probeLocal() {
129
+ const baseUrl = `http://127.0.0.1:${MCP_PORT}`;
130
+ const data = await fetchJson(`${baseUrl}/health`, { timeoutMs: 2000 });
131
+ return data?.status === 'ok' ? { baseUrl } : null;
132
+ }
133
+ async function probeRemote() {
134
+ if (!fs.existsSync(CLAUDE_JSON))
135
+ return null;
136
+ let claudeConfig;
137
+ try {
138
+ claudeConfig = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf-8'));
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ const channel = claudeConfig?.mcpServers?.['wecom-aibot-channel'];
144
+ const remoteUrl = channel?.env?.MCP_URL;
145
+ const remoteToken = channel?.env?.MCP_AUTH_TOKEN;
146
+ if (!remoteUrl)
147
+ return null;
148
+ const baseUrl = remoteUrl.replace(/\/+$/, '');
149
+ const headers = {};
150
+ if (remoteToken)
151
+ headers['Authorization'] = `Bearer ${remoteToken}`;
152
+ const data = await fetchJson(`${baseUrl}/health`, { timeoutMs: 5000, headers });
153
+ if (data?.status !== 'ok')
154
+ return null;
155
+ return remoteToken ? { baseUrl, authHeader: `Bearer ${remoteToken}` } : { baseUrl };
156
+ }
157
+ function authedHeaders(ep, extra) {
158
+ const h = { ...(extra || {}) };
159
+ if (ep.authHeader)
160
+ h['Authorization'] = ep.authHeader;
161
+ return h;
162
+ }
163
+ function extractStringField(obj, ...keys) {
164
+ for (const k of keys) {
165
+ if (typeof obj?.[k] === 'string' && obj[k])
166
+ return obj[k];
167
+ }
168
+ return '';
169
+ }
170
+ function isInProject(toolName, toolInput, projectDir) {
171
+ if (toolName === 'Bash') {
172
+ const cmd = toolInput?.command || '';
173
+ if (cmd.includes(projectDir))
174
+ return true;
175
+ const absPaths = cmd.match(/(^|[ \t])(\/[A-Za-z0-9][^ \t>|;&]*|[A-Za-z]:\\[^ \t>|;&]+)/g) || [];
176
+ const safe = (p) => p.startsWith(projectDir) ||
177
+ /^(\/tmp\/|\/var\/tmp\/|\/dev\/null|\/dev\/std|\/dev\/fd\/)/.test(p) ||
178
+ /^[A-Za-z]:\\(Users\\[^\\]+\\AppData\\Local\\Temp\\|Windows\\Temp\\)/i.test(p);
179
+ const outside = absPaths.map(s => s.trim()).filter(p => !safe(p));
180
+ if (absPaths.length > 0 && outside.length > 0)
181
+ return false;
182
+ // 无可疑外部路径 → 以 cwd 为准
183
+ return process.cwd().startsWith(projectDir);
184
+ }
185
+ if (toolName === 'Write' || toolName === 'Edit') {
186
+ const fp = extractStringField(toolInput, 'file_path');
187
+ return !fp || fp.startsWith(projectDir) || !path.isAbsolute(fp);
188
+ }
189
+ const fp = extractStringField(toolInput, 'file_path', 'path', 'directory');
190
+ if (!fp)
191
+ return true; // 无路径信息时倾向放行
192
+ return fp.startsWith(projectDir) || !path.isAbsolute(fp);
193
+ }
194
+ function isDeleteCommand(toolName, toolInput) {
195
+ if (toolName !== 'Bash')
196
+ return false;
197
+ const cmd = (toolInput?.command || '').toString();
198
+ const firstLine = cmd.split('\n')[0] || '';
199
+ return /(^|[;&|(]\s*)(rm\s|rmdir\s|del\s|Remove-Item\s)/i.test(firstLine);
200
+ }
201
+ async function notifyTimeout(ep, taskId, result, reason) {
202
+ await fetchJson(`${ep.baseUrl}/approval_timeout/${taskId}`, {
203
+ method: 'POST',
204
+ timeoutMs: 5000,
205
+ headers: authedHeaders(ep, { 'Content-Type': 'application/json' }),
206
+ body: JSON.stringify({ result, reason }),
207
+ });
208
+ }
209
+ async function main() {
210
+ const raw = readStdinSync();
211
+ let input = {};
212
+ try {
213
+ input = raw ? JSON.parse(raw) : {};
214
+ }
215
+ catch {
216
+ log('stdin not JSON, exit 0');
217
+ process.exit(0);
218
+ }
219
+ const toolName = input?.tool_name || '';
220
+ log(`tool_name=${toolName}`);
221
+ // MCP 工具:放行
222
+ if (toolName.startsWith('mcp__')) {
223
+ emit({ behavior: 'allow' });
224
+ return;
225
+ }
226
+ // 只读工具:放行
227
+ const readOnly = new Set([
228
+ 'Read', 'Glob', 'Grep', 'LS', 'TaskList', 'TaskGet', 'TaskOutput', 'TaskStop',
229
+ 'CronList', 'CronCreate', 'CronDelete', 'AskUserQuestion', 'Skill',
230
+ 'ListMcpResourcesTool', 'EnterPlanMode', 'ExitPlanMode',
231
+ 'WebSearch', 'WebFetch', 'NotebookEdit',
232
+ ]);
233
+ if (readOnly.has(toolName)) {
234
+ emit({ behavior: 'allow' });
235
+ return;
236
+ }
237
+ // 沿进程树查 active-projects.json
238
+ const projectDir = findProjectFromActiveIndex(process.ppid || process.pid);
239
+ if (!projectDir) {
240
+ log('No active project match');
241
+ process.exit(0);
242
+ }
243
+ const configFile = path.join(projectDir, '.claude', 'wecom-aibot.json');
244
+ if (!fs.existsSync(configFile)) {
245
+ log('No wecom-aibot.json in project');
246
+ process.exit(0);
247
+ }
248
+ let cfg;
249
+ try {
250
+ cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
251
+ }
252
+ catch {
253
+ process.exit(0);
254
+ }
255
+ if (cfg?.wechatMode !== true) {
256
+ log('wechatMode not true');
257
+ process.exit(0);
258
+ }
259
+ const mode = cfg?.mode === 'channel' ? 'channel' : 'http';
260
+ const endpoint = mode === 'channel'
261
+ ? (await probeRemote())
262
+ : ((await probeLocal()) || (await probeRemote()));
263
+ if (!endpoint) {
264
+ log('No reachable MCP server');
265
+ process.exit(0);
266
+ }
267
+ // 提交审批
268
+ const body = {
269
+ tool_name: toolName,
270
+ tool_input: input?.tool_input ?? {},
271
+ projectDir,
272
+ robotName: cfg?.robotName || '',
273
+ ccId: cfg?.ccId || '',
274
+ };
275
+ const approveRes = await fetchJson(`${endpoint.baseUrl}/approve`, {
276
+ method: 'POST',
277
+ timeoutMs: 10000,
278
+ headers: authedHeaders(endpoint, { 'Content-Type': 'application/json' }),
279
+ body: JSON.stringify(body),
280
+ });
281
+ const taskId = approveRes?.taskId || '';
282
+ if (!taskId) {
283
+ log('No taskId returned');
284
+ process.exit(0);
285
+ }
286
+ // 轮询
287
+ const timeoutSec = Number(cfg?.autoApproveTimeout) || 300;
288
+ const maxPoll = Math.max(1, Math.ceil(timeoutSec / 2));
289
+ log(`Polling taskId=${taskId} maxPoll=${maxPoll}`);
290
+ for (let i = 0; i < maxPoll; i++) {
291
+ await new Promise(r => setTimeout(r, 2000));
292
+ const data = await fetchJson(`${endpoint.baseUrl}/approval_status/${taskId}`, {
293
+ timeoutMs: 3000,
294
+ headers: authedHeaders(endpoint),
295
+ });
296
+ const result = data?.result;
297
+ if (result === 'allow-once' || result === 'allow-always') {
298
+ emit({ behavior: 'allow' });
299
+ return;
300
+ }
301
+ if (result === 'deny') {
302
+ emit({ behavior: 'deny', message: '用户拒绝' });
303
+ return;
304
+ }
305
+ }
306
+ // 超时智能决策
307
+ const toolInput = input?.tool_input ?? {};
308
+ if (isDeleteCommand(toolName, toolInput)) {
309
+ await notifyTimeout(endpoint, taskId, 'deny', '超时自动拒绝:删除操作需人工确认');
310
+ emit({ behavior: 'deny', message: '超时自动拒绝:删除操作需人工确认' });
311
+ return;
312
+ }
313
+ if (isInProject(toolName, toolInput, projectDir)) {
314
+ await notifyTimeout(endpoint, taskId, 'allow-once', '超时自动允许:项目内操作');
315
+ emit({ behavior: 'allow', message: '超时自动允许:项目内操作' });
316
+ return;
317
+ }
318
+ await notifyTimeout(endpoint, taskId, 'deny', '超时自动拒绝:项目外操作需人工确认');
319
+ emit({ behavior: 'deny', message: '超时自动拒绝:项目外操作需人工确认' });
320
+ }
321
+ main().catch(err => {
322
+ log(`hook error: ${err?.message || err}`);
323
+ // 任何错误都让 Claude 继续(exit 0 表示不干预)
324
+ process.exit(0);
325
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wecom-aibot-mcp Stop hook (Node.js, 跨平台)
4
+ *
5
+ * Claude 准备停止时触发。如果当前项目处于微信模式,输出 exit code 2 阻止停止,
6
+ * 同时通过 stderr 提示 Claude 调用 get_pending_messages 恢复轮询。
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+ const MCP_PORT = 18963;
12
+ const HOME = os.homedir();
13
+ const DEBUG_FILE = path.join(HOME, '.wecom-aibot-mcp', 'debug');
14
+ const CLAUDE_JSON = path.join(HOME, '.claude.json');
15
+ function log(msg) {
16
+ if (fs.existsSync(DEBUG_FILE)) {
17
+ process.stderr.write(`[${new Date().toISOString()}] [stop] ${msg}\n`);
18
+ }
19
+ }
20
+ function readStdinSync() {
21
+ const chunks = [];
22
+ try {
23
+ const buf = Buffer.alloc(65536);
24
+ while (true) {
25
+ const n = fs.readSync(0, buf, 0, buf.length, null);
26
+ if (!n)
27
+ break;
28
+ chunks.push(Buffer.from(buf.subarray(0, n)));
29
+ }
30
+ }
31
+ catch { /* ignore */ }
32
+ return Buffer.concat(chunks).toString('utf-8');
33
+ }
34
+ async function fetchHealth(url, headers, timeoutMs = 2000) {
35
+ const ctrl = new AbortController();
36
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
37
+ try {
38
+ const res = await fetch(`${url}/health`, { signal: ctrl.signal, headers });
39
+ if (!res.ok)
40
+ return false;
41
+ const data = await res.json().catch(() => null);
42
+ return data?.status === 'ok';
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ finally {
48
+ clearTimeout(t);
49
+ }
50
+ }
51
+ async function main() {
52
+ // 消费 stdin(Claude 会传 stop 事件 JSON,我们不需要内容)
53
+ readStdinSync();
54
+ const projectDir = process.cwd();
55
+ const configFile = path.join(projectDir, '.claude', 'wecom-aibot.json');
56
+ if (!fs.existsSync(configFile)) {
57
+ log('no config, allow stop');
58
+ process.exit(0);
59
+ }
60
+ let cfg;
61
+ try {
62
+ cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
63
+ }
64
+ catch {
65
+ process.exit(0);
66
+ }
67
+ if (cfg?.wechatMode !== true) {
68
+ log('wechatMode not true, allow stop');
69
+ process.exit(0);
70
+ }
71
+ const ccId = cfg?.ccId || '';
72
+ if (!ccId) {
73
+ log('no ccId, allow stop');
74
+ process.exit(0);
75
+ }
76
+ // 探测 daemon 是否在线,离线就允许停止
77
+ let alive = await fetchHealth(`http://127.0.0.1:${MCP_PORT}`);
78
+ if (!alive && fs.existsSync(CLAUDE_JSON)) {
79
+ try {
80
+ const claudeConfig = JSON.parse(fs.readFileSync(CLAUDE_JSON, 'utf-8'));
81
+ const channel = claudeConfig?.mcpServers?.['wecom-aibot-channel'];
82
+ const remoteUrl = channel?.env?.MCP_URL;
83
+ const token = channel?.env?.MCP_AUTH_TOKEN;
84
+ if (remoteUrl) {
85
+ const headers = {};
86
+ if (token)
87
+ headers['Authorization'] = `Bearer ${token}`;
88
+ alive = await fetchHealth(remoteUrl.replace(/\/+$/, ''), headers, 5000);
89
+ }
90
+ }
91
+ catch { /* ignore */ }
92
+ }
93
+ if (!alive) {
94
+ log('MCP server offline, allow stop');
95
+ process.exit(0);
96
+ }
97
+ log(`WeChat mode active, blocking stop for ccId=${ccId}`);
98
+ process.stderr.write(`任务已完成,请调用 mcp__wecom-aibot__get_pending_messages(cc_id="${ccId}", timeout_ms=30000) 恢复微信消息轮询\n`);
99
+ process.exit(2);
100
+ }
101
+ main().catch(() => process.exit(0));
@@ -57,6 +57,7 @@ export declare function clearCcIdRegistry(): {
57
57
  entries: string[];
58
58
  };
59
59
  export declare function getRobotByCcId(ccId: string): string | null;
60
+ export declare function touchCcId(ccId: string): void;
60
61
  export declare function getProjectDirByCcId(ccId: string): string | null;
61
62
  export declare function getCCRegistryEntry(ccId: string): CCRegistryEntry | null;
62
63
  export declare function getCCCount(): number;
@@ -23,7 +23,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
23
23
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
24
24
  import { registerTools } from './tools/index.js';
25
25
  import { getClient, getConnectionState, getAllConnectionStates, connectAllRobots } from './connection-manager.js';
26
- import { subscribeWecomMessage } from './message-bus.js';
26
+ import { subscribeWecomMessage, subscribeCcMessageByTarget } from './message-bus.js';
27
27
  import { listAllRobots, VERSION, getAuthToken } from './config-wizard.js';
28
28
  import { logger } from './logger.js';
29
29
  // ESM 兼容的 __dirname
@@ -171,7 +171,19 @@ export function clearCcIdRegistry() {
171
171
  return { cleared: entries.length, entries };
172
172
  }
173
173
  export function getRobotByCcId(ccId) {
174
- return ccIdRegistry.get(ccId)?.robotName || null;
174
+ const entry = ccIdRegistry.get(ccId);
175
+ if (!entry)
176
+ return null;
177
+ // 任何 lookup 都视为 CC 还活着,刷新 lastOnline 防止 30 分钟 idle 被 stale-cleanup
178
+ // 否则 channel 模式下 SSE 长连接还活、但 ccIdRegistry 被清,send_message 立刻报「未在微信模式」
179
+ entry.lastOnline = Date.now();
180
+ return entry.robotName;
181
+ }
182
+ // 显式刷新 ccId 的 lastOnline(SSE 心跳等场景调用)
183
+ export function touchCcId(ccId) {
184
+ const entry = ccIdRegistry.get(ccId);
185
+ if (entry)
186
+ entry.lastOnline = Date.now();
175
187
  }
176
188
  export function getProjectDirByCcId(ccId) {
177
189
  return ccIdRegistry.get(ccId)?.projectDir || null;
@@ -661,6 +673,41 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
661
673
  res.end(JSON.stringify({ ok: true, ...result }));
662
674
  return;
663
675
  }
676
+ // DELETE /admin/ccid/:id - 注销单个 ccId 并断开它的所有 SSE 客户端连接
677
+ // 让对应 channel-server 在 3s 内自动 re-call enter_headless_mode → connectRobot 重建链路
678
+ // 必须配置全局 auth token 才能用(避免裸暴露)
679
+ const ccidAdminMatch = url.match(/^\/admin\/ccid\/(.+)$/);
680
+ if (req.method === 'DELETE' && ccidAdminMatch) {
681
+ if (!getAuthToken()) {
682
+ res.writeHead(403, { 'Content-Type': 'application/json' });
683
+ res.end(JSON.stringify({ error: '/admin/ccid 需要先配置 Auth Token(--set-token)' }));
684
+ return;
685
+ }
686
+ const targetCcId = decodeURIComponent(ccidAdminMatch[1]);
687
+ const entry = ccIdRegistry.get(targetCcId);
688
+ if (!entry) {
689
+ res.writeHead(404, { 'Content-Type': 'application/json' });
690
+ res.end(JSON.stringify({ error: 'ccId not found', ccId: targetCcId }));
691
+ return;
692
+ }
693
+ // 关闭并清理所有匹配该 ccId 的 SSE 客户端
694
+ const closedClients = [];
695
+ for (const [clientId, client] of sseClients) {
696
+ if (client.ccId === targetCcId) {
697
+ try {
698
+ client.res.end();
699
+ }
700
+ catch { /* ignore */ }
701
+ sseClients.delete(clientId);
702
+ closedClients.push(clientId);
703
+ }
704
+ }
705
+ unregisterCcId(targetCcId);
706
+ logger.info('ccId admin unregister', { ccId: targetCcId, robot: entry.robotName, closedSse: closedClients.length });
707
+ res.writeHead(200, { 'Content-Type': 'application/json' });
708
+ res.end(JSON.stringify({ ok: true, ccId: targetCcId, robot: entry.robotName, closedSseClients: closedClients.length }));
709
+ return;
710
+ }
664
711
  // ============================================
665
712
  // 调试端点统一拦截
666
713
  // ============================================
@@ -1121,15 +1168,28 @@ function handleSSEConnect(req, res, _url) {
1121
1168
  logger.log(`[http] SSE 客户端连接: clientId=${clientId}, ccId=${targetCcId}, robotName=${entry.robotName}`);
1122
1169
  // 发送连接确认
1123
1170
  res.write(`event: connected\ndata: {"clientId":"${clientId}","ccId":"${targetCcId}"}\n\n`);
1124
- // 心跳机制:每 15 秒发送注释行保持连接活跃
1171
+ // 心跳机制:每 15 秒发送注释行保持连接活跃 + 刷新 ccIdRegistry 的 lastOnline
1172
+ // 避免 channel 模式 CC 长时间 idle 时 SSE 还活但 ccIdRegistry 被 30 分钟 stale-cleanup
1125
1173
  const heartbeatInterval = setInterval(() => {
1126
- // SSE 注释行(以冒号开头)会被客户端忽略,但保持连接
1127
1174
  res.write(': heartbeat\n\n');
1175
+ touchCcId(targetCcId);
1128
1176
  logger.log(`[http] SSE 心跳发送: clientId=${clientId}`);
1129
1177
  }, 15000);
1178
+ // 订阅发往当前 ccId 的 CC 间消息,转发为 cc_message SSE event
1179
+ const ccSub = subscribeCcMessageByTarget(targetCcId, (msg) => {
1180
+ try {
1181
+ const data = JSON.stringify(msg);
1182
+ res.write(`event: cc_message\ndata: ${data}\n\n`);
1183
+ logger.info('cc_message pushed', { from: msg.fromCc, to: msg.toCc, msgId: msg.msgId, kind: msg.kind });
1184
+ }
1185
+ catch (err) {
1186
+ logger.error(`[http] cc_message 推送失败 clientId=${clientId}:`, err);
1187
+ }
1188
+ });
1130
1189
  // 处理客户端断开
1131
1190
  req.on('close', () => {
1132
1191
  clearInterval(heartbeatInterval);
1192
+ ccSub.unsubscribe();
1133
1193
  sseClients.delete(clientId);
1134
1194
  logger.log(`[http] SSE 客户端断开: clientId=${clientId}`);
1135
1195
  });
@@ -23,6 +23,16 @@ export interface WecomMessage {
23
23
  timestamp: number;
24
24
  quoteContent?: string;
25
25
  }
26
+ export interface CcMessage {
27
+ msgId: string;
28
+ fromCc: string;
29
+ toCc: string;
30
+ content: string;
31
+ kind: 'request' | 'reply' | 'notify';
32
+ replyTo?: string;
33
+ hopCount: number;
34
+ timestamp: number;
35
+ }
26
36
  export interface ApprovalEvent {
27
37
  robotName: string;
28
38
  taskId: string;
@@ -32,6 +42,7 @@ export interface ApprovalEvent {
32
42
  }
33
43
  export declare function getSubscriberCount(robotName: string): number;
34
44
  declare const wecomMessage$: Subject<WecomMessage>;
45
+ declare const ccMessage$: Subject<CcMessage>;
35
46
  /**
36
47
  * 发布微信消息(由 WecomClient 调用)
37
48
  */
@@ -57,4 +68,7 @@ export declare function subscribeWecomMessageByRobot(robotName: string, callback
57
68
  * 用于多 CC 共用一个机器人场景的过滤
58
69
  */
59
70
  export declare function subscribeWecomMessageByCcId(robotName: string, ccId: string, callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
60
- export { wecomMessage$ };
71
+ export declare function publishCcMessage(msg: CcMessage): void;
72
+ /** 订阅发往指定 ccId 的所有 CC 间消息 */
73
+ export declare function subscribeCcMessageByTarget(toCc: string, callback: (msg: CcMessage) => void): import("rxjs").Subscription;
74
+ export { wecomMessage$, ccMessage$ };
@@ -36,6 +36,7 @@ function decrementSubscriberCount(robotName) {
36
36
  // ============================================
37
37
  const wecomMessage$ = new Subject();
38
38
  const approvalEvent$ = new Subject();
39
+ const ccMessage$ = new Subject();
39
40
  // ============================================
40
41
  // 发布/订阅接口
41
42
  // ============================================
@@ -99,5 +100,15 @@ function isMessageForCcId(msg, ccId) {
99
100
  // 在多 CC 场景下,需要用户指定
100
101
  return false;
101
102
  }
103
+ // ============================================
104
+ // CC 间消息(v2.6.0+)
105
+ // ============================================
106
+ export function publishCcMessage(msg) {
107
+ ccMessage$.next(msg);
108
+ }
109
+ /** 订阅发往指定 ccId 的所有 CC 间消息 */
110
+ export function subscribeCcMessageByTarget(toCc, callback) {
111
+ return ccMessage$.pipe(filter(m => m.toCc === toCc)).subscribe(callback);
112
+ }
102
113
  // 导出 Observable(供高级用法)
103
- export { wecomMessage$ };
114
+ export { wecomMessage$, ccMessage$ };
@@ -0,0 +1,14 @@
1
+ /** 通过 fetch /health 探测 daemon 是否在该端口监听(跨平台) */
2
+ export declare function isDaemonAlive(port: number, timeoutMs?: number): Promise<boolean>;
3
+ /** 取指定 PID 的父进程 PID;不存在返回 0 */
4
+ export declare function getParentPid(pid: number): number;
5
+ /** 取指定 PID 的可执行文件名(comm 字段,如 "claude" / "node") */
6
+ export declare function getProcessName(pid: number): string;
7
+ /**
8
+ * 沿进程树向上查找 Claude Code 进程的 PID。
9
+ * 用于 channel-server 注册 active-projects 时定位真正的 TUI 进程
10
+ * (npx 安装下 process.ppid 是 npx 不是 claude)。
11
+ */
12
+ export declare function findClaudePid(startPid: number, maxDepth?: number): number;
13
+ /** 进程是否还在(process.kill(pid, 0) 在 Win/Unix 都可用) */
14
+ export declare function isProcessAlive(pid: number): boolean;