Compare commits

...

10 Commits

Author SHA1 Message Date
claude-code-best
e4ce08fe39 Fixture/langfuse record auto mode data error (#308)
* fix: 修复状态栏 context 计数器在 loading 时闪现为 0 的问题

第三方 API(如智谱)在 message_start 中可能不返回完整 usage 数据,
导致 getCurrentUsage 返回全零 usage 对象,使 ctx 显示为 0%。

双重保护:
- getCurrentUsage: 跳过全零 usage,继续往前找有真实数据的 message
- calculateContextPercentages: totalInputTokens 为 0 时返回 null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 外部化 ESM 包使用 createRequire 替代裸 require

color-diff-napi、image-processor-napi、audio-capture-napi 声明
"type": "module" 但使用裸 require(),Node.js ESM 中 require
不可用。改用 createRequire(import.meta.url) 或顶层 import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: getDefaultSonnetModel 优先使用用户配置的模型,修复第三方 provider 模型不存在错误

当用户通过 ANTHROPIC_MODEL 或 settings 配置了自定义 provider 支持的模型时,
getDefaultSonnetModel/Haiku/Opus 现在会优先使用该配置,而非硬编码 Anthropic 官方模型 ID。
同时改进 Langfuse 可观测性:sideQuery 失败时记录错误信息到 span,
optional 模式下标记 WARNING 而非 ERROR。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 将 auto_mode classifier 的 side-query span 绑定到父 trace

classifyYoloAction 及 classifyYoloActionXml 接收 parentSpan 参数,
透传给 sideQuery 调用,使 auto_mode 的 side-query span 嵌套在主 agent trace 下。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 穷鬼模式下跳过 memdir_relevance side-query

Poor mode 启用时不执行 findRelevantMemories 的预取调用,
避免额外的 API token 消耗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 test:all 脚本用于完成任务后的全量检查

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Vite 构建补齐缺失的 feature flags,修复 auto mode 不可见

Vite 构建插件的 DEFAULT_BUILD_FEATURES 缺少 BUDDY、TRANSCRIPT_CLASSIFIER、
BRIDGE_MODE、ACP、BG_SESSIONS、TEMPLATES,导致 feature('TRANSCRIPT_CLASSIFIER')
被替换为 false,auto mode 从 Shift+Tab 循环中消失。与 build.ts 对齐。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 统一 feature flags 到 defines.ts,修复 Vite 构建缺失 auto mode

将 DEFAULT_BUILD_FEATURES 列表从 build.ts、dev.ts、vite-plugin-feature-flags.ts
三处内联定义统一到 scripts/defines.ts 单一导出。之前的 Vite 插件缺少
TRANSCRIPT_CLASSIFIER 等 feature flag,导致 auto mode 在 Vite 构建中不可见。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 13:30:05 +08:00
claude-code-best
92f8a92fbb feat: 正式启用 auto mode (#307)
* fix: 修复settings.json内存状态溢出的问题

* fix: 修复auto mode gate check未处理的promise rejection

在 bypassPermissionsKillswitch.ts 的 useKickOffCheckAndDisableAutoModeIfNeeded
中,void fire-and-forget 调用缺少 .catch() 处理,导致 verifyAutoModeGateAccess
失败时产生 unhandled promise rejection。同时移除 permissionSetup.ts 中冗余的
null check。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 开放 auto mode 和 bypass mode 给所有用户

通过 Shift+Tab 统一循环:default → acceptEdits → plan → auto → bypassPermissions → default

- 移除 USER_TYPE 分支判断,所有用户使用同一循环路径
- isBypassPermissionsModeAvailable 始终为 true
- isAutoModeAvailable 初始化直接为 true
- 移除 AutoModeOptInDialog 确认流程
- 简化 isAutoModeGateEnabled 仅保留快模式熔断器
- 简化 verifyAutoModeGateAccess 仅检查快模式
- 移除 GrowthBook/Statsig 远程门控
- bypass permissions killswitch 改为 no-op
- 新增 24 个测试覆盖循环逻辑和门控不变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 为sideQuery添加Langfuse追踪

sideQuery 绕过了 claude.ts 的主 API 路径,导致所有走 sideQuery 的调用
(auto mode classifier、permission explainer、session search 等)都没有
Langfuse 记录。现在为每次 sideQuery 调用创建独立 trace 并记录 LLM observation,
未配置 Langfuse 时全部 no-op。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ACP availableModes 补齐 bypassPermissions 并修正测试 import 路径

- ACP agent availableModes 按条件包含 bypassPermissions(非 root/sandbox)
- 顺序对齐 REPL 循环:default → acceptEdits → plan → auto → bypassPermissions
- 新增 2 个测试验证 availableModes 包含 bypassPermissions 及模式切换
- 修正 getNextPermissionMode.test.ts 和 permissionSetup.test.ts 的 import 路径

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:20:27 +08:00
claude-code-best
a67e2d0e97 docs: 更新 npm 安装 2026-04-19 22:00:48 +08:00
claude-code-best
8c629858ab chore: 1.6.0 2026-04-19 21:37:35 +08:00
claude-code-best
494eab7204 feat: 接入内建 weixin channel(同 #301 重构版本) (#303)
* feat: 接入 weixin 服务层与命令入口

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: 注册内建 weixin channel 插件

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修正 channel permission relay 路由与能力判定

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修复 builtin channel 的 ChannelsNotice 误报

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* docs: 补充内建 weixin channel 使用说明

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* docs: 更新微信 channel 接入计划状态

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 延迟加载 weixin 登录二维码依赖

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 改用 qrcode 生成 weixin 登录二维码

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修正 vite 构建的 Windows 路径解析

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* chore: 删除临时规划文档 wx_channel.md 并还原 package.json 排序

wx_channel.md 内容已整合到 docs/features/channels.md,不再需要。
package.json 中 @ant/model-provider 位置从原始位置被无意移动,还原。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 将 weixin 模块从 src/ 迁移至 packages/weixin 工作区包

将 src/services/weixin/ 中的纯业务逻辑迁入 @claude-code-best/weixin
workspace 包,降低 src/ 耦合度。仅保留 server.ts 作为薄适配层。

- 迁移 7 个无修改的纯模块 (types/api/accounts/login/pairing/media/send)
- monitor.ts 内联 PERMISSION_REPLY_RE 正则,解除对 src/ 的依赖
- permissions.ts 本地定义 ChannelPermissionRequestParams 接口
- cli.ts 拆分:serve 子命令通过回调注入,login/access 保留在包内
- server.ts 重写为从 @claude-code-best/weixin 导入
- 新增 cli-serve.ts 作为 serve 入口薄壳

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修正 weixin barrel export 中 interface 的导出方式

ChannelPermissionRequestParams 是纯类型,必须用 export type 导出,
否则 Bun 运行时会报 "export not found" 错误。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 将 server.ts 迁入 packages/weixin,彻底移除 src/services/weixin/

通过依赖注入(WeixinServerDeps)解耦 src/ 依赖(analytics、config、
MCP channel schema),server.ts 完全移入包内。cli.tsx 入口处一次性
注入所有依赖。

src/services/weixin/ 目录已完全删除。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 markdownToPlainText 中代码块正则的 ReDoS 风险

用非正则的线性扫描替代 \`\`\`[\s\S]*?\n([\s\S]*?)\`\`\` 匹配,
避免在含有大量重复 \`\`\` 序列的输入上触发多项式回溯。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 1111 <11111@asd.c>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 21:33:27 +08:00
claude-code-best
b83c3008d0 docs: 更新 discord 地址 2026-04-19 21:21:04 +08:00
claude-code-best
66d2671c98 feat: acp manager (#304)
* feat: acp 控制器第一版

* feat: acp-link 命令二合一
2026-04-19 21:18:18 +08:00
claude-code-best
c7bc8c8636 feat: remote control 支持 auto bind 功能 (#300)
* feat: acp-link 支持 --group 参数指定 channel group

- 添加 --group CLI flag,校验格式 [a-zA-Z0-9_-]+
- 支持 ACP_RCS_GROUP 环境变量 fallback
- 传递 channelGroupId 到 RcsUpstreamClient
- 更新 README 文档说明 --group 和相关环境变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: RCS 后端 session 复用与 group 绑定

- storeFindEnvironmentByMachineName 匹配 offline 状态,防止重连创建重复 session
- registerEnvironment 复用已有 session 而非每次新建
- EnvironmentResponse 返回 channel_group_id 字段
- 注册时将 session 绑定到 group ID,支持 web UI 按 group 查询
- apiKeyAuth 不再设置 uuid,由 uuidAuth 统一处理

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Web UI Token Manager — 多 token 切换与 session 隔离

- 新增 useTokens hook 管理 localStorage token CRUD
- 新增 TokenManagerDialog 弹窗组件(添加/编辑/删除/切换 token)
- api client 支持Bearer token 认证,UUID 跟随 token 变化
- Navbar 添加 token 切换按钮
- 切换 token 时自动 reload,实现 session 数据隔离

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 useTokens useState 初始化函数签名错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 13:04:09 +08:00
claude-code-best
673ccd1800 chore: 1.5.0 2026-04-19 12:34:51 +08:00
claude-code-best
d1ab38c089 chore: 移除 pre-commit git hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 12:31:31 +08:00
96 changed files with 4757 additions and 845 deletions

View File

@@ -1,22 +0,0 @@
#!/bin/sh
# pre-commit hook: 对暂存的文件运行 Biome 检查
# 仅检查 src/ 下的 .ts/.tsx/.js/.jsx 文件
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^src/.*\.(ts|tsx|js|jsx)$')
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
echo "Running Biome lint on staged files..."
# 使用 biome lint 对暂存文件进行检查(仅 lint不格式化不自动修复
echo "$STAGED_FILES" | xargs bunx biome lint --no-errors-on-unmatched
if [ $? -ne 0 ]; then
echo ""
echo "Biome lint failed. Fix errors or use --no-verify to bypass."
exit 1
fi
exit 0

View File

@@ -58,6 +58,9 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Remote Control Server

View File

@@ -6,13 +6,13 @@
[![GitHub License](https://img.shields.io/github/license/claude-code-best/claude-code?style=flat-square)](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
[![Last Commit](https://img.shields.io/github/last-commit/claude-code-best/claude-code?style=flat-square&color=blue)](https://github.com/claude-code-best/claude-code/commits/main)
[![Bun](https://img.shields.io/badge/runtime-Bun-black?style=flat-square&logo=bun)](https://bun.sh/)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord)](https://discord.gg/qZU6zS7Q)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord)](https://discord.gg/uApuzJWGKX)
> Which Claude do you like? The open source one is the best.
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
| 特性 | 说明 | 文档 |
|------|------|------|
@@ -22,7 +22,7 @@
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord 等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
@@ -41,8 +41,12 @@
不用克隆仓库, 从 NPM 下载后, 直接使用
```sh
bun i -g claude-code-best
bun pm -g trust claude-code-best
npm i -g claude-code-best
# bun 安装比较多问题, 推荐 npm 装
# bun i -g claude-code-best
# bun pm -g trust claude-code-best
ccb # 以 nodejs 打开 claude code
ccb-bun # 以 bun 形态打开
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制

View File

@@ -1,6 +1,7 @@
import { readdir, readFile, writeFile, cp } from 'fs/promises'
import { join } from 'path'
import { getMacroDefines } from './scripts/defines.ts'
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
const outdir = 'dist'
@@ -8,48 +9,6 @@ const outdir = 'dist'
const { rmSync } = await import('fs')
rmSync(outdir, { recursive: true, force: true })
// Default features that match the official CLI build.
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
// 'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
]
// Collect FEATURE_* env vars → Bun.build features
const envFeatures = Object.keys(process.env)
.filter(k => k.startsWith('FEATURE_'))

View File

@@ -17,23 +17,24 @@
"@ant/computer-use-swift": "workspace:*",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.26.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
"@anthropic-ai/mcpb": "^2.1.2",
"@anthropic-ai/sandbox-runtime": "^0.0.44",
"@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0",
"@aws-sdk/credential-provider-node": "^3.972.28",
"@aws-sdk/credential-providers": "^3.1020.0",
"@aws-sdk/client-bedrock": "^3.1032.0",
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
"@aws-sdk/client-sts": "^3.1032.0",
"@aws-sdk/credential-provider-node": "^3.972.32",
"@aws-sdk/credential-providers": "^3.1032.0",
"@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10",
"@biomejs/biome": "^2.4.12",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@claude-code-best/weixin": "workspace:*",
"@commander-js/extra-typings": "^14.0.0",
"@growthbook/growthbook": "^1.6.5",
"@langfuse/otel": "^5.1.0",
@@ -41,7 +42,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/core": "^2.6.1",
"@opentelemetry/core": "^2.7.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
@@ -52,14 +53,14 @@
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/resources": "^2.7.0",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.7.0",
"@opentelemetry/sdk-trace-base": "^2.7.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1",
"@sentry/node": "^10.49.0",
"@smithy/core": "^3.23.15",
"@smithy/node-http-handler": "^4.5.3",
"@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
@@ -81,7 +82,7 @@
"asciichart": "^1.5.25",
"audio-capture-napi": "workspace:*",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"axios": "^1.15.0",
"bidi-js": "^1.0.3",
"cacache": "^20.0.4",
"chalk": "^5.6.2",
@@ -96,7 +97,7 @@
"execa": "^9.6.1",
"fflate": "^0.8.2",
"figures": "^6.1.0",
"fuse.js": "^7.1.0",
"fuse.js": "^7.3.0",
"get-east-asian-width": "^1.5.0",
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
@@ -106,21 +107,21 @@
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.1.1",
"lodash-es": "^4.17.23",
"lru-cache": "^11.2.7",
"marked": "^17.0.5",
"knip": "^6.4.1",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
"modifiers-napi": "workspace:*",
"openai": "^6.33.0",
"openai": "^6.34.0",
"p-map": "^7.0.4",
"picomatch": "^4.0.4",
"plist": "^3.1.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react": "^19.2.5",
"react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"rollup": "^4.60.2",
"semver": "^7.7.4",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
@@ -129,10 +130,10 @@
"strip-ansi": "^7.2.0",
"supports-hyperlinks": "^4.4.0",
"tree-kill": "^1.2.2",
"turndown": "^7.2.2",
"type-fest": "^5.5.0",
"typescript": "^6.0.2",
"undici": "^7.24.6",
"turndown": "^7.2.4",
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"undici": "^7.25.0",
"url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
@@ -194,9 +195,10 @@
},
"packages/acp-link": {
"name": "acp-link",
"version": "1.0.1",
"version": "1.1.0",
"bin": {
"acp-link": "dist/cli/bin.js",
"acp-manager": "dist/manager/bin.js",
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
@@ -210,6 +212,7 @@
"selfsigned": "^5.5.0",
},
"devDependencies": {
"@types/bun": "^1.3.12",
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1",
},
@@ -320,6 +323,13 @@
"name": "url-handler-napi",
"version": "1.0.0",
},
"packages/weixin": {
"name": "@claude-code-best/weixin",
"version": "1.0.0",
"dependencies": {
"qrcode": "^1.5.4",
},
},
},
"packages": {
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.19.0", "https://registry.npmmirror.com/@agentclientprotocol/sdk/-/sdk-0.19.0.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug=="],
@@ -564,6 +574,8 @@
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
"@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"],
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],

View File

@@ -10,12 +10,18 @@ Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Cla
- **官方文档**[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
- **飞书插件**[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
本仓库现在内置了 **微信 WeChat channel**,不需要单独安装外部 marketplace 插件。
## 快速开始
```bash
# 启用频道监听plugin 格式)
ccb --channels plugin:feishu@claude-code-feishu-channel
# 启用内置微信 channel
ccb weixin login
ccb --channels plugin:weixin@builtin
# 启用频道监听server 格式)
ccb --channels server:my-slack-bridge
@@ -34,6 +40,37 @@ ccb --dangerously-load-development-channels server:my-custom-channel
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
| **微信 (WeChat)** | 内置 channel支持扫码登录、双向消息、附件透传 | `ccb weixin login` + `ccb --channels plugin:weixin@builtin` |
## 微信内置 Channel
### 登录
```bash
ccb weixin login
```
已登录状态可清除:
```bash
ccb weixin login clear
```
### 会话启用
```bash
ccb --channels plugin:weixin@builtin
```
### 配对授权
首次收到未授权微信用户消息时weixin channel 会回一条 6 位 pairing code。运营侧可在终端执行
```bash
ccb weixin access pair <code>
```
确认后,该微信用户后续消息才会进入 Claude Code 会话。
## 相关文件

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.4.4",
"version": "1.6.0",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -58,6 +58,7 @@
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"test:all": "bun run typecheck && bun test",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {
@@ -90,6 +91,7 @@
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@claude-code-best/weixin": "workspace:*",
"@commander-js/extra-typings": "^14.0.0",
"@growthbook/growthbook": "^1.6.5",
"@langfuse/otel": "^5.1.0",

View File

@@ -41,6 +41,9 @@ acp-link --https /path/to/agent
# Disable authentication (dangerous)
acp-link --no-auth /path/to/agent
# Register to RCS with a specific channel group
acp-link --group my-team /path/to/agent
# Pass arguments to the agent (use -- to separate)
acp-link /path/to/agent -- --verbose --model gpt-4
```
@@ -49,7 +52,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4
```
USAGE
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
acp-link --help
acp-link --version
@@ -59,6 +62,7 @@ FLAGS
[--debug] Enable debug logging to file
[--no-auth] Disable authentication (dangerous)
[--https] Enable HTTPS with self-signed cert
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
-h --help Print help information and exit
-v --version Print version information and exit
@@ -84,6 +88,34 @@ ws://localhost:9315/ws?token=<your-token>
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
## RCS Upstream
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
| Variable | Description |
|----------|-------------|
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
| `ACP_RCS_TOKEN` | API token for RCS authentication |
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
## Manager UI
通过 `--manager` flag 启动独立的管理服务(不启动代理):
```bash
# 启动 Manager默认端口 9315
acp-link --manager
# 指定端口
acp-link --manager --port 3210
```
在浏览器打开 `http://localhost:<port>` 即可访问管理界面,创建、停止、删除多个 acp-link 子进程实例并实时查看日志。
通过 Manager UI 创建的子进程会自动跳过 Manager UI。
## License
MIT

View File

@@ -1,6 +1,6 @@
{
"name": "acp-link",
"version": "1.0.1",
"version": "2.0.0",
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
"author": "claude-code-best",
"type": "module",
@@ -14,12 +14,15 @@
],
"scripts": {
"build": "tsc",
"dev": "bun run src/cli/bin.ts",
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
"dev:remote": "ACP_RCS_URL=https://remote-control.claude-code-best.win/ ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
"dev:manager": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts --manager",
"prepublishOnly": "bun run build"
},
"devDependencies": {
"@types/selfsigned": "^2.0.4",
"@types/ws": "^8.18.1"
"@types/ws": "^8.18.1",
"@types/bun": "^1.3.12"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",

View File

@@ -9,6 +9,8 @@ export const command = buildCommand({
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
"Use -- to pass arguments to the agent:\n" +
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
"Use --manager to start the Manager Web UI instead:\n" +
" acp-link --manager\n\n" +
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
},
parameters: {
@@ -40,6 +42,22 @@ export const command = buildCommand({
brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false,
},
manager: {
kind: "boolean",
brief: "Start Manager Web UI (no proxy)",
default: false,
},
group: {
kind: "parsed",
parse: (value: string) => {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
}
return value;
},
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
optional: true,
},
},
positional: {
kind: "array",
@@ -48,12 +66,12 @@ export const command = buildCommand({
parse: String,
placeholder: "command",
},
minimum: 1,
minimum: 0,
},
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
...args: readonly string[]
) {
const port = flags.port;
@@ -61,6 +79,21 @@ export const command = buildCommand({
const debug = flags.debug;
const noAuth = flags["no-auth"];
const https = flags.https;
const manager = flags.manager;
const group = flags.group;
// Manager mode: start web UI only, no proxy
if (manager) {
const { startManager } = await import("../manager/index.js");
await startManager(port);
return;
}
// Proxy mode: agent command is required
if (args.length === 0) {
console.error("Error: agent command is required (or use --manager)");
process.exit(1);
}
const [command, ...agentArgs] = args;
const cwd = process.cwd();
@@ -85,6 +118,6 @@ export const command = buildCommand({
// Import and run the server
const { startServer } = await import("../server.js");
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
},
});

View File

@@ -0,0 +1,345 @@
export const MANAGER_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ACP Manager</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8f7f5;
color: #1a1a1a;
padding: 24px;
min-height: 100vh;
}
h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a1a1a; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.create-form {
background: #fff;
border: 1px solid #e5e2de;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
display: flex;
gap: 10px;
align-items: flex-end;
}
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; color: #888; }
.form-group input {
background: #fff;
border: 1px solid #d5d2ce;
border-radius: 4px;
padding: 8px 12px;
color: #1a1a1a;
font-size: 14px;
width: 200px;
}
.form-group input.wide { width: 400px; }
button {
background: #d77757;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
button:hover { background: #c4694b; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { background: #a63d3d; }
button.danger:hover { background: #c44a4a; }
button.small { padding: 4px 10px; font-size: 12px; }
.instances { display: flex; flex-direction: column; gap: 8px; }
.instance-card {
background: #fff;
border: 1px solid #e5e2de;
border-radius: 8px;
overflow: hidden;
}
.instance-header {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 12px;
cursor: pointer;
user-select: none;
}
.instance-header:hover { background: #f5f3f0; }
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
.status-dot.stopped { background: #aaa; }
.status-dot.failed { background: #f87171; box-shadow: 0 0 6px #f8717166; }
.instance-info { flex: 1; display: flex; gap: 16px; align-items: center; font-size: 13px; }
.instance-info .group { font-weight: 600; color: #d77757; }
.instance-info .cmd { color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.instance-info .pid { color: #999; font-size: 12px; }
.instance-info .uptime { color: #999; font-size: 12px; }
.instance-actions { display: flex; gap: 6px; }
.expand-icon { color: #999; font-size: 12px; transition: transform 0.2s; }
.expand-icon.open { transform: rotate(90deg); }
.log-panel {
display: none;
border-top: 1px solid #e5e2de;
background: #faf9f7;
max-height: 300px;
overflow-y: auto;
padding: 12px 16px;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-panel.visible { display: block; }
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-line.stdout { color: #333; }
.log-line.stderr { color: #d94040; }
.empty { color: #999; text-align: center; padding: 40px; font-size: 14px; }
@media (max-width: 640px) {
body { padding: 12px; }
.create-form { flex-wrap: wrap; }
.form-group input, .form-group input.wide { width: 100%; }
.form-group { flex: 1 1 120px; min-width: 0; }
.instance-header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
.instance-info { flex-wrap: wrap; gap: 6px; font-size: 12px; }
.instance-info .cmd { max-width: 100%; }
button.small { padding: 8px 14px; min-height: 44px; font-size: 13px; }
.log-panel { max-height: 50vh; }
}
</style>
</head>
<body>
<div class="header">
<h1>ACP Manager</h1>
</div>
<div class="create-form">
<div class="form-group">
<label>Group</label>
<input type="text" id="inp-group" placeholder="my-group" />
</div>
<div class="form-group">
<label>ACP Command</label>
<input type="text" id="inp-command" class="wide" placeholder="/path/to/agent --verbose" />
</div>
<button id="btn-create">Create</button>
</div>
<div class="instances" id="instance-list"></div>
<script>
var listEl = document.getElementById('instance-list');
var esMap = {};
var instances = [];
var inpGroup = document.getElementById('inp-group');
var inpCommand = document.getElementById('inp-command');
var btnCreate = document.getElementById('btn-create');
// localStorage persistence
function loadForm() {
try {
inpGroup.value = localStorage.getItem('acp-mgr-group') || '';
inpCommand.value = localStorage.getItem('acp-mgr-command') || '';
} catch(e) {}
}
function saveForm() {
try {
localStorage.setItem('acp-mgr-group', inpGroup.value);
localStorage.setItem('acp-mgr-command', inpCommand.value);
} catch(e) {}
}
inpGroup.addEventListener('input', saveForm);
inpCommand.addEventListener('input', saveForm);
loadForm();
btnCreate.addEventListener('click', function() {
var group = inpGroup.value.trim();
var command = inpCommand.value.trim();
if (!group || !command) return alert('Both fields required');
btnCreate.disabled = true;
fetch('/api/instances', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ group: group, command: command }),
}).then(function() { fetchInstances(); })
.finally(function() { btnCreate.disabled = false; });
});
// event delegation for instance actions
listEl.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (btn) {
e.stopPropagation();
var id = btn.getAttribute('data-id');
var action = btn.getAttribute('data-action');
if (action === 'stop') stopInstance(id);
else if (action === 'delete') deleteInstance(id);
return;
}
var header = e.target.closest('.instance-header');
if (header) {
var cardId = header.closest('.instance-card').getAttribute('data-id');
toggleLog(cardId);
}
});
async function fetchInstances() {
var res = await fetch('/api/instances');
instances = await res.json();
render();
}
function uptime(start) {
var s = Math.floor((Date.now() - start) / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function render() {
if (instances.length === 0) {
listEl.innerHTML = '<div class="empty">No instances. Create one above.</div>';
return;
}
// Diff-based update: only rebuild cards whose status changed
var existingCards = {};
listEl.querySelectorAll('.instance-card').forEach(function(card) {
existingCards[card.getAttribute('data-id')] = card;
});
var newIds = new Set(instances.map(function(i) { return i.id; }));
// Remove cards that no longer exist
for (var eid in existingCards) {
if (!newIds.has(eid)) {
closeLog(eid);
existingCards[eid].remove();
delete existingCards[eid];
}
}
// Update or create cards in order
instances.forEach(function(inst) {
var card = existingCards[inst.id];
if (!card) {
// New instance — create card
card = document.createElement('div');
card.className = 'instance-card';
card.setAttribute('data-id', inst.id);
card.innerHTML =
'<div class="instance-header">' +
'<span class="expand-icon">&#9654;</span>' +
'<span class="status-dot"></span>' +
'<div class="instance-info">' +
'<span class="group"></span>' +
'<span class="cmd"></span>' +
'<span class="pid"></span>' +
'<span class="uptime"></span>' +
'</div>' +
'<div class="instance-actions"></div>' +
'</div>' +
'<div class="log-panel" id="log-' + inst.id + '"></div>';
listEl.appendChild(card);
}
// Update card content
card.querySelector('.status-dot').className = 'status-dot ' + inst.status;
card.querySelector('.group').textContent = inst.group;
card.querySelector('.cmd').textContent = inst.command;
card.querySelector('.pid').textContent = inst.pid ? 'PID ' + inst.pid : '';
card.querySelector('.uptime').textContent = inst.status === 'running' ? uptime(inst.startTime) : '';
// Update action buttons
var actions = card.querySelector('.instance-actions');
var prevStatus = card.getAttribute('data-status');
if (prevStatus !== inst.status) {
card.setAttribute('data-status', inst.status);
actions.innerHTML = inst.status === 'running'
? '<button class="small danger" data-action="stop" data-id="' + inst.id + '">Stop</button>'
: '<button class="small danger" data-action="delete" data-id="' + inst.id + '">Delete</button>';
}
});
}
async function stopInstance(id) {
var btn = listEl.querySelector('[data-action="stop"][data-id="' + id + '"]');
if (btn) btn.disabled = true;
await fetch('/api/instances/' + id + '/stop', { method: 'POST' });
await fetchInstances();
}
async function deleteInstance(id) {
var btn = listEl.querySelector('[data-action="delete"][data-id="' + id + '"]');
if (btn) btn.disabled = true;
await fetch('/api/instances/' + id, { method: 'DELETE' });
closeLog(id);
await fetchInstances();
}
function toggleLog(id) {
var panel = document.getElementById('log-' + id);
if (!panel) return;
if (panel.classList.contains('visible')) {
closeLog(id);
} else {
openLog(id);
}
var icon = listEl.querySelector('[data-id="' + id + '"] .expand-icon');
if (icon) icon.classList.toggle('open', panel.classList.contains('visible'));
}
function openLog(id) {
var panel = document.getElementById('log-' + id);
if (!panel) return;
panel.classList.add('visible');
panel.innerHTML = '';
var es = new EventSource('/api/instances/' + id + '/logs');
esMap[id] = es;
var scrollPending = false;
es.onmessage = function(e) {
try {
var entry = JSON.parse(e.data);
var line = document.createElement('div');
line.className = 'log-line ' + entry.stream;
var time = new Date(entry.timestamp).toLocaleTimeString();
line.textContent = '[' + time + '] ' + entry.text;
panel.appendChild(line);
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
if (!scrollPending) {
scrollPending = true;
requestAnimationFrame(function() {
panel.scrollTop = panel.scrollHeight;
scrollPending = false;
});
}
} catch(err) {}
};
es.onerror = function() {
es.close();
delete esMap[id];
};
}
function closeLog(id) {
if (esMap[id]) {
esMap[id].close();
delete esMap[id];
}
var panel = document.getElementById('log-' + id);
if (panel) panel.classList.remove('visible');
}
fetchInstances();
setInterval(fetchInstances, 3000);
</script>
</body>
</html>`;

View File

@@ -0,0 +1,44 @@
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { ProcessManager } from "./manager.js";
import { createApp } from "./routes.js";
export async function startManager(port: number): Promise<void> {
const manager = new ProcessManager();
const app = createApp(manager);
// Health check
app.get("/health", (c) => c.json({ status: "ok" }));
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("Shutting down...");
await manager.shutdownAll();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
const server = serve({ fetch: app.fetch, port });
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
} else {
console.error(`\n Error: ${err.message}\n`);
}
process.exit(1);
});
console.log();
console.log(` 🖥️ ACP Manager`);
console.log();
console.log(` URL: http://localhost:${port}`);
console.log();
console.log(` Press Ctrl+C to stop`);
console.log();
// Keep running
await new Promise(() => {});
}

View File

@@ -0,0 +1,233 @@
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
function log(tag: string, msg: string) {
const ts = new Date().toISOString();
console.log(`[${ts}] [${tag}] ${msg}`);
}
const MAX_LOG_LINES = 2000;
const SHUTDOWN_TIMEOUT_MS = 5000;
export class ProcessManager {
private instances = new Map<string, AcpInstance>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private processes = new Map<string, any>();
create(group: string, command: string): AcpInstance {
const id = crypto.randomUUID();
const instance: AcpInstance = {
id,
group,
command,
status: "running",
pid: undefined,
startTime: Date.now(),
exitCode: null,
logs: [],
subscribers: new Set(),
};
const args = this.parseCommand(command);
const fullArgs = ["--group", group, ...args];
const proc = Bun.spawn(["acp-link", ...fullArgs], {
stdout: "pipe",
stderr: "pipe",
env: { ...Bun.env, ACP_CHILD: "1" },
});
instance.pid = proc.pid;
this.instances.set(id, instance);
this.processes.set(id, proc);
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
this.pipeStream(proc.stdout, id, "stdout");
this.pipeStream(proc.stderr, id, "stderr");
proc.exited.then((code) => {
instance.status = code === 0 ? "stopped" : "failed";
instance.exitCode = code;
instance.pid = undefined;
this.processes.delete(id);
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
this.notifyStatus(instance);
});
return instance;
}
stop(id: string): boolean {
const proc = this.processes.get(id);
if (!proc) return false;
const inst = this.instances.get(id);
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
proc.kill("SIGTERM");
// Immediately mark as stopped to prevent stale state
if (inst) {
inst.status = "stopped";
}
return true;
}
remove(id: string): boolean {
const instance = this.instances.get(id);
if (!instance) return false;
if (instance.status === "running") return false;
instance.subscribers.clear();
this.instances.delete(id);
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
return true;
}
list(): InstanceSummary[] {
return Array.from(this.instances.values()).map(this.toSummary);
}
get(id: string): AcpInstance | undefined {
return this.instances.get(id);
}
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
const instance = this.instances.get(id);
if (!instance) return () => {};
instance.subscribers.add(callback);
return () => instance.subscribers.delete(callback);
}
async shutdownAll(): Promise<void> {
const running = Array.from(this.processes.entries());
if (running.length === 0) return;
log("manager", `shutting down ${running.length} running instance(s)...`);
for (const [id, proc] of running) {
try {
proc.kill("SIGTERM");
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
} catch {
// already dead
}
}
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
await Promise.race([
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
timeout,
]);
for (const [id, proc] of running) {
try {
proc.kill("SIGKILL");
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
} catch {
// already dead
}
}
log("manager", "all instances shut down");
}
private parseCommand(command: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (const ch of command) {
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === " " || ch === "\t") {
if (current) {
args.push(current);
current = "";
}
} else {
current += ch;
}
}
if (current) args.push(current);
return args;
}
private pipeStream(
readable: ReadableStream<Uint8Array>,
instanceId: string,
stream: "stdout" | "stderr",
) {
const reader = readable.getReader();
const decoder = new TextDecoder();
let buffer = "";
const processChunk = () => {
reader
.read()
.then(({ done, value }) => {
if (done) {
if (buffer) this.appendLog(instanceId, buffer, stream);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line) this.appendLog(instanceId, line, stream);
}
processChunk();
})
.catch(() => {
// stream ended or error
});
};
processChunk();
}
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
const instance = this.instances.get(instanceId);
if (!instance) return;
const entry: LogEntry = { timestamp: Date.now(), stream, text };
instance.logs.push(entry);
if (instance.logs.length > MAX_LOG_LINES) {
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
}
for (const sub of instance.subscribers) {
try {
sub(entry);
} catch {
// subscriber error, remove it
instance.subscribers.delete(sub);
}
}
}
private notifyStatus(instance: AcpInstance) {
const statusEntry: LogEntry = {
timestamp: Date.now(),
stream: "stderr",
text: `[${instance.status}] exit code: ${instance.exitCode}`,
};
for (const sub of instance.subscribers) {
try {
sub(statusEntry);
} catch {
instance.subscribers.delete(sub);
}
}
}
private toSummary(inst: AcpInstance): InstanceSummary {
return {
id: inst.id,
group: inst.group,
command: inst.command,
status: inst.status,
pid: inst.pid,
startTime: inst.startTime,
exitCode: inst.exitCode,
};
}
}

View File

@@ -0,0 +1,153 @@
import { Hono } from "hono";
import type { ProcessManager } from "./manager.js";
import { MANAGER_HTML } from "./html.js";
function logReq(method: string, path: string, status?: number) {
const ts = new Date().toISOString();
const suffix = status != null ? ` -> ${status}` : "";
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
}
export function createApp(manager: ProcessManager): Hono {
const app = new Hono();
app.get("/", (c) => {
logReq("GET", "/", 200);
return c.html(MANAGER_HTML);
});
app.get("/api/instances", (c) => {
const list = manager.list();
logReq("GET", "/api/instances", 200);
return c.json(list);
});
app.post("/api/instances", async (c) => {
let body: { group?: string; command?: string };
try {
body = await c.req.json<{ group?: string; command?: string }>();
} catch {
logReq("POST", "/api/instances", 400);
return c.json({ error: "invalid JSON body" }, 400);
}
if (!body.group?.trim() || !body.command?.trim()) {
logReq("POST", "/api/instances", 400);
return c.json({ error: "group and command are required" }, 400);
}
const instance = manager.create(body.group.trim(), body.command.trim());
logReq("POST", `/api/instances group=${body.group}`, 201);
return c.json(
{
id: instance.id,
group: instance.group,
command: instance.command,
status: instance.status,
pid: instance.pid,
startTime: instance.startTime,
exitCode: instance.exitCode,
},
201,
);
});
app.post("/api/instances/:id/stop", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
if (!inst) {
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
return c.json({ error: "not found" }, 404);
}
if (inst.status !== "running") {
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
return c.json({ error: "not running" }, 400);
}
manager.stop(inst.id);
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
return c.json({ ok: true });
});
app.delete("/api/instances/:id", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
if (!inst) {
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
return c.json({ error: "not found" }, 404);
}
if (inst.status === "running") {
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
return c.json({ error: "still running" }, 400);
}
manager.remove(inst.id);
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
return c.json({ ok: true });
});
app.get("/api/instances/:id/logs", (c) => {
const id = c.req.param("id");
const inst = manager.get(id);
if (!inst) {
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
return c.json({ error: "not found" }, 404);
}
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const send = (data: string) => {
try {
controller.enqueue(encoder.encode(data));
} catch {
// stream closed
}
};
// send historical logs
for (const log of inst.logs) {
send(`data: ${JSON.stringify(log)}\n\n`);
}
// subscribe to new logs
const unsub = manager.subscribe(inst.id, (entry) => {
send(`data: ${JSON.stringify(entry)}\n\n`);
});
// keepalive every 15s
const keepalive = setInterval(() => {
send(": keepalive\n\n");
}, 15000);
const cleanup = () => {
unsub();
clearInterval(keepalive);
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
try {
controller.close();
} catch {
// already closed
}
};
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
});
// Catch-all: log unmatched routes for debugging
app.all("*", (c) => {
logReq(c.req.method, c.req.path, 404);
return c.json({ error: "not found", path: c.req.path }, 404);
});
return app;
}

View File

@@ -0,0 +1,34 @@
export type InstanceStatus = "running" | "stopped" | "failed";
export interface AcpInstance {
id: string;
group: string;
command: string;
status: InstanceStatus;
pid: number | undefined;
startTime: number;
exitCode: number | null;
logs: LogEntry[];
subscribers: Set<(entry: LogEntry) => void>;
}
export interface LogEntry {
timestamp: number;
stream: "stdout" | "stderr";
text: string;
}
export interface CreateInstanceRequest {
group: string;
command: string;
}
export interface InstanceSummary {
id: string;
group: string;
command: string;
status: InstanceStatus;
pid: number | undefined;
startTime: number;
exitCode: number | null;
}

View File

@@ -22,6 +22,8 @@ export interface ServerConfig {
https?: boolean;
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
permissionMode?: string;
/** Channel group ID for RCS registration */
group?: string;
}
// Pending permission request
@@ -608,11 +610,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
// Initialize RCS upstream client if configured
const rcsUrl = process.env.ACP_RCS_URL;
const rcsToken = process.env.ACP_RCS_TOKEN;
const rcsGroup = config.group || process.env.ACP_RCS_GROUP;
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`);
}
if (rcsUrl) {
rcsUpstream = new RcsUpstreamClient({
rcsUrl,
apiToken: rcsToken || "",
agentName: command,
channelGroupId: rcsGroup || undefined,
maxSessions: 1,
});
@@ -876,20 +883,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
authEnabled: !!AUTH_TOKEN,
}, "started");
// Graceful shutdown — close RCS upstream
const shutdown = async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Keep the server running
await new Promise(() => {});
}
// Graceful shutdown — close RCS upstream on process exit
process.on("SIGINT", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});
process.on("SIGTERM", async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
});

View File

@@ -3,12 +3,12 @@
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ES2022",
"module": "NodeNext",
"module": "esnext",
"moduleDetection": "force",
"allowJs": true,
// Node.js module resolution
"moduleResolution": "NodeNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
// Output
@@ -30,7 +30,8 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
"types": ["bun"],
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/__tests__"]

View File

@@ -1,3 +1,9 @@
import { createRequire } from 'node:module'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// loading native .node addons — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
type AudioCaptureNapi = {
startRecording(
@@ -41,7 +47,7 @@ function loadModule(): AudioCaptureNapi | null {
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require(
cachedModule = nodeRequire(
process.env.AUDIO_CAPTURE_NODE_PATH,
) as AudioCaptureNapi
return cachedModule
@@ -63,7 +69,7 @@ function loadModule(): AudioCaptureNapi | null {
for (const p of fallbacks) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
cachedModule = require(p) as AudioCaptureNapi
cachedModule = nodeRequire(p) as AudioCaptureNapi
return cachedModule
} catch {
// try next

View File

@@ -17,10 +17,16 @@
* getSyntaxTheme always returns the default for the given Claude theme.
*/
import { createRequire } from 'node:module'
import { diffArrays } from 'diff'
import type * as hljsNamespace from 'highlight.js'
import { basename, extname } from 'path'
// createRequire works in both Bun and Node.js ESM contexts.
// Needed because this package is "type": "module" but uses require() for
// lazy loading — bare require is not available in Node.js ESM.
const nodeRequire = createRequire(import.meta.url)
// Lazy: defers loading highlight.js until first render. The full bundle
// registers 190+ language grammars at require time (~50MB, 100-200ms on
// macOS, several× that on Windows). With a top-level import, any caller
@@ -34,8 +40,7 @@ type HLJSApi = typeof hljsNamespace.default
let cachedHljs: HLJSApi | null = null
function hljs(): HLJSApi {
if (cachedHljs) return cachedHljs
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('highlight.js')
const mod = nodeRequire('highlight.js')
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
// in .default; under node CJS the module IS the API. Check at runtime.
cachedHljs = 'default' in mod && mod.default ? mod.default : mod

View File

@@ -1,3 +1,4 @@
import { readFileSync, unlinkSync } from 'node:fs'
import sharpModule from 'sharp'
export const sharp = sharpModule
@@ -62,13 +63,11 @@ return "${tmpPath}"
}
const file = Bun.file(tmpPath)
// Use synchronous read via Node compat
const fs = require('fs')
const buffer: Buffer = fs.readFileSync(tmpPath)
const buffer: Buffer = readFileSync(tmpPath)
// Clean up temp file
try {
fs.unlinkSync(tmpPath)
unlinkSync(tmpPath)
} catch {
// ignore cleanup errors
}

View File

@@ -90,9 +90,20 @@ export function getUuidFromRequest(c: Context): string | undefined {
/**
* UUID-based auth for Web UI routes (no-login mode).
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
* Accepts UUID in query param/header, OR a valid API key via Authorization header.
*/
export async function uuidAuth(c: Context, next: Next) {
// Try API key auth via Authorization header
const bearer = extractBearerToken(c);
if (bearer && validateApiKey(bearer)) {
// Valid API key — generate a stable UUID from the key for downstream use
const uuid = getUuidFromRequest(c);
c.set("uuid", uuid || bearer);
await next();
return;
}
// Fall back to UUID auth
const uuid = getUuidFromRequest(c);
if (!uuid) {
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);

View File

@@ -1,6 +1,7 @@
import { Hono } from "hono";
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { storeBindSession } from "../../store";
const app = new Hono();
@@ -9,6 +10,13 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const result = registerEnvironment({ ...body, username });
// Bind ACP session to the group ID so the web UI can find it by group
if (result.session_id) {
const groupId = body.bridge_id as string | undefined;
if (groupId) {
storeBindSession(result.session_id, groupId);
}
}
return c.json(result, 200);
});

View File

@@ -6,6 +6,7 @@ import {
storeUpdateEnvironment,
storeListActiveEnvironments,
storeListActiveEnvironmentsByUsername,
storeListSessionsByEnvironment,
} from "../store";
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
import type { EnvironmentRecord } from "../store";
@@ -20,6 +21,7 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
username: row.username,
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
worker_type: row.workerType,
channel_group_id: row.bridgeId,
capabilities: row.capabilities,
};
}
@@ -41,14 +43,19 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
});
let sessionId: string | undefined;
// ACP agents: auto-create a session so they appear in the dashboard sessions list
// ACP agents: reuse existing session or create one
if (workerType === "acp") {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
const existing = storeListSessionsByEnvironment(record.id);
if (existing.length > 0) {
sessionId = existing[0].id;
} else {
const session = storeCreateSession({
environmentId: record.id,
title: req.machine_name || "ACP Agent",
source: "acp",
});
sessionId = session.id;
}
}
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };

View File

@@ -98,13 +98,14 @@ export function storeDeleteToken(token: string): boolean {
// ---------- Environment ----------
/** Find an active environment by machineName (optionally filtered by workerType) */
/** Find an active or offline environment by machineName (optionally filtered by workerType).
* Includes "offline" so ACP agents can be reused on reconnect. */
export function storeFindEnvironmentByMachineName(
machineName: string,
workerType?: string,
): EnvironmentRecord | undefined {
for (const rec of environments.values()) {
if (rec.machineName === machineName && rec.status === "active") {
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
if (!workerType || rec.workerType === workerType) {
return rec;
}
@@ -313,12 +314,32 @@ export function storeGetSessionOwners(sessionId: string): Set<string> | undefine
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
const result: SessionRecord[] = [];
const resultIds = new Set<string>();
// Collect sessions already owned by this UUID
for (const [sessionId, owners] of sessionOwners) {
if (owners.has(uuid)) {
const session = sessions.get(sessionId);
if (session) result.push(session);
if (session) {
result.push(session);
resultIds.add(sessionId);
}
}
}
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
for (const [sessionId, session] of sessions) {
if (resultIds.has(sessionId)) continue;
const owners = sessionOwners.get(sessionId);
// No owners map entry at all, or empty owners set
const isOrphaned = !owners || owners.size === 0;
if (isOrphaned) {
storeBindSession(sessionId, uuid);
result.push(session);
resultIds.add(sessionId);
}
}
return result;
}

View File

@@ -107,6 +107,7 @@ export interface EnvironmentResponse {
username: string | null;
last_poll_at: number | null;
worker_type?: string;
channel_group_id?: string | null;
capabilities?: Record<string, unknown> | null;
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
import { Navbar } from "./components/Navbar";
import { IdentityPanel } from "./components/IdentityPanel";
import { TokenManagerDialog } from "./components/TokenManagerDialog";
import { ThemeProvider } from "./lib/theme";
import { getUuid, setUuid, apiBind } from "./api/client";
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
import { ACPDirectView } from "./components/ACPDirectView";
import { useTokens } from "./hooks/useTokens";
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
@@ -11,7 +13,18 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({
export default function App() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [identityOpen, setIdentityOpen] = useState(false);
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
// Sync active token to API client
useEffect(() => {
setActiveApiToken(activeTokenValue);
}, [activeTokenValue]);
const handleSetActiveToken = useCallback((id: string) => {
setActiveTokenId(id);
}, [setActiveTokenId]);
// Simple hash-based router
const parseRoute = useCallback(() => {
@@ -97,6 +110,8 @@ export default function App() {
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
<Navbar
onIdentityClick={() => setIdentityOpen(true)}
onTokenClick={() => setTokenDialogOpen(true)}
activeTokenLabel={currentSessionId ? undefined : activeLabel}
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
/>
@@ -114,6 +129,17 @@ export default function App() {
</Suspense>
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
<TokenManagerDialog
open={tokenDialogOpen}
onClose={() => setTokenDialogOpen(false)}
tokens={tokens}
activeTokenId={activeTokenId}
onSetActive={handleSetActiveToken}
onAdd={addToken}
onRemove={removeToken}
onUpdate={updateToken}
/>
</div>
</ThemeProvider>
);

View File

@@ -24,11 +24,35 @@ export function setUuid(uuid: string): void {
localStorage.setItem("rcs_uuid", uuid);
}
/** Active API token for Authorization header (set by useTokens) */
let _activeToken: string | null = null;
export function setActiveApiToken(token: string | null): void {
_activeToken = token;
}
export function getActiveApiToken(): string | null {
return _activeToken;
}
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
if (_activeToken) {
headers["Authorization"] = `Bearer ${_activeToken}`;
}
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
// Otherwise fall back to UUID auth via query param.
let url: string;
if (_activeToken) {
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
} else {
const uuid = getUuid();
const sep = path.includes("?") ? "&" : "?";
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
}
const opts: RequestInit = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);

View File

@@ -1,14 +1,16 @@
import { cn } from "../lib/utils";
import { ThemeToggle } from "../../components/ui/theme-toggle";
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react";
interface NavbarProps {
onIdentityClick: () => void;
onTokenClick: () => void;
activeTokenLabel?: string | null;
sessionTitle?: string;
onBack?: () => void;
}
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) {
return (
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
@@ -51,6 +53,19 @@ export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
</a>
)}
<ThemeToggle />
<button
onClick={onTokenClick}
className={cn(
"flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm transition-colors",
activeTokenLabel
? "bg-brand/10 text-brand hover:bg-brand/20"
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
)}
title="Token Manager"
>
<KeyRound className="h-4 w-4" />
<span className="hidden sm:inline max-w-24 truncate">{activeTokenLabel || "No Token"}</span>
</button>
<button
onClick={onIdentityClick}
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"

View File

@@ -0,0 +1,217 @@
import { useState } from "react";
import type { TokenEntry } from "../hooks/useTokens";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "../../components/ui/dialog";
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from "lucide-react";
interface TokenManagerDialogProps {
open: boolean;
onClose: () => void;
tokens: TokenEntry[];
activeTokenId: string | null;
onSetActive: (id: string) => void;
onAdd: (token: string, label: string) => string | null;
onRemove: (id: string) => void;
onUpdate: (id: string, label: string) => void;
}
export function TokenManagerDialog({
open,
onClose,
tokens,
activeTokenId,
onSetActive,
onAdd,
onRemove,
onUpdate,
}: TokenManagerDialogProps) {
const [newToken, setNewToken] = useState("");
const [newLabel, setNewLabel] = useState("");
const [addError, setAddError] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState("");
const [visibleTokenId, setVisibleTokenId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopy = (id: string, token: string) => {
navigator.clipboard.writeText(token).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const handleAdd = () => {
const error = onAdd(newToken, newLabel);
if (error) {
setAddError(error);
return;
}
setNewToken("");
setNewLabel("");
setAddError("");
};
const handleStartEdit = (entry: TokenEntry) => {
setEditingId(entry.id);
setEditLabel(entry.label);
};
const handleSaveEdit = (id: string) => {
onUpdate(id, editLabel.trim() || "Unnamed");
setEditingId(null);
};
const handleSwitch = (id: string) => {
onSetActive(id);
onClose();
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
<DialogHeader>
<DialogTitle className="font-display text-lg font-semibold text-text-primary">
Token Manager
</DialogTitle>
<DialogDescription className="text-sm text-text-muted">
Manage API tokens for RCS authentication.
</DialogDescription>
</DialogHeader>
{/* Token list */}
<div className="space-y-1 max-h-64 overflow-y-auto">
{tokens.map((entry) => (
<div key={entry.id} className="group flex items-center gap-1">
{editingId === entry.id ? (
<div className="flex flex-1 items-center gap-2 rounded-lg bg-surface-2 px-3 py-1.5">
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEdit(entry.id);
if (e.key === "Escape") setEditingId(null);
}}
className="flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-text-primary focus:border-brand focus:outline-none"
autoFocus
/>
<button
onClick={() => handleSaveEdit(entry.id)}
className="text-brand hover:text-brand-light transition-colors"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setEditingId(null)}
className="text-text-muted hover:text-text-primary transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<>
<button
onClick={() => handleSwitch(entry.id)}
className={`flex flex-1 items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors ${
activeTokenId === entry.id
? "bg-brand/10 text-brand"
: "text-text-secondary hover:bg-surface-2"
}`}
>
<div className="flex flex-col items-start min-w-0">
<span className="font-medium truncate w-full">{entry.label}</span>
<span className="text-xs text-text-muted font-mono">
{visibleTokenId === entry.id
? entry.token
: `${entry.token.slice(0, 6)}${"\u2022".repeat(6)}`}
</span>
</div>
{activeTokenId === entry.id && <Check className="h-4 w-4 flex-shrink-0" />}
</button>
<button
onClick={() => setVisibleTokenId(visibleTokenId === entry.id ? null : entry.id)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Toggle token visibility"
>
{visibleTokenId === entry.id ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => handleCopy(entry.id, entry.token)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Copy token"
>
{copiedId === entry.id ? <Check className="h-3.5 w-3.5 text-status-active" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => handleStartEdit(entry)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
title="Edit label"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onRemove(entry.id)}
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-status-error transition-all"
title="Delete token"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
{tokens.length === 0 && (
<div className="py-4 text-center text-sm text-text-muted">
No tokens saved yet. Add one below.
</div>
)}
</div>
{/* Add form */}
<div className="border-t border-border pt-4 space-y-3">
<div className="text-sm font-medium text-text-secondary">Add Token</div>
<div className="space-y-2">
<input
type="text"
value={newToken}
onChange={(e) => {
setNewToken(e.target.value);
setAddError("");
}}
placeholder="API Token"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none font-mono"
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<div className="flex gap-2">
<input
type="text"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<button
onClick={handleAdd}
disabled={!newToken.trim()}
className="rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{addError && <div className="text-xs text-status-error">{addError}</div>}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,120 @@
import { useState, useCallback } from "react";
export interface TokenEntry {
id: string;
token: string;
label: string;
}
const TOKENS_KEY = "rcs_tokens";
const ACTIVE_TOKEN_KEY = "rcs_uuid";
const DEFAULT_ID = "__default__";
function generateId(): string {
return `tk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Ensure the existing rcs_uuid is present as the default token entry */
function ensureDefault(tokens: TokenEntry[]): TokenEntry[] {
if (tokens.some((t) => t.id === DEFAULT_ID)) return tokens;
let uuid: string | null = null;
try {
uuid = localStorage.getItem("rcs_uuid");
} catch {
// ignore
}
if (!uuid) return tokens;
return [{ id: DEFAULT_ID, token: uuid, label: "Default" }, ...tokens];
}
function loadTokens(): TokenEntry[] {
let tokens: TokenEntry[] = [];
try {
const raw = localStorage.getItem(TOKENS_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) tokens = parsed;
}
} catch {
// ignore
}
return ensureDefault(tokens);
}
function loadActiveTokenId(tokens: TokenEntry[]): string {
// Try saved active token
try {
const saved = localStorage.getItem(ACTIVE_TOKEN_KEY);
if (saved && tokens.some((t) => t.id === saved)) return saved;
} catch {
// ignore
}
// Fall back to default (rcs_uuid) entry
const defaultEntry = tokens.find((t) => t.id === DEFAULT_ID);
if (defaultEntry) return defaultEntry.id;
// Fall back to first entry
return tokens[0]?.id ?? DEFAULT_ID;
}
export function useTokens() {
const [tokens, setTokens] = useState<TokenEntry[]>(loadTokens);
const [activeTokenId, setActiveTokenIdState] = useState<string>(() => loadActiveTokenId(loadTokens()));
const persistTokens = useCallback((next: TokenEntry[]) => {
setTokens(next);
try {
localStorage.setItem(TOKENS_KEY, JSON.stringify(next));
} catch {
// ignore
}
}, []);
const setActiveTokenId = useCallback((id: string) => {
setActiveTokenIdState(id);
try {
localStorage.setItem(ACTIVE_TOKEN_KEY, id);
location.reload(); // Reload to ensure api client picks up new token from localStorage
} catch {
// ignore
}
}, []);
const addToken = useCallback((token: string, label: string): string | null => {
const trimmed = token.trim();
if (!trimmed) return "Token is required";
const entry: TokenEntry = { id: generateId(), token: trimmed, label: label.trim() || trimmed.slice(0, 8) };
const next = [...tokens, entry];
persistTokens(next);
return null;
}, [tokens, persistTokens]);
const removeToken = useCallback((id: string) => {
if (id === DEFAULT_ID) return; // Cannot remove default
const next = tokens.filter((t) => t.id !== id);
persistTokens(next);
if (activeTokenId === id) {
setActiveTokenId(DEFAULT_ID);
}
}, [tokens, persistTokens, activeTokenId, setActiveTokenId]);
const updateToken = useCallback((id: string, label: string) => {
const next = tokens.map((t) => t.id === id ? { ...t, label } : t);
persistTokens(next);
}, [tokens, persistTokens]);
const activeToken = tokens.find((t) => t.id === activeTokenId) ?? tokens[0] ?? null;
const activeLabel = activeToken?.label ?? "Default";
const activeTokenValue = activeToken?.token ?? null;
return {
tokens,
activeTokenId,
activeToken,
activeLabel,
activeTokenValue,
setActiveTokenId,
addToken,
removeToken,
updateToken,
};
}

View File

@@ -5,6 +5,7 @@ export interface Environment {
status: string;
branch?: string;
worker_type?: string;
channel_group_id?: string | null;
capabilities?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,11 @@
{
"name": "@claude-code-best/weixin",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"qrcode": "^1.5.4"
}
}

View File

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-accounts-'))
process.env.WEIXIN_STATE_DIR = testDir
import { clearAccount, loadAccount, saveAccount } from '../accounts.js'
afterEach(() => {
rmSync(testDir, { recursive: true, force: true })
})
describe('account storage', () => {
test('loadAccount returns null when no account exists', () => {
expect(loadAccount()).toBeNull()
})
test('saveAccount and loadAccount round-trip', () => {
const data = {
token: 'test-token',
baseUrl: 'https://example.com',
userId: 'user1',
savedAt: '2025-01-01T00:00:00.000Z',
}
saveAccount(data)
expect(loadAccount()).toEqual(data)
})
test('saveAccount sets file permissions to 0600', () => {
saveAccount({
token: 'test',
baseUrl: 'https://example.com',
savedAt: new Date().toISOString(),
})
const stats = statSync(join(testDir, 'account.json'))
if (process.platform === 'win32') {
expect(stats.isFile()).toBe(true)
return
}
expect(stats.mode & 0o777).toBe(0o600)
})
test('clearAccount removes the file', () => {
saveAccount({
token: 'test',
baseUrl: 'https://example.com',
savedAt: new Date().toISOString(),
})
clearAccount()
expect(loadAccount()).toBeNull()
})
})

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test'
import { randomBytes } from 'node:crypto'
import {
aesEcbPaddedSize,
buildCdnDownloadUrl,
buildCdnUploadUrl,
decryptAesEcb,
encryptAesEcb,
guessMediaType,
parseAesKey,
} from '../media.js'
import { UploadMediaType } from '../types.js'
describe('AES-128-ECB', () => {
test('encrypt then decrypt returns original data', () => {
const key = randomBytes(16)
const plaintext = Buffer.from('hello world test data!!')
const ciphertext = encryptAesEcb(plaintext, key)
expect(decryptAesEcb(ciphertext, key)).toEqual(plaintext)
})
test('different keys produce different ciphertext', () => {
const plaintext = Buffer.from('test data')
expect(
encryptAesEcb(plaintext, randomBytes(16)),
).not.toEqual(encryptAesEcb(plaintext, randomBytes(16)))
})
})
describe('aesEcbPaddedSize', () => {
test('pads to next 16-byte boundary', () => {
expect(aesEcbPaddedSize(1)).toBe(16)
expect(aesEcbPaddedSize(16)).toBe(32)
expect(aesEcbPaddedSize(17)).toBe(32)
expect(aesEcbPaddedSize(32)).toBe(48)
})
})
describe('parseAesKey', () => {
test('parses 16 raw bytes from base64', () => {
const raw = randomBytes(16)
expect(parseAesKey(raw.toString('base64'))).toEqual(raw)
})
test('parses hex-encoded key from base64', () => {
const raw = randomBytes(16)
const b64 = Buffer.from(raw.toString('hex'), 'ascii').toString('base64')
expect(parseAesKey(b64)).toEqual(raw)
})
test('throws on invalid key length', () => {
expect(() => parseAesKey(Buffer.from('short').toString('base64'))).toThrow(
'Invalid aes_key',
)
})
})
describe('CDN URL builders', () => {
test('buildCdnDownloadUrl encodes param', () => {
expect(buildCdnDownloadUrl('abc=123', 'https://cdn.example.com')).toBe(
'https://cdn.example.com/download?encrypted_query_param=abc%3D123',
)
})
test('buildCdnUploadUrl encodes params', () => {
expect(
buildCdnUploadUrl('https://cdn.example.com', 'param1', 'key1'),
).toBe(
'https://cdn.example.com/upload?encrypted_query_param=param1&filekey=key1',
)
})
})
describe('guessMediaType', () => {
test('detects image extensions', () => {
expect(guessMediaType('photo.jpg')).toBe(UploadMediaType.IMAGE)
expect(guessMediaType('photo.png')).toBe(UploadMediaType.IMAGE)
expect(guessMediaType('photo.webp')).toBe(UploadMediaType.IMAGE)
})
test('detects video extensions', () => {
expect(guessMediaType('video.mp4')).toBe(UploadMediaType.VIDEO)
expect(guessMediaType('video.mov')).toBe(UploadMediaType.VIDEO)
})
test('defaults to FILE for unknown extensions', () => {
expect(guessMediaType('doc.pdf')).toBe(UploadMediaType.FILE)
expect(guessMediaType('archive.zip')).toBe(UploadMediaType.FILE)
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { extractPermissionReply } from '../monitor.js'
describe('extractPermissionReply', () => {
test('parses allow replies', () => {
expect(extractPermissionReply('yes abcde')).toEqual({
requestId: 'abcde',
behavior: 'allow',
})
})
test('parses deny replies', () => {
expect(extractPermissionReply('No abcde')).toEqual({
requestId: 'abcde',
behavior: 'deny',
})
})
test('ignores unrelated text', () => {
expect(extractPermissionReply('yes please do it')).toBeNull()
})
})

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-pairing-'))
process.env.WEIXIN_STATE_DIR = testDir
import {
addPendingPairing,
confirmPairing,
isAllowed,
loadAccessConfig,
saveAccessConfig,
} from '../pairing.js'
afterEach(() => {
rmSync(testDir, { recursive: true, force: true })
})
describe('loadAccessConfig', () => {
test('returns default config when no file exists', () => {
const config = loadAccessConfig()
expect(config.policy).toBe('pairing')
expect(config.allowFrom).toEqual([])
})
test('round-trips saved config', () => {
saveAccessConfig({ policy: 'allowlist', allowFrom: ['user1'] })
const config = loadAccessConfig()
expect(config.policy).toBe('allowlist')
expect(config.allowFrom).toEqual(['user1'])
})
})
describe('isAllowed', () => {
test('returns false for unknown user under pairing policy', () => {
expect(isAllowed('unknown')).toBe(false)
})
test('returns true for allowed user', () => {
saveAccessConfig({ policy: 'pairing', allowFrom: ['user1'] })
expect(isAllowed('user1')).toBe(true)
})
test('returns true for any user under disabled policy', () => {
saveAccessConfig({ policy: 'disabled', allowFrom: [] })
expect(isAllowed('anyone')).toBe(true)
})
})
describe('pairing flow', () => {
test('generates 6-digit code', () => {
expect(addPendingPairing('user1')).toMatch(/^\d{6}$/)
})
test('returns same code for same user', () => {
const code1 = addPendingPairing('user1')
const code2 = addPendingPairing('user1')
expect(code1).toBe(code2)
})
test('confirm adds user to allowlist', () => {
const code = addPendingPairing('user1')
expect(confirmPairing(code)).toBe('user1')
expect(isAllowed('user1')).toBe(true)
})
test('confirm returns null for invalid code', () => {
expect(confirmPairing('000000')).toBeNull()
})
test('code cannot be reused after confirmation', () => {
const code = addPendingPairing('user1')
confirmPairing(code)
expect(confirmPairing(code)).toBeNull()
})
})

View File

@@ -0,0 +1,43 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
clearPermissionStateForTests,
consumePendingPermission,
getActivePermissionChat,
savePendingPermission,
setActivePermissionChat,
} from '../permissions.js'
afterEach(() => {
clearPermissionStateForTests()
})
describe('permission state', () => {
test('tracks active permission chat', () => {
setActivePermissionChat('user-1', 'ctx-1')
expect(getActivePermissionChat()).toEqual({
chatId: 'user-1',
contextToken: 'ctx-1',
updatedAt: expect.any(Number),
})
})
test('consumes pending permission only for matching user', () => {
savePendingPermission(
{
request_id: 'abcde',
tool_name: 'Bash',
description: 'Run a command',
input_preview: '{"command":"pwd"}',
},
'user-1',
'ctx-1',
)
expect(consumePendingPermission('abcde', 'user-2')).toBeNull()
expect(consumePendingPermission('ABCDE', 'user-1')).toMatchObject({
request_id: 'abcde',
chatId: 'user-1',
})
expect(consumePendingPermission('abcde', 'user-1')).toBeNull()
})
})

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test'
import { markdownToPlainText } from '../send.js'
describe('markdownToPlainText', () => {
test('removes bold markers', () => {
expect(markdownToPlainText('**bold**')).toBe('bold')
})
test('removes italic markers', () => {
expect(markdownToPlainText('*italic*')).toBe('italic')
})
test('removes inline code backticks', () => {
expect(markdownToPlainText('`code`')).toBe('code')
})
test('removes code block fences', () => {
expect(markdownToPlainText("```js\nconsole.log('hi');\n```"))
.toBe("console.log('hi');")
})
test('converts links to text with URL', () => {
expect(markdownToPlainText('[click](https://example.com)')).toBe(
'click (https://example.com)',
)
})
test('handles mixed markdown', () => {
expect(markdownToPlainText('# Hello\n\n**bold** and *italic* with `code`'))
.toBe('Hello\n\nbold and italic with code')
})
})

View File

@@ -0,0 +1,57 @@
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
export interface AccountData {
token: string
baseUrl: string
userId?: string
savedAt: string
}
export function getStateDir(): string {
const dir =
process.env.WEIXIN_STATE_DIR ||
join(homedir(), '.claude', 'channels', 'weixin')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
return dir
}
function accountPath(): string {
return join(getStateDir(), 'account.json')
}
export function loadAccount(): AccountData | null {
const path = accountPath()
if (!existsSync(path)) return null
try {
return JSON.parse(readFileSync(path, 'utf-8')) as AccountData
} catch {
return null
}
}
export function saveAccount(data: AccountData): void {
const path = accountPath()
writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8')
chmodSync(path, 0o600)
}
export function clearAccount(): void {
const path = accountPath()
if (existsSync(path)) {
unlinkSync(path)
}
}

148
packages/weixin/src/api.ts Normal file
View File

@@ -0,0 +1,148 @@
import { randomBytes } from 'node:crypto'
import type {
BaseInfo,
GetConfigResp,
GetUpdatesReq,
GetUpdatesResp,
GetUploadUrlReq,
GetUploadUrlResp,
SendMessageReq,
SendTypingReq,
SendTypingResp,
} from './types.js'
const CHANNEL_VERSION = '0.1.0'
function baseInfo(): BaseInfo {
return { channel_version: CHANNEL_VERSION }
}
function randomUin(): string {
return randomBytes(4).toString('base64')
}
function buildHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-WECHAT-UIN': randomUin(),
}
if (token) {
headers.AuthorizationType = 'ilink_bot_token'
headers.Authorization = `Bearer ${token}`
}
return headers
}
async function post<T>(
baseUrl: string,
path: string,
body: unknown,
token?: string,
timeoutMs = 40_000,
signal?: AbortSignal,
): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
try {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: buildHeaders(token),
body: JSON.stringify(body),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return (await response.json()) as T
} finally {
clearTimeout(timeout)
}
}
export async function getUpdates(
baseUrl: string,
token: string,
getUpdatesBuf: string,
signal?: AbortSignal,
): Promise<GetUpdatesResp> {
const body: GetUpdatesReq = {
get_updates_buf: getUpdatesBuf,
base_info: baseInfo(),
}
try {
return await post<GetUpdatesResp>(
baseUrl,
'/ilink/bot/getupdates',
body,
token,
40_000,
signal,
)
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }
}
throw error
}
}
export async function sendMessage(
baseUrl: string,
token: string,
msg: SendMessageReq['msg'],
): Promise<void> {
const body: SendMessageReq = { msg, base_info: baseInfo() }
await post(baseUrl, '/ilink/bot/sendmessage', body, token)
}
export async function getUploadUrl(
baseUrl: string,
token: string,
params: Omit<GetUploadUrlReq, 'base_info'>,
): Promise<GetUploadUrlResp> {
return post<GetUploadUrlResp>(
baseUrl,
'/ilink/bot/getuploadurl',
{ ...params, base_info: baseInfo() },
token,
)
}
export async function getConfig(
baseUrl: string,
token: string,
userId: string,
contextToken?: string,
): Promise<GetConfigResp> {
return post<GetConfigResp>(
baseUrl,
'/ilink/bot/getconfig',
{
ilink_user_id: userId,
context_token: contextToken,
base_info: baseInfo(),
},
token,
)
}
export async function sendTyping(
baseUrl: string,
token: string,
req: Omit<SendTypingReq, 'base_info'>,
): Promise<SendTypingResp> {
return post<SendTypingResp>(
baseUrl,
'/ilink/bot/sendtyping',
{ ...req, base_info: baseInfo() },
token,
)
}

119
packages/weixin/src/cli.ts Normal file
View File

@@ -0,0 +1,119 @@
import { clearAccount, DEFAULT_BASE_URL, loadAccount, saveAccount } from './accounts.js'
import { startLogin, waitForLogin } from './login.js'
import { confirmPairing } from './pairing.js'
import { runWeixinMcpServer } from './server.js'
import type { WeixinServerDeps } from './server.js'
function printUsage(): void {
process.stdout.write(
[
'Usage:',
' ccb weixin serve',
' ccb weixin login',
' ccb weixin login clear',
' ccb weixin access pair <code>',
'',
'Session enablement:',
' ccb --channels plugin:weixin@builtin',
].join('\n') + '\n',
)
}
async function runLogin(clear = false): Promise<void> {
if (clear) {
clearAccount()
process.stdout.write('WeChat account cleared.\n')
return
}
const existing = loadAccount()
if (existing) {
process.stdout.write(
[
'Already connected:',
` User ID: ${existing.userId || 'unknown'}`,
` Connected since: ${existing.savedAt}`,
'',
'Run `ccb weixin login clear` to disconnect.',
'Restart Claude Code with:',
' ccb --channels plugin:weixin@builtin',
].join('\n') + '\n',
)
return
}
process.stdout.write('Starting WeChat QR login...\n\n')
const qr = await startLogin(DEFAULT_BASE_URL)
process.stdout.write(
`\nScan the QR code above with WeChat, or open this URL:\n${qr.qrcodeUrl || ''}\n\n`,
)
const result = await waitForLogin({
qrcodeId: qr.qrcodeId,
apiBaseUrl: DEFAULT_BASE_URL,
})
if (!result.connected || !result.token) {
process.stderr.write(`Login failed: ${result.message}\n`)
process.exit(1)
}
saveAccount({
token: result.token,
baseUrl: result.baseUrl || DEFAULT_BASE_URL,
userId: result.userId,
savedAt: new Date().toISOString(),
})
process.stdout.write(
[
'Connected successfully!',
` User ID: ${result.userId || 'unknown'}`,
` Base URL: ${result.baseUrl || DEFAULT_BASE_URL}`,
'',
'Restart Claude Code with:',
' ccb --channels plugin:weixin@builtin',
].join('\n') + '\n',
)
}
function runAccess(args: string[]): void {
if (args[0] !== 'pair' || !args[1]) {
printUsage()
process.exit(1)
}
const userId = confirmPairing(args[1])
if (!userId) {
process.stderr.write('Invalid or expired pairing code.\n')
process.exit(1)
}
process.stdout.write(`Paired successfully: ${userId}\n`)
}
export async function handleWeixinCli(
args: string[],
serverDeps?: WeixinServerDeps,
version?: string,
): Promise<void> {
const [subcommand, ...rest] = args
switch (subcommand) {
case 'serve':
if (!serverDeps) {
process.stderr.write('[weixin] serve handler not available in this context.\n')
process.exit(1)
}
await runWeixinMcpServer(version ?? '0.0.0', serverDeps)
return
case 'login':
await runLogin(rest[0] === 'clear')
return
case 'access':
runAccess(rest)
return
default:
printUsage()
}
}

View File

@@ -0,0 +1,115 @@
// @claude-code-best/weixin — WeChat channel integration
// Types
export {
MessageType,
MessageItemType,
MessageState,
UploadMediaType,
TypingStatus,
} from './types.js'
export type {
BaseInfo,
CDNMedia,
TextItem,
ImageItem,
VoiceItem,
FileItem,
VideoItem,
RefMessage,
MessageItem,
WeixinMessage,
GetUpdatesReq,
GetUpdatesResp,
SendMessageReq,
GetUploadUrlReq,
GetUploadUrlResp,
GetConfigResp,
SendTypingReq,
SendTypingResp,
} from './types.js'
// API client
export {
getUpdates,
sendMessage,
getUploadUrl,
getConfig,
sendTyping,
} from './api.js'
// Account management
export {
DEFAULT_BASE_URL,
CDN_BASE_URL,
getStateDir,
loadAccount,
saveAccount,
clearAccount,
} from './accounts.js'
export type { AccountData } from './accounts.js'
// Login
export { startLogin, waitForLogin } from './login.js'
export type { QRCodeResult, LoginResult } from './login.js'
// Pairing / access control
export {
loadAccessConfig,
saveAccessConfig,
isAllowed,
addPendingPairing,
confirmPairing,
} from './pairing.js'
export type { AccessConfig } from './pairing.js'
// Media encryption / upload
export {
encryptAesEcb,
decryptAesEcb,
aesEcbPaddedSize,
buildCdnDownloadUrl,
buildCdnUploadUrl,
parseAesKey,
downloadAndDecrypt,
uploadFile,
guessMediaType,
downloadRemoteToTemp,
} from './media.js'
export type { UploadedFileInfo } from './media.js'
// Message sending
export { markdownToPlainText, sendText, sendMediaFile } from './send.js'
// Monitor (message polling)
export {
getContextToken,
extractPermissionReply,
startPollLoop,
} from './monitor.js'
export type {
ParsedMessage,
OnMessageCallback,
PermissionResponse,
OnPermissionResponseCallback,
} from './monitor.js'
// Permission state
export {
setActivePermissionChat,
getActivePermissionChat,
savePendingPermission,
consumePendingPermission,
} from './permissions.js'
export type {
ChannelPermissionRequestParams,
PendingPermissionRequest,
ActivePermissionChat,
} from './permissions.js'
// Server (MCP)
export { createWeixinMcpServer, runWeixinMcpServer } from './server.js'
export type { WeixinServerDeps } from './server.js'
// CLI
export { handleWeixinCli } from './cli.js'

View File

@@ -0,0 +1,134 @@
import { toString as qrToString } from 'qrcode'
export interface QRCodeResult {
qrcodeUrl?: string
qrcodeId: string
message: string
}
export interface LoginResult {
connected: boolean
token?: string
accountId?: string
baseUrl?: string
userId?: string
message: string
}
async function renderQrCodeToTerminal(qrcodeUrl: string): Promise<void> {
const output = await qrToString(qrcodeUrl, {
type: 'terminal',
errorCorrectionLevel: 'L',
small: true,
})
process.stderr.write(`${output}\n`)
}
export async function startLogin(apiBaseUrl: string): Promise<QRCodeResult> {
const response = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`)
if (!response.ok) {
throw new Error(`Failed to get QR code: HTTP ${response.status}`)
}
const data = (await response.json()) as {
qrcode?: string
qrcode_img_content?: string
}
if (!data.qrcode) {
throw new Error('No qrcode in response')
}
const qrcodeUrl = data.qrcode_img_content || ''
if (qrcodeUrl) {
await renderQrCodeToTerminal(qrcodeUrl)
}
return {
qrcodeUrl,
qrcodeId: data.qrcode,
message: 'Scan the QR code with WeChat to connect.',
}
}
export async function waitForLogin(params: {
qrcodeId: string
apiBaseUrl: string
timeoutMs?: number
maxRetries?: number
}): Promise<LoginResult> {
const { qrcodeId, apiBaseUrl, timeoutMs = 480_000, maxRetries = 3 } = params
const deadline = Date.now() + timeoutMs
let currentQrcodeId = qrcodeId
let retryCount = 0
while (Date.now() < deadline) {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 60_000)
const response = await fetch(
`${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`,
{
headers: { 'iLink-App-ClientVersion': '1' },
signal: controller.signal,
},
)
clearTimeout(timeout)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = (await response.json()) as {
status?: string
bot_token?: string
ilink_bot_id?: string
baseurl?: string
ilink_user_id?: string
}
switch (data.status) {
case 'confirmed':
return {
connected: true,
token: data.bot_token,
accountId: data.ilink_bot_id,
baseUrl: data.baseurl,
userId: data.ilink_user_id,
message: 'Connected to WeChat successfully!',
}
case 'scaned':
process.stderr.write(
'QR code scanned, waiting for confirmation...\n',
)
break
case 'expired': {
retryCount += 1
if (retryCount >= maxRetries) {
return {
connected: false,
message: 'QR code expired after maximum retries.',
}
}
process.stderr.write('QR code expired, refreshing...\n')
const refreshed = await startLogin(apiBaseUrl)
currentQrcodeId = refreshed.qrcodeId
break
}
case 'wait':
default:
break
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
continue
}
throw error
}
await new Promise(resolve => setTimeout(resolve, 1000))
}
return { connected: false, message: 'Login timed out.' }
}

View File

@@ -0,0 +1,163 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto'
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, extname, join } from 'node:path'
import { getUploadUrl } from './api.js'
import { UploadMediaType } from './types.js'
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
const cipher = createCipheriv('aes-128-ecb', key, null)
return Buffer.concat([cipher.update(plaintext), cipher.final()])
}
export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
const decipher = createDecipheriv('aes-128-ecb', key, null)
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
}
export function aesEcbPaddedSize(size: number): number {
return size + (16 - (size % 16))
}
export function buildCdnDownloadUrl(
encryptedQueryParam: string,
cdnBaseUrl: string,
): string {
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`
}
export function buildCdnUploadUrl(
cdnBaseUrl: string,
uploadParam: string,
filekey: string,
): string {
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`
}
export function parseAesKey(aesKeyBase64: string): Buffer {
const decoded = Buffer.from(aesKeyBase64, 'base64')
if (decoded.length === 16) {
return decoded
}
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) {
return Buffer.from(decoded.toString('ascii'), 'hex')
}
throw new Error(
`Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`,
)
}
export async function downloadAndDecrypt(params: {
encryptQueryParam: string
aesKey: string
cdnBaseUrl: string
}): Promise<Buffer> {
const url = buildCdnDownloadUrl(params.encryptQueryParam, params.cdnBaseUrl)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`CDN download failed: HTTP ${response.status}`)
}
const ciphertext = Buffer.from(await response.arrayBuffer())
return decryptAesEcb(ciphertext, parseAesKey(params.aesKey))
}
export interface UploadedFileInfo {
encryptQueryParam: string
aesKey: string
fileSize: number
rawSize: number
fileName: string
}
export async function uploadFile(params: {
filePath: string
toUserId: string
mediaType: number
apiBaseUrl: string
token: string
cdnBaseUrl: string
}): Promise<UploadedFileInfo> {
const plaintext = readFileSync(params.filePath)
const rawSize = plaintext.length
const rawMd5 = createHash('md5').update(plaintext).digest('hex')
const aesKey = randomBytes(16)
const filekey = randomBytes(16).toString('hex')
const ciphertext = encryptAesEcb(plaintext, aesKey)
const fileSize = ciphertext.length
const uploadResp = await getUploadUrl(params.apiBaseUrl, params.token, {
filekey,
media_type: params.mediaType,
to_user_id: params.toUserId,
rawsize: rawSize,
rawfilemd5: rawMd5,
filesize: fileSize,
no_need_thumb: true,
aeskey: aesKey.toString('hex'),
})
if (!uploadResp.upload_param) {
throw new Error('No upload_param in response')
}
const uploadUrl = buildCdnUploadUrl(
params.cdnBaseUrl,
uploadResp.upload_param,
filekey,
)
const uploadResult = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: new Uint8Array(ciphertext),
})
if (!uploadResult.ok) {
throw new Error(`CDN upload failed: HTTP ${uploadResult.status}`)
}
return {
encryptQueryParam: uploadResult.headers.get('x-encrypted-param') || '',
aesKey: Buffer.from(aesKey.toString('hex')).toString('base64'),
fileSize,
rawSize,
fileName: basename(params.filePath),
}
}
export function guessMediaType(filePath: string): number {
const ext = extname(filePath).toLowerCase()
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']
if (imageExts.includes(ext)) return UploadMediaType.IMAGE
if (videoExts.includes(ext)) return UploadMediaType.VIDEO
return UploadMediaType.FILE
}
export async function downloadRemoteToTemp(
url: string,
destDir?: string,
): Promise<string> {
const dir = destDir || join(tmpdir(), 'weixin-downloads')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const response = await fetch(url)
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`)
const buffer = Buffer.from(await response.arrayBuffer())
const urlPath = new URL(url).pathname
const name = basename(urlPath) || `file_${Date.now()}`
const dest = join(dir, name)
writeFileSync(dest, buffer)
return dest
}

View File

@@ -0,0 +1,303 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
// Matches the canonical definition in src/services/mcp/channelPermissions.ts
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
import { getUpdates } from './api.js'
import { getStateDir } from './accounts.js'
import { downloadAndDecrypt } from './media.js'
import { addPendingPairing, isAllowed } from './pairing.js'
import { consumePendingPermission, setActivePermissionChat } from './permissions.js'
import { sendText } from './send.js'
import { MessageItemType, MessageType, type MessageItem, type WeixinMessage } from './types.js'
const contextTokens = new Map<string, string>()
export function getContextToken(userId: string): string | undefined {
return contextTokens.get(userId)
}
function cursorPath(): string {
return join(getStateDir(), 'cursor.txt')
}
function loadCursor(): string {
const path = cursorPath()
if (existsSync(path)) return readFileSync(path, 'utf-8').trim()
return ''
}
function saveCursor(cursor: string): void {
writeFileSync(cursorPath(), cursor, 'utf-8')
}
async function downloadMedia(
item: MessageItem,
cdnBaseUrl: string,
): Promise<{ path: string; type: string } | null> {
let encryptQueryParam: string | undefined
let aesKey: string | undefined
let ext = ''
let mediaType = ''
switch (item.type) {
case MessageItemType.IMAGE:
encryptQueryParam = item.image_item?.media?.encrypt_query_param
aesKey = item.image_item?.aeskey
? Buffer.from(item.image_item.aeskey, 'hex').toString('base64')
: item.image_item?.media?.aes_key
ext = '.jpg'
mediaType = 'image'
break
case MessageItemType.VOICE:
encryptQueryParam = item.voice_item?.media?.encrypt_query_param
aesKey = item.voice_item?.media?.aes_key
ext = '.silk'
mediaType = 'voice'
break
case MessageItemType.FILE:
encryptQueryParam = item.file_item?.media?.encrypt_query_param
aesKey = item.file_item?.media?.aes_key
ext = item.file_item?.file_name
? `.${item.file_item.file_name.split('.').pop()}`
: ''
mediaType = 'file'
break
case MessageItemType.VIDEO:
encryptQueryParam = item.video_item?.media?.encrypt_query_param
aesKey = item.video_item?.media?.aes_key
ext = '.mp4'
mediaType = 'video'
break
default:
return null
}
if (!encryptQueryParam || !aesKey) return null
try {
const data = await downloadAndDecrypt({
encryptQueryParam,
aesKey,
cdnBaseUrl,
})
const dir = join(tmpdir(), 'weixin-media')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const rawFileName = item.file_item?.file_name || `${Date.now()}${ext}`
const fileName = basename(rawFileName)
const filePath = join(dir, fileName)
writeFileSync(filePath, data)
return { path: filePath, type: mediaType }
} catch (error) {
process.stderr.write(`[weixin] Failed to download media: ${error}\n`)
return null
}
}
export interface ParsedMessage {
fromUserId: string
messageId: string
text: string
attachmentPath?: string
attachmentType?: string
}
export type OnMessageCallback = (msg: ParsedMessage) => Promise<void>
export type PermissionResponse = {
requestId: string
behavior: 'allow' | 'deny'
fromUserId: string
}
export type OnPermissionResponseCallback = (
response: PermissionResponse,
) => Promise<void>
export function extractPermissionReply(
text: string,
): { requestId: string; behavior: 'allow' | 'deny' } | null {
const match = text.match(PERMISSION_REPLY_RE)
if (!match) return null
const behavior =
match[1]?.toLowerCase().startsWith('y') ? 'allow' : 'deny'
const requestId = match[2]?.toLowerCase()
if (!requestId) return null
return { requestId, behavior }
}
export async function startPollLoop(params: {
baseUrl: string
cdnBaseUrl: string
token: string
onMessage: OnMessageCallback
onPermissionResponse?: OnPermissionResponseCallback
abortSignal: AbortSignal
}): Promise<void> {
const {
baseUrl,
cdnBaseUrl,
token,
onMessage,
onPermissionResponse,
abortSignal,
} = params
let cursor = loadCursor()
let consecutiveErrors = 0
process.stderr.write('[weixin] Starting message poll loop...\n')
while (!abortSignal.aborted) {
try {
const response = await getUpdates(baseUrl, token, cursor, abortSignal)
if (response.errcode === -14) {
process.stderr.write(
'[weixin] Session expired (errcode -14). Pausing for 30s...\n',
)
await new Promise(resolve => setTimeout(resolve, 30_000))
continue
}
if (response.ret !== 0 && response.ret !== undefined) {
throw new Error(
`getUpdates error: ret=${response.ret} errcode=${response.errcode} ${response.errmsg}`,
)
}
consecutiveErrors = 0
if (response.get_updates_buf) {
cursor = response.get_updates_buf
saveCursor(cursor)
}
if (response.msgs && response.msgs.length > 0) {
for (const msg of response.msgs) {
await processMessage(msg, {
baseUrl,
cdnBaseUrl,
token,
onMessage,
onPermissionResponse,
})
}
}
} catch (error) {
if (abortSignal.aborted) break
consecutiveErrors += 1
process.stderr.write(
`[weixin] Poll error (${consecutiveErrors}): ${error instanceof Error ? error.message : String(error)}\n`,
)
if (consecutiveErrors >= 3) {
process.stderr.write(
'[weixin] Too many consecutive errors, backing off 30s...\n',
)
await new Promise(resolve => setTimeout(resolve, 30_000))
consecutiveErrors = 0
} else {
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
}
process.stderr.write('[weixin] Poll loop stopped.\n')
}
async function processMessage(
msg: WeixinMessage,
ctx: {
baseUrl: string
cdnBaseUrl: string
token: string
onMessage: OnMessageCallback
onPermissionResponse?: OnPermissionResponseCallback
},
): Promise<void> {
if (msg.message_type !== MessageType.USER) return
const fromUserId = msg.from_user_id
if (!fromUserId) return
if (msg.context_token) {
contextTokens.set(fromUserId, msg.context_token)
}
if (!isAllowed(fromUserId)) {
const code = addPendingPairing(fromUserId)
try {
await sendText({
to: fromUserId,
text: `Your pairing code is: ${code}\n\nAsk the operator to confirm:\nccb weixin access pair ${code}`,
baseUrl: ctx.baseUrl,
token: ctx.token,
contextToken: msg.context_token || '',
})
} catch (error) {
process.stderr.write(`[weixin] Failed to send pairing code: ${error}\n`)
}
return
}
setActivePermissionChat(fromUserId, msg.context_token)
let textContent = ''
let mediaPath: string | undefined
let mediaType: string | undefined
if (msg.item_list) {
for (const item of msg.item_list) {
if (item.type === MessageItemType.TEXT && item.text_item?.text) {
textContent += `${textContent ? '\n' : ''}${item.text_item.text}`
} else if (
item.type === MessageItemType.IMAGE ||
item.type === MessageItemType.VOICE ||
item.type === MessageItemType.FILE ||
item.type === MessageItemType.VIDEO
) {
const downloaded = await downloadMedia(item, ctx.cdnBaseUrl)
if (downloaded) {
mediaPath = downloaded.path
mediaType = downloaded.type
}
if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
textContent += `${textContent ? '\n' : ''}[Voice transcription]: ${item.voice_item.text}`
}
}
}
}
if (!textContent && !mediaPath) return
if (textContent && ctx.onPermissionResponse) {
const permissionReply = extractPermissionReply(textContent)
if (permissionReply) {
const pending = consumePendingPermission(
permissionReply.requestId,
fromUserId,
)
if (pending) {
await ctx.onPermissionResponse({
requestId: pending.request_id,
behavior: permissionReply.behavior,
fromUserId,
})
return
}
}
}
await ctx.onMessage({
fromUserId,
messageId: String(msg.message_id || ''),
text: textContent || '(media attachment)',
attachmentPath: mediaPath,
attachmentType: mediaType,
})
}

View File

@@ -0,0 +1,101 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { getStateDir } from './accounts.js'
export interface AccessConfig {
policy: 'pairing' | 'allowlist' | 'disabled'
allowFrom: string[]
}
interface PendingEntry {
userId: string
expiresAt: number
}
function configPath(): string {
return join(getStateDir(), 'access.json')
}
function pendingPath(): string {
return join(getStateDir(), 'pending-pairings.json')
}
function loadPending(): Record<string, PendingEntry> {
const path = pendingPath()
if (!existsSync(path)) return {}
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, PendingEntry>
} catch {
return {}
}
}
function savePending(data: Record<string, PendingEntry>): void {
writeFileSync(pendingPath(), JSON.stringify(data, null, 2), 'utf-8')
}
export function loadAccessConfig(): AccessConfig {
const path = configPath()
if (!existsSync(path)) {
return { policy: 'pairing', allowFrom: [] }
}
try {
return JSON.parse(readFileSync(path, 'utf-8')) as AccessConfig
} catch {
return { policy: 'pairing', allowFrom: [] }
}
}
export function saveAccessConfig(config: AccessConfig): void {
writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8')
}
export function isAllowed(userId: string): boolean {
const config = loadAccessConfig()
if (config.policy === 'disabled') return true
return config.allowFrom.includes(userId)
}
export function addPendingPairing(userId: string): string {
const pending = loadPending()
const now = Date.now()
for (const code of Object.keys(pending)) {
if (pending[code]!.expiresAt < now) {
delete pending[code]
}
}
for (const [code, entry] of Object.entries(pending)) {
if (entry.userId === userId) {
savePending(pending)
return code
}
}
const code = String(Math.floor(100000 + Math.random() * 900000))
pending[code] = { userId, expiresAt: now + 10 * 60 * 1000 }
savePending(pending)
return code
}
export function confirmPairing(code: string): string | null {
const pending = loadPending()
const entry = pending[code]
if (!entry || entry.expiresAt < Date.now()) {
delete pending[code]
savePending(pending)
return null
}
delete pending[code]
savePending(pending)
const config = loadAccessConfig()
if (!config.allowFrom.includes(entry.userId)) {
config.allowFrom.push(entry.userId)
saveAccessConfig(config)
}
return entry.userId
}

View File

@@ -0,0 +1,83 @@
/** Mirrors ChannelPermissionRequestParams from src/services/mcp/channelNotification.ts */
export interface ChannelPermissionRequestParams {
request_id: string
tool_name: string
description: string
input_preview: string
channel_context?: {
source_server?: string
chat_id?: string
}
}
export type PendingPermissionRequest = ChannelPermissionRequestParams & {
chatId: string
contextToken?: string
createdAt: number
expiresAt: number
}
export type ActivePermissionChat = {
chatId: string
contextToken?: string
updatedAt: number
}
const PENDING_PERMISSION_TTL_MS = 15 * 60 * 1000
const pendingPermissions = new Map<string, PendingPermissionRequest>()
let activePermissionChat: ActivePermissionChat | null = null
function pruneExpiredPendingPermissions(now = Date.now()): void {
for (const [requestId, entry] of pendingPermissions.entries()) {
if (entry.expiresAt <= now) {
pendingPermissions.delete(requestId)
}
}
}
export function setActivePermissionChat(
chatId: string,
contextToken?: string,
): void {
activePermissionChat = { chatId, contextToken, updatedAt: Date.now() }
}
export function getActivePermissionChat(): ActivePermissionChat | null {
return activePermissionChat
}
export function savePendingPermission(
request: ChannelPermissionRequestParams,
chatId: string,
contextToken?: string,
): PendingPermissionRequest {
pruneExpiredPendingPermissions()
const entry: PendingPermissionRequest = {
...request,
chatId,
contextToken,
createdAt: Date.now(),
expiresAt: Date.now() + PENDING_PERMISSION_TTL_MS,
}
pendingPermissions.set(request.request_id.toLowerCase(), entry)
return entry
}
export function consumePendingPermission(
requestId: string,
fromUserId: string,
): PendingPermissionRequest | null {
pruneExpiredPendingPermissions()
const key = requestId.toLowerCase()
const entry = pendingPermissions.get(key)
if (!entry) return null
if (entry.chatId !== fromUserId) return null
pendingPermissions.delete(key)
return entry
}
export function clearPermissionStateForTests(): void {
pendingPermissions.clear()
activePermissionChat = null
}

180
packages/weixin/src/send.ts Normal file
View File

@@ -0,0 +1,180 @@
import { randomUUID } from 'node:crypto'
import type { CDNMedia, MessageItem } from './types.js'
import { sendMessage } from './api.js'
import { guessMediaType, uploadFile } from './media.js'
import { MessageItemType, MessageState, MessageType } from './types.js'
function stripCodeBlocks(text: string): string {
// Non-regex approach to avoid ReDoS on inputs with many ``` sequences.
let result = ''
let i = 0
while (i < text.length) {
if (text.startsWith('```', i)) {
// Skip the opening fence (including optional language tag on same line)
let j = i + 3
// skip to end of first line (the fence line itself)
while (j < text.length && text[j] !== '\n') j++
if (j < text.length) j++ // skip the \n
// Collect content until closing ```
const contentStart = j
while (j < text.length) {
if (text.startsWith('```', j)) {
result += text.slice(contentStart, j)
// skip closing fence and its trailing newline
j += 3
while (j < text.length && text[j] !== '\n') j++
if (j < text.length) j++ // skip \n
break
}
j++
}
// If no closing fence found, include rest as-is
if (j >= text.length && !text.startsWith('```', j - 3)) {
result += text.slice(i)
}
i = j
} else {
result += text[i]
i++
}
}
return result
}
export function markdownToPlainText(text: string): string {
return stripCodeBlocks(text)
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/___(.+?)___/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/_(.+?)_/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]')
.replace(/^>\s+/gm, '')
.replace(/^[-*_]{3,}$/gm, '---')
.replace(/^[\s]*[-*+]\s+/gm, '- ')
.replace(/^[\s]*(\d+)\.\s+/gm, '$1. ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export async function sendText(params: {
to: string
text: string
baseUrl: string
token: string
contextToken: string
}): Promise<{ messageId: string }> {
const clientId = randomUUID()
await sendMessage(params.baseUrl, params.token, {
to_user_id: params.to,
from_user_id: '',
client_id: clientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
context_token: params.contextToken,
item_list: [
{
type: MessageItemType.TEXT,
text_item: { text: markdownToPlainText(params.text) },
},
],
})
return { messageId: clientId }
}
async function sendItems(params: {
items: MessageItem[]
to: string
baseUrl: string
token: string
contextToken: string
}): Promise<string> {
let lastClientId = ''
for (const item of params.items) {
lastClientId = randomUUID()
await sendMessage(params.baseUrl, params.token, {
to_user_id: params.to,
from_user_id: '',
client_id: lastClientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
context_token: params.contextToken,
item_list: [item],
})
}
return lastClientId
}
export async function sendMediaFile(params: {
filePath: string
to: string
text: string
baseUrl: string
token: string
contextToken: string
cdnBaseUrl: string
}): Promise<{ messageId: string }> {
const mediaType = guessMediaType(params.filePath)
const uploaded = await uploadFile({
filePath: params.filePath,
toUserId: params.to,
mediaType,
apiBaseUrl: params.baseUrl,
token: params.token,
cdnBaseUrl: params.cdnBaseUrl,
})
const cdnMedia: CDNMedia = {
encrypt_query_param: uploaded.encryptQueryParam,
aes_key: uploaded.aesKey,
encrypt_type: 1,
}
const items: MessageItem[] = []
if (params.text) {
items.push({
type: MessageItemType.TEXT,
text_item: { text: markdownToPlainText(params.text) },
})
}
switch (mediaType) {
case 1:
items.push({
type: MessageItemType.IMAGE,
image_item: { media: cdnMedia, mid_size: uploaded.fileSize },
})
break
case 2:
items.push({
type: MessageItemType.VIDEO,
video_item: { media: cdnMedia, video_size: uploaded.fileSize },
})
break
default:
items.push({
type: MessageItemType.FILE,
file_item: {
media: cdnMedia,
file_name: uploaded.fileName,
len: String(uploaded.rawSize),
},
})
break
}
const messageId = await sendItems({
items,
to: params.to,
baseUrl: params.baseUrl,
token: params.token,
contextToken: params.contextToken,
})
return { messageId }
}

View File

@@ -0,0 +1,353 @@
import { existsSync } from 'node:fs'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import {
CDN_BASE_URL,
DEFAULT_BASE_URL,
loadAccount,
getConfig,
sendTyping,
getContextToken,
startPollLoop,
getActivePermissionChat,
savePendingPermission,
sendMediaFile,
sendText,
TypingStatus,
} from './index.js'
import type { ParsedMessage } from './monitor.js'
import type { ChannelPermissionRequestParams } from './permissions.js'
export interface WeixinServerDeps {
enableConfigs(): void
initializeAnalyticsSink(): void
shutdownDatadog(): Promise<void>
shutdown1PEventLogging(): Promise<void>
logForDebugging(message: string): void
registerPermissionHandler(
server: Server,
handler: (request: ChannelPermissionRequestParams) => Promise<void>,
): void
}
function formatPermissionRequestMessage(
request: ChannelPermissionRequestParams,
): string {
return [
'Claude Code needs your approval.',
'',
`Tool: ${request.tool_name}`,
`Reason: ${request.description}`,
`Input: ${request.input_preview}`,
'',
`Reply with: yes ${request.request_id}`,
`Or deny with: no ${request.request_id}`,
].join('\n')
}
export function createWeixinMcpServer(version: string): Server {
const server = new Server(
{ name: 'weixin', version },
{
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {},
},
tools: {},
},
instructions:
'Messages from WeChat arrive as <channel source="plugin:weixin:weixin" chat_id="..." sender_id="...">. Reply using the reply tool with the chat_id from the channel tag. Use absolute paths for file attachments.',
},
)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'reply',
description:
'Reply to a WeChat message. Pass the chat_id from the channel tag.',
inputSchema: {
type: 'object' as const,
properties: {
chat_id: {
type: 'string',
description: 'The chat_id from the channel notification',
},
text: { type: 'string', description: 'The reply text' },
files: {
type: 'array',
items: { type: 'string' },
description: 'Optional absolute file paths to attach',
},
},
required: ['chat_id', 'text'],
},
},
{
name: 'send_typing',
description: 'Send a typing indicator to a WeChat user.',
inputSchema: {
type: 'object' as const,
properties: {
chat_id: { type: 'string', description: 'The chat_id (user ID)' },
},
required: ['chat_id'],
},
},
],
}))
server.setRequestHandler(CallToolRequestSchema, async request => {
const { name, arguments: args } = request.params
const account = loadAccount()
if (!account) {
return {
content: [
{
type: 'text',
text: 'WeChat not connected. Run `ccb weixin login` first.',
},
],
isError: true,
}
}
const baseUrl = account.baseUrl || DEFAULT_BASE_URL
const cdnBaseUrl = CDN_BASE_URL
switch (name) {
case 'reply': {
const chatId = typeof args?.chat_id === 'string' ? args.chat_id : ''
const text = typeof args?.text === 'string' ? args.text : ''
const files = Array.isArray(args?.files)
? args.files.filter((value): value is string => typeof value === 'string')
: undefined
if (!chatId || !text) {
return {
content: [
{ type: 'text', text: 'Missing chat_id or text parameter.' },
],
isError: true,
}
}
const contextToken = getContextToken(chatId) || ''
try {
if (files && files.length > 0) {
for (const [index, filePath] of files.entries()) {
if (!existsSync(filePath)) {
return {
content: [
{ type: 'text', text: `File not found: ${filePath}` },
],
isError: true,
}
}
await sendMediaFile({
filePath,
to: chatId,
text: index === 0 ? text : '',
baseUrl,
token: account.token,
contextToken,
cdnBaseUrl,
})
}
return {
content: [{ type: 'text', text: 'Message sent with attachments.' }],
}
}
await sendText({
to: chatId,
text,
baseUrl,
token: account.token,
contextToken,
})
return { content: [{ type: 'text', text: 'Message sent.' }] }
} catch (error) {
return {
content: [{ type: 'text', text: `Failed to send: ${error}` }],
isError: true,
}
}
}
case 'send_typing': {
const chatId = typeof args?.chat_id === 'string' ? args.chat_id : ''
if (!chatId) {
return {
content: [{ type: 'text', text: 'Missing chat_id parameter.' }],
isError: true,
}
}
try {
const contextToken = getContextToken(chatId)
const config = await getConfig(
baseUrl,
account.token,
chatId,
contextToken,
)
if (config.typing_ticket) {
await sendTyping(baseUrl, account.token, {
ilink_user_id: chatId,
typing_ticket: config.typing_ticket,
status: TypingStatus.TYPING,
})
}
return {
content: [{ type: 'text', text: 'Typing indicator sent.' }],
}
} catch (error) {
return {
content: [{ type: 'text', text: `Failed to send typing: ${error}` }],
isError: true,
}
}
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
}
}
})
return server
}
export async function runWeixinMcpServer(
version: string,
deps: WeixinServerDeps,
): Promise<void> {
deps.enableConfigs()
deps.initializeAnalyticsSink()
const account = loadAccount()
if (!account) {
process.stderr.write(
'[weixin] No account configured. Run `ccb weixin login` to connect your WeChat account.\n',
)
await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()])
process.exit(1)
}
const server = createWeixinMcpServer(version)
const transport = new StdioServerTransport()
deps.registerPermissionHandler(server, async request => {
const targetChatId = request.channel_context?.chat_id
const targetChat = targetChatId
? {
chatId: targetChatId,
contextToken: getContextToken(targetChatId),
}
: getActivePermissionChat()
if (!targetChat) {
deps.logForDebugging(
`[Weixin MCP] No active chat available for permission request ${request.request_id}`,
)
return
}
try {
savePendingPermission(
request,
targetChat.chatId,
targetChat.contextToken,
)
await sendText({
to: targetChat.chatId,
text: formatPermissionRequestMessage(request),
baseUrl,
token: account.token,
contextToken: targetChat.contextToken || '',
})
} catch (error) {
process.stderr.write(
`[weixin] Failed to relay permission request ${request.request_id}: ${error}\n`,
)
}
})
await server.connect(transport)
const baseUrl = account.baseUrl || DEFAULT_BASE_URL
const controller = new AbortController()
let exiting = false
const shutdownAndExit = async (): Promise<void> => {
if (exiting) return
exiting = true
if (!controller.signal.aborted) {
controller.abort()
}
await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()])
process.exit(0)
}
process.stdin.on('end', () => void shutdownAndExit())
process.stdin.on('error', () => void shutdownAndExit())
process.on('SIGINT', () => void shutdownAndExit())
process.on('SIGTERM', () => void shutdownAndExit())
process.on('SIGHUP', () => void shutdownAndExit())
const ppid = process.ppid
const parentCheck = setInterval(() => {
try {
process.kill(ppid, 0)
} catch {
process.stderr.write('[weixin] Parent process exited, shutting down...\n')
clearInterval(parentCheck)
void shutdownAndExit()
}
}, 5000)
deps.logForDebugging('[Weixin MCP] Starting poll loop')
await startPollLoop({
baseUrl,
cdnBaseUrl: CDN_BASE_URL,
token: account.token,
onMessage: async (msg: ParsedMessage) => {
await server.notification({
method: 'notifications/claude/channel',
params: {
content: msg.text,
meta: {
chat_id: msg.fromUserId,
sender_id: msg.fromUserId,
message_id: msg.messageId,
...(msg.attachmentPath && { attachment_path: msg.attachmentPath }),
...(msg.attachmentType && { attachment_type: msg.attachmentType }),
},
},
})
},
onPermissionResponse: async response => {
await server.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: response.requestId,
behavior: response.behavior,
},
})
},
abortSignal: controller.signal,
})
clearInterval(parentCheck)
await shutdownAndExit()
}

View File

@@ -0,0 +1,178 @@
export const MessageType = {
NONE: 0,
USER: 1,
BOT: 2,
} as const
export const MessageItemType = {
NONE: 0,
TEXT: 1,
IMAGE: 2,
VOICE: 3,
FILE: 4,
VIDEO: 5,
} as const
export const MessageState = {
NEW: 0,
GENERATING: 1,
FINISH: 2,
} as const
export const UploadMediaType = {
IMAGE: 1,
VIDEO: 2,
FILE: 3,
VOICE: 4,
} as const
export const TypingStatus = {
TYPING: 1,
CANCEL: 2,
} as const
export interface BaseInfo {
channel_version?: string
}
export interface CDNMedia {
encrypt_query_param?: string
aes_key?: string
encrypt_type?: number
}
export interface TextItem {
text?: string
}
export interface ImageItem {
media?: CDNMedia
thumb_media?: CDNMedia
aeskey?: string
url?: string
mid_size?: number
thumb_size?: number
thumb_height?: number
thumb_width?: number
hd_size?: number
}
export interface VoiceItem {
media?: CDNMedia
encode_type?: number
bits_per_sample?: number
sample_rate?: number
playtime?: number
text?: string
}
export interface FileItem {
media?: CDNMedia
file_name?: string
md5?: string
len?: string
}
export interface VideoItem {
media?: CDNMedia
video_size?: number
play_length?: number
video_md5?: string
thumb_media?: CDNMedia
thumb_size?: number
thumb_height?: number
thumb_width?: number
}
export interface RefMessage {
message_item?: MessageItem
title?: string
}
export interface MessageItem {
type?: number
create_time_ms?: number
update_time_ms?: number
is_completed?: boolean
msg_id?: string
ref_msg?: RefMessage
text_item?: TextItem
image_item?: ImageItem
voice_item?: VoiceItem
file_item?: FileItem
video_item?: VideoItem
}
export interface WeixinMessage {
seq?: number
message_id?: number
from_user_id?: string
to_user_id?: string
client_id?: string
create_time_ms?: number
update_time_ms?: number
delete_time_ms?: number
session_id?: string
group_id?: string
message_type?: number
message_state?: number
item_list?: MessageItem[]
context_token?: string
}
export interface GetUpdatesReq {
get_updates_buf?: string
base_info?: BaseInfo
}
export interface GetUpdatesResp {
ret?: number
errcode?: number
errmsg?: string
msgs?: WeixinMessage[]
get_updates_buf?: string
longpolling_timeout_ms?: number
}
export interface SendMessageReq {
msg?: WeixinMessage
base_info?: BaseInfo
}
export interface GetUploadUrlReq {
filekey?: string
media_type?: number
to_user_id?: string
rawsize?: number
rawfilemd5?: string
filesize?: number
thumb_rawsize?: number
thumb_rawfilemd5?: string
thumb_filesize?: number
no_need_thumb?: boolean
aeskey?: string
base_info?: BaseInfo
}
export interface GetUploadUrlResp {
upload_param?: string
thumb_upload_param?: string
}
export interface GetConfigResp {
ret?: number
errmsg?: string
typing_ticket?: string
}
export interface SendTypingReq {
ilink_user_id?: string
typing_ticket?: string
status?: number
base_info?: BaseInfo
}
export interface SendTypingResp {
ret?: number
errmsg?: string
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -16,3 +16,52 @@ export function getMacroDefines(): Record<string, string> {
"MACRO.VERSION_CHANGELOG": JSON.stringify(""),
};
}
/**
* Default feature flags enabled in both Bun.build and Vite builds.
* Additional features can be enabled via FEATURE_<NAME>=1 env vars.
*
* Used by:
* - build.ts (Bun.build)
* - scripts/vite-plugin-feature-flags.ts (Vite/Rollup)
* - scripts/dev.ts (bun run dev)
*/
export const DEFAULT_BUILD_FEATURES = [
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
'AGENT_TRIGGERS_REMOTE',
'CHICAGO_MCP',
'VOICE_MODE',
'SHOT_STATS',
'PROMPT_CACHE_BREAK_DETECTION',
'TOKEN_BUDGET',
// P0: local features
'AGENT_TRIGGERS',
'ULTRATHINK',
'BUILTIN_EXPLORE_PLAN_AGENTS',
'LODESTONE',
// P1: API-dependent features
'EXTRACT_MEMORIES',
'VERIFICATION_AGENT',
'KAIROS_BRIEF',
'AWAY_SUMMARY',
'ULTRAPLAN',
// P2: daemon + remote control server
'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features
'WORKFLOW_SCRIPTS',
'HISTORY_SNIP',
'CONTEXT_COLLAPSE',
'MONITOR_TOOL',
'FORK_SUBAGENT',
// 'UDS_INBOX',
'KAIROS',
'COORDINATOR_MODE',
'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR',
] as const;

View File

@@ -6,7 +6,7 @@
*/
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { getMacroDefines } from "./defines.ts";
import { getMacroDefines, DEFAULT_BUILD_FEATURES } from "./defines.ts";
// Resolve project root from this script's location
const __filename = fileURLToPath(import.meta.url);
@@ -22,39 +22,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
]);
// Bun --feature flags: enable feature() gates at runtime.
// Default features enabled in dev mode.
const DEFAULT_FEATURES = [
"BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE",
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET",
// P0: local features
"AGENT_TRIGGERS",
"ULTRATHINK",
"BUILTIN_EXPLORE_PLAN_AGENTS",
"LODESTONE",
// P1: API-dependent features
"EXTRACT_MEMORIES", "VERIFICATION_AGENT",
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
// P2: daemon + remote control server
"DAEMON",
// ACP (Agent Client Protocol) agent mode
"ACP",
// PR-package restored features
"WORKFLOW_SCRIPTS",
"HISTORY_SNIP",
"CONTEXT_COLLAPSE",
"MONITOR_TOOL",
"FORK_SUBAGENT",
"UDS_INBOX",
"KAIROS",
"COORDINATOR_MODE",
"LAN_PIPES",
"BG_SESSIONS",
"TEMPLATES",
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion)
"POOR",
];
// Uses the shared DEFAULT_BUILD_FEATURES list from defines.ts.
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
// e.g. FEATURE_PROACTIVE=1 bun run dev
@@ -62,7 +30,7 @@ const envFeatures = Object.entries(process.env)
.filter(([k]) => k.startsWith("FEATURE_"))
.map(([k]) => k.replace("FEATURE_", ""));
const allFeatures = [...new Set([...DEFAULT_FEATURES, ...envFeatures])];
const allFeatures = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])];
const featureArgs = allFeatures.flatMap((name) => ["--feature", name]);
// If BUN_INSPECT is set, pass --inspect-wait to the child process

View File

@@ -1,41 +1,5 @@
import type { Plugin } from "rollup";
/**
* Default features that match the official CLI build.
* Additional features can be enabled via FEATURE_<NAME>=1 env vars.
*/
const DEFAULT_BUILD_FEATURES = [
"AGENT_TRIGGERS_REMOTE",
"CHICAGO_MCP",
"VOICE_MODE",
"SHOT_STATS",
"PROMPT_CACHE_BREAK_DETECTION",
"TOKEN_BUDGET",
// P0: local features
"AGENT_TRIGGERS",
"ULTRATHINK",
"BUILTIN_EXPLORE_PLAN_AGENTS",
"LODESTONE",
// P1: API-dependent features
"EXTRACT_MEMORIES",
"VERIFICATION_AGENT",
"KAIROS_BRIEF",
"AWAY_SUMMARY",
"ULTRAPLAN",
// P2: daemon + remote control server
"DAEMON",
// PR-package restored features
"WORKFLOW_SCRIPTS",
"HISTORY_SNIP",
"CONTEXT_COLLAPSE",
"MONITOR_TOOL",
"FORK_SUBAGENT",
"KAIROS",
"COORDINATOR_MODE",
"LAN_PIPES",
// P3: poor mode
"POOR",
];
import { DEFAULT_BUILD_FEATURES } from "./defines.ts";
/**
* Collect enabled feature flags from defaults + env vars.

View File

@@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext =
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: false,
isBypassPermissionsModeAvailable: true,
})
export type CompactProgressEvent =

View File

@@ -166,9 +166,9 @@ describe('getEmptyToolPermissionContext', () => {
expect(ctx.alwaysAskRules).toEqual({})
})
test('returns isBypassPermissionsModeAvailable as false', () => {
test('returns isBypassPermissionsModeAvailable as true', () => {
const ctx = getEmptyToolPermissionContext()
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
})

View File

@@ -18,9 +18,7 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { stripSignatureBlocks } from '../../utils/messages.js'
import {
checkAndDisableAutoModeIfNeeded,
checkAndDisableBypassPermissionsIfNeeded,
resetAutoModeGateCheck,
resetBypassPermissionsCheck,
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
import { resetUserCache } from '../../utils/user.js'
@@ -54,20 +52,13 @@ export async function call(
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
void enrollTrustedDevice()
// Reset killswitch gate checks and re-run with new org
resetBypassPermissionsCheck()
resetAutoModeGateCheck()
const appState = context.getAppState()
void checkAndDisableBypassPermissionsIfNeeded(
void checkAndDisableAutoModeIfNeeded(
appState.toolPermissionContext,
context.setAppState,
appState.fastMode,
)
if (feature('TRANSCRIPT_CLASSIFIER')) {
resetAutoModeGateCheck()
void checkAndDisableAutoModeIfNeeded(
appState.toolPermissionContext,
context.setAppState,
appState.fastMode,
)
}
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
context.setAppState(prev => ({
...prev,

View File

@@ -11,6 +11,7 @@ import {
getAllowedChannels,
getHasDevChannels,
} from '../../bootstrap/state.js'
import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js'
import { Box, Text } from '@anthropic/ink'
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
@@ -75,25 +76,39 @@ function formatEntry(c: ChannelEntry): string {
type Unmatched = { entry: ChannelEntry; why: string }
function findUnmatched(
type FindUnmatchedDeps = {
configuredServerNames?: ReadonlySet<string>
installedPluginIds?: ReadonlySet<string>
}
export function findUnmatched(
entries: readonly ChannelEntry[],
deps?: FindUnmatchedDeps,
): Unmatched[] {
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
// is not cached (project scope walks the dir tree); getMcpConfigByName would
// redo that walk per entry.
const scopes = ['enterprise', 'user', 'project', 'local'] as const
const configured = new Set<string>()
for (const scope of scopes) {
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
configured.add(name)
const configured = deps?.configuredServerNames ?? (() => {
const scopes = ['enterprise', 'user', 'project', 'local'] as const
const names = new Set<string>()
for (const scope of scopes) {
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
names.add(name)
}
}
}
return names
})()
// Plugin-kind installed check: installed_plugins.json keys are
// `name@marketplace`. loadInstalledPluginsV2 is cached.
const installedPluginIds = new Set(
Object.keys(loadInstalledPluginsV2().plugins),
)
const installedPluginIds = deps?.installedPluginIds ?? (() => {
const ids = new Set(Object.keys(loadInstalledPluginsV2().plugins))
const builtinPlugins = getBuiltinPlugins()
for (const plugin of [...builtinPlugins.enabled, ...builtinPlugins.disabled]) {
ids.add(plugin.source)
}
return ids
})()
const out: Unmatched[] = []
for (const entry of entries) {

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { findUnmatched } from '../ChannelsNotice.js'
describe('findUnmatched', () => {
test('does not flag builtin weixin as plugin not installed', () => {
expect(
findUnmatched(
[{ kind: 'plugin', name: 'weixin', marketplace: 'builtin' }],
{
configuredServerNames: new Set(),
installedPluginIds: new Set(['weixin@builtin']),
},
),
).toEqual([])
})
})

View File

@@ -151,16 +151,14 @@ import {
isOpus1mMergeEnabled,
modelDisplayString,
} from '../../utils/model/model.js'
import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'
import {
cyclePermissionMode,
getNextPermissionMode,
} from '../../utils/permissions/getNextPermissionMode.js'
import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'
import { getPlatform } from '../../utils/platform.js'
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
import { editPromptInEditor } from '../../utils/promptEditor.js'
import { hasAutoModeOptIn } from '../../utils/settings/settings.js'
// hasAutoModeOptIn removed — auto mode is available to all users
import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
import {
@@ -187,7 +185,7 @@ import {
findUltraplanTriggerPositions,
findUltrareviewTriggerPositions,
} from '../../utils/ultraplan/keyword.js'
import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js'
// AutoModeOptInDialog removed — auto mode is available to all users
import { BridgeDialog } from '../BridgeDialog.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import {
@@ -571,10 +569,6 @@ function PromptInput({
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
const [showFastModePicker, setShowFastModePicker] = useState(false)
const [showThinkingToggle, setShowThinkingToggle] = useState(false)
const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false)
const [previousModeBeforeAuto, setPreviousModeBeforeAuto] =
useState<PermissionMode | null>(null)
const autoModeOptInTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Check if cursor is on the first line of input
const isCursorOnFirstLine = useMemo(() => {
@@ -1883,86 +1877,11 @@ function PromptInput({
// Compute the next mode without triggering side effects first
logForDebugging(
`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`,
`[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode}`,
)
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
// Check if user is entering auto mode for the first time. Gated on the
// persistent settings flag (hasAutoModeOptIn) rather than the broader
// hasAutoModeOptInAnySource so that --enable-auto-mode users still see
// the warning dialog once — the CLI flag should grant carousel access,
// not bypass the safety text.
let isEnteringAutoModeFirstTime = false
if (feature('TRANSCRIPT_CLASSIFIER')) {
isEnteringAutoModeFirstTime =
nextMode === 'auto' &&
toolPermissionContext.mode !== 'auto' &&
!hasAutoModeOptIn() &&
!viewingAgentTaskId // Only show for primary agent, not subagents
}
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (isEnteringAutoModeFirstTime) {
// Store previous mode so we can revert if user declines
setPreviousModeBeforeAuto(toolPermissionContext.mode)
// Only update the UI mode label — do NOT call transitionPermissionMode
// or cyclePermissionMode yet; we haven't confirmed with the user.
setAppState(prev => ({
...prev,
toolPermissionContext: {
...prev.toolPermissionContext,
mode: 'auto',
},
}))
setToolPermissionContext({
...toolPermissionContext,
mode: 'auto',
})
// Show opt-in dialog after 400ms debounce
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
}
autoModeOptInTimeoutRef.current = setTimeout(
(setShowAutoModeOptIn, autoModeOptInTimeoutRef) => {
setShowAutoModeOptIn(true)
autoModeOptInTimeoutRef.current = null
},
400,
setShowAutoModeOptIn,
autoModeOptInTimeoutRef,
)
if (helpOpen) {
setHelpOpen(false)
}
return
}
}
// Dismiss auto mode opt-in dialog if showing or pending (user is cycling away).
// Do NOT revert to previousModeBeforeAuto here — shift+tab means "advance the
// carousel", not "decline". Reverting causes a ping-pong loop: auto reverts to
// the prior mode, whose next mode is auto again, forever.
// The dialog's own decline button (handleAutoModeOptInDecline) handles revert.
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) {
if (showAutoModeOptIn) {
logEvent('tengu_auto_mode_opt_in_dialog_decline', {})
}
setShowAutoModeOptIn(false)
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
autoModeOptInTimeoutRef.current = null
}
setPreviousModeBeforeAuto(null)
// Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'.
}
}
// Now that we know this is NOT the first-time auto mode path,
// call cyclePermissionMode to apply side effects (e.g. strip
// Call cyclePermissionMode to apply side effects (e.g. strip
// dangerous permissions, activate classifier)
const { context: preparedContext } = cyclePermissionMode(
toolPermissionContext,
@@ -2007,91 +1926,10 @@ function PromptInput({
}, [
toolPermissionContext,
teamContext,
viewingAgentTaskId,
viewedTeammate,
setAppState,
setToolPermissionContext,
helpOpen,
showAutoModeOptIn,
])
// Handler for auto mode opt-in dialog acceptance
const handleAutoModeOptInAccept = useCallback(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
setShowAutoModeOptIn(false)
setPreviousModeBeforeAuto(null)
// Now that the user accepted, apply the full transition: activate the
// auto mode backend (classifier, beta headers) and strip dangerous
// permissions (e.g. Bash(*) always-allow rules).
const strippedContext = transitionPermissionMode(
previousModeBeforeAuto ?? toolPermissionContext.mode,
'auto',
toolPermissionContext,
)
setAppState(prev => ({
...prev,
toolPermissionContext: {
...strippedContext,
mode: 'auto',
},
}))
setToolPermissionContext({
...strippedContext,
mode: 'auto',
})
// Close help tips if they're open when auto mode is enabled
if (helpOpen) {
setHelpOpen(false)
}
}
}, [
helpOpen,
setHelpOpen,
previousModeBeforeAuto,
toolPermissionContext,
setAppState,
setToolPermissionContext,
])
// Handler for auto mode opt-in dialog decline
const handleAutoModeOptInDecline = useCallback(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
logForDebugging(
`[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`,
)
setShowAutoModeOptIn(false)
if (autoModeOptInTimeoutRef.current) {
clearTimeout(autoModeOptInTimeoutRef.current)
autoModeOptInTimeoutRef.current = null
}
// Revert to previous mode and remove auto from the carousel
// for the rest of this session
if (previousModeBeforeAuto) {
setAutoModeActive(false)
setAppState(prev => ({
...prev,
toolPermissionContext: {
...prev.toolPermissionContext,
mode: previousModeBeforeAuto,
isAutoModeAvailable: false,
},
}))
setToolPermissionContext({
...toolPermissionContext,
mode: previousModeBeforeAuto,
isAutoModeAvailable: false,
})
setPreviousModeBeforeAuto(null)
}
}
}, [
previousModeBeforeAuto,
toolPermissionContext,
setAppState,
setToolPermissionContext,
])
// Handler for chat:imagePaste - paste image from clipboard
@@ -2758,20 +2596,7 @@ function PromptInput({
// Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
// Must be called before early returns below to satisfy rules-of-hooks.
// Memoized so the portal useEffect doesn't churn on every PromptInput render.
const autoModeOptInDialog = useMemo(
() =>
feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? (
<AutoModeOptInDialog
onAccept={handleAutoModeOptInAccept}
onDecline={handleAutoModeOptInDecline}
/>
) : null,
[showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline],
)
useSetPromptOverlayDialog(
isFullscreenEnvEnabled() ? autoModeOptInDialog : null,
)
useSetPromptOverlayDialog(null)
if (showBashesDialog) {
return (
@@ -3077,7 +2902,6 @@ function PromptInput({
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
}
/>
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
{isFullscreenEnvEnabled() ? (
// position=absolute takes zero layout height so the spinner
// doesn't shift when a notification appears/disappears. Yoga
@@ -3098,7 +2922,7 @@ function PromptInput({
<Box
position="absolute"
marginTop={briefOwnsGap ? -2 : -1}
height={suggestions.length === 0 && !showAutoModeOptIn ? 1 : 0}
height={suggestions.length === 0 ? 1 : 0}
width="100%"
paddingLeft={2}
paddingRight={1}

View File

@@ -140,6 +140,31 @@ async function main(): Promise<void> {
return
}
if (args[0] === 'weixin') {
profileCheckpoint('cli_weixin_path')
const { handleWeixinCli } = await import('@claude-code-best/weixin')
const { enableConfigs } = await import('../utils/config.js')
const { initializeAnalyticsSink } = await import('../services/analytics/sink.js')
const { shutdownDatadog } = await import('../services/analytics/datadog.js')
const { shutdown1PEventLogging } = await import('../services/analytics/firstPartyEventLogger.js')
const { logForDebugging } = await import('../utils/debug.js')
const { ChannelPermissionRequestNotificationSchema } = await import('../services/mcp/channelNotification.js')
await handleWeixinCli(args.slice(1), {
enableConfigs,
initializeAnalyticsSink,
shutdownDatadog,
shutdown1PEventLogging,
logForDebugging,
registerPermissionHandler(server, handler) {
server.setNotificationHandler(
ChannelPermissionRequestNotificationSchema(),
async notification => handler(notification.params),
)
},
}, MACRO.VERSION)
return
}
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
// Must come before the daemon subcommand check: spawned per-worker, so
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test'
import { getLatestChannelContextHint } from '../interactiveHandler.js'
describe('getLatestChannelContextHint', () => {
test('extracts source server and chat id from latest channel user message', () => {
expect(
getLatestChannelContextHint([
{
type: 'user',
origin: { kind: 'channel', server: 'plugin:weixin:weixin' },
message: {
content: [
{
type: 'text',
text: '<channel source="plugin:weixin:weixin" chat_id="user-1" sender_id="user-1">\nhello\n</channel>',
},
],
},
},
]),
).toEqual({
sourceServer: 'plugin:weixin:weixin',
chatId: 'user-1',
})
})
test('returns null when there is no channel-origin user message', () => {
expect(
getLatestChannelContextHint([
{
type: 'user',
origin: { kind: 'manual' },
message: { content: [{ type: 'text', text: 'hello' }] },
},
]),
).toBeNull()
})
})

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import { randomUUID } from 'crypto'
import { CHANNEL_TAG } from 'src/constants/xml.js'
import { logForDebugging } from 'src/utils/debug.js'
import { getAllowedChannels } from '../../../bootstrap/state.js'
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
@@ -46,6 +47,76 @@ type InteractivePermissionParams = {
channelCallbacks?: ChannelPermissionCallbacks
}
type ChannelContextHint = {
sourceServer?: string
chatId?: string
}
function getTextBlocksText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
return content
.filter(
(block): block is { type: 'text'; text: string } =>
typeof block === 'object' &&
block !== null &&
(block as { type?: unknown }).type === 'text' &&
typeof (block as { text?: unknown }).text === 'string',
)
.map(block => block.text)
.join('\n')
}
function parseChannelContextHintFromText(text: string): ChannelContextHint | null {
const tagMatch = text.match(new RegExp(`<${CHANNEL_TAG}\\b([^>]*)>`))
if (!tagMatch?.[1]) {
return null
}
const attrs = tagMatch[1]
const sourceServer = attrs.match(/\bsource="([^"]+)"/)?.[1]
const chatId = attrs.match(/\bchat_id="([^"]+)"/)?.[1]
if (!sourceServer && !chatId) {
return null
}
return { sourceServer, chatId }
}
export function getLatestChannelContextHint(messages: readonly unknown[]): ChannelContextHint | null {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as {
type?: unknown
origin?: { kind?: unknown; server?: unknown }
message?: { content?: unknown }
}
if (message?.type !== 'user' || message?.origin?.kind !== 'channel') {
continue
}
const text = getTextBlocksText(message.message?.content)
const parsed = parseChannelContextHintFromText(text)
if (parsed) {
return {
sourceServer:
parsed.sourceServer ||
(typeof message.origin.server === 'string'
? message.origin.server
: undefined),
chatId: parsed.chatId,
}
}
}
return null
}
/**
* Handles the interactive (main-agent) permission flow.
*
@@ -420,6 +491,17 @@ function handleInteractivePermission(
description,
input_preview: truncateForPreview(displayInput),
}
const channelContext = getLatestChannelContextHint(
ctx.toolUseContext.messages,
)
if (channelContext?.sourceServer || channelContext?.chatId) {
params.channel_context = {
...(channelContext.sourceServer && {
source_server: channelContext.sourceServer,
}),
...(channelContext.chatId && { chat_id: channelContext.chatId }),
}
}
for (const client of channelClients) {
if (client.type !== 'connected') continue // refine for TS

View File

@@ -52,7 +52,6 @@ import type { PermissionMode } from './utils/permissions/PermissionMode.js'
import { getBaseRenderOptions } from './utils/renderOptions.js'
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
import {
hasAutoModeOptIn,
hasSkipDangerousModePermissionPrompt,
} from './utils/settings/settings.js'
@@ -309,25 +308,6 @@ export async function showSetupScreens(
))
}
if (feature('TRANSCRIPT_CLASSIFIER')) {
// Only show the opt-in dialog if auto mode actually resolved — if the
// gate denied it (org not allowlisted, settings disabled), showing
// consent for an unavailable feature is pointless. The
// verifyAutoModeGateAccess notification will explain why instead.
if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
const { AutoModeOptInDialog } = await import(
'./components/AutoModeOptInDialog.js'
)
await showSetupDialog(root, done => (
<AutoModeOptInDialog
onAccept={done}
onDecline={() => gracefulShutdownSync(1)}
declineExits
/>
))
}
}
// --dangerously-load-development-channels confirmation. On accept, append
// dev channels to any --channels list already set in main.tsx. Org policy
// is NOT bypassed — gateChannelServer() still runs; this flag only exists

View File

@@ -242,7 +242,6 @@ import {
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
import {
checkAndDisableBypassPermissions,
getAutoModeEnabledStateIfCached,
initializeToolPermissionContext,
initialPermissionModeFromCLI,
@@ -3910,19 +3909,7 @@ async function run(): Promise<CommanderCommand> {
onChangeAppState,
);
// Check if bypassPermissions should be disabled based on Statsig gate
// This runs in parallel to the code below, to avoid blocking the main loop.
if (
toolPermissionContext.mode === "bypassPermissions" ||
allowDangerouslySkipPermissions
) {
void checkAndDisableBypassPermissions(
toolPermissionContext,
);
}
// Async check of auto mode gate — corrects state and disables auto if needed.
// Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too.
if (feature("TRANSCRIPT_CLASSIFIER")) {
void verifyAutoModeGateAccess(
toolPermissionContext,

View File

@@ -3,6 +3,7 @@ import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { getDefaultSonnetModel } from '../utils/model/model.js'
import { sideQuery } from '../utils/sideQuery.js'
import type { LangfuseSpan } from '../services/langfuse/index.js'
import { jsonParse } from '../utils/slowOperations.js'
import {
formatMemoryManifest,
@@ -42,6 +43,7 @@ export async function findRelevantMemories(
signal: AbortSignal,
recentTools: readonly string[] = [],
alreadySurfaced: ReadonlySet<string> = new Set(),
parentSpan?: LangfuseSpan | null,
): Promise<RelevantMemory[]> {
const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
m => !alreadySurfaced.has(m.filePath),
@@ -55,6 +57,7 @@ export async function findRelevantMemories(
memories,
signal,
recentTools,
parentSpan,
)
const byFilename = new Map(memories.map(m => [m.filename, m]))
const selected = selectedFilenames
@@ -79,6 +82,7 @@ async function selectRelevantMemories(
memories: MemoryHeader[],
signal: AbortSignal,
recentTools: readonly string[],
parentSpan?: LangfuseSpan | null,
): Promise<string[]> {
const validFilenames = new Set(memories.map(m => m.filename))
@@ -119,6 +123,8 @@ async function selectRelevantMemories(
},
signal,
querySource: 'memdir_relevance',
optional: true,
parentSpan,
})
const textBlock = result.content.find(block => block.type === 'text')

View File

@@ -14,10 +14,11 @@
* 2. Call registerBuiltinPlugin() with the plugin definition here
*/
import { registerWeixinBuiltinPlugin } from './weixin.js'
/**
* Initialize built-in plugins. Called during CLI startup.
*/
export function initBuiltinPlugins(): void {
// No built-in plugins registered yet — this is the scaffolding for
// migrating bundled skills that should be user-toggleable.
registerWeixinBuiltinPlugin()
}

View File

@@ -0,0 +1,21 @@
import { registerBuiltinPlugin } from '../builtinPlugins.js'
import { buildCliLaunch } from '../../utils/cliLaunch.js'
export function registerWeixinBuiltinPlugin(): void {
const launch = buildCliLaunch(['weixin', 'serve'])
registerBuiltinPlugin({
name: 'weixin',
description:
'WeChat channel integration. Enables inbound WeChat messages via channels and provides reply/send_typing MCP tools. Configure with `ccb weixin login` and enable for a session with `--channels plugin:weixin@builtin`.',
version: MACRO.VERSION,
defaultEnabled: true,
mcpServers: {
weixin: {
type: 'stdio',
command: launch.execPath,
args: launch.args,
},
},
})
}

View File

@@ -422,9 +422,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
import type { Theme } from 'src/utils/theme.js';
import {
checkAndDisableBypassPermissionsIfNeeded,
checkAndDisableAutoModeIfNeeded,
useKickOffCheckAndDisableBypassPermissionsIfNeeded,
useKickOffCheckAndDisableAutoModeIfNeeded,
} from 'src/utils/permissions/bypassPermissionsKillswitch.js';
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js';
@@ -434,7 +432,6 @@ import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPerm
import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js';
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js';
import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js';
import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js';
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
@@ -948,7 +945,6 @@ export function REPL({
[toolPermissionContext, proactiveActive, isBriefOnly],
);
useKickOffCheckAndDisableBypassPermissionsIfNeeded();
useKickOffCheckAndDisableAutoModeIfNeeded();
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
@@ -1006,7 +1002,6 @@ export function REPL({
useCanSwitchToExistingSubscription();
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
useMcpConnectivityStatus({ mcpClients });
useAutoModeUnavailableNotification();
usePluginInstallationStatus();
usePluginAutoupdateNotification();
useSettingsErrors();
@@ -3314,8 +3309,8 @@ export function REPL({
queryCheckpoint('query_context_loading_start');
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
// IMPORTANT: do this after setMessages() above, to avoid UI jank
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
undefined,
// Fast-mode circuit breaker check
feature('TRANSCRIPT_CLASSIFIER')
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
: undefined,

View File

@@ -42,7 +42,7 @@ const mockGetDefaultAppState = mock(() => ({
alwaysAllowRules: { user: [], project: [], local: [] },
alwaysDenyRules: { user: [], project: [], local: [] },
alwaysAskRules: { user: [], project: [], local: [] },
isBypassPermissionsModeAvailable: false,
isBypassPermissionsModeAvailable: true,
},
fastMode: false,
settings: {},
@@ -627,6 +627,23 @@ describe('AcpAgent', () => {
agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any),
).rejects.toThrow('Session not found')
})
test('availableModes includes bypassPermissions when not root', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
expect(modeIds).toContain('bypassPermissions')
})
test('can switch to bypassPermissions mode', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any)
const session = agent.sessions.get(sessionId)
expect(session?.modes.currentModeId).toBe('bypassPermissions')
expect(session?.appState.toolPermissionContext.mode).toBe('bypassPermissions')
})
})
describe('setSessionConfigOption', () => {

View File

@@ -519,12 +519,15 @@ export class AcpAgent implements Agent {
const queryEngine = new QueryEngine(engineConfig)
// Build modes
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
const availableModes = [
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
...(isBypassAvailable
? [{ id: 'bypassPermissions' as const, name: 'Bypass Permissions', description: 'Skip all permission checks' }]
: []),
{ id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" },
]

View File

@@ -1,4 +1,4 @@
export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js'
export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js'
export { createTrace, createSubagentTrace, createChildSpan, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js'
export type { LangfuseSpan } from './tracing.js'
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'

View File

@@ -282,6 +282,60 @@ export function createSubagentTrace(params: {
}
}
/**
* Create a child span under a parent trace — used for side queries
* that should be nested under the main agent trace in Langfuse.
*/
export function createChildSpan(
parentSpan: LangfuseSpan | null,
params: {
name: string
sessionId: string
model: string
provider: string
input?: unknown
querySource?: string
username?: string
},
): LangfuseSpan | null {
if (!parentSpan || !isLangfuseEnabled()) return null
try {
const span = startObservation(
params.name,
{
input: params.input,
metadata: {
provider: params.provider,
model: params.model,
querySource: params.querySource,
},
},
{
asType: 'span',
parentSpanContext: parentSpan.otelSpan.spanContext(),
},
) as LangfuseSpan
// Propagate session ID and user ID from parent
const parent = parentSpan as unknown as RootTrace
const sessionId = parent._sessionId ?? params.sessionId
if (sessionId) {
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
;(span as unknown as RootTrace)._sessionId = sessionId
}
const userId = parent._userId ?? resolveLangfuseUserId(params.username)
if (userId) {
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
;(span as unknown as RootTrace)._userId = userId
}
logForDebugging(`[langfuse] Child span created: ${span.id} (parent=${parentSpan.id})`)
return span
} catch (e) {
logForDebugging(`[langfuse] createChildSpan failed: ${e}`, { level: 'error' })
return null
}
}
export function endTrace(
rootSpan: LangfuseSpan | null,
output?: unknown,

View File

@@ -0,0 +1,17 @@
import { describe, expect, mock, test } from 'bun:test'
mock.module('../../analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => [],
}))
import { isChannelAllowlisted } from '../channelAllowlist.js'
describe('isChannelAllowlisted', () => {
test('allows builtin weixin plugin', () => {
expect(isChannelAllowlisted('weixin@builtin')).toBe(true)
})
test('rejects undefined plugin source', () => {
expect(isChannelAllowlisted(undefined)).toBe(false)
})
})

View File

@@ -5,6 +5,7 @@ mock.module("src/services/analytics/growthbook.js", () => ({
}));
const {
filterPermissionRelayClients,
shortRequestId,
truncateForPreview,
PERMISSION_REPLY_RE,
@@ -160,3 +161,34 @@ describe("createChannelPermissionCallbacks", () => {
expect(received?.behavior).toBe("deny");
});
});
describe("filterPermissionRelayClients", () => {
test("requires truthy permission capability", () => {
const clients = [
{
type: "connected",
name: "plugin:weixin:weixin",
capabilities: {
experimental: {
"claude/channel": {},
"claude/channel/permission": false,
},
},
},
{
type: "connected",
name: "plugin:telegram:telegram",
capabilities: {
experimental: {
"claude/channel": {},
"claude/channel/permission": {},
},
},
},
];
expect(
filterPermissionRelayClients(clients, () => true).map(client => client.name),
).toEqual(["plugin:telegram:telegram"]);
});
});

View File

@@ -16,6 +16,7 @@
*/
import { z } from 'zod/v4'
import { BUILTIN_MARKETPLACE_NAME } from '../../plugins/builtinPlugins.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
@@ -68,6 +69,9 @@ export function isChannelAllowlisted(
if (!pluginSource) return false
const { name, marketplace } = parsePluginIdentifier(pluginSource)
if (!marketplace) return false
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
return true
}
return getChannelAllowlist().some(
e => e.plugin === name && e.marketplace === marketplace,
)

View File

@@ -91,8 +91,33 @@ export type ChannelPermissionRequestParams = {
* input is in the local terminal dialog; this is a phone-sized
* preview. Server decides whether/how to show it. */
input_preview: string
/** Optional source-channel routing hint for servers that support
* multi-chat routing. Backwards compatible: servers that don't care can
* ignore it and keep their existing fallback behavior. */
channel_context?: {
source_server?: string
chat_id?: string
}
}
export const ChannelPermissionRequestNotificationSchema = lazySchema(() =>
z.object({
method: z.literal(CHANNEL_PERMISSION_REQUEST_METHOD),
params: z.object({
request_id: z.string(),
tool_name: z.string(),
description: z.string(),
input_preview: z.string(),
channel_context: z
.object({
source_server: z.string().optional(),
chat_id: z.string().optional(),
})
.optional(),
}),
}),
)
/**
* Meta keys become XML attribute NAMES — a crafted key like
* `x="" injected="y` would break out of the attribute structure. Only

View File

@@ -34,7 +34,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
* don't apply until restart.
*/
export function isChannelPermissionRelayEnabled(): boolean {
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false)
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', true)
}
export type ChannelPermissionResponse = {
@@ -188,8 +188,8 @@ export function filterPermissionRelayClients<
(c): c is T & { type: 'connected' } =>
c.type === 'connected' &&
isInAllowlist(c.name) &&
c.capabilities?.experimental?.['claude/channel'] !== undefined &&
c.capabilities?.experimental?.['claude/channel/permission'] !== undefined,
Boolean(c.capabilities?.experimental?.['claude/channel']) &&
Boolean(c.capabilities?.experimental?.['claude/channel/permission']),
)
}

View File

@@ -538,7 +538,7 @@ export function useManageMCPConnections(
if (
client.capabilities?.experimental?.[
'claude/channel/permission'
] !== undefined
]
) {
client.client.setNotificationHandler(
ChannelPermissionNotificationSchema(),

View File

@@ -109,7 +109,6 @@ const externalTips: Tip[] = [
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
cooldownSessions: 5,
isRelevant: async () => {
if (process.env.USER_TYPE === 'ant') return false
const config = getGlobalConfig()
// Show to users who haven't used plan mode recently (7+ days)
const daysSinceLastUse = config.lastPlanModeUse
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
{
id: 'shift-tab',
content: async () =>
process.env.USER_TYPE === 'ant'
? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode`
: `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
`Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default, accept edits, plan, auto, and bypass modes`,
cooldownSessions: 10,
isRelevant: async () => true,
},

View File

@@ -17,7 +17,6 @@ import {
notifySessionMetadataChanged,
type SessionExternalMetadata,
} from '../utils/sessionState.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
import type { AppState } from './AppStateStore.js'
// Inverse of the push below — restore on worker restart.
@@ -91,23 +90,11 @@ export function onChangeAppState({
notifyPermissionModeChanged(newMode)
}
// mainLoopModel: remove it from settings?
if (
newState.mainLoopModel !== oldState.mainLoopModel &&
newState.mainLoopModel === null
) {
// Remove from settings
updateSettingsForSource('userSettings', { model: undefined })
setMainLoopModelOverride(null)
}
// mainLoopModel: add it to settings?
if (
newState.mainLoopModel !== oldState.mainLoopModel &&
newState.mainLoopModel !== null
) {
// Save to settings
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
// mainLoopModel: session-scoped only (do NOT persist to userSettings).
// Writing to settings.json would leak model changes into other running
// sessions (anthropics/claude-code#37596). Each process keeps its own
// model override in memory via setMainLoopModelOverride.
if (newState.mainLoopModel !== oldState.mainLoopModel) {
setMainLoopModelOverride(newState.mainLoopModel)
}

View File

@@ -2201,6 +2201,7 @@ async function getRelevantMemoryAttachments(
recentTools: readonly string[],
signal: AbortSignal,
alreadySurfaced: ReadonlySet<string>,
parentSpan?: unknown,
): Promise<Attachment[]> {
// If an agent is @-mentioned, search only its memory dir (isolation).
// Otherwise search the auto-memory dir.
@@ -2221,6 +2222,7 @@ async function getRelevantMemoryAttachments(
signal,
recentTools,
alreadySurfaced,
parentSpan as Parameters<typeof findRelevantMemories>[5],
).catch(() => []),
),
)
@@ -2370,6 +2372,12 @@ export function startRelevantMemoryPrefetch(
return undefined
}
// Poor mode: skip the side-query to save tokens
const { isPoorModeActive } = require('../commands/poor/poorMode.js') as typeof import('../commands/poor/poorMode.js')
if (isPoorModeActive()) {
return undefined
}
const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta)
if (!lastUserMessage) {
return undefined
@@ -2397,6 +2405,7 @@ export function startRelevantMemoryPrefetch(
collectRecentSuccessfulTools(messages, lastUserMessage),
controller.signal,
surfaced.paths,
toolUseContext.langfuseTrace,
).catch(e => {
if (!isAbortError(e)) {
logError(e)

View File

@@ -133,6 +133,12 @@ export function calculateContextPercentages(
currentUsage.cache_creation_input_tokens +
currentUsage.cache_read_input_tokens
// Treat zero input tokens the same as no usage data — avoids flashing
// "ctx:0%" when a third-party API omits usage from message_start.
if (totalInputTokens === 0) {
return { used: null, remaining: null }
}
const usedPercentage = Math.round(
(totalInputTokens / contextWindowSize) * 100,
)

View File

@@ -126,6 +126,12 @@ export function getDefaultOpusModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
}
// Fall back to user's configured model — custom providers may not
// recognize hardcoded Anthropic model IDs.
const userSpecifiedOpus = getUserSpecifiedModelSetting()
if (userSpecifiedOpus) {
return parseUserSpecifiedModel(userSpecifiedOpus)
}
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
// even when values match, since 3P availability lags firstParty and
// these will diverge again at the next model launch.
@@ -153,6 +159,13 @@ export function getDefaultSonnetModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
}
// Fall back to user's configured model (ANTHROPIC_MODEL / settings) —
// custom providers (proxies, national clouds) may not recognize the
// hardcoded Anthropic model IDs.
const userSpecified = getUserSpecifiedModelSetting()
if (userSpecified) {
return parseUserSpecifiedModel(userSpecified)
}
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
if (provider !== 'firstParty') {
return getModelStrings().sonnet45
@@ -175,6 +188,12 @@ export function getDefaultHaikuModel(): ModelName {
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
}
// Fall back to user's configured model — custom providers may not
// recognize hardcoded Anthropic model IDs.
const userSpecifiedHaiku = getUserSpecifiedModelSetting()
if (userSpecifiedHaiku) {
return parseUserSpecifiedModel(userSpecifiedHaiku)
}
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
return getModelStrings().haiku45

View File

@@ -0,0 +1,204 @@
/**
* Tests for src/utils/permissions/getNextPermissionMode.ts
*
* Covers the unified permission mode cycling logic:
* default → acceptEdits → plan → auto → bypassPermissions → default
*
* After the "open auto/bypass to all users" change, there is no USER_TYPE
* distinction — all users share the same cycle order.
*/
import { describe, expect, test } from 'bun:test'
import type { ToolPermissionContext } from '../../../Tool.js'
import type { PermissionMode } from '../PermissionMode.js'
// Inline getNextPermissionMode to avoid importing the heavy permissionSetup
// dependency chain (growthbook, settings, etc.).
// The function under test is small and pure enough to copy for testing.
import { getNextPermissionMode } from '../getNextPermissionMode.js'
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeContext(
mode: PermissionMode,
overrides: Partial<ToolPermissionContext> = {},
): ToolPermissionContext {
return {
mode,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: true,
...overrides,
}
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('getNextPermissionMode', () => {
// ── Full cycle ──────────────────────────────────────────────────────────
describe('unified cycle order', () => {
test('default → acceptEdits', () => {
expect(getNextPermissionMode(makeContext('default'))).toBe('acceptEdits')
})
test('acceptEdits → plan', () => {
expect(getNextPermissionMode(makeContext('acceptEdits'))).toBe('plan')
})
test('plan → auto', () => {
expect(getNextPermissionMode(makeContext('plan'))).toBe('auto')
})
test('auto → bypassPermissions (when bypass available)', () => {
expect(getNextPermissionMode(makeContext('auto'))).toBe('bypassPermissions')
})
test('bypassPermissions → default', () => {
expect(getNextPermissionMode(makeContext('bypassPermissions'))).toBe('default')
})
test('full cycle completes back to default', () => {
const cycle: PermissionMode[] = []
let ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycle.push(next)
ctx = makeContext(next)
}
expect(cycle).toEqual([
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'default',
])
})
})
// ── auto → default when bypass unavailable ─────────────────────────────
describe('auto mode with bypass unavailable', () => {
test('auto → default when isBypassPermissionsModeAvailable is false', () => {
const ctx = makeContext('auto', {
isBypassPermissionsModeAvailable: false,
})
expect(getNextPermissionMode(ctx)).toBe('default')
})
})
// ── dontAsk mode ────────────────────────────────────────────────────────
describe('dontAsk mode', () => {
test('dontAsk → default', () => {
expect(getNextPermissionMode(makeContext('dontAsk'))).toBe('default')
})
})
// ── USER_TYPE independence ──────────────────────────────────────────────
describe('no USER_TYPE distinction', () => {
test('cycle order is the same regardless of USER_TYPE', () => {
// Save original
const originalUserType = process.env.USER_TYPE
// Test with no USER_TYPE
delete process.env.USER_TYPE
const cycleNoType: PermissionMode[] = []
let ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycleNoType.push(next)
ctx = makeContext(next)
}
// Test with USER_TYPE=ant
process.env.USER_TYPE = 'ant'
const cycleAnt: PermissionMode[] = []
ctx = makeContext('default')
for (let i = 0; i < 5; i++) {
const next = getNextPermissionMode(ctx)
cycleAnt.push(next)
ctx = makeContext(next)
}
// Restore
if (originalUserType !== undefined) {
process.env.USER_TYPE = originalUserType
} else {
delete process.env.USER_TYPE
}
// Both should produce the same cycle
expect(cycleNoType).toEqual(cycleAnt)
expect(cycleNoType).toEqual([
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'default',
])
})
})
// ── teamContext parameter ───────────────────────────────────────────────
describe('teamContext parameter', () => {
test('does not affect cycle when provided', () => {
const ctx = makeContext('default')
const teamCtx = { leadAgentId: 'agent-123' }
expect(getNextPermissionMode(ctx, teamCtx)).toBe('acceptEdits')
})
test('does not affect cycle for plan mode', () => {
const ctx = makeContext('plan')
const teamCtx = { leadAgentId: 'agent-456' }
expect(getNextPermissionMode(ctx, teamCtx)).toBe('auto')
})
})
// ── cycle stability (no infinite loops) ─────────────────────────────────
describe('cycle stability', () => {
test('all modes return to default within 6 steps', () => {
const modes: PermissionMode[] = [
'default',
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'dontAsk',
]
for (const startMode of modes) {
let current = startMode
let returnedToDefault = false
for (let i = 0; i < 6; i++) {
current = getNextPermissionMode(makeContext(current))
if (current === 'default') {
returnedToDefault = true
break
}
}
expect(returnedToDefault).toBe(true)
}
})
test('cycling 100 times never produces an invalid mode', () => {
const validModes = new Set<string>([
'default',
'acceptEdits',
'plan',
'auto',
'bypassPermissions',
'dontAsk',
])
let ctx = makeContext('default')
for (let i = 0; i < 100; i++) {
const next = getNextPermissionMode(ctx)
expect(validModes.has(next)).toBe(true)
ctx = makeContext(next)
}
})
})
})

View File

@@ -0,0 +1,148 @@
/**
* Tests for the simplified permission gate functions.
*
* After the "open auto/bypass to all users" change, the key guarantees are:
* - shouldDisableBypassPermissions() always returns false
* - isBypassPermissionsModeDisabled() always returns false
* - hasAutoModeOptInAnySource() always returns true
* - isAutoModeGateEnabled() returns true unless fast-mode circuit breaker fires
* - getAutoModeUnavailableReason() returns null when no breaker fires
*
* These functions are tested through the getNextPermissionMode cycle
* and through direct unit tests of the gate functions.
*/
import { describe, expect, test } from 'bun:test'
import type { ToolPermissionContext } from '../../../Tool.js'
import type { PermissionMode } from '../PermissionMode.js'
import { getNextPermissionMode } from '../getNextPermissionMode.js'
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeContext(
mode: PermissionMode,
overrides: Partial<ToolPermissionContext> = {},
): ToolPermissionContext {
return {
mode,
additionalWorkingDirectories: new Map(),
alwaysAllowRules: {},
alwaysDenyRules: {},
alwaysAskRules: {},
isBypassPermissionsModeAvailable: true,
...overrides,
}
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('permission gate invariants (after opening auto/bypass)', () => {
// ── Bypass permissions is always available ──────────────────────────────
describe('bypass mode always reachable in cycle', () => {
test('auto → bypassPermissions when isBypassPermissionsModeAvailable is true', () => {
const ctx = makeContext('auto', { isBypassPermissionsModeAvailable: true })
expect(getNextPermissionMode(ctx)).toBe('bypassPermissions')
})
test('isBypassPermissionsModeAvailable true is the default from getEmptyToolPermissionContext', () => {
// This test verifies the Tool.ts default is true
// (imported indirectly through the cycle behavior)
const ctx = makeContext('auto')
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
expect(getNextPermissionMode(ctx)).toBe('bypassPermissions')
})
})
// ── Auto mode is always available in cycle ──────────────────────────────
describe('auto mode always reachable in cycle', () => {
test('plan → auto (always, no gate check)', () => {
expect(getNextPermissionMode(makeContext('plan'))).toBe('auto')
})
test('plan → auto even when isBypassPermissionsModeAvailable is false', () => {
const ctx = makeContext('plan', { isBypassPermissionsModeAvailable: false })
expect(getNextPermissionMode(ctx)).toBe('auto')
})
test('bypassPermissions → default (then default → acceptEdits → plan → auto)', () => {
// Verify that after bypass, you can reach auto by cycling through
const fromBypass = getNextPermissionMode(makeContext('bypassPermissions'))
expect(fromBypass).toBe('default')
const fromDefault = getNextPermissionMode(makeContext('default'))
expect(fromDefault).toBe('acceptEdits')
const fromAcceptEdits = getNextPermissionMode(makeContext('acceptEdits'))
expect(fromAcceptEdits).toBe('plan')
const fromPlan = getNextPermissionMode(makeContext('plan'))
expect(fromPlan).toBe('auto')
})
})
// ── No opt-in gate between modes ────────────────────────────────────────
describe('no opt-in gate between modes', () => {
test('cycling from default to auto completes in 3 steps without any opt-in check', () => {
let mode: PermissionMode = 'default'
const steps: PermissionMode[] = []
// default → acceptEdits → plan → auto
for (let i = 0; i < 3; i++) {
mode = getNextPermissionMode(makeContext(mode))
steps.push(mode)
}
expect(steps).toEqual(['acceptEdits', 'plan', 'auto'])
})
test('cycling from default to bypassPermissions completes in 4 steps', () => {
let mode: PermissionMode = 'default'
const steps: PermissionMode[] = []
for (let i = 0; i < 4; i++) {
mode = getNextPermissionMode(makeContext(mode))
steps.push(mode)
}
expect(steps).toEqual(['acceptEdits', 'plan', 'auto', 'bypassPermissions'])
})
})
// ── Mode ordering safety (most dangerous modes last) ────────────────────
describe('safety ordering', () => {
test('auto comes before bypassPermissions in the cycle', () => {
// Starting from plan, user must press Shift+Tab twice to reach bypass
// (plan → auto → bypassPermissions)
const fromPlan = getNextPermissionMode(makeContext('plan'))
expect(fromPlan).toBe('auto')
const fromAuto = getNextPermissionMode(makeContext('auto'))
expect(fromAuto).toBe('bypassPermissions')
})
test('default comes before any dangerous mode', () => {
// default → acceptEdits (safe, just auto-accept edits)
const fromDefault = getNextPermissionMode(makeContext('default'))
expect(fromDefault).toBe('acceptEdits')
// acceptEdits is the least dangerous mode
})
})
})
describe('Tool.ts default context', () => {
test('getEmptyToolPermissionContext has isBypassPermissionsModeAvailable = true', async () => {
const { getEmptyToolPermissionContext } = await import('../../../Tool.js')
const ctx = getEmptyToolPermissionContext()
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
})
})
describe('settings hasAutoModeOptIn', () => {
test('always returns true after change', async () => {
const { hasAutoModeOptIn } = await import('../../settings/settings.js')
expect(hasAutoModeOptIn()).toBe(true)
})
})

View File

@@ -1,79 +1,44 @@
import { feature } from 'bun:bundle'
import { useEffect, useRef } from 'react'
import {
type AppState,
useAppState,
useAppStateStore,
useSetAppState,
} from 'src/state/AppState.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { useNotifications } from 'src/context/notifications.js'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { useAppState, useAppStateStore, useSetAppState } from '../../state/AppState.js'
import type { ToolPermissionContext } from '../../Tool.js'
import {
createDisabledBypassPermissionsContext,
shouldDisableBypassPermissions,
verifyAutoModeGateAccess,
} from './permissionSetup.js'
let bypassPermissionsCheckRan = false
/**
* No-op — bypass permissions is always available.
*/
export async function checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void,
_toolPermissionContext: ToolPermissionContext,
_setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
): Promise<void> {
// Check if bypassPermissions should be disabled based on Statsig gate
// Do this only once, before the first query, to ensure we have the latest gate value
if (bypassPermissionsCheckRan) {
return
}
bypassPermissionsCheckRan = true
if (!toolPermissionContext.isBypassPermissionsModeAvailable) {
return
}
const shouldDisable = await shouldDisableBypassPermissions()
if (!shouldDisable) {
return
}
setAppState(prev => {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(
prev.toolPermissionContext,
),
}
})
// Bypass permissions is always available — no gate check needed
}
/**
* Reset the run-once flag for checkAndDisableBypassPermissionsIfNeeded.
* Call this after /login so the gate check re-runs with the new org.
* Reset stub — kept for interface compatibility.
*/
export function resetBypassPermissionsCheck(): void {
bypassPermissionsCheckRan = false
// No-op
}
/**
* No-op hook — bypass permissions is always available.
*/
export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void {
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
const setAppState = useSetAppState()
// Run once, when the component mounts
useEffect(() => {
if (getIsRemoteMode()) return
void checkAndDisableBypassPermissionsIfNeeded(
toolPermissionContext,
setAppState,
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// No-op
}
let autoModeCheckRan = false
export async function checkAndDisableAutoModeIfNeeded(
toolPermissionContext: ToolPermissionContext,
setAppState: (f: (prev: AppState) => AppState) => void,
setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
fastMode?: boolean,
): Promise<void> {
if (feature('TRANSCRIPT_CLASSIFIER')) {
@@ -87,10 +52,6 @@ export async function checkAndDisableAutoModeIfNeeded(
fastMode,
)
setAppState(prev => {
// Apply the transform to CURRENT context, not the stale snapshot we
// passed to verifyAutoModeGateAccess. The async GrowthBook await inside
// can be outrun by a mid-turn shift-tab; spreading a stale context here
// would revert the user's mode change.
const nextCtx = updateContext(prev.toolPermissionContext)
const newState =
nextCtx === prev.toolPermissionContext
@@ -133,11 +94,6 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
const isFirstRunRef = useRef(true)
// Runs on mount (startup check) AND whenever the model or fast mode changes
// (kick-out / carousel-restore). Watching both model fields covers /model,
// Cmd+P picker, /config, and bridge onSetModel paths; fastMode covers
// /fast on|off for the tengu_auto_mode_config.disableFastMode circuit
// breaker. The print.ts headless paths are covered by the sync
// isAutoModeGateEnabled() check.
useEffect(() => {
if (getIsRemoteMode()) return
if (isFirstRunRef.current) {
@@ -149,7 +105,9 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
store.getState().toolPermissionContext,
setAppState,
fastMode,
)
).catch(error => {
logError(new Error('Auto mode gate check failed', { cause: toError(error) }))
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainLoopModel, mainLoopModelForSession, fastMode])
}

View File

@@ -1,35 +1,13 @@
import { feature } from 'bun:bundle'
import type { ToolPermissionContext } from '../../Tool.js'
import { logForDebugging } from '../debug.js'
import type { PermissionMode } from './PermissionMode.js'
import {
getAutoModeUnavailableReason,
isAutoModeGateEnabled,
transitionPermissionMode,
} from './permissionSetup.js'
// Checks both the cached isAutoModeAvailable (set at startup by
// verifyAutoModeGateAccess) and the live isAutoModeGateEnabled() — these can
// diverge if the circuit breaker or settings change mid-session. The
// live check prevents transitionPermissionMode from throwing
// (permissionSetup.ts:~559), which would silently crash the shift+tab handler
// and leave the user stuck at the current mode.
function canCycleToAuto(ctx: ToolPermissionContext): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const gateEnabled = isAutoModeGateEnabled()
const can = !!ctx.isAutoModeAvailable && gateEnabled
if (!can) {
logForDebugging(
`[auto-mode] canCycleToAuto=false: ctx.isAutoModeAvailable=${ctx.isAutoModeAvailable} isAutoModeGateEnabled=${gateEnabled} reason=${getAutoModeUnavailableReason()}`,
)
}
return can
}
return false
}
import { transitionPermissionMode } from './permissionSetup.js'
/**
* Determines the next permission mode when cycling through modes with Shift+Tab.
*
* Unified cycle for all users (no USER_TYPE distinction):
* default → acceptEdits → plan → auto → bypassPermissions → default
*/
export function getNextPermissionMode(
toolPermissionContext: ToolPermissionContext,
@@ -37,43 +15,29 @@ export function getNextPermissionMode(
): PermissionMode {
switch (toolPermissionContext.mode) {
case 'default':
// Ants skip acceptEdits and plan — auto mode replaces them
if (process.env.USER_TYPE === 'ant') {
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
}
return 'acceptEdits'
case 'acceptEdits':
return 'plan'
case 'plan':
return 'auto'
case 'auto':
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'bypassPermissions':
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'dontAsk':
// Not exposed in UI cycle yet, but return default if somehow reached
return 'default'
default:
// Covers auto (when TRANSCRIPT_CLASSIFIER is enabled) and any future modes — always fall back to default
// Covers any future modes — always fall back to default
return 'default'
}
}

View File

@@ -799,10 +799,6 @@ export function initialPermissionModeFromCLI({
result = { mode: 'default', notification }
}
if (!result) {
result = { mode: 'default', notification }
}
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
autoModeStateModule?.setAutoModeActive(true)
}
@@ -927,20 +923,9 @@ export async function initializeToolPermissionContext({
})
}
// Check if bypassPermissions mode is available (not disabled by Statsig gate or settings)
// Use cached values to avoid blocking on startup
const growthBookDisableBypassPermissionsMode =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
'tengu_disable_bypass_permissions_mode',
)
// Bypass permissions mode is available to all users
const isBypassPermissionsModeAvailable = true
const settings = getSettings_DEPRECATED() || {}
const settingsDisableBypassPermissionsMode =
settings.permissions?.disableBypassPermissionsMode === 'disable'
const isBypassPermissionsModeAvailable =
(permissionMode === 'bypassPermissions' ||
allowDangerouslySkipPermissions) &&
!growthBookDisableBypassPermissionsMode &&
!settingsDisableBypassPermissionsMode
// Load all permission rules from disk
const rulesFromDisk = loadAllPermissionRulesFromDisk()
@@ -984,7 +969,7 @@ export async function initializeToolPermissionContext({
alwaysAskRules: {},
isBypassPermissionsModeAvailable,
...(feature('TRANSCRIPT_CLASSIFIER')
? { isAutoModeAvailable: isAutoModeGateEnabled() }
? { isAutoModeAvailable: true }
: {}),
},
rulesFromDisk,
@@ -1076,131 +1061,54 @@ export function getAutoModeUnavailableNotification(
* kicking the user out of a mode they've already left during the await.
*/
export async function verifyAutoModeGateAccess(
currentContext: ToolPermissionContext,
_currentContext: ToolPermissionContext,
// Runtime AppState.fastMode — passed from callers with AppState access so
// the disableFastMode circuit breaker reads current state, not stale
// settings.fastMode (which is intentionally sticky across /model auto-
// downgrades). Optional for callers without AppState (e.g. SDK init paths).
fastMode?: boolean,
): Promise<AutoModeGateCheckResult> {
// Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out)
// Fresh read of tengu_auto_mode_config.enabled — this async check runs once
// after GrowthBook initialization and is the authoritative source for
// isAutoModeAvailable. The sync startup path uses stale cache; this
// corrects it. Circuit breaker (enabled==='disabled') takes effect here.
// Only fast-mode circuit breaker remains. All other gates (GrowthBook,
// settings, model support, opt-in) have been removed.
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
enabled?: AutoModeEnabledState
disableFastMode?: boolean
}>('tengu_auto_mode_config', {})
const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled)
const disabledBySettings = isAutoModeDisabledBySettings()
// Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker
// semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled().
autoModeStateModule?.setAutoModeCircuitBroken(
enabledState === 'disabled' || disabledBySettings,
)
// Carousel availability: not circuit-broken, not disabled-by-settings,
// model supports it, disableFastMode breaker not firing, and (enabled or opted-in)
const mainModel = getMainLoopModel()
// Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto
// mode when fast mode is on. Checks runtime AppState.fastMode (if provided)
// and, for ants, model name '-fast' substring (ant-internal fast models
// like capybara-v2-fast[1m] encode speed in the model ID itself).
// Remove once auto+fast mode interaction is validated.
const disableFastModeBreakerFires =
!!autoModeConfig?.disableFastMode &&
(!!fastMode ||
(process.env.USER_TYPE === 'ant' &&
mainModel.toLowerCase().includes('-fast')))
const modelSupported =
modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires
let carouselAvailable = false
if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) {
carouselAvailable =
enabledState === 'enabled' || hasAutoModeOptInAnySource()
}
// canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto)
// — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model
const canEnterAuto =
enabledState !== 'disabled' && !disabledBySettings && modelSupported
// If fast-mode breaker fires, circuit-break auto mode
autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires)
logForDebugging(
`[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`,
`[auto-mode] verifyAutoModeGateAccess: disableFastModeBreakerFires=${disableFastModeBreakerFires}`,
)
// Capture CLI-flag intent now (doesn't depend on context).
const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false
// Return a transform function that re-evaluates context-dependent conditions
// against the CURRENT context at setAppState time. The async GrowthBook
// results above (canEnterAuto, carouselAvailable, enabledState, reason) are
// closure-captured — those don't depend on context. But mode, prePlanMode,
// and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await
// shift-tab gets reverted (or worse, the user stays in auto despite the
// circuit breaker if they entered auto DURING the await — which is possible
// because setAutoModeCircuitBroken above runs AFTER the await).
const setAvailable = (
ctx: ToolPermissionContext,
available: boolean,
): ToolPermissionContext => {
if (ctx.isAutoModeAvailable !== available) {
logForDebugging(
`[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`,
)
}
return ctx.isAutoModeAvailable === available
? ctx
: { ...ctx, isAutoModeAvailable: available }
if (!disableFastModeBreakerFires) {
// Auto mode available — no kick-out needed
return { updateContext: ctx => ctx }
}
if (canEnterAuto) {
return { updateContext: ctx => setAvailable(ctx, carouselAvailable) }
}
// Fast-mode breaker fired — kick out of auto if currently in it
const notification = getAutoModeUnavailableNotification('circuit-breaker')
// Gate is off or circuit-broken — determine reason (context-independent).
let reason: AutoModeUnavailableReason
if (disabledBySettings) {
reason = 'settings'
logForDebugging('auto mode disabled: disableAutoMode in settings', {
level: 'warn',
})
} else if (enabledState === 'disabled') {
reason = 'circuit-breaker'
logForDebugging(
'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)',
{ level: 'warn' },
)
} else {
reason = 'model'
logForDebugging(
`auto mode disabled: model ${getMainLoopModel()} does not support auto mode`,
{ level: 'warn' },
)
}
const notification = getAutoModeUnavailableNotification(reason)
// Unified kick-out transform. Re-checks the FRESH ctx and only fires
// side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment)
// when the kick-out actually applies. This keeps autoModeActive in sync
// with toolPermissionContext.mode even if the user changed modes during
// the await: if they already left auto on their own, handleCycleMode
// already deactivated the classifier and we don't fire again; if they
// ENTERED auto during the await (possible before setAutoModeCircuitBroken
// landed), we kick them out here.
const kickOutOfAutoIfNeeded = (
ctx: ToolPermissionContext,
): ToolPermissionContext => {
const inAuto = ctx.mode === 'auto'
logForDebugging(
`[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`,
`[auto-mode] kickOutOfAutoIfNeeded (fast-mode): ctx.mode=${ctx.mode}`,
)
// Plan mode with auto active: either from prePlanMode='auto' (entered
// from auto) or from opt-in (strippedDangerousRules present).
const inPlanWithAutoActive =
ctx.mode === 'plan' &&
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
if (!inAuto && !inPlanWithAutoActive) {
return setAvailable(ctx, false)
return { ...ctx, isAutoModeAvailable: false }
}
if (inAuto) {
autoModeStateModule?.setAutoModeActive(false)
@@ -1214,8 +1122,6 @@ export async function verifyAutoModeGateAccess(
isAutoModeAvailable: false,
}
}
// Plan with auto active: deactivate auto, restore permissions, defuse
// prePlanMode so ExitPlanMode goes to default.
autoModeStateModule?.setAutoModeActive(false)
setNeedsAutoModeExitAttachment(true)
return {
@@ -1225,65 +1131,23 @@ export async function verifyAutoModeGateAccess(
}
}
// Notification decisions use the stale context — that's OK: we're deciding
// WHETHER to notify based on what the user WAS doing when this check started.
// (Side effects and mode mutation are decided inside the transform above,
// against the fresh ctx.)
const wasInAuto = currentContext.mode === 'auto'
// Auto was used during plan: entered from auto or opt-in auto active
const autoActiveDuringPlan =
currentContext.mode === 'plan' &&
(currentContext.prePlanMode === 'auto' ||
!!currentContext.strippedDangerousRules)
const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli
if (!wantedAuto) {
// User didn't want auto at call time — no notification. But still apply
// the full kick-out transform: if they shift-tabbed INTO auto during the
// await (before setAutoModeCircuitBroken landed), we need to evict them.
return { updateContext: kickOutOfAutoIfNeeded }
}
if (wasInAuto || autoActiveDuringPlan) {
// User was in auto or had auto active during plan — kick out + notify.
return { updateContext: kickOutOfAutoIfNeeded, notification }
}
// autoModeFlagCli only: defaultMode was auto but sync check rejected it.
// Suppress notification if isAutoModeAvailable is already false (already
// notified on a prior check; prevents repeat notifications on successive
// unsupported-model switches).
return {
updateContext: kickOutOfAutoIfNeeded,
notification: currentContext.isAutoModeAvailable ? notification : undefined,
}
return { updateContext: kickOutOfAutoIfNeeded, notification }
}
/**
* Core logic to check if bypassPermissions should be disabled based on Statsig gate
* Bypass permissions is always available — no remote gate check needed.
*/
export function shouldDisableBypassPermissions(): Promise<boolean> {
return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')
}
function isAutoModeDisabledBySettings(): boolean {
const settings = getSettings_DEPRECATED() || {}
return (
(settings as { disableAutoMode?: 'disable' }).disableAutoMode ===
'disable' ||
(settings.permissions as { disableAutoMode?: 'disable' } | undefined)
?.disableAutoMode === 'disable'
)
return Promise.resolve(false)
}
/**
* Checks if auto mode can be entered: circuit breaker is not active and settings
* have not disabled it. Synchronous.
* Checks if auto mode can be entered: only fast-mode circuit breaker remains.
* Synchronous.
*/
export function isAutoModeGateEnabled(): boolean {
// Auto mode is available to all users — only fast-mode circuit breaker remains
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false
if (isAutoModeDisabledBySettings()) return false
if (!modelSupportsAutoMode(getMainLoopModel())) return false
return true
}
@@ -1292,11 +1156,9 @@ export function isAutoModeGateEnabled(): boolean {
* Synchronous — uses state populated by verifyAutoModeGateAccess.
*/
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
if (isAutoModeDisabledBySettings()) return 'settings'
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
return 'circuit-breaker'
}
if (!modelSupportsAutoMode(getMainLoopModel())) return 'model'
return null
}
@@ -1310,8 +1172,7 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null
*/
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState =
feature('TRANSCRIPT_CLASSIFIER') ? 'enabled' : 'disabled'
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled'
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
@@ -1361,27 +1222,15 @@ export function getAutoModeEnabledStateIfCached():
* dialog or by IDE/Desktop settings toggle)
*/
export function hasAutoModeOptInAnySource(): boolean {
if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true
return hasAutoModeOptIn()
return true
}
/**
* Checks if bypassPermissions mode is currently disabled by Statsig gate or settings.
* This is a synchronous version that uses cached Statsig values.
* Always returns false — bypass is available to all users.
*/
export function isBypassPermissionsModeDisabled(): boolean {
const growthBookDisableBypassPermissionsMode =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
'tengu_disable_bypass_permissions_mode',
)
const settings = getSettings_DEPRECATED() || {}
const settingsDisableBypassPermissionsMode =
settings.permissions?.disableBypassPermissionsMode === 'disable'
return (
growthBookDisableBypassPermissionsMode ||
settingsDisableBypassPermissionsMode
)
return false
}
/**
@@ -1406,29 +1255,12 @@ export function createDisabledBypassPermissionsContext(
}
/**
* Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate
* and returns an updated toolPermissionContext if needed
* No-op — bypass permissions is always available, no remote gate check needed.
*/
export async function checkAndDisableBypassPermissions(
currentContext: ToolPermissionContext,
_currentContext: ToolPermissionContext,
): Promise<void> {
// Only proceed if bypassPermissions mode is available
if (!currentContext.isBypassPermissionsModeAvailable) {
return
}
const shouldDisable = await shouldDisableBypassPermissions()
if (!shouldDisable) {
return
}
// Gate is enabled, need to disable bypassPermissions mode
logForDebugging(
'bypassPermissions mode is being disabled by Statsig gate (async check)',
{ level: 'warn' },
)
void gracefulShutdown(1, 'bypass_permissions_disabled')
// Bypass permissions is always available — no gate check needed
}
export function isDefaultPermissionModeAuto(): boolean {
@@ -1446,11 +1278,7 @@ export function isDefaultPermissionModeAuto(): boolean {
*/
export function shouldPlanUseAutoMode(): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
return (
hasAutoModeOptIn() &&
isAutoModeGateEnabled() &&
getUseAutoModeDuringPlan()
)
return isAutoModeGateEnabled() && getUseAutoModeDuringPlan()
}
return false
}

View File

@@ -696,6 +696,7 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
context.options.tools,
appState.toolPermissionContext,
context.abortController.signal,
context.langfuseTrace,
)
} finally {
clearClassifierChecking(toolUseID)

View File

@@ -31,6 +31,7 @@ import { resolveAntModel } from '../model/antModels.js'
import { getMainLoopModel } from '../model/model.js'
import { getAutoModeConfig } from '../settings/settings.js'
import { sideQuery } from '../sideQuery.js'
import type { LangfuseSpan } from '../../services/langfuse/index.js'
import { jsonStringify } from '../slowOperations.js'
import { tokenCountWithEstimation } from '../tokens.js'
import {
@@ -731,6 +732,7 @@ async function classifyYoloActionXml(
action: string
},
mode: TwoStageMode,
parentSpan?: LangfuseSpan | null,
): Promise<YoloClassifierResult> {
const classifierType =
mode === 'both'
@@ -791,6 +793,7 @@ async function classifyYoloActionXml(
signal,
...(mode !== 'fast' && { stop_sequences: ['</block>'] }),
querySource: 'auto_mode',
parentSpan,
}
const stage1Raw = await sideQuery(stage1Opts)
stage1DurationMs = Date.now() - stage1Start
@@ -877,6 +880,7 @@ async function classifyYoloActionXml(
maxRetries: getDefaultMaxRetries(),
signal,
querySource: 'auto_mode' as const,
parentSpan,
}
const stage2Raw = await sideQuery(stage2Opts)
const stage2DurationMs = Date.now() - stage2Start
@@ -1015,6 +1019,7 @@ export async function classifyYoloAction(
tools: Tools,
context: ToolPermissionContext,
signal: AbortSignal,
parentSpan?: LangfuseSpan | null,
): Promise<YoloClassifierResult> {
const lookup = buildToolLookup(tools)
const actionCompact = toCompact(action, lookup)
@@ -1126,6 +1131,7 @@ export async function classifyYoloAction(
action: actionCompact,
},
getTwoStageMode(),
parentSpan,
)
}
const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model)
@@ -1156,6 +1162,7 @@ export async function classifyYoloAction(
maxRetries: getDefaultMaxRetries(),
signal,
querySource: 'auto_mode' as const,
parentSpan,
}
const result = await sideQuery(sideQueryOpts)
void maybeDumpAutoMode(sideQueryOpts, result, start)

View File

@@ -894,20 +894,8 @@ export function hasSkipDangerousModePermissionPrompt(): boolean {
* a malicious project could otherwise auto-bypass the dialog (RCE risk).
*/
export function hasAutoModeOptIn(): boolean {
if (feature('TRANSCRIPT_CLASSIFIER')) {
const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt
const local =
getSettingsForSource('localSettings')?.skipAutoPermissionPrompt
const flag = getSettingsForSource('flagSettings')?.skipAutoPermissionPrompt
const policy =
getSettingsForSource('policySettings')?.skipAutoPermissionPrompt
const result = !!(user || local || flag || policy)
logForDebugging(
`[auto-mode] hasAutoModeOptIn=${result} skipAutoPermissionPrompt: user=${user} local=${local} flag=${flag} policy=${policy}`,
)
return result
}
return false
// Auto mode is available to all users — no opt-in needed
return true
}
/**

View File

@@ -2,6 +2,7 @@ import type Anthropic from '@anthropic-ai/sdk'
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js'
import {
getLastApiCompletionTimestamp,
getSessionId,
setLastApiCompletionTimestamp,
} from '../bootstrap/state.js'
import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js'
@@ -14,8 +15,13 @@ import { logEvent } from '../services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js'
import { getAPIMetadata } from '../services/api/claude.js'
import { getAnthropicClient } from '../services/api/client.js'
import { createTrace, createChildSpan, endTrace, recordLLMObservation } from '../services/langfuse/index.js'
import type { LangfuseSpan } from '../services/langfuse/index.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../services/langfuse/convert.js'
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
import { errorMessage } from './errors.js'
import { computeFingerprint } from './fingerprint.js'
import { getAPIProvider } from './model/providers.js'
import { normalizeModelStringForAPI } from './model/model.js'
type MessageParam = Anthropic.MessageParam
@@ -61,6 +67,11 @@ export type SideQueryOptions = {
stop_sequences?: string[]
/** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */
querySource: QuerySource
/** Parent Langfuse span to nest this side query under the main agent trace. */
parentSpan?: LangfuseSpan | null
/** When true, API failures are recorded as WARNING instead of ERROR in Langfuse.
* Use for optional/best-effort queries where failure is expected and handled gracefully. */
optional?: boolean
}
/**
@@ -177,25 +188,51 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
}
const normalizedModel = normalizeModelStringForAPI(model)
const provider = getAPIProvider()
const start = Date.now()
// biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution
const response = await client.beta.messages.create(
{
model: normalizedModel,
max_tokens,
system: systemBlocks,
messages,
...(tools && { tools }),
...(tool_choice && { tool_choice }),
...(output_format && { output_config: { format: output_format } }),
...(temperature !== undefined && { temperature }),
...(stop_sequences && { stop_sequences }),
...(thinkingConfig && { thinking: thinkingConfig }),
...(betas.length > 0 && { betas }),
metadata: getAPIMetadata(),
},
{ signal },
)
const traceName = `side-query:${opts.querySource}`
// When parentSpan is provided, create a child span nested under the
// main agent trace; otherwise create a standalone root trace.
const langfuseTrace = opts.parentSpan
? createChildSpan(opts.parentSpan, {
name: traceName,
sessionId: getSessionId(),
model: normalizedModel,
provider,
querySource: opts.querySource,
})
: createTrace({
sessionId: getSessionId(),
model: normalizedModel,
provider,
name: traceName,
querySource: opts.querySource,
})
let response: BetaMessage
try {
response = await client.beta.messages.create(
{
model: normalizedModel,
max_tokens,
system: systemBlocks,
messages,
...(tools && { tools }),
...(tool_choice && { tool_choice }),
...(output_format && { output_config: { format: output_format } }),
...(temperature !== undefined && { temperature }),
...(stop_sequences && { stop_sequences }),
...(thinkingConfig && { thinking: thinkingConfig }),
...(betas.length > 0 && { betas }),
metadata: getAPIMetadata(),
},
{ signal },
)
} catch (error) {
endTrace(langfuseTrace, { error: errorMessage(error) }, opts.optional ? 'interrupted' : 'error')
throw error
}
const requestId =
(response as { _request_id?: string | null })._request_id ?? undefined
@@ -218,5 +255,32 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
})
setLastApiCompletionTimestamp(now)
// Record LLM observation in Langfuse (no-op if not configured).
// Wrap SDK types into the internal message format expected by converters.
const wrappedInput = messages.map(m => ({
type: m.role === 'assistant' ? 'assistant' as const : 'user' as const,
message: { role: m.role, content: m.content },
})) as unknown as Parameters<typeof convertMessagesToLangfuse>[0]
const wrappedOutput = [{
type: 'assistant' as const,
message: { role: 'assistant' as const, content: response.content },
}] as unknown as Parameters<typeof convertOutputToLangfuse>[0]
recordLLMObservation(langfuseTrace, {
model: normalizedModel,
provider,
input: convertMessagesToLangfuse(wrappedInput, systemBlocks.length > 0 ? systemBlocks.map(b => b.text) : undefined),
output: convertOutputToLangfuse(wrappedOutput),
usage: {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens ?? undefined,
cache_read_input_tokens: response.usage.cache_read_input_tokens ?? undefined,
},
startTime: new Date(start),
endTime: new Date(),
...(tools && { tools: convertToolsToLangfuse(tools as unknown[]) }),
})
endTrace(langfuseTrace)
return response
}

View File

@@ -150,9 +150,17 @@ export function getCurrentUsage(messages: Message[]): {
const message = messages[i]
const usage = message ? getTokenUsage(message) : undefined
if (usage) {
const inputTokens =
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0)
// Skip placeholder usage (all zeros) — third-party APIs may emit
// message_start without real usage data, causing the context counter
// to flash to 0. Fall through to the previous message instead.
if (inputTokens === 0 && (usage.output_tokens ?? 0) === 0) continue
return {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
input_tokens: usage.input_tokens ?? 0,
output_tokens: usage.output_tokens ?? 0,
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
}

View File

@@ -19,7 +19,9 @@
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
"@claude-code-best/mcp-client": ["./packages/mcp-client/src/index.ts"],
"@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"],
"@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"]
"@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"],
"@claude-code-best/weixin/*": ["./packages/weixin/src/*"],
"@claude-code-best/weixin": ["./packages/weixin/src/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"],

View File

@@ -1,11 +1,12 @@
import { defineConfig, type Plugin } from "vite";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import { getMacroDefines } from "./scripts/defines";
import featureFlagsPlugin from "./scripts/vite-plugin-feature-flags";
import importMetaRequirePlugin from "./scripts/vite-plugin-import-meta-require";
const projectRoot = dirname(new URL(import.meta.url).pathname);
const projectRoot = dirname(fileURLToPath(import.meta.url));
/**
* Plugin to import .md files as raw strings (Bun's text loader behavior).