国庆本来计划出门的,结果一懒就窝在家里了。闲着也是闲着,我做了个挺有意思的东西:一个群聊机器人,能直接在群里 @ 某个队友,让他电脑上跑着的 Claude Code 立刻开始干活,干完把结果丢回群里。这听起来有点像远程控制,实际上也确实是把每个开发者的终端 Agent 变成了群里可被 @ 的“数字分身”。折腾完这套东西,我把里里外外的思路、代码、坑都梳理了一遍,这篇就当是完整复盘。
这个项目适合已经用上 Claude Code、又想把它从“个人单机工具”升级成“团队协作工具”的人。哪怕你只是刚接触 Claude Code 也没关系,我会从安装配置开始讲,再逐步深入到群聊接入、任务分发、安全控制这些核心环节。我会把为什么这么设计、每步怎么落地、我实际踩过的坑全部写出来,看完你可以在自己团队里直接复刻一套。
1. 项目思路拆解:为什么把 Claude Code 变成群聊里的“队友”
1.1 这个项目解决的真实问题
先说痛点。Claude Code 本身是一个跑在终端里的编码 Agent,你在命令行里给它一个任务,它就能读代码、改文件、跑测试、给你回复。这个工具单机用非常爽,但一旦到了团队协作场景,问题就出来了:每个人的 Claude Code 只在自己电脑上,我看不到你那边执行的进度,你也没办法把任务交给我机器上的 Agent 去跑。很多时候想请队友帮忙跑个测试、分析个报错,得靠人肉打字转述,再等他回来贴结果。
我做这个群聊系统,就是想消灭这种“人肉转发”。现在我在群里发一条消息,@ 队友的名字,再描述任务,这个任务会通过机器人自动推送到他电脑上的 Claude Code 实例,他不需要坐在电脑前盯着终端,Agent 自己跑完,把结果以消息形式发回群里,所有过程群里的人都能看到。
它解决的另外一个痛点是“异步任务管理”。Claude Code 跑一个较大的任务时,经常是几分钟起步。我与其开一个终端窗口死死盯输出,不如把它接入群聊,该干嘛干嘛,Agent 跑完自动 @ 我,等于把终端里的长任务变成了群里的异步消息。
1.2 为什么选“群聊”作为控制界面
市面上有很多 Agent 编排平台、任务看板,为什么我偏偏选了群聊?最核心的原因是:@ 本身就是最自然的“任务路由”机制。
群聊里有 @ 功能,我在消息里 @ 谁,这条消息的语义就是“这事归你”。把 @ 和本机 Agent 绑定之后,用户在群里发一条“@张三 帮我看一下 xxx 模块为什么编译不过”,系统就能从消息里解析出 @ 的对象是张三,再把任务派发到张三电脑上的 Claude Code。用户不需要额外学习任何路由规则,群聊天然给了我们一套现成的寻址协议。
其次是“群记录即审计日志”。任务是谁派的、什么时候派的、结果是什么,全在聊天记录里,天然留痕。相比 SSH 到某台机器上操作再回来贴日志,群聊的透明度高得多,团队里所有人共享同一份上下文。谁都不需要额外开一个后台管理页面,群里就是唯一的界面。
最后是“IM 客户端现成”。团队日常本来就在用群聊天,把 Agent 接进群聊,等于把开发工作流塞进一个大家天天打开的应用里,接受成本几乎为零。我也认真考虑过自己做 Web 控制台,但最后放弃了:做一个控制台需要处理登录、权限、消息推送,还要让大家养成“干活时去刷新页面”的习惯。群聊什么都不用改,人已经在里面了。
1.3 整体架构与消息流转
整个系统的架构并不复杂,拆开看就几个组件协作:
- 群聊机器人:负责接收群里带 @ 的消息,识别被 @ 的人和消息内容。
- 任务服务(Broker):把群里收到的任务统一收进队列,按顺序分发给对应节点的执行器。
- 节点适配器(Adapter):跑在每台开发者电脑上,监听分配到本节点的任务,调用本机 Claude Code 执行。
- Claude Code:真正干活的终端 Agent,以无交互模式运行。
- 出站 Webhook:把执行结果、日志、错误信息回传到群聊。
消息流转是这样的:群里有人发消息并 @ 指定队友 → 群聊机器人事件回调把消息推到任务服务 → 任务服务根据 @ 的 ID 找到对应节点,把任务塞进队列 → 那台电脑上的 Adapter 从队列取出任务,cd 到指定项目目录,调用 claude 命令 → Claude Code 执行完返回结果 → Adapter 把结果通过机器人 Webhook 发回群里 → 群里所有人都能看到任务完成情况。
这套架构最关键的一点是:每个节点的 Adapter 只做“执行”这件事,路由判断全部集中在任务服务层。这样加新人只需要在他电脑上跑一个 Adapter,再在任务服务里登记一个映射关系,不用改动任何机器人代码。我把这个映射简化成一个表:群聊里的成员 ID → 节点名称 → 项目工作目录。
2. 环境准备:Claude Code 安装与关键配置
2.1 安装、npm prefix 权限与自动更新报错
先过一遍 Claude Code 的安装。大多数人的机器上都有 Node.js,直接从 npm 全局装就行:
npm install -g @anthropic-ai/claude-code装完验证一下:
claude --version这一步实际执行时最容易出现的问题是权限。如果你用的是系统级 Node 安装目录,npm 全局装包经常遇到 EACCES 错误,表现是写入/usr/lib/node_modules或者/usr/local/lib/node_modules失败。网上有人直接让你用sudo npm install -g,我不建议这么干,因为它把全局包目录变成 root 所有,后续自更新也会遇到同样的权限问题。
我自己的做法是把 npm 全局目录改到用户目录下:
npm config set prefix ~/.npm echo 'export PATH=~/.npm/bin:$PATH' >> ~/.bashrc source ~/.bashrc这样全局安装不需要 sudo,以后 Claude Code 自动更新也不会出现“no write permission to npm prefix”的报错。说到自动更新,Claude Code 每次运行都会检查自身版本,如果你希望彻底关掉自动更新,可以设置环境变量:
export DISABLE_AUTOUPDATER=1注意这个环境变量要写进 Adapter 进程的环境里,否则子进程调用 claude 的时候还是会去触发更新检查。
2.2 模型与 Key 配置:官方接口与第三方兼容网关
Claude Code 默认通过ANTHROPIC_API_KEY环境变量读取密钥。你在终端跑之前,先配置好:
export ANTHROPIC_API_KEY=你的密钥如果你用的是第三方 Anthropic 协议兼容服务,或者内部部署的协议转换网关,还需要额外指定接口地址:
export ANTHROPIC_BASE_URL=https://你的网关地址 export ANTHROPIC_AUTH_TOKEN=sk-xxx这里有个容易搞混的点:Claude Code 会向${ANTHROPIC_BASE_URL}/v1/messages发请求,所以你的网关必须实现 Anthropic Messages API 格式。像 DeepSeek 这类服务本身提供的是 OpenAI 兼容接口,字段结构和请求格式都不同,直接填 DeepSeek 的地址进去是跑不通的。你需要在中间加一层协议转换服务,把 Anthropic 的请求转成 OpenAI 格式,再把 OpenAI 的响应转回 Anthropic 格式。这个转换层很多团队是自己维护的,用 Node 或者 Python 写几十行代码就能搞定,核心就是请求体和响应体的字段映射。
不管用官方服务还是兼容网关,我建议在生产环境里不要把密钥写死在代码里,而是放在环境变量文件里,比如.env,然后由进程管理器加载。我自己的 Adapter 就是从.env里读取这些配置再传给子进程的,这样每台电脑只需要维护一份自己的配置,不会把密钥同步到 Git 里。
2.3 权限模式与工具白名单怎么设
Claude Code 在交互模式下会自己问你“允许执行这条命令吗”,但我们要做的是无人值守的群聊任务,它根本不可能弹窗问人。所以权限模式必须在启动参数里明确指定。
我强烈建议不要全局使用--dangerously-skip-permissions,这等于把电脑完全交给 AI,群里谁 @ 你一下,你机器上什么命令都能跑,后果不堪设想。正确做法是给每个任务指定一个工具白名单和权限模式。
比如我只想让 Agent 读代码、查文件、看 git 状态、跑某个测试命令,我会这样启动:
claude -p "请分析 src/ 目录下代码的循环复杂度" \ --permission-mode acceptEdits \ --allowedTools "Read,Glob,Grep,Bash(git status),Bash(git diff),Bash(npx vitest run tests/*)"--permission-mode acceptEdits表示允许读写文件,但执行命令只允许白名单里的那几条。如果你的任务只要求代码分析、不需要改文件,就应该用--permission-mode plan或者不传 acceptEdits,这样 Agent 不会主动改代码。
还有一种更精细的做法是结合配置文件。Claude Code 支持在项目目录下放.claude/settings.json或者在用户目录下放全局配置,里面可以定义默认的权限规则和历史禁用项。我的习惯是全局配置里放开安全工具,项目级配置里放开对应的业务命令:
{ "permissions": { "allow": [ "Read", "Glob", "Grep", "Bash(git status)", "Bash(git log --oneline -20)" ], "deny": [ "Bash(rm -rf *)", "Bash(curl *)", "Bash(chmod 777 *)" ] } }注意 Claude Code 的配置文件权限规则是累积生效的,项目配置会和全局配置合并。我建议把 deny 规则放在全局,因为无论哪个项目的任务都不应该允许这种高危命令。
2.4 headless 跑任务的正确姿势:print 模式与会话管理
群聊场景下,Claude Code 必须跑在无交互模式,也就是常说的 headless。最常用的就是-p参数(print 模式),后面直接跟 prompt,Claude Code 执行完会自动退出。配合--output-format stream-json可以拿到结构化输出:
claude -p "帮我总结一下这个仓库最近 10 个 commit 的主题" \ --output-format stream-json \ --verbose输出是 JSON Lines 格式,每一行是一个事件对象。实际开发 Adapter 时,我会解析所有type: "stream_event"行来拿实时输出,最后再取type: "result"行拿最终结果和 token 用量。这些字段在官方文档里都有,但我是通过实际跑了一次才知道 stream-json 的输出量很大,如果直接把全部行转发到群里,瞬间刷屏。所以我的处理策略是:实时片段只写日志,群聊只回传最终结果和必要的摘要。
还有一个多轮会话问题。群聊里 @ 队友发任务,往往是就着同一个背景连续追问。如果每次任务都开一个新的 Claude Code 进程,上下文就断了。Claude Code 提供了--resume和--session-id参数。我把每个群聊会话映射成一个session-id,同一会话下的后续任务都带上这个 ID:
claude -p "继续,这次看一下刚才提到的那个函数" \ --resume <session-id> \ --output-format stream-json这样 Claude 能记住之前聊过的内容,多轮协作体验和交互式终端差不多。有一点要注意:同一个目录下的 Claude Code 会话是互斥的,如果同时有两个进程操作同一个 project 目录,后启动的进程会提示目录已被占用。这也是我后来加任务队列的重要原因。
3. 群聊消息接入与任务分发实现
3.1 入站消息:群聊 @ 事件怎么接
群聊机器人的接入分两种雅称,一种是你只往外发消息,比如自定义机器人 Webhook;另一种是能接收群里的事件,比如自建应用。要捕获 @ 消息,必须用后者。
我以飞书自建应用为例说明。在飞书开放平台创建应用后,开启机器人能力,订阅im.message.receive_v1事件,然后把事件回调地址指向你部署的任务服务。飞书会以 POST 方式推送事件 JSON,服务端需要验证token。核心代码很简单:
const express = require('express'); const app = express(); app.use(express.json()); const VERIFY_TOKEN = process.env.VERIFY_TOKEN || 'your-token'; app.post('/events', (req, res) => { const { header, event } = req.body || {}; if (!header || header.token !== VERIFY_TOKEN) { return res.status(401).end(); } if (header.event_type === 'im.message.receive_v1') { const msg = event.message; const content = JSON.parse(msg.content); const mentions = content.mentions || []; // 注意:content.text 里被 @ 的人名已经变成 <at>xxx</at> 标签 for (const m of mentions) { if (m.id && msg.chat_id) { enqueueTask({ userId: m.id, chatId: msg.chat_id, msgId: msg.message_id, text: content.text }); } } // 飞书要求快速应答,否则会重试 return res.json({ code: 0, data: {} }); } return res.status(200).end(); });这个回调里最关键的是mentions数组,里面是被 @ 的成员 ID。任务服务拿到这些 ID 后,才能决定任务派给哪台电脑。服务端收到事件之后必须立刻返回 200,否则 IM 平台会认为投递失败而反复重试,导致下游任务重复入队。我的做法是收到事件后只做“入队”这个轻操作,立刻回包,真正繁重的排队校验放在异步任务里做。
其他 IM 平台的逻辑类似:企业微信可以通过自建应用接收群聊消息,钉钉也有相应的机器人事件订阅。区别主要在消息体结构和验签方式,路由思路完全一致。如果你是团队内部用,我更推荐先用飞书或企业微信,因为群成员 ID 和企业内部通讯录天然打通,不需要额外绑定手机号。
3.2 出站消息:结果怎么回群
出站方向的实现比入站简单得多,用自定义机器人 Webhook 就能发消息。以飞书自定义机器人为例,你只需要一个独立的 Webhook 地址:
async function sendToGroup(chatId, text) { const url = process.env.BOT_WEBHOOK_URL; // 自定义机器人 webhook const payload = { msg_type: 'text', content: JSON.stringify({ text }) }; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }这里有个细节:为什么入站用自建应用,出站却用自定义机器人?因为自建应用机器人发消息需要拿到 tenant access token,还要调用发送消息接口,复杂度高得多。而自定义机器人只需要一个 Webhook 地址,谁拿到都能发。我这个系统里,任务服务只往固定的群发消息,所以出站直接用 Webhook 最省事。如果你需要根据任务动态发到不同群,那就得用自建应用机器人了,在入站事件里保存chat_id,发消息时调用开放接口。
我在回传结果时做了一层格式化:超过 4000 字符的结果会被截断,只保留头部关键信息,完整输出写到本地日志文件。原因很简单,群聊不适合承载长文本,一次任务输出 1 万行代码片段,群里根本没法看。格式化成“任务结果 + 改动文件列表 + token 消耗”这种卡片结构,体验会好很多。
3.3 任务队列与并发控制
任务队列是整个系统的节拍器。最开始我把入站处理和本机执行写在一个流程里,收到 @ 消息后直接开始跑 Claude Code。第一次联调就出了问题:群里有三个人同时 @ 我,本机 Claude Code 对同一个目录加了互斥锁,后面两个任务全部失败。
后来我把任务全部经过队列。队列不一定需要 Redis 或者 RabbitMQ,小规模团队场景,一个进程内的任务队列就够用。核心要求是:同一个节点同一个会话的任务必须 FIFO,不同节点的任务可以并行。我的简化实现:
const queue = []; let running = false; function enqueueTask(task) { const key = `${task.nodeId}:${task.sessionId}`; queue.push({ ...task, key }); pump(); } function pump() { if (running) return; running = true; while (queue.length > 0) { const task = queue.shift(); // 实际执行时会检查这个 nodeId+sessionId 是否有任务在跑 runTask(task); // 注意这里要 await,为示例简化 } running = false; }真实生产环境里,我用了一个简单的并发控制器:维护每个nodeId:sessionId的运行状态,同一 key 的任务必须等上一个任务结束才能开始。这样既保证了同一个 Agent 会话内的上下文连续,又不会阻塞其他队友的任务。如果团队规模大,任务量大,可以替换成 BullMQ 或者 Redis Stream,把队列从单机进程挪到独立服务里,这样重启任务服务不会丢任务。
3.4 Adapter:把任务交给本机 Claude Code
Adapter 是跑在每台开发者电脑上的执行器。它监听任务服务分配的队列,然后 spawn 一个claude子进程执行任务。我用的实现长这样:
const { spawn } = require('child_process'); const readline = require('readline'); async function runTask(task) { const cwd = task.workDir; // 每个任务指定工作目录 const prompt = buildPrompt(task.text); const args = [ '-p', prompt, '--output-format', 'stream-json', '--verbose' ]; if (task.sessionId) { args.push('--resume', task.sessionId); } if (task.allowedTools && task.allowedTools.length) { args.push('--allowedTools', task.allowedTools.join(',')); } const child = spawn('claude', args, { cwd, env: { ...process.env }, shell: false, timeout: task.timeoutMs || 10 * 60 * 1000 }); const rl = readline.createInterface({ input: child.stdout }); let resultText = ''; rl.on('line', (line) => { if (!line.trim()) return; try { const item = JSON.parse(line); if (item.type === 'stream_event') { // 这里可以实时记录日志 } if (item.type === 'result') { resultText = typeof item.result === 'string' ? item.result : JSON.stringify(item.result); } } catch (_) { // 非 JSON 行,忽略 } }); child.on('close', async (code) => { if (resultText) { await sendToGroup(task.chatId, formatResult(resultText, code)); } else { await sendToGroup(task.chatId, `任务失败,退出码 ${code}`); } }); }这段代码里我重点做了三件事:用spawn而不是exec,避免命令字符串注入;用shell: false让 claude 直接作为子进程运行,不经过 shell;用readline逐行解析 JSON 输出而不是一次性读完整段 stdout,因为 Claude Code 的流式输出可能非常大,一次性读进内存容易 OOM。
还有一个细节是超时。Claude Code 跑复杂任务可能超过 10 分钟,IM 平台的 Webhook 通常有超时限制,但我们是先把任务入队再回来发结果,所以执行时间长短不影响消息发送。我给 Adapter 设了默认 10 分钟超时,超时直接 kill 子进程并把超时原因回传到群里。你也需要处理子进程被杀后残留的 Claude Code 会话锁,最好在任务结束时调一次清理逻辑,或者给每次任务指定独立的--session-id。
3.5 安全机制:别把电脑变成公共后门
这是整套系统里我最看重的部分。说实话,让一个群成员 @ 你一下就能在你电脑上跑 Claude Code,本质上是开了一个远程命令执行入口,如果没有任何安全控制,后果非常严重。
我的安全设计分四层。第一层是成员绑定白名单:并不是群里任何人都能触发任务。任务服务维护一个映射表,只有登记过的用户 ID 才允许任务下发,未绑定的 ID 直接忽略并回一条“未注册”提示。第二层是工具白名单:每个节点预先配置允许执行的工具集,通常只放 Read、Glob、Grep、Edit,以及少量业务命令。第三层是高危命令拦截:我会在.claude/settings.json里配置 deny 规则,把rm -rf、curl、chmod 777、往系统目录写文件的命令全部禁掉。第四层是人工审批:如果任务匹配到高危模式,Adapter 不会直接执行,而是先往群里发一条待确认消息,需要本人在群里明确回复“同意”,任务才继续。
我甚至建议再加上一层:用 Claude Code 的 hook 机制拦截 PreToolUse,对 Bash 工具做二次校验。在全局 settings 里配置:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node /path/to/bash-guard.js" } ] } ] } }hook 脚本从 stdin 读取 JSON,检查要执行的命令,如果命中黑名单就让子进程返回非零退出码,Claude Code 就会中止执行该命令并告诉模型“命令被安全策略拦截”。这样即使模型的判断偶尔失误,底层也有一道物理拦截。
我还遇到过一个安全相关的坑:最开始测试时,我让 Adapter 直接以当前用户身份跑 claude,队友的代码里如果有恶意脚本,理论上可能被 Claude 调用。后来我把每个节点的 Adapter 改成了独立运行用户,只保留项目目录的读写权限,不放开全盘访问。代价是配置麻烦了一些,但安全收益明显。
4. 实操记录:从零到可运行
4.1 目录结构与模块拆分
整个项目我拆成了三部分,对应三个可独立部署的进程:
claude-group-chat/ ├── broker/ # 任务服务:消息路由 + 队列 │ ├── package.json │ ├── index.js # HTTP 事件服务 │ └── queue.js # 内存队列 + 并发控制 ├── adapter/ # 节点执行器:跑在每台电脑上 │ ├── package.json │ ├── index.js # 消费队列 + 调用 Claude Code │ ├── run-task.js # 子进程封装 │ └── bash-guard.js # 高危命令拦截 hook ├── shared/ │ ├── user-map.json # 群成员 ID → 节点映射 │ └── formatter.js # 结果格式化 └── .env.example # 环境变量模板Broker 和 Adapter 之间我用了一个简单的轮询接口:Adapter 定时向 Broker 拉取分配给自己的任务,而不是用消息队列服务。这样部署最简单,不需要额外装 Redis,Broker 启动后 Adapter 连上就能用。等后面任务量大了再平滑替换成消息队列。
4.2 核心代码:消息接收、队列消费、本地执行
整个系统最核心的三个环节就是消息接收、队列消费、本地执行。消息接收和本地执行我在前面已经给出了代码,这里补充队列消费的关键逻辑。我的 Broker 用一个 Map 存放每个 key 的待处理队列,Adapter 固定每隔两秒过来拉一次:
// broker/index.js const pendingTasks = new Map(); // key: nodeId => task[] app.get('/poll/:nodeId', (req, res) => { const nodeId = req.params.nodeId; const tasks = pendingTasks.get(nodeId) || []; if (tasks.length > 0) { res.json(tasks.splice(0, 1)[0]); } else { res.json(null); } });Adapter 端就是一个无限循环:
async function pollLoop() { while (true) { const task = await fetch(`${BROKER_URL}/poll/${MY_NODE_ID}`).then(r => r.json()); if (task) { await runTask(task); } else { await sleep(2000); } } }这个轮询方案的好处是简单、直观、不容易出消息丢失的问题,缺点是实时性差一点,最多有 2 秒延迟。对于一个“在群里 @ 一下,任务进队列,然后机器人回执”的场景,2 秒延迟完全感知不到。
4.3 本地联调:先模拟两个“队友”,再上真群
我在把系统接到真群之前,先在本机用两个终端模拟了两个“队友”。做法是:在 Broker 的user-map.json里注册两个虚拟节点,然后直接在本地跑两个 Adapter 实例,每个 Adapter 用不同的MY_NODE_ID,工作目录也指向两个不同的测试仓库。接着我往 Broker 的 HTTP 接口 POST 一条模拟事件:
curl -X POST http://localhost:3000/events \ -H "Content-Type: application/json" \ -d '{ "header": { "token": "your-token", "event_type": "im.message.receive_v1" }, "event": { "message": { "chat_id": "oc_test123", "message_id": "msg_test001", "content": "{\"text\":\"<at>user_a</at> 分析一下当前分支的代码结构\",\"mentions\":[{\"id\":\"user_a\"}]}" } } }'这条 curl 打完,本地终端 A 的 Adapter 立刻开始执行,几秒后在终端看到 claude 进程启动,再隔一会终端 B 的模拟 Webhook 把结果打印出来。这一步验证通过之后,我才去飞书开放平台创建应用、配事件订阅、拿到真实 Webhook,把地址填进.env里,完成了真群联调。
整个联调过程中我遇到的最大问题是事件消息体里的字段名记错,比如chat_id和message_id在飞书不同版本事件里字段不一样。我的建议是先写一个本地调试脚本,用 curl 模拟各种畸形事件,确保服务端不会因为字段缺失直接抛异常。群聊机器人上线后,任何崩溃都会被 IM 平台无限重试放大成风暴,所以入口处容错必须做好。
4.4 VS Code 与 WSL 环境细节
不少队友在 Windows 上开发,用的是 WSL,这里有几个容易踩的细节。首先,Windows 系统上装的 Node.js 和 WSL 里面是两套环境,你在 PowerShell 里执行claude没问题,不代表 WSL 里也有这个命令。需要在 WSL 内部重新装一遍 Node 环境,建议用 nvm 管理版本:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 npm install -g @anthropic-ai/claude-code装完之后验证一下which claude,确保路径在 WSL 的 PATH 里。如果配置了~/.npm作为全局目录,记得也要把 PATH 加上。
其次,WSL 里访问 Windows 目录(/mnt/c/...)会偶发文件权限和监听问题。比如 git 仓库在/mnt/c/workspace/project,Claude Code 读文件没问题,但跑 git 命令时可能因为文件权限位不对而报诡异错误。我测试下来,代码放在 WSL 自己的文件系统(~/workspace/project)里最稳定,读写性能和权限模型都和原生 Linux 一致。
VS Code 的使用场景是:队友平时用 Remote-WSL 扩展在 WSL 里开发,Claude Code 在 VS Code 集成终端里跑。这个组合没问题,只要你的 Adapter 进程是从 WSL 的 shell 启动的,它 spawn 的 claude 自然就是 WSL 里的那个版本。不要在 Windows 侧启动 Adapter 然后试图让它操作 WSL 里的文件,两条路径的权限模型会把你搞疯。
5. 常见问题与排查技巧实录
5.1 排障速查表
我把整个开发过程中遇到的典型问题整理成了表格,方便你直接对照:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
claude: command not found | Node 全局目录不在 PATH | 确认npm config get prefix并把它加入 PATH |
auto-update failed: no write permission to npm prefix | npm 全局目录没有写权限 | 修改 npm prefix 到用户目录,或设置DISABLE_AUTOUPDATER=1 |
| 群里 @ 队友没有反应 | 事件订阅未生效 / 回调地址不对 / 验签失败 | 先看任务服务日志,再确认 IM 平台是否推送了事件 |
| 任务被重复执行 | 事件回调重试导致消息重复入队 | 用msgId做去重,处理完记录最新 msgId |
| Claude Code 提示目录已被占用 | 同一个目录有交互进程在跑 | 给每个任务分配独立--session-id,或保证同 key 任务串行 |
| 第三方模型接口 404 | 网关路径不对,缺/v1/messages | 确认ANTHROPIC_BASE_URL拼接出来的完整 URL |
| 第三方模型返回 401 | 密钥或 token 格式不对 | 检查是填ANTHROPIC_AUTH_TOKEN还是ANTHROPIC_API_KEY,不同网关要求不同 |
| 任务超时被 kill 但群聊没收到消息 | 超时处理没写在 close 事件里 | 在child.on('close')里覆盖超时分支,确保任何退出都发消息 |
| 群里刷屏大量日志 | 把 stream-json 的实时事件全转发了 | 只回传 result 和摘要,原始日志写文件 |
5.2 我踩过的几个偏门坑
有一个问题困扰了我一个下午:任务明明执行成功了,结果却不在群里。后来发现是队列消费和 Webhook 发送之间的异步竞争——Adapter 在close事件里调用sendToGroup,但 Node 进程可能在 Promise 还没 resolve 时就退出了。解决办法是给 Adapter 的退出逻辑加上显式的 flush 等待,确保消息发出后再关闭进程。
还有一个容易被忽略的坑是 Claude Code 的自动更新机制。测试时一个队友的机器上 Claude Code 版本比较旧,任务执行到一半,自动更新下载了新版本并重启了进程,导致任务被截断。关掉自动更新(DISABLE_AUTOUPDATER=1)之后,这个现象就消失了。如果你希望保留自动更新,至少要在任务执行前先触发一次版本检查,不要在任务中途更新。
最后就是流式输出的解析边界问题。stream-json输出是一行一个 JSON,但如果 Claude 的输出里恰好包含很长的字符串,可能出现单行超大 JSON 被 stdout 分块切开,readline 会自动按\n切分所以通常没问题,但如果某些事件没有换行结尾,就要小心处理尾部残留。我在实现里用的是 readline,并且对解析失败的行做了忽略,保证不会因为单行解析失败导致整个任务崩溃。
5.3 安全与运维的补充建议
做这套系统,我最大的建议就是:先想清楚安全边界,再开始写代码。尤其是在团队群里开通这种能力之前,一定要跟队友同步好哪些任务可以被远程触发、哪些必须当面确认。我自己的实践是给每个节点设置了一个“任务等级”:普通任务直接执行,敏感任务必须审批,禁止任务直接拒绝。这个等级在user-map.json里配置,每次任务入队时判断。
运维上,日志要单独落盘。Claude Code 的 stream-json 输出信息量很足,包含每次工具调用的参数、token 消耗、耗时,这些是排查问题的重要线索。我会在 Adapter 里把每个任务的完整输出写到一个logs/目录,文件名带时间戳和任务 ID,这样无论群里消息怎么刷屏,回查都能找到完整记录。
另外建议做一个简单的健康检查接口。Adapter 每隔几秒上报自己的状态(在线、空闲、忙碌、最近任务时间),Broker 侧可以记录每个节点的健康状况。有队友开着电脑但 Adapter 挂了,群里 @ 他的时候至少能收到一条“该节点离线”的提示,总比消息发出去没人理要好。
我自己的下一步计划是给系统接上 MCP,让群里可以 @ 队友电脑上连接的外部工具,比如数据库查询、部署平台、代码评审服务,这样 Claude Code 不仅能操作本地文件系统,还能操作团队的工具链。不过这些都是后话了,目前这套群聊系统已经在我们小团队里跑了一周,效果超出预期:以前要人肉盯着的异步开发任务,现在都变成了群里的几行消息。每次看到群里有人发了条 @ 指令,过几分钟机器人把结果带回来,那种“代码自己动起来”的感觉,确实很上瘾。
最后分享一个小技巧:如果你也想做类似的东西,不必一开始就追求完整的群聊接入。你可以先用一个最简单的 HTTP 接口把任务发给本机 Claude Code,跑通了再加 IM 机器人。先把最核心的“任务到 Agent 的执行链路”打磨稳,交互层随时可以换。我就是被群里各种消息格式坑了太多次,才意识到核心价值不在群聊本身,而在那条可靠的“远程指令到本地 Agent”的链条上。