
Wechat Claude Code
- 821 installs
- 644 repo stars
- Updated June 27, 2026
- wechat-gggithub/wechat-claude-code
Helps with ai & agent building tasks.
About
wechat-claude-code is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- wechat-claude-code
- AI & Agent Building
- AI-coding skill
Wechat Claude Code by the numbers
- 821 all-time installs (skills.sh)
- +41 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #1,310 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wechat-gggithub/wechat-claude-code --skill wechat-claude-codeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 821 |
|---|---|
| repo stars | ★ 644 |
| Last updated | June 27, 2026 |
| Repository | wechat-gggithub/wechat-claude-code ↗ |
What it does
Helps with ai & agent building tasks.
Files
WeChat Claude Code Bridge
通过个人微信与本地 Claude Code 进行对话。
前置条件
- Node.js >= 18
- macOS(daemon 使用 launchd 管理)
- 个人微信账号(需扫码绑定)
- 已安装 Claude Code(
@anthropic-ai/claude-agent-sdk)
安装
方式一:通过 skills CLI(推荐)
npx skills add Wechat-ggGitHub/wechat-claude-code首次触发时 skill 会自动克隆完整项目源码并安装依赖。
方式二:手动克隆
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm install触发场景
用户提到"微信桥接"、"微信聊天"、"wechat bridge"、"连接微信"、"微信状态"、"停止微信"等与微信桥接相关的话题时触发。
触发后的执行流程
被触发时,不要直接执行任何操作,先探查当前状态再给出可用操作。
按顺序检查以下状态:
第 1 步:检查项目是否完整安装
test -f ~/.claude/skills/wechat-claude-code/package.json && echo "source_ok" || echo "source_missing"- 如果
source_missing:需要从 GitHub 克隆完整项目。执行:
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git /tmp/wechat-claude-code-install && cp -r /tmp/wechat-claude-code-install/{src,scripts,*.ts,*.json,*.md,LICENSE} ~/.claude/skills/wechat-claude-code/ && rm -rf /tmp/wechat-claude-code-install然后继续检查依赖。
- 如果
source_ok:继续检查依赖。
cd ~/.claude/skills/wechat-claude-code && test -d node_modules && echo "deps_ok" || echo "deps_missing"- 如果
deps_missing:执行cd ~/.claude/skills/wechat-claude-code && npm install安装依赖,然后继续。 - 如果
deps_ok:继续下一步。
第 2 步:检查是否已绑定微信账号
ls ~/.wechat-claude-code/accounts/*.json 2>/dev/null | head -1- 如果没有账号文件:提示用户需要先执行 setup 扫码绑定,询问是否现在执行。
- 如果有账号文件:继续下一步。
第 3 步:检查 daemon 运行状态
cd ~/.claude/skills/wechat-claude-code && npm run daemon -- status第 4 步:根据状态展示信息
如果 daemon 未运行:
微信桥接已绑定但未运行。
可用操作:
setup 重新扫码绑定(换号或过期时使用)
start 启动服务
logs 查看上次运行的日志如果 daemon 正在运行:
微信桥接正在运行(PID: xxx)。
可用操作:
stop 停止服务
restart 重启服务(代码更新后使用)
logs 查看运行日志
微信端命令(直接在微信中发送):
/help 显示帮助
/clear 清除当前会话,开始新对话
/status 查看当前会话状态
/model 切换 Claude 模型
/prompt 设置系统提示词
/cwd 切换工作目录
/skills 查看已安装的 skill如果用户明确指定了操作(如"启动微信"、"停止微信服务"、"看看日志"等),跳过状态展示直接执行对应命令。
子命令参考
所有命令的工作目录为 ~/.claude/skills/wechat-claude-code。
| 命令 | 执行 | 说明 |
|---|---|---|
| setup | npm run setup | 首次安装向导:生成 QR 码 → 微信扫码 → 配置工作目录 |
| start | npm run daemon -- start | 启动 launchd 守护进程(开机自启、自动重启) |
| stop | npm run daemon -- stop | 停止守护进程 |
| restart | npm run daemon -- restart | 重启守护进程 |
| status | npm run daemon -- status | 查看运行状态 |
| logs | npm run daemon -- logs | 查看最近日志(tail -100) |
数据目录
所有数据存储在 ~/.wechat-claude-code/:
~/.wechat-claude-code/
├── accounts/ # 绑定的微信账号数据(每个账号一个 JSON)
├── config.env # 全局配置(工作目录、模型、系统提示词)
├── sessions/ # 会话数据(每个账号一个 JSON)
├── get_updates_buf # 消息轮询同步缓冲
└── logs/ # 运行日志(每日轮转,保留 30 天)node_modules/
dist/
*.log
.env
.wechat-claude-code/
.claude/
CLAUDE.md
微信消息分块优化 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.Goal: 把 Claude CLI 最终答案从"按段落碎片推送"改为"按回合整段推送",仅在超 4000 字时按段落硬切;agent loop 期间的 interstitial 保持实时段落推送。
Architecture: 在 provider.ts 暴露 onTurnEnd(stopReason) 回调(基于 message_delta 事件的 stop_reason 字段);新增 src/claude/turn-router.ts 的 TurnRouter 类把 text_delta 按回合累积,根据 stop_reason 分流为 interstitial(立即发)或 final(流结束发);main.ts 替换原 textBuffer 逻辑,接入 TurnRouter,删除段落边界 flush 相关死代码。
Tech Stack: TypeScript(strict)、Node.js ESM("type": "module"、Node16 module resolution)、Node 内置 test runner(node --test)。
Global Constraints
- 不改
src/wechat/api.ts的限流逻辑(2.5s 间隔、60s 冷却、指数退避保持原样)。 - 不改
MAX_MESSAGE_LENGTH = 4000、splitMessage、parseBlocks、findSafeSplitPoint、splitByNewline。 - 不改 typing 指示器、silence warning 5min 兜底(
flushTimer)、文件自动推送。 - TypeScript strict 模式,编译命令
npm run build(tsc)。 - 测试入口
npm test=node --test dist/tests/*.test.js,必须先npm run build。 - ESM 导入必须带
.js后缀(即便源是.ts)。 - 提交信息遵循现有风格:
type: 中文描述(参考git log)。
Spec 参考:docs/superpowers/specs/2026-06-20-message-batching-design.md
---
Task 1: 提取 handleStreamLine 到 provider.ts(纯重构,为可测性铺路)
Files:
- Modify:
src/claude/provider.ts(提取rl.on('line', ...)内的 switch 到导出函数) - Create:
src/tests/provider.test.ts
Interfaces:
- Consumes: 无(首个任务)
- Produces:
export interface StreamParserState { sessionId: string; textParts: string[]; errorMessage?: string; trackingSkill: boolean; skillInputAccum: string; }export interface StreamParserCallbacks { onText?: (text: string) => void; onBlockEnd?: () => void; }export function handleStreamLine(line: string, state: StreamParserState, callbacks: StreamParserCallbacks): void
- [ ] Step 1: 写覆盖现有行为的失败测试
Create src/tests/provider.test.ts:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleStreamLine, type StreamParserState } from '../claude/provider.js';
function freshState(): StreamParserState {
return { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '' };
}
test('handleStreamLine: system init 设置 sessionId', () => {
const state = freshState();
handleStreamLine(
JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-123' }),
state,
{},
);
assert.equal(state.sessionId, 'sess-123');
});
test('handleStreamLine: text_delta 触发 onText', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } },
}),
freshState(),
{ onText: (t) => calls.push(t) },
);
assert.deepEqual(calls, ['hello']);
});
test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => {
let called = 0;
handleStreamLine(
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
freshState(),
{ onBlockEnd: () => called++ },
);
assert.equal(called, 1);
});
test('handleStreamLine: assistant 消息文本累积到 textParts', () => {
const state = freshState();
handleStreamLine(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: '回复内容' }] },
}),
state,
{},
);
assert.deepEqual(state.textParts, ['回复内容']);
});
test('handleStreamLine: 空行和非法 JSON 静默跳过', () => {
const state = freshState();
handleStreamLine('', state, {});
handleStreamLine('not json', state, {});
handleStreamLine(' ', state, {});
assert.deepEqual(state.textParts, []);
});- [ ] Step 2: 跑测试确认失败(函数不存在)
Run:
npm run build && node --test dist/tests/provider.test.jsExpected: 编译错误 handleStreamLine is not exported 或测试运行报错 cannot find module。
- [ ] Step 3: 实现提取
In src/claude/provider.ts:
3a. 在文件顶部 imports 之后、`claudeQuery` 之前,加入类型定义和提取的函数:
// ---------------------------------------------------------------------------
// Stream parser (extracted for testability)
// ---------------------------------------------------------------------------
export interface StreamParserState {
sessionId: string;
textParts: string[];
errorMessage?: string;
trackingSkill: boolean;
skillInputAccum: string;
}
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onBlockEnd?: () => void;
}
export function handleStreamLine(
line: string,
state: StreamParserState,
callbacks: StreamParserCallbacks,
): void {
if (!line.trim()) return;
let obj: any;
try {
obj = JSON.parse(line);
} catch {
return;
}
switch (obj.type) {
case 'system': {
if (obj.subtype === 'init' && obj.session_id) {
state.sessionId = obj.session_id;
}
break;
}
case 'assistant': {
const content = obj.message?.content;
if (Array.isArray(content)) {
const text = content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text ?? '')
.join('');
if (text) state.textParts.push(text);
}
break;
}
case 'stream_event': {
const evt = obj.event;
if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
if (evt.content_block.name === 'Skill') {
state.trackingSkill = true;
state.skillInputAccum = '';
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
const delta: string = evt.delta.text;
if (delta && callbacks.onText) {
callbacks.onText(delta);
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) {
state.skillInputAccum += evt.delta.partial_json ?? '';
try {
const parsed = JSON.parse(state.skillInputAccum);
if (parsed.skill) {
const msg = `\n正在调用 ${parsed.skill} 技能\n\n`;
if (callbacks.onText) callbacks.onText(msg);
state.trackingSkill = false;
}
} catch {
// JSON not complete yet
}
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
if (callbacks.onBlockEnd) callbacks.onBlockEnd();
}
break;
}
case 'result': {
if (obj.result && typeof obj.result === 'string') {
const combined = state.textParts.join('');
if (!combined.includes(obj.result)) {
state.textParts.push(obj.result);
}
}
if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) {
const errors = obj.errors ?? [obj.error_message ?? 'Unknown error'];
state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors);
logger.error('CLI returned error result', { errors });
}
break;
}
default:
break;
}
}3b. 在 `claudeQuery` 内替换原 `rl.on('line', ...)` 块。 找到现有的:
// Parse NDJSON from stdout
let skillInputAccum = '';
let trackingSkill = false;
const rl = createInterface({ input: child.stdout! });
rl.on('line', (line: string) => {
if (!line.trim()) return;
let obj: any;
try {
obj = JSON.parse(line);
} catch {
// Skip unparseable lines
return;
}
switch (obj.type) {
case 'system': {
if (obj.subtype === 'init' && obj.session_id) {
sessionId = obj.session_id;
}
break;
}
case 'assistant': {
const content = obj.message?.content;
if (Array.isArray(content)) {
const text = content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text ?? '')
.join('');
if (text) textParts.push(text);
}
break;
}
case 'stream_event': {
const evt = obj.event;
if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
if (evt.content_block.name === 'Skill') {
trackingSkill = true;
skillInputAccum = '';
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
const delta: string = evt.delta.text;
if (delta && onText) {
Promise.resolve(onText(delta)).catch(() => {});
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && trackingSkill) {
skillInputAccum += evt.delta.partial_json ?? '';
try {
const parsed = JSON.parse(skillInputAccum);
if (parsed.skill) {
const msg = `\n正在调用 ${parsed.skill} 技能\n\n`;
if (onText) Promise.resolve(onText(msg)).catch(() => {});
trackingSkill = false;
}
} catch {
// JSON not complete yet, keep accumulating
}
} else if (evt?.type === 'content_block_stop') {
trackingSkill = false;
if (onBlockEnd) Promise.resolve(onBlockEnd()).catch(() => {});
}
break;
}
case 'result': {
if (obj.result && typeof obj.result === 'string') {
const combined = textParts.join('');
if (!combined.includes(obj.result)) {
textParts.push(obj.result);
}
}
if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) {
const errors = obj.errors ?? [obj.error_message ?? 'Unknown error'];
errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors);
logger.error('CLI returned error result', { errors });
}
break;
}
default:
break;
}
});替换为:
// Parse NDJSON from stdout (logic in handleStreamLine for testability)
const parserState: StreamParserState = {
sessionId: '',
textParts: [],
trackingSkill: false,
skillInputAccum: '',
};
const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd };
const rl = createInterface({ input: child.stdout! });
rl.on('line', (line: string) => {
handleStreamLine(line, parserState, parserCallbacks);
});*3c. 把所有读写 `sessionId` / `textParts` / `errorMessage` 的地方改成操作 `parserState.。** 在 claudeQuery` 内:
- 函数开头删除三个局部声明:
let sessionId = '';、const textParts: string[] = [];、let errorMessage: string | undefined;(状态现在在parserState里)。 - timeout handler 内:
const partialText = textParts.join('\n').trim();→parserState.textParts.join('\n').trim();;finish({ ..., sessionId, ... })→finish({ ..., sessionId: parserState.sessionId, ... })。 - onAbort 内:同上两处替换。
child.on('close', ...)内(5 处替换,含读和写):!textParts.length && !errorMessage→!parserState.textParts.length && !parserState.errorMessageerrorMessage = stderr || \claude exited with code ${code}\;→parserState.errorMessage = stderr || \claude exited with code ${code}\;const fullText = textParts.join('\n').trim();→parserState.textParts.join('\n').trim();if (!fullText && !errorMessage)→if (!fullText && !parserState.errorMessage)errorMessage = 'Claude returned an empty response.';→parserState.errorMessage = 'Claude returned an empty response.';finish({ text: fullText, sessionId, error: errorMessage })→finish({ text: fullText, sessionId: parserState.sessionId, error: parserState.errorMessage })- 日志里
textLength: fullText.length等读取fullText的不动(它是局部变量)。 child.on('error', ...)内:finish({ text: '', sessionId, error: ... })→finish({ text: '', sessionId: parserState.sessionId, error: ... })。
- [ ] Step 4: 编译并跑测试确认通过
Run:
npm run build && node --test dist/tests/provider.test.jsExpected: 5 个测试全 PASS,无 TypeScript 编译错误。
- [ ] Step 5: 端到端冒烟(确认重构没破坏 claudeQuery)
Run:
echo "你好" | node dist/main.js 2>&1 | head -5 || trueExpected: 进程能启动(即使因为没有配置账号/凭证而退出,也不应在 provider.ts 上报 TypeError)。如果输出 未找到账号 之类的运行期错误,说明导入和类型正常。
- [ ] Step 6: 提交
git add src/claude/provider.ts src/tests/provider.test.ts
git commit -m "$(cat <<'EOF'
refactor: 提取 handleStreamLine 为可测的纯函数
把 claudeQuery 里 rl.on('line') 的 NDJSON 解析 switch 体抽成独立导出函数
handleStreamLine(line, state, callbacks),状态外置到 StreamParserState。
行为完全不变,仅是为后续 onTurnEnd 接入和单元测试铺路。
附首批单元测试覆盖 system init / text_delta / content_block_stop /
assistant 文本累积 / 非法行跳过 5 条路径。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"---
Task 2: 在 provider.ts 加 onTurnEnd 回调
Files:
- Modify:
src/claude/provider.ts(QueryOptions加字段、handleStreamLine加分支、StreamParserCallbacks加字段) - Modify:
src/tests/provider.test.ts(新增测试)
Interfaces:
- Consumes: Task 1 的
handleStreamLine/StreamParserState/StreamParserCallbacks - Produces:
QueryOptions.onTurnEnd?: (stopReason: string) => voidStreamParserCallbacks.onTurnEnd?: (stopReason: string) => voidhandleStreamLine在收到message_delta事件且delta.stop_reason存在时触发onTurnEnd
- [ ] Step 1: 写失败测试
Append to src/tests/provider.test.ts:
test('handleStreamLine: message_delta 带 stop_reason 触发 onTurnEnd', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, ['end_turn']);
});
test('handleStreamLine: message_delta 无 stop_reason 不触发 onTurnEnd', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: {} },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, []);
});
test('handleStreamLine: tool_use stop_reason 也正常透传', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: { stop_reason: 'tool_use' } },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, ['tool_use']);
});- [ ] Step 2: 跑测试确认失败
Run:
npm run build && node --test dist/tests/provider.test.jsExpected: 3 个新测试 FAIL(onTurnEnd 类型不存在或回调不触发),原 5 个 PASS。
- [ ] Step 3: 实现
3a. 在 `QueryOptions` 接口加字段(src/claude/provider.ts):
找到现有的:
/** Called when a content block ends — use to flush buffered text. */
onBlockEnd?: () => Promise<void> | void;在其之后加:
/** Called when an assistant turn ends, with its stop_reason
* ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...).
* Use to decide whether the turn's text is interstitial or final answer. */
onTurnEnd?: (stopReason: string) => Promise<void> | void;3b. 在 `StreamParserCallbacks` 接口加字段(同文件,Task 1 新增的部分):
找到:
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onBlockEnd?: () => void;
}改为:
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onBlockEnd?: () => void;
onTurnEnd?: (stopReason: string) => void;
}3c. 在 `handleStreamLine` 的 `stream_event` case 加分支。 找到 content_block_stop 分支:
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
if (callbacks.onBlockEnd) callbacks.onBlockEnd();
}
break;在其之后(仍在 stream_event case 内、break; 之前)插入:
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {
if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason);
}注意:因为这是 else if 链,要把上面那个 } 闭合改一下。完整片段应该是:
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
if (callbacks.onBlockEnd) callbacks.onBlockEnd();
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {
if (callbacks.onTurnEnd) callbacks.onTurnEnd(evt.delta.stop_reason);
}
break;3d. 把 `onTurnEnd` 透传到 parserCallbacks。 在 claudeQuery 内找到:
const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd };改为:
const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd };- [ ] Step 4: 跑测试确认通过
Run:
npm run build && node --test dist/tests/provider.test.jsExpected: 8 个测试全 PASS。
- [ ] Step 5: 提交
git add src/claude/provider.ts src/tests/provider.test.ts
git commit -m "$(cat <<'EOF'
feat: provider 暴露 onTurnEnd 回调,按 stop_reason 标记回合类型
handleStreamLine 在收到 message_delta 事件且带 stop_reason 时触发
onTurnEnd(stopReason)。下游可据此区分 'tool_use' 回合(interstitial)
和 'end_turn' / 'max_tokens' 等终态回合(final answer)。
本任务仅暴露信号,不改变任何现有 flush 行为——main.ts 下一任务接入。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"---
Task 3: 创建 TurnRouter 状态机
Files:
- Create:
src/claude/turn-router.ts - Create:
src/tests/turn-router.test.ts
Interfaces:
- Consumes: 无(独立模块)
- Produces:
export type MessageRole = 'interstitial' | 'final';export interface RoutedMessage { text: string; role: MessageRole; }export class TurnRouter { constructor(emit: (msg: RoutedMessage) => void); onText(delta: string): void; onTurnEnd(stopReason: string): void; drain(): void; }
行为契约:
onText累积到内部turnBuffer,不立即 emit。onTurnEnd('tool_use'):把turnBuffer作为interstitialemit(trim 后非空才发),清空turnBuffer。onTurnEnd(其他):把turnBuffer追加到pendingFinal(用\n\n连接非空两端),清空turnBuffer,不立即 emit。drain():先 emitpendingFinal作为final(非空才发),再 emit 残留turnBuffer作为interstitial(非空才发),清空两者。
- [ ] Step 1: 写失败测试
Create src/tests/turn-router.test.ts:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TurnRouter, type RoutedMessage } from '../claude/turn-router.js';
function newRouter() {
const emitted: RoutedMessage[] = [];
const router = new TurnRouter((m) => emitted.push(m));
return { router, emitted };
}
test('onText 累积不立即 emit', () => {
const { router, emitted } = newRouter();
router.onText('hello ');
router.onText('world');
assert.deepEqual(emitted, []);
});
test('onTurnEnd(tool_use) 把 turnBuffer 作为 interstitial emit', () => {
const { router, emitted } = newRouter();
router.onText('让我看一下');
router.onTurnEnd('tool_use');
assert.deepEqual(emitted, [{ text: '让我看一下', role: 'interstitial' }]);
});
test('onTurnEnd(end_turn) 不立即 emit,攒到 drain', () => {
const { router, emitted } = newRouter();
router.onText('最终答案第一段');
router.onTurnEnd('end_turn');
assert.deepEqual(emitted, []);
router.drain();
assert.deepEqual(emitted, [{ text: '最终答案第一段', role: 'final' }]);
});
test('多个 end_turn 回合用 \\n\\n 连接成一个 final', () => {
const { router, emitted } = newRouter();
router.onText('段一');
router.onTurnEnd('pause_turn');
router.onText('段二');
router.onTurnEnd('end_turn');
router.drain();
assert.deepEqual(emitted, [{ text: '段一\n\n段二', role: 'final' }]);
});
test('tool_use 和 end_turn 混合:interstitial 立即发,final 攒到 drain', () => {
const { router, emitted } = newRouter();
router.onText('让我查一下');
router.onTurnEnd('tool_use'); // → interstitial 立即
router.onText('找到了。');
router.onText('详细说明...');
router.onTurnEnd('end_turn'); // → final 攒着
router.drain(); // → final 发出
assert.deepEqual(emitted, [
{ text: '让我查一下', role: 'interstitial' },
{ text: '找到了。详细说明...', role: 'final' },
]);
});
test('空文本回合不产生空消息', () => {
const { router, emitted } = newRouter();
router.onTurnEnd('tool_use'); // turnBuffer 空
router.onTurnEnd('end_turn'); // turnBuffer 空
router.drain();
assert.deepEqual(emitted, []);
});
test('onTurnEnd 未触发时 drain 也能把残留 turnBuffer 当 interstitial 发出', () => {
const { router, emitted } = newRouter();
router.onText('未结束的残留');
router.drain();
assert.deepEqual(emitted, [
{ text: '未结束的残留', role: 'interstitial' },
]);
});
test('纯文本 Q&A(无 tool_use,单 end_turn)整段作为 final', () => {
const { router, emitted } = newRouter();
const chunks = ['闭包是...', '举个例子...', '总结...'];
for (const c of chunks) router.onText(c);
router.onTurnEnd('end_turn');
router.drain();
assert.deepEqual(emitted, [
{ text: chunks.join(''), role: 'final' },
]);
});- [ ] Step 2: 跑测试确认失败
Run:
npm run build && node --test dist/tests/turn-router.test.jsExpected: 导入失败 Cannot find module ../claude/turn-router.js。
- [ ] Step 3: 实现 TurnRouter
Create src/claude/turn-router.ts:
/**
* TurnRouter 把 Claude CLI 的流式输出按"回合"分流:
*
* - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度)
* - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...)
* 的文本 → 攒到 pendingFinal,drain 时一次性作为 final emit
*
* 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。
*
* 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。
* 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。
*/
export type MessageRole = 'interstitial' | 'final';
export interface RoutedMessage {
text: string;
role: MessageRole;
}
export class TurnRouter {
private turnBuffer = '';
private pendingFinal = '';
constructor(private readonly emit: (msg: RoutedMessage) => void) {}
onText(delta: string): void {
this.turnBuffer += delta;
}
onTurnEnd(stopReason: string): void {
const text = this.turnBuffer;
this.turnBuffer = '';
if (!text.trim()) return;
if (stopReason === 'tool_use') {
this.emit({ text, role: 'interstitial' });
} else {
// end_turn / max_tokens / stop_sequence / pause_turn / 未知值
// 一律当最终答案处理(宁可合并也不丢)
this.pendingFinal += this.pendingFinal ? '\n\n' + text : text;
}
}
/** 流结束时调用。先发 final,再 drain 残留 interstitial。 */
drain(): void {
if (this.pendingFinal.trim()) {
this.emit({ text: this.pendingFinal, role: 'final' });
this.pendingFinal = '';
}
if (this.turnBuffer.trim()) {
this.emit({ text: this.turnBuffer, role: 'interstitial' });
this.turnBuffer = '';
}
}
}- [ ] Step 4: 跑测试确认通过
Run:
npm run build && node --test dist/tests/turn-router.test.jsExpected: 8 个测试全 PASS。
- [ ] Step 5: 提交
git add src/claude/turn-router.ts src/tests/turn-router.test.ts
git commit -m "$(cat <<'EOF'
feat: 新增 TurnRouter 状态机,按 stop_reason 分流 interstitial/final
纯逻辑模块,不持 I/O。onText 累积到 turnBuffer;onTurnEnd(tool_use)
立即 emit 为 interstitial,其他 stop_reason 攒到 pendingFinal;
drain 时一次性 emit 为 final。
覆盖 8 条路径:累积不 emit / tool_use 立即发 / end_turn 攒到 drain /
多 end_turn 用 \\n\\n 连接 / 混合 / 空回合不产生空消息 / 残留 drain /
纯文本 Q&A 整段 final。
下一个任务把 main.ts 接进来。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"---
Task 4: 把 TurnRouter 接入 main.ts,删除死代码
Files:
- Modify:
src/main.ts(sendToClaude函数:替换 textBuffer 逻辑、接入 TurnRouter、改 flush 顺序、删除死代码) - Modify:
src/claude/provider.ts(删除QueryOptions.onBlockEnd字段、StreamParserCallbacks.onBlockEnd字段、handleStreamLine的content_block_stop分支调用) - Modify:
src/tests/provider.test.ts(删除 onBlockEnd 相关测试)
Interfaces:
- Consumes:
- Task 1/2 的
handleStreamLine/StreamParserCallbacks(无 onBlockEnd) - Task 2 的
QueryOptions.onTurnEnd - Task 3 的
TurnRouter/RoutedMessage - Produces: 无新对外接口(
sendToClaude仍是内部函数)
- [ ] Step 1: 删除 provider.ts 里的 onBlockEnd(先消提供方,让消费方编译报错暴露出来)
1a. 修改 `src/claude/provider.ts` 的 `QueryOptions` 接口,删除:
/** Called when a content block ends — use to flush buffered text. */
onBlockEnd?: () => Promise<void> | void;1b. 修改 `StreamParserCallbacks`,删除 onBlockEnd 字段:
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onBlockEnd?: () => void; // ← 删这一行
onTurnEnd?: (stopReason: string) => void;
}变成:
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onTurnEnd?: (stopReason: string) => void;
}1c. 修改 `handleStreamLine` 的 `content_block_stop` 分支,不再调用 callbacks:
找到:
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
if (callbacks.onBlockEnd) callbacks.onBlockEnd();
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {改为:
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {1d. 修改 `claudeQuery` 内的 parserCallbacks 构造,删除 onBlockEnd:
找到:
const parserCallbacks: StreamParserCallbacks = { onText, onBlockEnd, onTurnEnd };改为:
const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd };1e. 在 `claudeQuery` 函数签名解构里删除 `onBlockEnd`。 找到:
const {
prompt,
cwd,
resume,
model,
systemPrompt,
images,
onText,
onBlockEnd,
abortController,
} = options;改为(同时加 onTurnEnd):
const {
prompt,
cwd,
resume,
model,
systemPrompt,
images,
onText,
onTurnEnd,
abortController,
} = options;- [ ] Step 2: 改 provider.test.ts,删 onBlockEnd 测试
在 src/tests/provider.test.ts 删除:
test('handleStreamLine: content_block_stop 触发 onBlockEnd', () => {
let called = 0;
handleStreamLine(
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
freshState(),
{ onBlockEnd: () => called++ },
);
assert.equal(called, 1);
});替换为(验证 content_block_stop 仍重置 trackingSkill,但不发回调):
test('handleStreamLine: content_block_stop 重置 trackingSkill,无回调', () => {
const state = freshState();
state.trackingSkill = true;
let textCalls = 0;
handleStreamLine(
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
state,
{ onText: () => textCalls++ },
);
assert.equal(state.trackingSkill, false);
assert.equal(textCalls, 0);
});- [ ] Step 3: 编译确认 provider 侧改动不报错(此时 main.ts 还在传 onBlockEnd,预期会报错)
Run:
npm run build 2>&1 | head -20Expected: 在 src/main.ts 报 TypeScript 错误,类似:
error TS2322: Type '{ onText: ...; onBlockEnd: ...; onTurnEnd: ...; }' is not assignable to type 'QueryOptions' ...
Object literal may only specify known properties, and 'onBlockEnd' does not exist in type 'QueryOptions'.这是预期的——下一个 step 修 main.ts。
- [ ] Step 4: 重写 main.ts 的 sendToClaude,接入 TurnRouter
4a. 加 import。 在 src/main.ts 顶部 imports 区,找到:
import { claudeQuery, type QueryOptions } from './claude/provider.js';在其之后加:
import { TurnRouter } from './claude/turn-router.js';4b. 删除死代码 `endsWithStructuralBoundary`。 在 src/main.ts 的 sendToClaude 函数内(约 501-503 行)找到:
/** Check if buffer ends at a structural boundary (double newline or horizontal rule). */
function endsWithStructuralBoundary(text: string): boolean {
return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text);
}整个函数删除。
注意:MIN_BATCH_FLUSH_LEN和SOFT_FLUSH_LIMIT两个常量在 sendToClaude 内部声明,紧跟在endsWithStructuralBoundary上方。它们会在 step 4c 的整段替换里一并消失(OLD 块包含它们,NEW 块不包含),无需单独处理。
4c. 替换 sendToClaude 内的流式处理段。 找到现有的(约 493-591 行):
let textBuffer = '';
let anySent = false;
let lastSentTime = Date.now();
const MIN_BATCH_FLUSH_LEN = 30;
const SOFT_FLUSH_LIMIT = 3800;
/** Check if buffer ends at a structural boundary (double newline or horizontal rule). */
function endsWithStructuralBoundary(text: string): boolean {
return /\n\n\s*$/.test(text) || /\n[-*_]{3,}\s*$/.test(text);
}
// Serial promise chain — each flushText() appends to the chain, no flags needed
let flushChain: Promise<void> = Promise.resolve();
function flushText(): Promise<void> {
// Capture and clear synchronously to prevent race condition:
// new deltas can arrive while the chain awaits sendText,
// causing the async callback to clear content it never captured.
const captured = textBuffer.trim();
textBuffer = '';
if (!captured) return flushChain;
flushChain = flushChain.then(async () => {
const chunks = splitMessage(captured);
for (let i = 0; i < chunks.length; i++) {
try {
await sender.sendText(fromUserId, contextToken, chunks[i]);
} catch (err) {
// Rate-limit exhaustion etc.: put the unsent chunks back at the
// front of the buffer so the next flush retries them. Content is
// never silently dropped (previously the for-loop aborted here and
// the already-cleared buffer lost everything from this chunk on).
const remaining = chunks.slice(i).join('\n\n');
textBuffer = remaining + (textBuffer ? '\n\n' + textBuffer : '');
logger.warn('flushText send failed, content retained for retry', {
error: err instanceof Error ? err.message : String(err),
retainedChunks: chunks.length - i,
});
return;
}
}
anySent = true;
lastSentTime = Date.now();
});
return flushChain;
}
// Safety net: send keepalive if nothing was sent for 5 minutes
const SILENCE_WARNING_MS = 5 * 60 * 1000;
const SILENCE_MESSAGES = [
'我还在处理中,这个问题有点复杂,请再稍等一下',
'正在努力干活中,马上就有结果了,请稍等片刻',
'有点复杂正在处理,再给我一点时间,很快就好',
'快好了别着急,正在收尾阶段,马上给你回复',
'还在跑呢,任务量比较大,不过马上就能出结果了',
'任务比想象的复杂一些,再等等我,正在全力处理',
'正在处理中,进展顺利,再等一会儿就好',
'还没完不过已经快了,再给我一分钟就能搞定',
'我在认真思考这个问题,请再稍等一会儿',
'稍微有点棘手,不过已经快解决了,再等我一下',
];
flushTimer = setInterval(() => {
if (Date.now() - lastSentTime > SILENCE_WARNING_MS) {
const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)];
sender.sendText(fromUserId, contextToken, msg).catch(() => {});
lastSentTime = Date.now();
}
}, 2000);
const queryOptions: QueryOptions = {
prompt,
cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()),
resume: session.sdkSessionId,
model: session.model,
systemPrompt: [
'你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。',
config.systemPrompt,
].filter(Boolean).join('\n'),
abortController,
images,
onText: async (delta: string) => {
textBuffer += delta;
// Flush at structural boundaries (only if buffer is substantial) or when approaching size limit
const shouldFlush =
(endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN)
|| textBuffer.length > SOFT_FLUSH_LIMIT;
if (shouldFlush) {
await flushText();
}
},
onBlockEnd: () => {
if (textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN || textBuffer.length > SOFT_FLUSH_LIMIT) {
flushText();
}
},
};整段替换为:
let anySent = false;
let lastSentTime = Date.now();
let pendingRetry = ''; // sendText 失败时未发出的 chunks,下一次 flush 优先重试
// Serial promise chain — each emit appends to the chain, no flags needed
let flushChain: Promise<void> = Promise.resolve();
/** 把一段文本切分后串行发到微信。失败时把未发的 chunks 攒到 pendingRetry,下次重试。 */
function emitText(text: string, role: 'interstitial' | 'final'): void {
if (!text.trim()) return;
flushChain = flushChain.then(async () => {
const combined = pendingRetry ? pendingRetry + '\n\n' + text : text;
pendingRetry = '';
if (!combined.trim()) return;
const chunks = splitMessage(combined);
for (let i = 0; i < chunks.length; i++) {
try {
await sender.sendText(fromUserId, contextToken, chunks[i]);
} catch (err) {
// Rate-limit exhaustion etc.: put the unsent chunks back so the
// next emit retries them. Content is never silently dropped.
pendingRetry = chunks.slice(i).join('\n\n');
logger.warn('emitText send failed, content retained for retry', {
role,
error: err instanceof Error ? err.message : String(err),
retainedChunks: chunks.length - i,
});
return;
}
}
anySent = true;
lastSentTime = Date.now();
});
}
const router = new TurnRouter((msg) => emitText(msg.text, msg.role));
// Safety net: send keepalive if nothing was sent for 5 minutes
const SILENCE_WARNING_MS = 5 * 60 * 1000;
const SILENCE_MESSAGES = [
'我还在处理中,这个问题有点复杂,请再稍等一下',
'正在努力干活中,马上就有结果了,请稍等片刻',
'有点复杂正在处理,再给我一点时间,很快就好',
'快好了别着急,正在收尾阶段,马上给你回复',
'还在跑呢,任务量比较大,不过马上就能出结果了',
'任务比想象的复杂一些,再等等我,正在全力处理',
'正在处理中,进展顺利,再等一会儿就好',
'还没完不过已经快了,再给我一分钟就能搞定',
'我在认真思考这个问题,请再稍等一会儿',
'稍微有点棘手,不过已经快解决了,再等我一下',
];
flushTimer = setInterval(() => {
if (Date.now() - lastSentTime > SILENCE_WARNING_MS) {
const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)];
sender.sendText(fromUserId, contextToken, msg).catch(() => {});
lastSentTime = Date.now();
}
}, 2000);
const queryOptions: QueryOptions = {
prompt,
cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()),
resume: session.sdkSessionId,
model: session.model,
systemPrompt: [
'你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。',
config.systemPrompt,
].filter(Boolean).join('\n'),
abortController,
images,
onText: (delta: string) => {
router.onText(delta);
},
onTurnEnd: (stopReason: string) => {
router.onTurnEnd(stopReason);
},
};4d. 修改流结束后的 flush 顺序。 找到(约 605-627 行):
// Stop periodic flush and send any remaining buffered content
clearInterval(flushTimer);
await flushText();
// Send result back to WeChat
if (result.text) {
if (result.error) {
logger.warn('Claude query had error but returned text, using text', { error: result.error });
}
sessionStore.addChatMessage(session, 'assistant', result.text);
// If nothing was streamed at all (e.g. streaming not supported), send full text now
if (!anySent) {
const chunks = splitMessage(result.text);
for (const chunk of chunks) {
await sender.sendText(fromUserId, contextToken, chunk);
}
}
} else if (result.error) {替换 clearInterval(flushTimer); 和 await flushText(); 这两行(保留后面所有内容不变):
// Stop periodic flush, drain router (final 先于 interstitial), wait for queued sends
clearInterval(flushTimer);
router.drain();
await flushChain;
// Send result back to WeChat
if (result.text) {
if (result.error) {
logger.warn('Claude query had error but returned text, using text', { error: result.error });
}
sessionStore.addChatMessage(session, 'assistant', result.text);
// If nothing was streamed at all (e.g. streaming not supported), send full text now
if (!anySent) {
const chunks = splitMessage(result.text);
for (const chunk of chunks) {
await sender.sendText(fromUserId, contextToken, chunk);
}
}
} else if (result.error) {- [ ] Step 5: 编译并跑所有测试
Run:
npm run build && npm testExpected:
- TypeScript 编译零错误(确认 onBlockEnd 已彻底从 main.ts 移除)。
- 所有测试 PASS:
provider.test.ts(8 个)+turn-router.test.ts(8 个)。
- [ ] Step 6: 端到端冒烟
Run:
echo "" | node dist/main.js 2>&1 | head -5 || trueExpected: 进程启动、读到 未找到账号 或类似的运行期错误(因为没配置),但不应报 TypeScript / 模块解析错误。这验证编译产物导入正常。
- [ ] Step 7: 真实 trace 验证新逻辑依赖的信号确实存在
重新跑一份 trace,确认 Claude CLI 真实输出里包含新逻辑依赖的 message_delta 事件:
mkdir -p /tmp/verify-batching && cd /tmp/verify-batching && \
echo "用三段话解释 JavaScript 闭包" | claude -p - --output-format stream-json --verbose --include-partial-messages --dangerously-skip-permissions 2>/dev/null > trace.jsonl && \
echo "trace 行数: $(wc -l < trace.jsonl)" && \
echo "--- message_delta 事件数(应 >= 1)---" && \
grep -c '"type":"message_delta"' trace.jsonl && \
echo "--- 各 stop_reason 分布 ---" && \
grep -o '"stop_reason":"[^"]*"' trace.jsonl | sort | uniq -cExpected:
- trace 行数 > 100。
- 至少 1 个
message_delta事件。 - stop_reason 分布里至少有 1 个
end_turn(纯 Q&A 场景)。
这一步只验证「我们依赖的信号真实存在」。完整端到端效果验证(消息条数从 N 降到 1)需要装上 daemon 在微信里实测,见下方「验收清单」。
- [ ] Step 8: 提交
git add src/main.ts src/claude/provider.ts src/tests/provider.test.ts
git commit -m "$(cat <<'EOF'
feat: main.ts 接入 TurnRouter,最终答案改为按回合整段推送
把 sendToClaude 原来的单 textBuffer + 段落边界 flush 逻辑替换为
TurnRouter 状态机:onText 只累积不 flush,onTurnEnd(tool_use) 立即
emit 为 interstitial,其他 stop_reason 攒到 pendingFinal,流结束时
router.drain() 一次性 emit 为 final(splitMessage 按 4000 字硬切)。
真实 trace 验证效果:
- 纯文本 Q&A(1485 字):8 条 → 1 条
- tool_use + 9121 字长答案:59 条 → 4 条
顺带清理死代码:删除 MIN_BATCH_FLUSH_LEN / SOFT_FLUSH_LIMIT /
endsWithStructuralBoundary / onBlockEnd(provider.ts 同步移除字段
和 content_block_stop 回调,测试相应更新)。
限流逻辑、splitMessage、typing、silence warning、文件自动推送
全部不动。emitText 保留了 commit d6d7d62 引入的失败重试语义
(pendingRetry)。
Spec: docs/superpowers/specs/2026-06-20-message-batching-design.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"---
验收清单(实现完成后人工跑一遍)
参考 spec § 7.3:
1. 纯 Q&A:微信发"用三段话解释闭包"→ 应收到 1 条完整答案(而非现在的 8 条)。 2. 多 tool_use + 长答案:微信发"分析 src/main.ts 的结构"→ 应收到 1 条 interstitial + N 条最终答案块(按 4000 字切),总条数远少于现在。 3. Abort:发任务后立即发 /stop → 已发的 interstitial 不丢,部分生成的最终答案按已生成内容推送,无内容丢失。 4. 限流恢复:观察日志,若偶发 emitText send failed, content retained for retry,下一次 emit 应自动重试成功(pendingRetry 机制)。
微信消息分块优化:按回合分流最终答案
日期:2026-06-20 作者:brainstorming session 状态:待评审
1. 问题陈述
当前流式推送逻辑把 Claude CLI 的所有 text_delta 一视同仁地按"段落边界"切割推送,导致最终答案也被切成大量碎片。
真实 trace 验证(/tmp/claude-stream-test/traces/):
| Trace | 场景 | 现状推送条数 |
|---|---|---|
| A | 纯文本 Q&A(1485 字) | 8 条 |
| D | tool_use + 9121 字长答案 | 59 条 |
Trace D 的 59 条会反复撞服务端 ~10 条/分钟的突发限制(ret:-2),触发多次 60s 冷却,累计卡顿 5-6 分钟。这是用户报告的"体验特别差"的根因。
2. 目标与非目标
目标
- 最终答案(Claude 给用户的交付内容)尽可能合并成最少条消息,仅在超过微信单条 4000 字硬上限时按段落边界切分。
- Agent loop 期间的 interstitial(工具调用之间的简短评注)保持现状的实时段落推送,不引入额外延迟。
非目标
- 不改限流逻辑。
api.ts的 2.5s 间隔、60s 冷却、指数退避全部保持现状。理由:Problem 1 修复后发送量大幅下降(D 场景 59 → 4),足以让绝大多数任务在突发限制以内完成;用户已确认接受重度 agent loop(10+ 工具调用 + 密集 interstitial)仍可能撞墙的边界情况。 - 不改
splitMessage的 4000 字上限。 - 不改 typing 指示器、silence warning(5min 兜底)、文件自动推送等机制。
3. 根因分析
src/main.ts:574-590 的 onText 回调对每个 text_delta 执行同一段 flush 判断:
const shouldFlush =
(endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN)
|| textBuffer.length > SOFT_FLUSH_LIMIT;这段逻辑同时作用于两类语义不同的文本:
1. Interstitial:agent 在工具调用之间吐出的简短进度("让我看一下代码"、"找到问题了")。 2. Final answer:回合自然结束时交付给用户的完整回答。
两者都被段落边界切分。src/main.ts:610-621 虽然有"整段重发"分支,但条件是 !anySent——只要流式期间推过任何内容就不再触发,所以最终答案只能依靠流式 flush,被切成 N 段。
4. 关键洞察:stop_reason 是明确的回合类型信号
抓取真实 NDJSON trace(claude -p - --output-format stream-json --verbose --include-partial-messages)后发现:每个 assistant 回合结束时会发 `message_delta` 事件,带明确的 `stop_reason` 字段。
stop_reason | 含义 | 文本应如何处理 |
|---|---|---|
"tool_use" | 回合因要调工具结束,agent loop 继续 | 按段落 flush(interstitial) |
"end_turn" | 自然结束,这就是最终答案 | 整回合 buffer,流结束才一次性发 |
"max_tokens" / "stop_sequence" / "pause_turn" | 非自然结束但属终态 | 同 end_turn |
这比早期讨论的"是否有 tool_use"启发式更可靠——不需要猜测、不需要特例处理纯 Q&A 场景,API 直接告诉我们这回合是什么。
5. 设计
5.1 状态机
sendToClaude 内部维护三个局部状态(替换现在的单个 textBuffer):
let turnBuffer = ''; // 当前回合累积的 text_delta
let pendingFinal = ''; // 标记为最终答案的回合文本(可能跨多个 end_turn 回合)
let anySent = false; // 是否曾成功推送(保留现有安全网语义)5.2 事件路由
`onText(delta)`:每个 text_delta 追加到 turnBuffer。不再做段落边界 flush 判断——段落 flush 推迟到回合结束时根据 stop_reason 决定。
`onTurnEnd(stopReason)`(新回调,由 provider.ts 在 message_delta 事件时触发):
const turnText = turnBuffer;
turnBuffer = '';
if (stopReason === 'tool_use') {
// interstitial:按段落边界 flush(保留 agent loop 进度的实时性)
await flushInterstitial(turnText);
} else {
// end_turn / max_tokens / 等:标记为最终答案,不立即发
pendingFinal += (pendingFinal && turnText) ? '\n\n' : '';
pendingFinal += turnText;
}流结束(claudeQuery 返回后):
// 1. 先发最终答案(splitMessage 按 4000 字硬切,自然按段落打包)
await flushFinal(pendingFinal);
// 2. drain 残留 interstitial(保险)
await flushInterstitial(turnBuffer);
// 3. 安全网:完全没流式过任何内容时,用 result.text 整段发
if (!anySent && result.text) {
for (const chunk of splitMessage(result.text)) await sender.sendText(...);
}5.3 flushInterstitial 与 flushFinal
两者都复用现有 flushChain(串行 promise 链)保证发送顺序,都调用 splitMessage:
flushInterstitial(text):把 text 通过splitMessage切(对长 interstitial 也能处理),逐块sender.sendText。flushFinal(text):同上。差别只在调用时机——interstitial 在回合结束立即调用,final 只在流结束调用。
实现上可以参数化合并成一个 flush(text, role) 函数,role 仅用于日志区分。
5.4 边界情况
| 场景 | 行为 |
|---|---|
| 纯 Q&A(无 tool_use,单回合 end_turn) | 所有文本进 pendingFinal,流结束一次性发。这正是 Trace A 的 8→1。 |
| Agent 多轮 tool_use 后给最终答案 | 每个 tool_use 回合的文本立即当 interstitial 推;最后的 end_turn 回合 buffer 到流结束一次性推。 |
| 最终答案超 4000 字 | splitMessage 按段落边界硬切成 N 块(N = ceil(字数/4000))。Trace D 的 9121 字 → 3 块。 |
多个 end_turn 回合(罕见,如 pause_turn 后续接 end_turn) | 用 \n\n 连接累积到 pendingFinal,结束时一起发。 |
| Abort(被新消息打断) | provider.ts 现有 onAbort 捕获 partialText 返回。sendToClaude 的 finally 会 drain 两个 buffer(interstitial + final);被打断时部分最终答案也能推送给用户,不丢内容。 |
流式完全失败(onText 从未触发) | anySent 保持 false,安全网分支用 result.text 整段发(沿用现有逻辑)。 |
5.5 不变的部分
splitMessage/parseBlocks/findSafeSplitPoint/splitByNewline:完全不动,仍是最终切分手段。MAX_MESSAGE_LENGTH = 4000:不动。- silence warning 5min 兜底(
flushTimer):保留。长最终答案生成期间 typing 指示器 + 5min 兜底仍在。 result.text写入 chat history:不变。api.ts限流逻辑:不变。
5.6 删除的死代码
新方案不再需要按段落边界判断 flush 时机,以下符号变成死代码,一并删除(遵循项目"不留无用符号"规范):
MIN_BATCH_FLUSH_LEN(常量)SOFT_FLUSH_LIMIT(常量)endsWithStructuralBoundary(函数)
onBlockEnd 回调也不再需要——回合边界由 onTurnEnd 接管。QueryOptions.onBlockEnd 字段从 provider.ts 删除,main.ts 不再传它。
6. 改动文件
| 文件 | 改动 |
|---|---|
src/claude/provider.ts | stream_event 分支里,message_delta 事件触发新回调 onTurnEnd(stopReason)。QueryOptions 增加可选字段 onTurnEnd?: (stopReason: string) => void,删除 onBlockEnd 字段及对应的 content_block_stop 分支调用(不再需要)。 |
src/main.ts | sendToClaude 内:把单个 textBuffer 拆成 turnBuffer + pendingFinal;onText 只累积不 flush;新增 onTurnEnd 路由;流结束顺序改为先 flushFinal 再 flushInterstitial;删除 MIN_BATCH_FLUSH_LEN、SOFT_FLUSH_LIMIT、endsWithStructuralBoundary、onBlockEnd 入参。splitMessage 等辅助函数不动。 |
改动量预估:provider.ts 加约 5 行(一个 case 分支 + 类型定义)- 3 行(删 onBlockEnd);main.ts 改约 30-40 行(路由重写)+ 删约 15 行(死代码)。净增量小。
7. 验证计划
7.1 单元测试
splitMessage行为不变(现有测试继续通过)。- 新增:构造模拟的
turn序列,验证simulateProposed对各种stop_reason组合的分流正确。
7.2 集成验证(用真实 trace 回放)
复用本次 brainstorming 期间的模拟器 /tmp/claude-stream-test/simulate.mjs,把 provider.ts 改完后的真实输出再抓一次 trace,确认推送条数符合预期:
- 纯 Q&A → 1 条
- 多 tool_use + 最终答案 → interstitial 数 + 1(或按 4000 字切的 N 块)
7.3 手测(微信端)
1. 在微信里发"用三段话解释闭包"→ 应该收到 1 条完整答案(而非现在的 8 条)。 2. 发"分析 src/main.ts 的结构"→ 应该收到 1 条 interstitial + 3 条最终答案块(而非现在的 59 条)。 3. 发一个会触发 abort 的任务(/stop)→ 已推的 interstitial 不丢,未发的最终答案部分按已生成的部分推送。
8. 风险与缓解
| 风险 | 缓解 |
|---|---|
| Agent 单回合内既有长文本又有 tool_use(如先写 500 字解释再调工具) | 文本会延迟到回合结束(看到 stop_reason=tool_use)才作为 interstitial 推送。延迟量级 = 回合内文本生成时间,通常几秒。silence warning 5min 兜底覆盖极端情况。 |
message_delta 事件因 CLI 异常未触发 | 流结束时的 flushFinal(pendingFinal) + flushInterstitial(turnBuffer) 兜底 drain,任何已累积的文本都不会丢。 |
stop_reason 取值未来扩展(新枚举) | 默认走 else 分支当最终答案处理——对新值也是安全选择(宁可少切也不丢内容)。 |
| Interstitial 过长(agent 在工具间吐大段评注) | flushInterstitial 走 splitMessage,会按 4000 字硬切。行为与现状一致。 |
9. 未来可考虑的改进(不在本次 scope)
- 限流改造:如果 Problem 1 修复后重度 agent loop 仍频繁撞墙,再考虑把
api.ts的固定间隔改成 60s 滑动窗口(~8 条上限)。 - 长最终答案期间的进度反馈:用户当前选择"完全等完再发"。若未来希望长答案期间有部分漏出,可在
pendingFinal超过某阈值(如 5000 字)或等待超过 60s 时,主动 flush 一次部分内容。 - 更激进的 interstitial 合并:把连续多个 tool_use 回合的 interstitial 攒到一起发,进一步减少条数。代价是 agent loop 实时性下降。
MIT License
Copyright (c) 2026 Wechat-ggGitHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "wechat-claude-code",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-claude-code",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.0",
"typescript": "^5.7.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.77",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz",
"integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.33.5",
"@img/sharp-darwin-x64": "^0.33.5",
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-linuxmusl-arm64": "^0.33.5",
"@img/sharp-linuxmusl-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode-terminal": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
"integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
"bin": {
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
{
"name": "wechat-claude-code",
"version": "1.0.0",
"description": "Chat with Claude Code from WeChat - a Claude Code Skill that bridges personal WeChat to local Claude Code",
"type": "module",
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"start": "node dist/main.js",
"run": "node dist/main.js start",
"dev": "tsc --watch",
"test": "node --test dist/tests/*.test.js",
"setup": "node dist/main.js setup",
"daemon": "bash scripts/daemon.sh",
"visualize": "npx tsx src/tools/visualize-logs.ts"
},
"dependencies": {
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.0",
"typescript": "^5.7.0"
},
"keywords": ["wechat", "claude-code", "claude", "bridge", "chat", "skill"],
"author": "Wechat-ggGitHub",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Wechat-ggGitHub/wechat-claude-code.git"
}
}
WeChat Claude Code Bridge
<p align="center"> <strong>Chat with Claude Code in WeChat, just like texting a friend</strong> </p>
<p align="center"> <a href="https://github.com/Wechat-ggGitHub/wechat-claude-code/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License: MIT"></a> <a href="https://skills.sh/Wechat-ggGitHub/wechat-claude-code"><img src="https://img.shields.io/badge/skills.sh-view_page-blue?style=flat-square" alt="skills.sh"></a> <a href="README.md"><img src="https://img.shields.io/badge/Lang-中文-lightgrey?style=flat-square" alt="中文"></a> </p>
Scan a QR code to bind your WeChat, and a new "friend" appears in your contacts. Send it a message — it gets forwarded to Claude Code running on your computer, and the reply streams back to WeChat in real time. Supports text, images, voice, and files.
---
Highlights
| Scan and go | No account signup, no server deployment. Scan a QR code and you're done in a minute. All data stays on your machine. |
| Clean messages | Only key info gets pushed — progress, results, key decisions. Tool calls and intermediate noise are filtered out automatically. |
| "Typing..." indicator | WeChat shows a typing indicator while Claude is working, so you always know it's on it. |
| Consistent experience | Mobile and desktop Claude Code behave identically — same orchestration, same output. Not two disconnected AIs. |
| Two-way files | Send images, Word docs, PDFs for Claude to analyze. Files Claude generates get pushed directly to WeChat — no need to go back to your computer. |
| Timeout reassurance | Task taking longer than 5 minutes? You'll get an automatic message letting you know it's still working. |
---
Install
Option 1: skills CLI (recommended)
npx skills add Wechat-ggGitHub/wechat-claude-codeThe first time you trigger the skill, it will automatically clone the source and install dependencies.
Option 2: Manual clone
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm installQuick Start
1. Bind WeChat
cd ~/.claude/skills/wechat-claude-code
npm run setupA QR code will pop up — scan it with WeChat.
2. Start the service
npm run daemon -- startOn macOS, this registers a launchd agent for auto-start on boot and auto-restart on crash.
3. Start chatting
Open WeChat and send a message to your new "friend".
Manage the service
npm run daemon -- status # Check if running
npm run daemon -- stop # Stop the service
npm run daemon -- restart # Restart (after code updates)
npm run daemon -- logs # View recent logs---
WeChat Commands
Send these directly in the WeChat chat:
| Command | Description |
|---|---|
/help | Show available commands |
/clear | Clear current session, start fresh |
/stop | Stop current task |
/model <name> | Switch Claude model |
/prompt <text> | Set a system prompt (e.g. "reply in Chinese") |
/cwd <path> | Switch working directory |
/skills | List installed Skills |
/status | View current session state |
/history [n] | View recent chat history |
/compact | Compact context, start a new CLI session |
/reset | Full reset including working directory |
/undo [n] | Remove last N messages from history |
/<skill> [args] | Trigger any installed Skill |
---
How It Works
WeChat (phone) ←→ ilink Bot API ←→ Node.js daemon ←→ Claude Code CLI (local)The daemon long-polls WeChat for new messages, forwards them to the local claude CLI, and streams replies back to WeChat. Everything runs on your own machine.
---
Roadmap
- Message queue optimization — Consecutive messages can produce mixed-up replies. Working on a better queuing strategy. Ideas welcome.
- Prevent sleep — Use macOS
caffeinateto keep the system awake, so closing the lid doesn't interrupt the service. - Resume desktop session — Chat on your computer for a while, then continue the same session from WeChat on the go. Same workspace, same context.
---
Prerequisites
- Node.js >= 18
- macOS or Linux
- A personal WeChat account
- Claude Code CLI installed and authenticated
Note: Claude Code supports third-party API providers (OpenRouter, AWS Bedrock, etc.) — setANTHROPIC_BASE_URLandANTHROPIC_API_KEYaccordingly.
Data Directory
All data is stored in ~/.wechat-claude-code/:
~/.wechat-claude-code/
├── accounts/ # WeChat account credentials
├── config.json # Global config
├── sessions/ # Session data
└── logs/ # Rotating logs (daily, 30-day retention)License
MIT
WeChat Claude Code Bridge
<p align="center"> <strong>Chat with Claude Code in WeChat, just like texting a friend</strong> </p>
<p align="center"> <a href="https://github.com/Wechat-ggGitHub/wechat-claude-code/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License: MIT"></a> <a href="https://skills.sh/Wechat-ggGitHub/wechat-claude-code"><img src="https://img.shields.io/badge/skills.sh-view_page-blue?style=flat-square" alt="skills.sh"></a> <a href="README_en.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=flat-square" alt="English"></a> </p>
扫码绑定微信后,你的微信里会多出一个好友。给它发消息,消息会自动转发给你电脑上运行的 Claude Code,回复也会实时推送到微信。支持文字、图片、语音、文件的收发。
<img width="3018" height="1216" alt="ScreenShot_2026-06-10_211251_410" src="https://github.com/user-attachments/assets/2ba4c53b-9c63-4ffd-bd0a-71935a6eabec" />
核心亮点
| 扫码即用 | 不用注册账号,不用部署服务器。微信扫码绑定,一分钟搞定。数据全在本地,隐私有保障。 |
| 消息不刷屏 | 只推送核心信息——进度、结果、关键决策。工具调用、中间过程等噪音自动过滤,阅读体验清爽。 |
| "对方正在输入中..." | Claude 在处理任务时,微信顶部会显示输入状态,随时感知它在干活。 |
| 电脑手机体验一致 | 手机端和电脑端 Claude Code 行为完全相同——同样的编排逻辑、同样的输出效果。不是两个割裂的 AI。 |
| 文件双向收发 | 发图片、Word、PDF 给 Claude 分析;Claude 生成的文件也会直接推送到微信,不用回到电脑前查看。 |
| 超时安抚 | 任务超过 5 分钟没响应?它会自动发一条消息告诉你还在干,不会让你对着空白聊天框干等。 |
快速安装
方式一:skills CLI(推荐)
npx skills add Wechat-ggGitHub/wechat-claude-code首次在对话中触发时,会自动克隆项目源码并安装依赖。
方式二:手动克隆
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm install快速开始
1. 扫码绑定
cd ~/.claude/skills/wechat-claude-code
npm run setup弹出二维码,用微信扫码。
2. 启动服务
npm run daemon -- startmacOS 下自动注册 launchd,开机自启、崩溃自动重启。
3. 开始聊天
打开微信,给你新出现的那个"好友"发条消息试试。
管理服务
npm run daemon -- status # 查看运行状态
npm run daemon -- stop # 停止服务
npm run daemon -- restart # 重启服务(更新代码后使用)
npm run daemon -- logs # 查看日志微信端命令
直接在微信聊天中发送即可:
| 命令 | 说明 |
|---|---|
/help | 显示帮助 |
/clear | 清除当前会话,开始新对话 |
/stop | 停止当前任务 |
/model <名称> | 切换 Claude 模型 |
/prompt <内容> | 设置系统提示词(如"用中文回答") |
/cwd <路径> | 切换工作目录 |
/skills | 查看已安装的 Skill |
/status | 查看当前会话状态 |
/history [数量] | 查看最近对话记录 |
/compact | 压缩上下文,开始新 CLI 会话 |
/reset | 完全重置(包括工作目录等设置) |
/undo [数量] | 撤销最近几条对话 |
/<skill> [参数] | 触发任意已安装的 Skill |
工作原理
微信(手机) ←→ ilink Bot API ←→ Node.js 守护进程 ←→ Claude Code CLI(本地)守护进程通过长轮询监听微信消息,转发给本地 claude CLI 处理,回复实时流式推送回微信。全程跑在你自己电脑上。
后续计划
- 消息队列优化 — 连续发多条指令时,回复容易串。正在研究更好的队列策略,也欢迎讨论。
- 电脑休眠不中断 — 利用 macOS 的
caffeinate命令阻止系统睡眠,合上盖子也能响应微信消息。 - 接续电脑会话 — 在电脑上聊了很久,出门想接着聊。计划支持从当前电脑端的 Claude Code 会话直接续聊,工作空间和上下文保持一致。
前置条件
- Node.js >= 18
- macOS 或 Linux
- 个人微信账号
- 已安装 Claude Code CLI 并完成认证
提示: Claude Code 支持第三方 API 提供商(OpenRouter、AWS Bedrock 等),设置ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY即可。
数据目录
所有数据存储在 ~/.wechat-claude-code/:
~/.wechat-claude-code/
├── accounts/ # 微信账号凭证
├── config.json # 全局配置
├── sessions/ # 会话数据
└── logs/ # 运行日志(每日轮转,保留 30 天)License
MIT
#!/bin/bash
set -euo pipefail
# =============================================================================
# wechat-claude-code cross-platform daemon manager
# Supports: macOS (launchd) / Linux (systemd + nohup fallback)
# =============================================================================
DATA_DIR="${HOME}/.wechat-claude-code"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SERVICE_NAME="wechat-claude-code"
# Platform detection
OS_TYPE="$(uname -s)"
# =============================================================================
# macOS (launchd) functions
# =============================================================================
macos_plist_label() {
echo "com.wechat-claude-code.bridge"
}
macos_plist_path() {
echo "${HOME}/Library/LaunchAgents/$(macos_plist_label).plist"
}
macos_is_loaded() {
launchctl print "gui/$(id -u)/$(macos_plist_label)" &>/dev/null
}
macos_start() {
local plist_label="$(macos_plist_label)"
local plist_path="$(macos_plist_path)"
local node_bin="$(command -v node || echo '/usr/local/bin/node')"
if macos_is_loaded; then
echo "Already running (or plist loaded)"
exit 0
fi
mkdir -p "$DATA_DIR/logs"
# Collect Anthropic/Claude env vars for plist
local plist_extra_env=""
for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do
if [ -n "${!var:-}" ]; then
plist_extra_env="${plist_extra_env} <key>${var}</key>
<string>${!var}</string>
"
fi
done
cat > "$plist_path" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${plist_label}</string>
<key>ProgramArguments</key>
<array>
<string>${node_bin}</string>
<string>${PROJECT_DIR}/dist/main.js</string>
<string>start</string>
</array>
<key>WorkingDirectory</key>
<string>${PROJECT_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${DATA_DIR}/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>${DATA_DIR}/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${HOME}/.local/bin:${node_bin%/*}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
${plist_extra_env} </dict>
</dict>
</plist>
PLIST
launchctl load "$plist_path"
echo "Started wechat-claude-code daemon (macOS launchd)"
}
macos_stop() {
local plist_label="$(macos_plist_label)"
local plist_path="$(macos_plist_path)"
launchctl bootout "gui/$(id -u)/${plist_label}" 2>/dev/null || true
rm -f "$plist_path"
echo "Stopped wechat-claude-code daemon (macOS launchd)"
}
macos_status() {
if macos_is_loaded; then
local pid=$(pgrep -f "dist/main.js start" 2>/dev/null | head -1)
if [ -n "$pid" ]; then
echo "Running (PID: $pid)"
else
echo "Loaded but not running"
fi
else
echo "Not running"
fi
}
macos_logs() {
local log_dir="${DATA_DIR}/logs"
if [ -d "$log_dir" ]; then
local latest=$(ls -t "${log_dir}"/bridge-*.log 2>/dev/null | head -1)
if [ -n "$latest" ]; then
tail -100 "$latest"
else
echo "No bridge logs found. Checking stdout/stderr:"
for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do
if [ -f "$f" ]; then
echo "=== $(basename "$f") ==="
tail -30 "$f"
fi
done
fi
else
echo "No logs found"
fi
}
# =============================================================================
# Linux (systemd) functions
# =============================================================================
linux_ensure_user_session() {
if [ -z "${XDG_RUNTIME_DIR:-}" ]; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || true
fi
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
fi
}
linux_service_file() {
echo "${HOME}/.config/systemd/user/${SERVICE_NAME}.service"
}
linux_pid_file() {
echo "${DATA_DIR}/${SERVICE_NAME}.pid"
}
linux_node_bin() {
local node_bin="$(command -v node 2>/dev/null || echo '')"
if [ -z "$node_bin" ]; then
local nvm_default="${NVM_DIR:-${HOME}/.nvm}/versions/node"
if [ -d "$nvm_default" ]; then
node_bin="$(find "$nvm_default" -name "node" -type f 2>/dev/null | head -1)"
fi
fi
echo "${node_bin:-/usr/bin/node}"
}
linux_systemd_available() {
linux_ensure_user_session
systemctl --user list-units &>/dev/null
}
linux_create_service_file() {
local service_file="$(linux_service_file)"
local node_bin="$(linux_node_bin)"
mkdir -p "$(dirname "$service_file")"
# Collect Anthropic/Claude env vars to pass through to the service
local extra_env=""
for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do
if [ -n "${!var:-}" ]; then
extra_env="${extra_env}Environment=${var}=${!var}
"
fi
done
cat > "$service_file" <<SERVICE
[Unit]
Description=WeChat Claude Code Bridge
Documentation=https://github.com/Wechat-ggGitHub/wechat-claude-code
After=network.target
[Service]
Type=simple
ExecStart=${node_bin} ${PROJECT_DIR}/dist/main.js start
WorkingDirectory=${PROJECT_DIR}
Restart=always
RestartSec=10
Environment=PATH=${HOME}/.local/bin:${node_bin%/*}:/usr/local/bin:/usr/bin:/bin
${extra_env}StandardOutput=append:${DATA_DIR}/logs/stdout.log
StandardError=append:${DATA_DIR}/logs/stderr.log
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=default.target
SERVICE
chmod 644 "$service_file"
}
linux_reload_daemon() {
linux_ensure_user_session
systemctl --user daemon-reload 2>/dev/null || true
}
linux_direct_start() {
local pid_file="$(linux_pid_file)"
local node_bin="$(linux_node_bin)"
if [ -f "$pid_file" ]; then
local old_pid=$(cat "$pid_file" 2>/dev/null)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "Already running (PID: $old_pid)"
exit 0
fi
rm -f "$pid_file"
fi
mkdir -p "$DATA_DIR/logs"
echo "Starting wechat-claude-code daemon (direct mode)..."
nohup "$node_bin" "${PROJECT_DIR}/dist/main.js" start \
>> "$DATA_DIR/logs/stdout.log" \
2>> "$DATA_DIR/logs/stderr.log" &
local pid=$!
echo "$pid" > "$pid_file"
echo "Started (PID: $pid)"
echo "Logs: $DATA_DIR/logs/stdout.log"
}
linux_direct_stop() {
local pid_file="$(linux_pid_file)"
if [ ! -f "$pid_file" ]; then
echo "Not running (no PID file)"
exit 0
fi
local pid=$(cat "$pid_file" 2>/dev/null)
if [ -z "$pid" ]; then
rm -f "$pid_file"
echo "Stopped"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
local count=0
while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do
sleep 1
count=$((count + 1))
done
kill -9 "$pid" 2>/dev/null || true
echo "Stopped (PID: $pid)"
else
echo "Process not running (cleaning up PID file)"
fi
rm -f "$pid_file"
}
linux_direct_status() {
local pid_file="$(linux_pid_file)"
if [ ! -f "$pid_file" ]; then
echo "Not running"
exit 0
fi
local pid=$(cat "$pid_file" 2>/dev/null)
if [ -z "$pid" ]; then
echo "Not running (invalid PID file)"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
echo "Running (PID: $pid)"
else
echo "Not running (stale PID file)"
fi
}
linux_start() {
if linux_systemd_available; then
local service_file="$(linux_service_file)"
if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
echo "Already running"
exit 0
fi
mkdir -p "$DATA_DIR/logs"
linux_create_service_file
linux_reload_daemon
systemctl --user start "${SERVICE_NAME}"
systemctl --user enable "${SERVICE_NAME}" 2>/dev/null || true
echo "Started wechat-claude-code daemon (Linux systemd)"
else
echo "Note: systemd user session not available, using direct mode"
echo "To enable systemd mode, run: 'loginctl enable-linger $(whoami)'"
echo ""
linux_direct_start
fi
}
linux_stop() {
if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
systemctl --user stop "${SERVICE_NAME}" 2>/dev/null || true
systemctl --user disable "${SERVICE_NAME}" 2>/dev/null || true
echo "Stopped wechat-claude-code daemon (Linux systemd)"
else
linux_direct_stop
fi
}
linux_restart() {
linux_stop
sleep 1
linux_start
}
linux_status() {
if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
local pid=$(systemctl --user show-property --value=MainPID "${SERVICE_NAME}" 2>/dev/null)
if [ -n "$pid" ] && [ "$pid" != "0" ]; then
echo "Running (PID: $pid)"
else
echo "Active"
fi
else
echo "Not running"
fi
if systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
echo ""
systemctl --user status "${SERVICE_NAME}" --no-pager 2>/dev/null || true
fi
else
linux_direct_status
fi
}
linux_logs() {
if command -v journalctl >/dev/null 2>&1; then
if journalctl --user --unit="${SERVICE_NAME}" --quiet &>/dev/null; then
echo "=== systemd journal logs (last 100 lines) ==="
journalctl --user --unit="${SERVICE_NAME}" --no-pager -n 100 2>/dev/null || true
echo ""
echo "=== File logs ==="
fi
fi
local log_dir="${DATA_DIR}/logs"
if [ -d "$log_dir" ]; then
for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do
if [ -f "$f" ]; then
echo "=== $(basename "$f") ==="
tail -50 "$f"
echo ""
fi
done
else
echo "No logs found"
fi
}
# =============================================================================
# Main dispatcher
# =============================================================================
main() {
local command="${1:-}"
case "$OS_TYPE" in
Darwin)
case "$command" in
start) macos_start ;;
stop) macos_stop ;;
restart) macos_stop; sleep 1; macos_start ;;
status) macos_status ;;
logs) macos_logs ;;
*)
echo "Usage: daemon.sh {start|stop|restart|status|logs}"
echo "Platform: macOS (launchd)"
exit 1
;;
esac
;;
Linux)
case "$command" in
start) linux_start ;;
stop) linux_stop ;;
restart) linux_restart ;;
status) linux_status ;;
logs) linux_logs ;;
*)
echo "Usage: daemon.sh {start|stop|restart|status|logs}"
echo "Platform: Linux (systemd)"
exit 1
;;
esac
;;
*)
echo "Error: Unsupported platform '$OS_TYPE'"
echo "Supported platforms: macOS (Darwin), Linux"
exit 1
;;
esac
}
main "$@"
import { spawn, type ChildProcess } from 'node:child_process';
import { writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createInterface } from 'node:readline';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface QueryOptions {
prompt: string;
cwd: string;
resume?: string;
model?: string;
systemPrompt?: string;
images?: Array<{
type: "image";
source: { type: "base64"; media_type: string; data: string };
}>;
/** Called each time an assistant text chunk is produced (e.g. before/after tool calls). */
onText?: (text: string) => Promise<void> | void;
/** Called when an assistant turn ends, with its stop_reason
* ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...).
* Use to decide whether the turn's text is interstitial or final answer. */
onTurnEnd?: (stopReason: string) => Promise<void> | void;
/** Optional abort controller to cancel the query (e.g. when user sends a new message). */
abortController?: AbortController;
}
export interface QueryResult {
text: string;
sessionId: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEMP_DIR = join(tmpdir(), 'wechat-claude-code');
function saveImageTemp(images: NonNullable<QueryOptions['images']>): string[] {
mkdirSync(TEMP_DIR, { recursive: true });
const paths: string[] = [];
for (const img of images) {
const ext = img.source.media_type.split('/')[1] || 'png';
const fileName = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const filePath = join(TEMP_DIR, fileName);
writeFileSync(filePath, Buffer.from(img.source.data, 'base64'));
paths.push(filePath);
}
return paths;
}
function cleanupTempFiles(paths: string[]): void {
for (const p of paths) {
try { unlinkSync(p); } catch { /* ignore */ }
}
}
// ---------------------------------------------------------------------------
// Stream parser (extracted for testability)
// ---------------------------------------------------------------------------
export interface StreamParserState {
sessionId: string;
textParts: string[];
errorMessage?: string;
trackingSkill: boolean;
skillInputAccum: string;
}
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onTurnEnd?: (stopReason: string) => void;
}
export function handleStreamLine(
line: string,
state: StreamParserState,
callbacks: StreamParserCallbacks,
): void {
if (!line.trim()) return;
let obj: any;
try {
obj = JSON.parse(line);
} catch {
return;
}
switch (obj.type) {
case 'system': {
if (obj.subtype === 'init' && obj.session_id) {
state.sessionId = obj.session_id;
}
break;
}
case 'assistant': {
const content = obj.message?.content;
if (Array.isArray(content)) {
const text = content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text ?? '')
.join('');
if (text) state.textParts.push(text);
}
break;
}
case 'stream_event': {
const evt = obj.event;
if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
if (evt.content_block.name === 'Skill') {
state.trackingSkill = true;
state.skillInputAccum = '';
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
const delta: string = evt.delta.text;
if (delta && callbacks.onText) {
Promise.resolve(callbacks.onText(delta)).catch(() => {});
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) {
state.skillInputAccum += evt.delta.partial_json ?? '';
try {
const parsed = JSON.parse(state.skillInputAccum);
if (parsed.skill) {
const msg = `\n正在调用 ${parsed.skill} 技能\n\n`;
if (callbacks.onText) Promise.resolve(callbacks.onText(msg)).catch(() => {});
state.trackingSkill = false;
}
} catch {
// JSON not complete yet
}
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {
if (callbacks.onTurnEnd) Promise.resolve(callbacks.onTurnEnd(evt.delta.stop_reason)).catch(() => {});
}
break;
}
case 'result': {
if (obj.result && typeof obj.result === 'string') {
const combined = state.textParts.join('');
if (!combined.includes(obj.result)) {
state.textParts.push(obj.result);
}
}
if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) {
const errors = obj.errors ?? [obj.error_message ?? 'Unknown error'];
state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors);
logger.error('CLI returned error result', { errors });
}
break;
}
default:
break;
}
}
// ---------------------------------------------------------------------------
// Core function
// ---------------------------------------------------------------------------
export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
const {
prompt,
cwd,
resume,
model,
systemPrompt,
images,
onText,
onTurnEnd,
abortController,
} = options;
logger.info("Starting Claude CLI query", {
cwd,
model,
resume: !!resume,
hasImages: !!images?.length,
});
// Build CLI arguments
const args: string[] = [
'-p', '-',
'--output-format', 'stream-json',
'--verbose',
'--include-partial-messages',
'--dangerously-skip-permissions',
];
if (resume) args.push('--resume', resume);
if (model) args.push('--model', model);
if (systemPrompt) args.push('--append-system-prompt', systemPrompt);
// Handle images: save to temp files and append paths to prompt
const tempImagePaths = images?.length ? saveImageTemp(images) : [];
let fullPrompt = prompt;
if (tempImagePaths.length > 0) {
const imageLines = tempImagePaths.map(p => `\n`).join('');
fullPrompt += imageLines;
}
// Accumulators
let child: ChildProcess | undefined;
let settled = false;
const QUERY_TIMEOUT_MS = 60 * 60 * 1000;
return new Promise<QueryResult>((resolve) => {
const finish = (result: QueryResult) => {
if (settled) return;
settled = true;
cleanupTempFiles(tempImagePaths);
resolve(result);
};
try {
child = spawn('claude', args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
finish({ text: '', sessionId: '', error: `Failed to spawn claude: ${msg}` });
return;
}
// Write prompt to stdin and close
child.stdin!.write(fullPrompt);
child.stdin!.end();
// Timeout
const timeoutId = setTimeout(() => {
logger.warn('Claude CLI query timed out, killing process');
child!.kill('SIGTERM');
const partialText = parserState.textParts.join('\n').trim();
finish({
text: partialText,
sessionId: parserState.sessionId,
error: partialText ? undefined : 'Claude query timed out after 60 minutes',
});
}, QUERY_TIMEOUT_MS);
// Abort handling
const onAbort = () => {
logger.info('Claude CLI query aborted');
child!.kill('SIGTERM');
const partialText = parserState.textParts.join('\n').trim();
finish({ text: partialText, sessionId: parserState.sessionId });
};
abortController?.signal.addEventListener('abort', onAbort, { once: true });
// Collect stderr
const stderrParts: string[] = [];
child.stderr!.setEncoding('utf8');
child.stderr!.on('data', (chunk: string) => {
stderrParts.push(chunk);
});
// Parse NDJSON from stdout (logic in handleStreamLine for testability)
const parserState: StreamParserState = {
sessionId: '',
textParts: [],
trackingSkill: false,
skillInputAccum: '',
};
const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd };
const rl = createInterface({ input: child.stdout! });
rl.on('line', (line: string) => {
handleStreamLine(line, parserState, parserCallbacks);
});
// Handle process exit
child.on('close', (code: number | null) => {
clearTimeout(timeoutId);
abortController?.signal.removeEventListener('abort', onAbort);
if (code !== 0 && code !== null && !parserState.textParts.length && !parserState.errorMessage) {
const stderr = stderrParts.join('').trim();
parserState.errorMessage = stderr || `claude exited with code ${code}`;
logger.error('Claude CLI exited with error', { code, stderr: stderr.slice(0, 500) });
}
const fullText = parserState.textParts.join('\n').trim();
if (!fullText && !parserState.errorMessage) {
parserState.errorMessage = 'Claude returned an empty response.';
}
logger.info("Claude CLI query completed", {
sessionId: parserState.sessionId,
textLength: fullText.length,
hasError: !!parserState.errorMessage,
});
finish({
text: fullText,
sessionId: parserState.sessionId,
error: parserState.errorMessage,
});
});
child.on('error', (err: Error) => {
clearTimeout(timeoutId);
abortController?.signal.removeEventListener('abort', onAbort);
finish({ text: '', sessionId: parserState.sessionId, error: `Failed to spawn claude: ${err.message}` });
});
});
}
import { readdirSync, readFileSync, existsSync, type Dirent } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { logger } from '../logger.js';
export interface SkillInfo {
name: string;
description: string;
path: string;
}
/**
* Parse YAML-like frontmatter from a SKILL.md file.
* Only extracts `name` and `description` fields.
*/
function parseSkillMd(filePath: string): { name: string; description: string } | null {
try {
const content = readFileSync(filePath, 'utf-8');
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const frontmatter = match[1];
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
if (!nameMatch) return null;
return {
name: nameMatch[1].trim().replace(/^["']|["']$/g, ''),
description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, '') : '',
};
} catch {
logger.warn(`Failed to read SKILL.md: ${filePath}`);
return null;
}
}
/**
* Scan a directory for SKILL.md files, reading skill info from each.
*/
function scanDirectory(baseDir: string, depth: number = 2): SkillInfo[] {
const skills: SkillInfo[] = [];
if (!existsSync(baseDir)) return skills;
let entries: Dirent[];
try {
entries = readdirSync(baseDir, { withFileTypes: true });
} catch {
return skills;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = join(baseDir, entry.name);
if (depth > 1) {
// Recurse one level deeper
skills.push(...scanDirectory(fullPath, depth - 1));
}
const skillFile = join(fullPath, 'SKILL.md');
if (existsSync(skillFile)) {
const info = parseSkillMd(skillFile);
if (info) {
skills.push({ ...info, path: fullPath });
}
}
}
return skills;
}
/**
* Scan all known skill directories for installed Claude Code skills.
*
* Locations scanned:
* 1. ~/.claude/skills/ (each subdirectory)
* 2. ~/.claude/plugins/cache/{plugin}/skills/ (each subdirectory)
* 3. ~/.claude/plugins/cache/{plugin}/superpowers/skills/ (each subdirectory)
*/
export function scanAllSkills(): SkillInfo[] {
const home = homedir();
const claudeDir = join(home, '.claude');
const skills: SkillInfo[] = [];
const seen = new Set<string>();
// 1. ~/.claude/skills/*/
const userSkillsDir = join(claudeDir, 'skills');
for (const skill of scanDirectory(userSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
// 2. ~/.claude/plugins/cache/*/skills/*/
const pluginsCacheDir = join(claudeDir, 'plugins', 'cache');
if (existsSync(pluginsCacheDir)) {
let cacheEntries: Dirent[];
try {
cacheEntries = readdirSync(pluginsCacheDir, { withFileTypes: true });
} catch {
cacheEntries = [];
}
for (const cacheEntry of cacheEntries) {
if (!cacheEntry.isDirectory()) continue;
const cacheDir = join(pluginsCacheDir, cacheEntry.name);
// Regular skills
const pluginSkillsDir = join(cacheDir, 'skills');
for (const skill of scanDirectory(pluginSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
// Superpowers skills
const superpowersSkillsDir = join(cacheDir, 'superpowers', 'skills');
for (const skill of scanDirectory(superpowersSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
}
}
logger.info(`Scanned ${skills.length} skills`);
return skills;
}
/**
* Format a list of skills into a readable string for display.
*/
export function formatSkillList(skills: SkillInfo[]): string {
if (skills.length === 0) {
return 'No skills found.';
}
const lines = skills.map((s, i) => {
const desc = s.description ? ` - ${s.description}` : '';
return ` ${i + 1}. ${s.name}${desc}`;
});
return `Available skills (${skills.length}):\n${lines.join('\n')}`;
}
/**
* Find a skill by name (case-insensitive match).
*/
export function findSkill(skills: SkillInfo[], name: string): SkillInfo | undefined {
const lower = name.toLowerCase();
return skills.find(
(s) => s.name.toLowerCase() === lower || s.name.toLowerCase().replace(/\s+/g, '-') === lower,
);
}
/**
* TurnRouter 把 Claude CLI 的流式输出按"回合"分流:
*
* - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度)
* - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...)
* 的文本 → 攒到 pendingFinal,drain 时一次性作为 final emit
*
* 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。
*
* 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。
* 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。
*/
export type MessageRole = 'interstitial' | 'final';
export interface RoutedMessage {
text: string;
role: MessageRole;
}
export class TurnRouter {
private turnBuffer = '';
private pendingFinal = '';
constructor(private readonly emit: (msg: RoutedMessage) => void) {}
onText(delta: string): void {
this.turnBuffer += delta;
}
onTurnEnd(stopReason: string): void {
const text = this.turnBuffer;
this.turnBuffer = '';
if (!text.trim()) return;
if (stopReason === 'tool_use') {
this.emit({ text, role: 'interstitial' });
} else {
// end_turn / max_tokens / stop_sequence / pause_turn / 未知值
// 一律当最终答案处理(宁可合并也不丢)
this.pendingFinal += this.pendingFinal ? '\n\n' + text : text;
}
}
/** 流结束时调用。先发 final,再 drain 残留 interstitial。 */
drain(): void {
if (this.pendingFinal.trim()) {
this.emit({ text: this.pendingFinal, role: 'final' });
this.pendingFinal = '';
}
if (this.turnBuffer.trim()) {
this.emit({ text: this.turnBuffer, role: 'interstitial' });
this.turnBuffer = '';
}
}
}
import type { Session } from '../session.js';
import { findSkill } from '../claude/skill-scanner.js';
import { logger } from '../logger.js';
import { handleHelp, handleClear, handleCwd, handleModel, handleStatus, handleSkills, handleHistory, handleReset, handleCompact, handleUndo, handleVersion, handlePrompt, handleSend, handleUnknown } from './handlers.js';
export interface CommandContext {
accountId: string;
session: Session;
updateSession: (partial: Partial<Session>) => void;
clearSession: () => Session;
getChatHistoryText?: (limit?: number) => string;
text: string;
}
export interface CommandResult {
reply?: string;
handled: boolean;
claudePrompt?: string;
sendFile?: string; // Absolute path to a file to send to the user
}
/**
* Parse and dispatch a slash command.
*
* Supported commands:
* /help - Show help text with all available commands
* /clear - Clear the current session
* /model <name> - Update the session model
* /status - Show current session info
* /skills - List all installed skills
* /<skill> - Invoke a skill by name (args are forwarded to Claude)
*/
export function routeCommand(ctx: CommandContext): CommandResult {
const text = ctx.text.trim();
if (!text.startsWith('/')) {
return { handled: false };
}
const spaceIdx = text.indexOf(' ');
const cmd = (spaceIdx === -1 ? text.slice(1) : text.slice(1, spaceIdx)).toLowerCase();
const args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1).trim();
logger.info(`Slash command: /${cmd} ${args}`.trimEnd());
switch (cmd) {
case 'help':
return handleHelp(args);
case 'clear':
return handleClear(ctx);
case 'reset':
return handleReset(ctx);
case 'cwd':
return handleCwd(ctx, args);
case 'model':
return handleModel(ctx, args);
case 'prompt':
return handlePrompt(ctx, args);
case 'status':
return handleStatus(ctx);
case 'skills':
return handleSkills(args);
case 'history':
return handleHistory(ctx, args);
case 'undo':
return handleUndo(ctx, args);
case 'compact':
return handleCompact(ctx);
case 'send':
return handleSend(ctx, args);
case 'version':
case 'v':
return handleVersion();
default:
return handleUnknown(cmd, args);
}
}
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { DEFAULT_WORKING_DIR } from "./constants.js";
export interface Config {
workingDirectory: string;
model?: string;
systemPrompt?: string;
}
const CONFIG_DIR = join(homedir(), ".wechat-claude-code");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
const DEFAULT_CONFIG: Config = {
workingDirectory: DEFAULT_WORKING_DIR,
};
export function loadConfig(): Config {
try {
const content = readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON.parse(content);
const config: Config = {
workingDirectory: parsed.workingDirectory || DEFAULT_CONFIG.workingDirectory,
model: parsed.model,
systemPrompt: parsed.systemPrompt,
};
mkdirSync(config.workingDirectory, { recursive: true });
return config;
} catch {
const config = { ...DEFAULT_CONFIG };
mkdirSync(config.workingDirectory, { recursive: true });
return config;
}
}
export function saveConfig(config: Config): void {
mkdirSync(CONFIG_DIR, { recursive: true });
const data: Record<string, string> = {
workingDirectory: config.workingDirectory,
};
if (config.model) data.model = config.model;
if (config.systemPrompt) data.systemPrompt = config.systemPrompt;
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(CONFIG_PATH, 0o600);
}
}
import { homedir } from 'node:os';
import { join } from 'node:path';
export const DATA_DIR = process.env.WCC_DATA_DIR || join(homedir(), '.wechat-claude-code');
export const DEFAULT_WORKING_DIR = join(homedir(), 'Documents', 'ClaudeCode');
export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
import { mkdirSync, appendFileSync, readdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const LOG_DIR = join(homedir(), ".wechat-claude-code", "logs");
const MAX_LOG_FILES = 30; // Keep at most 30 days of logs
/** Clean up old log files beyond MAX_LOG_FILES retention. */
function cleanupOldLogs(): void {
try {
const files = readdirSync(LOG_DIR)
.filter((f) => f.startsWith("bridge-") && f.endsWith(".log"))
.sort();
while (files.length > MAX_LOG_FILES) {
unlinkSync(join(LOG_DIR, files.shift()!));
}
} catch {
// Ignore errors during cleanup
}
}
/**
* Redact sensitive values from a string:
* - Bearer tokens (Authorization headers)
* - aes_key values
* - generic token/secret values in JSON payloads
*/
export function redact(obj: unknown): string {
const raw = typeof obj === "string" ? obj : JSON.stringify(obj);
if (!raw) return raw;
let safe = raw;
// Mask Bearer tokens: "Bearer <anything>"
safe = safe.replace(/Bearer\s+[^\s"\\]+/gi, "Bearer ***");
// Mask generic token/secret/password/api_key values in JSON
// Matches both snake_case (bot_token) and camelCase (botToken)
safe = safe.replace(
/"(?:(?:[\w]+_)?[Tt]oken|(?:[\w]+_)?[Ss]ecret|(?:[\w]+_)?[Pp]assword|(?:[\w]+_)?api_key|[Aa]es_[Kk]ey)"\s*:\s*"[^"]*"/gi,
(match) => {
const key = match.match(/"[^"]*"/)?.[0] ?? '""';
return `${key}: "***"`;
},
);
return safe;
}
function ensureLogDir(): void {
mkdirSync(LOG_DIR, { recursive: true });
cleanupOldLogs();
}
function getLogFilePath(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
return join(LOG_DIR, `bridge-${date}.log`);
}
function writeLogLine(level: string, message: string, data?: unknown): void {
ensureLogDir();
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
const timestamp = ts.replace('Z', '+08:00');
const parts = [timestamp, level, message];
if (data !== undefined) {
parts.push(redact(data));
}
const line = parts.join(" ") + "\n";
appendFileSync(getLogFilePath(), line, "utf-8");
}
export const logger = {
info(message: string, data?: unknown): void {
writeLogLine("INFO", message, data);
},
warn(message: string, data?: unknown): void {
writeLogLine("WARN", message, data);
},
error(message: string, data?: unknown): void {
writeLogLine("ERROR", message, data);
},
debug(message: string, data?: unknown): void {
writeLogLine("DEBUG", message, data);
},
} as const;
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
export function generateAesKey(): string {
return randomBytes(16).toString("base64");
}
export function aesEcbPaddedSize(size: number): number {
const block = 16;
return Math.floor((size + block - 1) / block) * block;
}
export function encryptAesEcb(key: Buffer, plaintext: Buffer): Buffer {
const cipher = createCipheriv("aes-128-ecb", key, null);
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
export function decryptAesEcb(key: Buffer, ciphertext: Buffer): Buffer {
const decipher = createDecipheriv("aes-128-ecb", key, null);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
import { loadJson, saveJson } from '../store.js';
import { DATA_DIR } from '../constants.js';
import { join } from 'node:path';
const SYNC_BUF_PATH = join(DATA_DIR, 'get_updates_buf');
export function loadSyncBuf(): string {
return loadJson<string>(SYNC_BUF_PATH, '');
}
export function saveSyncBuf(buf: string): void {
saveJson(SYNC_BUF_PATH, buf);
}