🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

cc-im

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cc-im - npm Package Compare versions

Comparing version
1.5.0
to
1.6.0
+87
dist/hook/watch-script.js
#!/usr/bin/env node
/**
* Claude Code Hook 脚本 — watch 通知
*
* 适用于 PostToolUse / Stop / SubagentStart / SubagentStop 等事件。
* 从 stdin 读取事件数据,POST 到 cc-im 服务端的 /watch-notify 端点。
*
* 关键原则:永远不阻塞 Claude Code,所有错误静默处理,始终 exit 0。
*
* 环境变量:
* CC_IM_HOOK_PORT - 本地服务端口(默认 18900)
*
* stdin: JSON { hook_event_name, cwd, tool_name, tool_input, tool_response, last_assistant_message, agent_type }
*/
import { request } from 'node:http';
// 定义在本文件而非 constants.ts,因为 watch-script 作为独立 hook 脚本由 Claude Code 直接执行,
// 需要最小化依赖以保持快速启动。
const WATCH_NOTIFY_TIMEOUT_MS = 2000;
function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
// If stdin is empty/closed immediately
setTimeout(() => resolve(data), 100);
});
}
function httpPost(port, path, body) {
return new Promise((resolve) => {
const payload = JSON.stringify(body);
const req = request({
hostname: '127.0.0.1',
port,
path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
timeout: WATCH_NOTIFY_TIMEOUT_MS,
}, (res) => {
// Drain response
res.on('data', () => { });
res.on('end', () => resolve());
});
req.on('error', () => resolve());
req.on('timeout', () => {
req.destroy();
resolve();
});
req.write(payload);
req.end();
});
}
async function main() {
const port = parseInt(process.env.CC_IM_HOOK_PORT ?? '18900', 10);
let input;
try {
const raw = await readStdin();
input = raw.trim() ? JSON.parse(raw) : {};
}
catch {
// 无法解析输入,静默退出
process.exit(0);
}
const eventName = input.hook_event_name;
if (!eventName) {
process.exit(0);
}
await httpPost(port, '/watch-notify', {
eventName,
sessionId: input.session_id,
cwd: input.cwd ?? process.cwd(),
toolName: input.tool_name,
toolInput: input.tool_input,
toolResponse: input.tool_response,
lastAssistantMessage: input.last_assistant_message,
agentType: input.agent_type,
});
process.exit(0);
}
/* c8 ignore next 3 */
const isDirectRun = process.argv[1]?.endsWith('watch-script.js');
if (isDirectRun)
main();
export { main, readStdin, httpPost };
/**
* Watch 监控管理模块
*
* 管理 watchMap(workDir -> WatchEntry[]),提供注册/注销/查询/格式化功能。
* 用于让用户通过聊天平台实时监控终端 Claude Code 的活动。
*/
/** 每个级别订阅的事件集合 */
const LEVEL_EVENTS = {
stop: new Set(['Stop']),
tool: new Set(['PostToolUse', 'Stop']),
full: new Set(['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop']),
};
/** Stop 消息预览最大字符数 */
const STOP_PREVIEW_MAX_LENGTH = 200;
/** workDir -> WatchEntry[] */
const watchMap = new Map();
/**
* 注册监控。同一 chatId+threadId 更新 level 而不重复注册。
*/
export function registerWatch(workDir, entry) {
const entries = watchMap.get(workDir) ?? [];
const threadId = entry.threadCtx?.threadId;
const existing = entries.find((e) => e.chatId === entry.chatId && e.threadCtx?.threadId === threadId);
if (existing) {
existing.level = entry.level;
existing.platform = entry.platform;
existing.threadCtx = entry.threadCtx;
// 保留已有的 mutedSessions
}
else {
entries.push({ ...entry, mutedSessions: entry.mutedSessions ?? new Set() });
}
watchMap.set(workDir, entries);
}
/**
* 注销监控。按 chatId + threadId 精确匹配移除。
*/
export function unregisterWatch(workDir, chatId, threadId) {
const entries = watchMap.get(workDir);
if (!entries)
return false;
const idx = entries.findIndex((e) => e.chatId === chatId && e.threadCtx?.threadId === threadId);
if (idx === -1)
return false;
entries.splice(idx, 1);
if (entries.length === 0) {
watchMap.delete(workDir);
}
return true;
}
/**
* 按 cwd 前缀匹配 + 可选事件级别过滤 + 可选 sessionId mute 过滤。
*
* 匹配规则:cwd === workDir || cwd.startsWith(workDir + '/')
*/
export function getWatchEntries(cwd, eventName, sessionId) {
const results = [];
for (const [workDir, entries] of watchMap) {
if (cwd === workDir || cwd.startsWith(workDir + '/')) {
for (const entry of entries) {
if (eventName && !LEVEL_EVENTS[entry.level].has(eventName)) {
continue;
}
if (sessionId && entry.mutedSessions?.size && [...entry.mutedSessions].some(s => sessionId.endsWith(s))) {
continue;
}
results.push(entry);
}
}
}
return results;
}
/**
* 查询某聊天的监控状态。返回匹配的 WatchEntry 或 undefined。
*/
export function getWatchStatus(chatId, threadId) {
for (const [workDir, entries] of watchMap) {
const entry = entries.find((e) => e.chatId === chatId && e.threadCtx?.threadId === threadId);
if (entry) {
return { ...entry, workDir };
}
}
return undefined;
}
/**
* 屏蔽指定会话的通知。
*/
export function muteSession(workDir, chatId, sessionSuffix, threadId) {
const entries = watchMap.get(workDir);
if (!entries)
return false;
const entry = entries.find(e => e.chatId === chatId && e.threadCtx?.threadId === threadId);
if (!entry)
return false;
if (!entry.mutedSessions)
entry.mutedSessions = new Set();
entry.mutedSessions.add(sessionSuffix);
return true;
}
/**
* 取消屏蔽指定会话的通知。
*/
export function unmuteSession(workDir, chatId, sessionSuffix, threadId) {
const entries = watchMap.get(workDir);
if (!entries)
return false;
const entry = entries.find(e => e.chatId === chatId && e.threadCtx?.threadId === threadId);
if (!entry || !entry.mutedSessions)
return false;
return entry.mutedSessions.delete(sessionSuffix);
}
/**
* 清空所有监控(测试用)
*/
export function clearAllWatches() {
watchMap.clear();
}
/**
* 格式化通知消息。
*
* - PostToolUse: `🔧 Bash: npm test` / `🔧 Write: src/index.ts`
* - Stop: `✅ Claude 已完成\n> 前 200 字预览...`
* - SubagentStart: `🤖 子代理启动: Explore`
* - SubagentStop: `🤖 子代理完成: Explore`
*/
export function formatWatchNotify(data) {
const sid = data.sessionId ? `[${data.sessionId.slice(-4)}] ` : '';
switch (data.eventName) {
case 'PostToolUse': {
const tool = data.toolName ?? 'unknown';
const summary = getToolSummary(tool, data.toolInput);
return summary ? `🔧 ${sid}${tool}: ${summary}` : `🔧 ${sid}${tool}`;
}
case 'Stop': {
const preview = data.lastAssistantMessage ?? '';
if (!preview)
return `✅ ${sid}Claude 已完成`;
const truncated = preview.length > STOP_PREVIEW_MAX_LENGTH
? preview.slice(0, STOP_PREVIEW_MAX_LENGTH) + '...'
: preview;
return `✅ ${sid}Claude 已完成\n> ${truncated}`;
}
case 'SubagentStart':
return `🤖 ${sid}子代理启动: ${data.agentType ?? 'unknown'}`;
case 'SubagentStop':
return `🤖 ${sid}子代理完成: ${data.agentType ?? 'unknown'}`;
default:
return `❓ ${sid}未知事件: ${data.eventName}`;
}
}
/**
* 提取工具参数摘要
*/
function getToolSummary(toolName, toolInput) {
if (!toolInput)
return '';
switch (toolName) {
case 'Bash':
return truncate(String(toolInput.command ?? ''), 100);
case 'Write':
case 'Edit':
case 'Read':
return truncate(String(toolInput.file_path ?? ''), 100);
case 'Glob':
case 'Grep':
return truncate(String(toolInput.pattern ?? ''), 100);
default: {
// 取第一个字符串类型的值作为摘要
for (const val of Object.values(toolInput)) {
if (typeof val === 'string' && val.length > 0) {
return truncate(val, 100);
}
}
return '';
}
}
}
function truncate(str, maxLength) {
return str.length > maxLength ? str.slice(0, maxLength) + '...' : str;
}
+4
-1

@@ -40,2 +40,5 @@ import { spawn } from 'node:child_process';

}
if (options?.skipPermissions) {
env.CC_IM_SKIP_PERMISSIONS = '1';
}
if (options?.proxyUrl) {

@@ -55,3 +58,3 @@ env.HTTPS_PROXY = options.proxyUrl;

let model = '';
let toolStats = {};
const toolStats = {};
let timeoutHandle = null;

@@ -58,0 +61,0 @@ const pendingToolInputs = new Map();

+126
-4
#!/usr/bin/env node
import { join } from 'path';
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, openSync, closeSync } from 'fs';
import { spawn } from 'child_process';
import { spawn, execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { createLogger } from './logger.js';

@@ -10,2 +11,5 @@ import { APP_HOME } from './constants.js';

const LOG_DIR = join(APP_HOME, 'logs');
const SERVICE_NAME = 'cc-im';
const SERVICE_DIR = join(homedir(), '.config', 'systemd', 'user');
const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`);
const logger = createLogger('CLI');

@@ -33,2 +37,9 @@ function getPidFromFile() {

async function stop() {
// 优先停 systemd 服务
if (isSystemdActive()) {
systemctl('stop', SERVICE_NAME);
logger.info('已停止 systemd 服务');
return;
}
// 其次停守护进程
const pid = getPidFromFile();

@@ -38,3 +49,3 @@ if (pid && isRunning(pid)) {

process.kill(pid);
logger.info(`已停止服务 (PID: ${pid})`);
logger.info(`已停止守护进程 (PID: ${pid})`);
}

@@ -108,2 +119,104 @@ catch (e) {

}
function generateServiceContent() {
const scriptPath = fileURLToPath(import.meta.url);
// dist/cli.js -> project root
const projectDir = join(scriptPath, '..', '..');
const nodeBinDir = join(process.execPath, '..');
return `[Unit]
Description=CC-IM Bot Bridge Service
After=network.target
[Service]
Type=simple
WorkingDirectory=${projectDir}
ExecStart=${process.execPath} ${join(projectDir, 'dist', 'index.js')}
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PATH=${nodeBinDir}:/usr/local/bin:/usr/bin:/bin
[Install]
WantedBy=default.target
`;
}
function systemctl(...args) {
execSync(`systemctl --user ${args.join(' ')}`, { stdio: 'inherit' });
}
function systemctlQuery(...args) {
try {
return execSync(`systemctl --user ${args.join(' ')}`, { encoding: 'utf-8' }).trim();
}
catch {
return '';
}
}
function isSystemdActive() {
return systemctlQuery('is-active', SERVICE_NAME) === 'active';
}
function isSystemdEnabled() {
return systemctlQuery('is-enabled', SERVICE_NAME) === 'enabled';
}
function install() {
mkdirSync(SERVICE_DIR, { recursive: true });
writeFileSync(SERVICE_FILE, generateServiceContent());
logger.info(`服务文件已写入: ${SERVICE_FILE}`);
systemctl('daemon-reload');
systemctl('enable', SERVICE_NAME);
logger.info('开机自启已启用');
// enable-linger 让用户服务在未登录时也能运行
try {
execSync('loginctl enable-linger', { stdio: 'inherit' });
logger.info('linger 已启用(服务可在未登录时运行)');
}
catch {
logger.warn('enable-linger 失败,服务可能需要用户登录后才会启动');
}
systemctl('start', SERVICE_NAME);
systemctl('status', SERVICE_NAME);
}
function uninstall() {
try {
systemctl('stop', SERVICE_NAME);
}
catch { /* 服务可能没在运行 */ }
try {
systemctl('disable', SERVICE_NAME);
}
catch { /* 服务可能没启用 */ }
if (existsSync(SERVICE_FILE)) {
unlinkSync(SERVICE_FILE);
logger.info(`服务文件已删除: ${SERVICE_FILE}`);
}
systemctl('daemon-reload');
logger.info('开机自启已卸载');
}
function status() {
if (isSystemdActive()) {
logger.info(`运行方式: systemd(开机自启: ${isSystemdEnabled() ? '已启用' : '未启用'})`);
systemctl('status', SERVICE_NAME, '--no-pager');
return;
}
const pid = getPidFromFile();
if (pid && isRunning(pid)) {
let uptime = '';
let mem = '';
try {
uptime = execSync(`ps -o etime= -p ${pid}`, { encoding: 'utf-8' }).trim();
const rss = parseInt(execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' }).trim(), 10);
mem = `${(rss / 1024).toFixed(1)} MB`;
}
catch { /* ignore */ }
logger.info('运行方式: 守护进程(cc-im -d)');
logger.info(`PID: ${pid}`);
if (uptime)
logger.info(`运行时间: ${uptime}`);
if (mem)
logger.info(`内存占用: ${mem}`);
return;
}
logger.info('服务未运行');
if (isSystemdEnabled()) {
logger.info('systemd 开机自启已启用,可用 cc-im install 重新启动');
}
}
function parseArgs() {

@@ -114,4 +227,4 @@ const args = process.argv.slice(2);

for (const arg of args) {
if (arg === 'stop') {
command = 'stop';
if (arg === 'stop' || arg === 'install' || arg === 'uninstall' || arg === 'status') {
command = arg;
}

@@ -151,2 +264,11 @@ else if (arg === '-d' || arg === '--daemon') {

}
else if (command === 'status') {
status();
}
else if (command === 'install') {
install();
}
else if (command === 'uninstall') {
uninstall();
}
else if (daemon) {

@@ -153,0 +275,0 @@ startDaemon();

@@ -9,2 +9,3 @@ import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js';

import { getHistory, formatHistoryPage, getSessionList, formatSessionList } from '../shared/history.js';
import { registerWatch, unregisterWatch, getWatchStatus, muteSession, unmuteSession } from '../hook/watch.js';
/**

@@ -66,2 +67,5 @@ * 共享的命令处理器

}
if (trimmed === '/watch' || trimmed.startsWith('/watch ')) {
return this.handleWatch(chatId, userId, trimmed.slice(6).trim(), platform, threadCtx);
}
// 仅终端可用的命令

@@ -98,2 +102,3 @@ const cmdName = trimmed.split(/\s+/)[0];

'/resume [序号] - 浏览/恢复历史会话',
'/watch [级别] - 监控终端 Claude Code 状态',
threadsCmd,

@@ -193,3 +198,3 @@ stopCmd,

: this.deps.sessionManager.getWorkDir(userId);
const lines = dirs.map((d, i) => (d === current ? `▶ ${i + 1}. ${d}` : ` ${i + 1}. ${d}`));
const lines = dirs.map((d, i) => (d === current ? `${i + 1}. ▶ ${d}` : `${i + 1}. ${d}`));
await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <序号> 或 /cd <路径> 切换`, threadCtx);

@@ -399,3 +404,3 @@ }

const index = parseInt(args, 10) - 1;
if (isNaN(index) || index < 0 || index >= listResult.data.length) {
if (Number.isNaN(index) || index < 0 || index >= listResult.data.length) {
await this.deps.sender.sendTextReply(chatId, `无效的序号 ${args},共 ${listResult.data.length} 个会话。`, threadCtx);

@@ -414,2 +419,65 @@ return true;

/**
* 处理 /watch 命令 - 监控终端 Claude Code 状态
*/
async handleWatch(chatId, userId, args, platform, threadCtx) {
const workDir = threadCtx
? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getWorkDir(userId);
const threadId = threadCtx?.threadId;
if (!args) {
const status = getWatchStatus(chatId, threadId);
if (status) {
const mutedList = status.mutedSessions?.size
? `\n屏蔽: ${[...status.mutedSessions].map(s => `[${s}]`).join(' ')}`
: '';
await this.deps.sender.sendTextReply(chatId, `📡 监控中 [${status.level}]\n工作区: ${status.workDir}${mutedList}`, threadCtx);
}
else {
await this.deps.sender.sendTextReply(chatId, '📡 未开启监控\n\n使用 /watch <级别> 开启:\n stop - 仅完成事件\n tool - 工具调用 + 完成\n full - 全量(含子代理)', threadCtx);
}
return true;
}
if (args === 'off') {
unregisterWatch(workDir, chatId, threadId);
await this.deps.sender.sendTextReply(chatId, '📡 已关闭监控', threadCtx);
return true;
}
if (args.startsWith('mute ')) {
const sid = args.slice(5).trim();
if (!sid) {
await this.deps.sender.sendTextReply(chatId, '请指定要屏蔽的会话 ID 后 4 位,如: /watch mute a1b2', threadCtx);
return true;
}
if (muteSession(workDir, chatId, sid, threadId)) {
await this.deps.sender.sendTextReply(chatId, `📡 已屏蔽会话 [${sid}]`, threadCtx);
}
else {
await this.deps.sender.sendTextReply(chatId, '未找到活跃的监控,请先 /watch <级别> 开启。', threadCtx);
}
return true;
}
if (args.startsWith('unmute ')) {
const sid = args.slice(7).trim();
if (!sid) {
await this.deps.sender.sendTextReply(chatId, '请指定要取消屏蔽的会话 ID,如: /watch unmute a1b2', threadCtx);
return true;
}
if (unmuteSession(workDir, chatId, sid, threadId)) {
await this.deps.sender.sendTextReply(chatId, `📡 已取消屏蔽会话 [${sid}]`, threadCtx);
}
else {
await this.deps.sender.sendTextReply(chatId, '未找到该屏蔽记录。', threadCtx);
}
return true;
}
const validLevels = ['stop', 'tool', 'full'];
if (!validLevels.includes(args)) {
await this.deps.sender.sendTextReply(chatId, `无效的监控级别: ${args}\n可选: stop / tool / full / off / mute <id> / unmute <id>`, threadCtx);
return true;
}
registerWatch(workDir, { chatId, platform, threadCtx, level: args });
await this.deps.sender.sendTextReply(chatId, `📡 已开启监控 [${args}]\n工作区: ${workDir}\n\n在此工作区启动的终端 Claude Code 的事件将推送到此处。\n使用 /watch off 关闭`, threadCtx);
return true;
}
/**
* 处理 /threads 命令 - 列出所有话题会话

@@ -416,0 +484,0 @@ */

@@ -74,2 +74,3 @@ try {

const wecomBotSecret = process.env.WECOM_BOT_SECRET ?? file.wecomBotSecret ?? '';
const wecomBotName = process.env.WECOM_BOT_NAME ?? file.wecomBotName;
const allowedUserIds = process.env.ALLOWED_USER_IDS !== undefined

@@ -98,3 +99,3 @@ ? parseCommaSeparated(process.env.ALLOWED_USER_IDS)

}
catch (err) {
catch (_err) {
throw new Error(`Claude CLI 不可访问或不可执行: ${claudeCliPath}\n` +

@@ -112,3 +113,3 @@ `请检查:\n` +

}
catch (err) {
catch (_err) {
throw new Error(`Claude CLI 在 PATH 中未找到: ${claudeCliPath}\n` +

@@ -134,2 +135,3 @@ `请检查:\n` +

wecomBotSecret,
wecomBotName,
allowedUserIds,

@@ -136,0 +138,0 @@ claudeCliPath,

@@ -109,1 +109,25 @@ import { join } from 'node:path';

export const MAX_WECOM_MESSAGE_LENGTH = 4000;
/**
* 请求队列最大排队数
*/
export const MAX_QUEUE_SIZE = 3;
/**
* 消息去重最大容量
*/
export const MAX_DEDUP_SIZE = 1000;
/**
* 任务超时时间(毫秒),超时自动清理
*/
export const TASK_TIMEOUT_MS = 30 * 60 * 1000;
/**
* 任务清理检查间隔(毫秒)
*/
export const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
/**
* Telegram 消息发送最大重试次数
*/
export const TELEGRAM_MAX_RETRIES = 3;
/**
* Telegram 限频最大等待秒数
*/
export const TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC = 60;

@@ -9,2 +9,3 @@ import { join } from 'node:path';

import { registerFeishuPermissionSender, handlePermissionAction } from './permission-handler.js';
import { registerWatchSender } from '../hook/permission-server.js';
import { CommandHandler } from '../commands/handler.js';

@@ -76,2 +77,6 @@ import { safeStringify } from '../shared/utils.js';

registerFeishuPermissionSender();
// Register feishu watch sender
registerWatchSender('feishu', {
sendWatchNotify: (chatId, text, threadCtx) => sendTextReply(chatId, text, threadCtx),
});
// ─── 内部函数(闭包访问 runningTasks 等) ───

@@ -78,0 +83,0 @@ async function routeToThread(userId, chatId, threadId, rootMessageId, text, mentionedBot) {

@@ -9,2 +9,3 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';

const HOOK_MATCHER = 'Bash|Write|Edit';
const WATCH_EVENTS = ['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop'];
/**

@@ -20,2 +21,10 @@ * 获取 hook-script.js 的绝对路径。

}
/**
* 获取 watch-script.js 的绝对路径。
*/
function getWatchScriptPath() {
const thisFile = fileURLToPath(import.meta.url);
const projectRoot = dirname(dirname(dirname(thisFile)));
return join(projectRoot, 'dist', 'hook', 'watch-script.js');
}
/** 判断一个 hook command 是否指向本项目的 hook-script.js */

@@ -79,2 +88,18 @@ function isOurHook(command, projectHookPath) {

hooks.PreToolUse = preToolUse;
// Watch hook 注册(PostToolUse, Stop, SubagentStart, SubagentStop)
const watchScriptPath = getWatchScriptPath();
if (existsSync(watchScriptPath)) {
for (const eventName of WATCH_EVENTS) {
const eventHooks = (hooks[eventName] ?? []);
const hasOurHook = eventHooks.some(entry => entry.hooks?.some(h => isOurHook(h.command, watchScriptPath)));
if (!hasOurHook) {
eventHooks.push({
matcher: '',
hooks: [{ type: 'command', command: watchScriptPath }],
});
hooks[eventName] = eventHooks;
needsWrite = true;
}
}
}
settings.hooks = hooks;

@@ -81,0 +106,0 @@ try {

@@ -94,2 +94,9 @@ #!/usr/bin/env node

}
// Skip permissions mode - auto-allow all tools
// 新版 Claude Code 的 --dangerously-skip-permissions 不再跳过 hooks,
// 需要通过环境变量让 hook 脚本自行放行
if (process.env.CC_IM_SKIP_PERMISSIONS === '1') {
process.stdout.write(JSON.stringify({ permissionDecision: 'allow' }));
process.exit(HOOK_EXIT_CODES.SUCCESS);
}
const threadRootMsgId = process.env.CC_IM_THREAD_ROOT_MSG_ID;

@@ -96,0 +103,0 @@ const threadId = process.env.CC_IM_THREAD_ID;

import { createServer } from 'node:http';
import { createLogger } from '../logger.js';
import { PERMISSION_REQUEST_TIMEOUT_MS, MAX_BODY_SIZE } from '../constants.js';
import { getWatchEntries, formatWatchNotify, } from './watch.js';
const log = createLogger('PermissionServer');

@@ -9,2 +10,6 @@ const senders = new Map();

}
const watchSenders = new Map();
export function registerWatchSender(platform, s) {
watchSenders.set(platform, s);
}
const pendingRequests = new Map();

@@ -130,3 +135,2 @@ // 反向索引:chatId → Set<requestId>,O(1) 查询

const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
let savedMessageId = '';
const decision = await new Promise((resolve) => {

@@ -146,3 +150,2 @@ if (!platformSender) {

platformSender.sendPermissionCard(chatId, id, toolName, toolInput ?? {}, threadCtx).then((messageId) => {
savedMessageId = messageId;
// 超时定时器在卡片发送成功后才启动,确保用户有完整的决策时间

@@ -203,2 +206,39 @@ const timeout = setTimeout(() => {

}
if (req.method === 'POST' && url.pathname === '/watch-notify') {
try {
const body = JSON.parse(await readBody(req));
const { cwd, eventName, sessionId } = body;
if (!cwd || !eventName) {
sendJson(res, 400, { error: 'cwd and eventName required' });
return;
}
const validEvents = ['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop'];
if (!validEvents.includes(eventName)) {
sendJson(res, 200, { sent: 0 });
return;
}
const entries = getWatchEntries(cwd, eventName, sessionId);
if (entries.length === 0) {
sendJson(res, 200, { sent: 0 });
return;
}
const message = formatWatchNotify(body);
let sent = 0;
for (const entry of entries) {
const sender = watchSenders.get(entry.platform);
if (sender) {
sender.sendWatchNotify(entry.chatId, message, entry.threadCtx).catch((err) => {
log.warn(`Failed to send watch notify to ${entry.chatId}:`, err);
});
sent++;
}
}
sendJson(res, 200, { sent });
}
catch (err) {
log.error('Error handling watch notify:', err);
sendJson(res, 500, { error: 'Internal error' });
}
return;
}
if (req.method === 'GET' && url.pathname === '/health') {

@@ -205,0 +245,0 @@ sendJson(res, 200, { status: 'ok', pending: pendingRequests.size });

@@ -49,2 +49,9 @@ import { loadConfig } from './config.js';

export async function main() {
process.on('unhandledRejection', (reason) => {
log.error('Unhandled rejection:', reason);
});
process.on('uncaughtException', (err) => {
log.error('Uncaught exception:', err);
process.exit(1);
});
const config = loadConfig();

@@ -64,8 +71,6 @@ initLogger(config.logDir, config.logLevel);

log.info(`Allowed base dirs: ${config.allowedBaseDirs.length} dirs configured`);
let permissionServer = null;
if (!config.claudeSkipPermissions) {
ensureHookConfigured();
permissionServer = await startPermissionServer(config.hookPort);
log.info(`Permission hook server started on port ${permissionServer.port}`);
}
// Hook 注册和 HTTP 服务器始终启动(watch 功能不依赖权限设置)
ensureHookConfigured();
const permissionServer = await startPermissionServer(config.hookPort);
log.info(`Hook server started on port ${permissionServer.port}${config.claudeSkipPermissions ? ' (permissions skipped, watch-only)' : ''}`);
// 创建共享的 SessionManager 单例

@@ -154,7 +159,7 @@ const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs);

].filter(Boolean).join('\n');
sendLifecycleNotification(activeBots, startupMsg).catch(() => { });
checkForUpdate(APP_VERSION).catch(() => { });
sendLifecycleNotification(activeBots, startupMsg).catch((e) => log.warn('Startup notification failed:', e?.message ?? e));
checkForUpdate(APP_VERSION).catch((e) => log.warn('Update check failed:', e?.message ?? e));
const imageCleanupTimer = setInterval(() => {
cleanOldImages().then((n) => { if (n > 0)
log.info(`Cleaned ${n} old image(s)`); }).catch(() => { });
log.info(`Cleaned ${n} old image(s)`); }).catch((e) => log.warn('Image cleanup failed:', e?.message ?? e));
}, 10 * 60 * 1000);

@@ -189,3 +194,3 @@ imageCleanupTimer.unref();

}
permissionServer?.close();
permissionServer.close();
// 持久化会话和活跃聊天数据

@@ -192,0 +197,0 @@ sessionManager.destroy();

import { createLogger } from '../logger.js';
import { MAX_QUEUE_SIZE } from '../constants.js';
const log = createLogger('Queue');
const MAX_QUEUE_SIZE = 3;
export class RequestQueue {

@@ -5,0 +5,0 @@ queues = new Map();

@@ -1,3 +0,2 @@

import { DEDUP_TTL_MS } from '../constants.js';
const MAX_DEDUP_SIZE = 1000;
import { DEDUP_TTL_MS, MAX_DEDUP_SIZE } from '../constants.js';
/**

@@ -4,0 +3,0 @@ * 消息去重器

@@ -0,5 +1,4 @@

import { TASK_TIMEOUT_MS, TASK_CLEANUP_INTERVAL_MS } from '../constants.js';
import { createLogger } from '../logger.js';
const log = createLogger('TaskCleanup');
const TASK_TIMEOUT_MS = 30 * 60 * 1000;
const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
/**

@@ -6,0 +5,0 @@ * 启动定期任务清理(30分钟超时,每10分钟检查一次)

@@ -7,6 +7,6 @@ import { join } from 'node:path';

import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendPermissionMessage, updatePermissionMessage, startTypingLoop, sendImageReply } from './message-sender.js';
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { registerPermissionSender, registerWatchSender, resolvePermissionById } from '../hook/permission-server.js';
import { CommandHandler } from '../commands/handler.js';
import { getBotUsername } from './client.js';
import { runClaudeTask } from '../shared/claude-task.js';
import { runClaudeTask, } from '../shared/claude-task.js';
import { startTaskCleanup } from '../shared/task-cleanup.js';

@@ -49,2 +49,6 @@ import { MessageDedup } from '../shared/message-dedup.js';

});
// Register telegram watch sender
registerWatchSender('telegram', {
sendWatchNotify: (chatId, text) => sendTextReply(chatId, text),
});
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) {

@@ -51,0 +55,0 @@ const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;

@@ -5,7 +5,5 @@ import { getBot } from './client.js';

import { splitLongContent, buildInputSummary, truncateText } from '../shared/utils.js';
import { MAX_TELEGRAM_MESSAGE_LENGTH } from '../constants.js';
import { MAX_TELEGRAM_MESSAGE_LENGTH, TELEGRAM_MAX_RETRIES, TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC } from '../constants.js';
import { withRetry } from '../shared/retry.js';
const log = createLogger('TgSender');
const MAX_RETRIES = 3;
const RATE_LIMIT_MAX_WAIT_SEC = 60; // Cap retry wait time to avoid excessive blocking
const COOLDOWN_CLEANUP_INTERVAL_MS = 3600000; // Clean up cooldown map every hour

@@ -64,5 +62,5 @@ const TYPING_INTERVAL_MS = 4000; // Telegram typing status expires after ~5s

return withRetry(fn, {
maxRetries: MAX_RETRIES - 1, // withRetry counts retries after first attempt
maxRetries: TELEGRAM_MAX_RETRIES - 1, // withRetry counts retries after first attempt
baseDelayMs: 1000,
maxDelayMs: RATE_LIMIT_MAX_WAIT_SEC * 1000,
maxDelayMs: TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC * 1000,
shouldRetry: (err) => {

@@ -160,3 +158,3 @@ const retryAfter = parseRetryAfter(err);

else {
log.error(`Failed to deliver final message ${messageId} after ${MAX_RETRIES} retries (rate limited)`);
log.error(`Failed to deliver final message ${messageId} after ${TELEGRAM_MAX_RETRIES} retries (rate limited)`);
}

@@ -163,0 +161,0 @@ }

import { join } from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import { createWecomSender } from './message-sender.js';
import { createWecomSender, sendTextReply as wecomSendText } from './message-sender.js';
import { AccessControl } from '../access/access-control.js';
import { RequestQueue } from '../queue/request-queue.js';
import { CommandHandler } from '../commands/handler.js';
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { registerPermissionSender, registerWatchSender, resolvePermissionById } from '../hook/permission-server.js';
import { runClaudeTask } from '../shared/claude-task.js';

@@ -27,8 +27,29 @@ import { startTaskCleanup } from '../shared/task-cleanup.js';

/**
* 清理群聊消息文本
* 清理群聊消息文本:去掉 @机器人名 标记
* 企业微信智能机器人 SDK 在群聊中只会推送 @机器人的消息,
* 所以只要收到群聊消息,就一定是被 mention 的,无需额外检查。
* 但 text.content 中仍包含 @机器人名 文本,需要去掉。
*/
function cleanGroupText(text) {
return text.trim();
export function cleanGroupText(text, botName) {
let cleaned = text;
if (botName) {
// 精确匹配机器人名称(大小写不敏感)
const escaped = botName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
cleaned = cleaned.replace(new RegExp(`@${escaped}\\s*`, 'gi'), '');
}
else {
// 启发式去除 @ 提及(降级方案,推荐配置 WECOM_BOT_NAME 以精确匹配)
// 局限:若命令参数中包含 @(如 /ask 给 @someone 发邮件 @Bot),
// 正则会从第一个 @ 开始截断,导致参数丢失。
const slashIdx = cleaned.indexOf('/');
if (slashIdx >= 0) {
const cmdPart = cleaned.substring(slashIdx);
cleaned = cmdPart.replace(/\s*@.*$/s, '');
}
else {
// 非命令文本:去掉开头的 @word(无法完美处理多词名称)
cleaned = cleaned.replace(/^@\S+\s*/, '');
}
}
return cleaned.trim();
}

@@ -58,2 +79,24 @@ /**

let taskCounter = 0;
/**
* 构建群聊/私聊的会话上下文(threadCtx、workDir、convId、queueKey)
*/
function resolveSessionContext(userId, chatId, isGroup) {
// 企业微信没有话题根消息概念,rootMessageId 留空
const threadCtx = isGroup ? { threadId: chatId, rootMessageId: '' } : undefined;
// 群聊:提前创建 thread session,确保 workDir 独立于单聊
// 避免 getWorkDirForThread 回退到用户单聊 workDir 导致两者联动
if (threadCtx && !sessionManager.getThreadSession(userId, threadCtx.threadId)) {
sessionManager.setThreadSession(userId, threadCtx.threadId, {
workDir: sessionManager.getWorkDir(userId),
rootMessageId: '',
threadId: threadCtx.threadId,
});
}
const workDir = threadCtx
? sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
: sessionManager.getWorkDir(userId);
const convId = threadCtx ? undefined : sessionManager.getConvId(userId);
const queueKey = threadCtx ? threadCtx.threadId : (convId ?? userId);
return { threadCtx, workDir, convId, queueKey };
}
const commandHandler = new CommandHandler({

@@ -72,7 +115,13 @@ config,

});
// 注册 watch 通知发送器
registerWatchSender('wecom', {
sendWatchNotify: (chatId, text) => wecomSendText(chatId, text),
});
/**
* 核心请求处理器(frame 可选,有 frame 时使用流式回复,无 frame 时退化为 sendMessage)
*/
async function handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame) {
const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
async function handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame, threadCtx) {
const sessionId = threadCtx
? sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
log.info(`Running Claude for user ${userId}, convId=${convId}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);

@@ -95,2 +144,3 @@ const taskKey = `${userId}:${++taskCounter}`;

convId,
threadId: threadCtx?.threadId,
platform: 'wecom',

@@ -192,4 +242,4 @@ taskKey,

// CommandHandler 使用的签名(无 frame)
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId) {
await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId);
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, threadCtx) {
await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, undefined, threadCtx);
}

@@ -222,5 +272,5 @@ /**

let cleanText = text.trim();
// 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention)
// 群聊文本清理:去掉 @机器人名 标记
if (isGroup) {
cleanText = cleanGroupText(cleanText);
cleanText = cleanGroupText(cleanText, config.wecomBotName);
}

@@ -257,11 +307,11 @@ if (!cleanText)

}
// 构建会话上下文(群聊隔离 workDir/sessionId)
const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup);
// 统一命令分发
if (await commandHandler.dispatch(cleanText, chatId, userId, 'wecom', handleClaudeRequest)) {
if (await commandHandler.dispatch(cleanText, chatId, userId, 'wecom', handleClaudeRequest, threadCtx)) {
return;
}
// 路由到 Claude
const workDirSnapshot = sessionManager.getWorkDir(userId);
const convIdSnapshot = sessionManager.getConvId(userId);
const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, cleanText, async (prompt) => {
await handleClaudeRequestCore(userId, chatId, prompt, workDirSnapshot, convIdSnapshot, frame);
const enqueueResult = requestQueue.enqueue(userId, queueKey, cleanText, async (prompt) => {
await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame, threadCtx);
});

@@ -319,6 +369,5 @@ if (enqueueResult === 'rejected') {

log.info(`User ${userId} [image]: ${prompt.slice(0, 100)}...`);
const workDirSnapshot = sessionManager.getWorkDir(userId);
const convIdSnapshot = sessionManager.getConvId(userId);
const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => {
await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame);
const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup);
const enqueueResult = requestQueue.enqueue(userId, queueKey, prompt, async (p) => {
await handleClaudeRequestCore(userId, chatId, p, workDir, convId, frame, threadCtx);
});

@@ -357,5 +406,5 @@ if (enqueueResult === 'rejected') {

}
// 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention)
// 群聊文本清理:去掉 @机器人名 标记
const textContent = isGroup
? cleanGroupText(textParts.join(' '))
? cleanGroupText(textParts.join(' '), config.wecomBotName)
: textParts.join(' ').trim();

@@ -374,6 +423,5 @@ let prompt;

log.info(`User ${userId} [mixed]: ${prompt.slice(0, 100)}...`);
const workDirSnapshot = sessionManager.getWorkDir(userId);
const convIdSnapshot = sessionManager.getConvId(userId);
const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => {
await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame);
const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup);
const enqueueResult = requestQueue.enqueue(userId, queueKey, prompt, async (p) => {
await handleClaudeRequestCore(userId, chatId, p, workDir, convId, frame, threadCtx);
});

@@ -380,0 +428,0 @@ if (enqueueResult === 'rejected') {

{
"name": "cc-im",
"version": "1.5.0",
"description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI",
"version": "1.6.0",
"description": "Multi-platform bot bridge (Feishu & Telegram & WeCom) for Claude Code CLI",
"repository": {

@@ -20,6 +20,7 @@ "type": "git",

"scripts": {
"build": "tsc",
"build": "tsc && chmod +x dist/hook/hook-script.js dist/hook/watch-script.js dist/cli.js",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"prepublishOnly": "npm run build",
"lint": "biome check src/ tests/",
"test": "vitest run",

@@ -32,2 +33,4 @@ "test:watch": "vitest"

"telegram",
"wecom",
"wechat-work",
"claude",

@@ -45,2 +48,3 @@ "claude-code",

"devDependencies": {
"@biomejs/biome": "^2.4.9",
"@types/node": "^25.3.0",

@@ -47,0 +51,0 @@ "@vitest/coverage-v8": "^4.0.18",

@@ -26,3 +26,4 @@ # cc-im

- **生命周期通知**:服务启动/关闭时通知活跃用户(含版本信息和运行时长)
- **守护进程模式**:支持 `-d` 后台运行和 `stop` 停止
- **守护进程模式**:支持 `-d` 后台运行和 `stop` 停止,`install` 注册为 systemd 开机自启服务
- **终端监控**:通过 `/watch` 命令实时监控终端 Claude Code 的运行状态(工具调用、完成事件)
- **版本更新检查**:启动时自动检查 npm 最新版本,有更新时提示

@@ -125,2 +126,5 @@ - **日志等级配置**:支持 DEBUG/INFO/WARN/ERROR 四级日志

cc-im stop
# 查看运行状态
cc-im status
```

@@ -130,2 +134,14 @@

### 开机自启(systemd)
在 Linux 上可注册为用户级 systemd 服务,开机自动启动:
```bash
# 注册并启动服务
cc-im install
# 卸载服务
cc-im uninstall
```
## 命令列表

@@ -147,2 +163,4 @@

| `/history [page]` | 查看当前会话的对话历史 |
| `/resume [n]` | 浏览/恢复历史会话 |
| `/watch [level]` | 监控终端 Claude Code(stop/tool/full/off) |
| `/threads` | 列出所有话题会话(飞书) |

@@ -162,2 +180,3 @@ | `/stop` | 停止当前运行的任务(企业微信) |

| `WECOM_BOT_SECRET` | 企业微信机器人 Secret | 企业微信平台必填 |
| `WECOM_BOT_NAME` | 企业微信机器人显示名称,用于精确去除群聊 @提及 | 空(启发式匹配) |
| `ALLOWED_USER_IDS` | 白名单用户 ID,逗号分隔,留空不限制 | 空(不限制) |

@@ -192,2 +211,3 @@ | `CLAUDE_CLI_PATH` | Claude CLI 可执行文件路径 | `claude` |

"wecomBotSecret": "",
"wecomBotName": "",
"allowedUserIds": ["123456789"],

@@ -276,3 +296,3 @@ "claudeCliPath": "/usr/local/bin/claude",

├── sanitize.ts # 日志脱敏规则
├── cli.ts # CLI 入口(前台/守护进程/停止)
├── cli.ts # CLI 入口(前台/守护进程/systemd 服务管理)
├── access/

@@ -301,4 +321,7 @@ │ └── access-control.ts # 白名单访问控制

├── hook/
│ ├── permission-server.ts # 权限确认 HTTP 服务
│ └── hook-script.ts # Claude Code PreToolUse Hook
│ ├── permission-server.ts # 权限确认 HTTP 服务 + 监控通知端点
│ ├── hook-script.ts # Claude Code PreToolUse Hook
│ ├── watch-script.ts # Claude Code 监控 Hook(PostToolUse/Stop 等)
│ ├── watch.ts # 监控状态管理与消息格式化
│ └── ensure-hook.ts # Hook 自动配置
├── shared/

@@ -305,0 +328,0 @@ │ ├── active-chats.ts # 活跃聊天记录(生命周期通知)