mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956e98a445 | ||
|
|
cee62bc654 | ||
|
|
5fc7c8e13d | ||
|
|
300faa18d0 | ||
|
|
96ec96c720 | ||
|
|
13a0bfc479 | ||
|
|
84f0271813 | ||
|
|
ed4bdb9338 | ||
|
|
e4ce08fe39 | ||
|
|
92f8a92fbb | ||
|
|
a67e2d0e97 | ||
|
|
8c629858ab | ||
|
|
494eab7204 | ||
|
|
b83c3008d0 | ||
|
|
66d2671c98 |
@@ -58,6 +58,9 @@ bun run health
|
|||||||
# Check unused exports
|
# Check unused exports
|
||||||
bun run check:unused
|
bun run check:unused
|
||||||
|
|
||||||
|
# Full check (typecheck + lint + test) — run after completing any task
|
||||||
|
bun run test:all
|
||||||
|
|
||||||
bun run typecheck
|
bun run typecheck
|
||||||
|
|
||||||
# Remote Control Server
|
# Remote Control Server
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -6,45 +6,50 @@
|
|||||||
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
||||||
[](https://github.com/claude-code-best/claude-code/commits/main)
|
[](https://github.com/claude-code-best/claude-code/commits/main)
|
||||||
[](https://bun.sh/)
|
[](https://bun.sh/)
|
||||||
[](https://discord.gg/qZU6zS7Q)
|
[](https://discord.gg/uApuzJWGKX)
|
||||||
|
|
||||||
> Which Claude do you like? The open source one is the best.
|
> 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 账号才能使用的特性, 实现技术普惠
|
牢 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)
|
||||||
|
|
||||||
| 特性 | 说明 | 文档 |
|
|
||||||
|------|------|------|
|
| 特性 | 说明 | 文档 |
|
||||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord 等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||||
|
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||||
|
|
||||||
- 🚀 [想要启动项目](#快速开始源码版)
|
- 🚀 [想要启动项目](#快速开始源码版)
|
||||||
- 🐛 [想要调试项目](#vs-code-调试)
|
- 🐛 [想要调试项目](#vs-code-调试)
|
||||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
- 📖 [想要学习项目](#teach-me-学习项目)
|
||||||
|
|
||||||
|
|
||||||
## ⚡ 快速开始(安装版)
|
## ⚡ 快速开始(安装版)
|
||||||
|
|
||||||
不用克隆仓库, 从 NPM 下载后, 直接使用
|
不用克隆仓库, 从 NPM 下载后, 直接使用
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun i -g claude-code-best
|
npm i -g claude-code-best
|
||||||
bun pm -g trust claude-code-best
|
|
||||||
|
# bun 安装比较多问题, 推荐 npm 装
|
||||||
|
# bun i -g claude-code-best
|
||||||
|
# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge
|
||||||
|
|
||||||
ccb # 以 nodejs 打开 claude code
|
ccb # 以 nodejs 打开 claude code
|
||||||
ccb-bun # 以 bun 形态打开
|
ccb-bun # 以 bun 形态打开
|
||||||
|
ccb update # 更新到最新版本
|
||||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,17 +91,17 @@ bun run build
|
|||||||
|
|
||||||
需要填写的字段:
|
需要填写的字段:
|
||||||
|
|
||||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
|
||||||
|------|------|------|
|
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
| ------------ | ------------- | ---------------------------- |
|
||||||
| API Key | 认证密钥 | `sk-xxx` |
|
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
| API Key | 认证密钥 | `sk-xxx` |
|
||||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||||
|
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||||
|
|
||||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||||
|
|
||||||
|
|
||||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||||
|
|
||||||
## Feature Flags
|
## Feature Flags
|
||||||
@@ -116,16 +121,17 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
|||||||
### 步骤
|
### 步骤
|
||||||
|
|
||||||
1. **终端启动 inspect 服务**:
|
1. **终端启动 inspect 服务**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev:inspect
|
bun run dev:inspect
|
||||||
```
|
```
|
||||||
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
|
||||||
|
|
||||||
|
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
||||||
2. **VS Code 附着调试器**:
|
2. **VS Code 附着调试器**:
|
||||||
|
|
||||||
- 在 `src/` 文件中打断点
|
- 在 `src/` 文件中打断点
|
||||||
- F5 → 选择 **"Attach to Bun (TUI debug)"**
|
- F5 → 选择 **"Attach to Bun (TUI debug)"**
|
||||||
|
|
||||||
|
|
||||||
## Teach Me 学习项目
|
## Teach Me 学习项目
|
||||||
|
|
||||||
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
|
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
|
||||||
@@ -152,7 +158,7 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
|||||||
## 相关文档及网站
|
## 相关文档及网站
|
||||||
|
|
||||||
- **在线文档(Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
|
- **在线文档(Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
|
||||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
- **DeepWiki**: [https://deepwiki.com/claude-code-best/claude-code](https://deepwiki.com/claude-code-best/claude-code)
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
|
|||||||
43
build.ts
43
build.ts
@@ -1,6 +1,7 @@
|
|||||||
import { readdir, readFile, writeFile, cp } from 'fs/promises'
|
import { readdir, readFile, writeFile, cp } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { getMacroDefines } from './scripts/defines.ts'
|
import { getMacroDefines } from './scripts/defines.ts'
|
||||||
|
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
|
||||||
|
|
||||||
const outdir = 'dist'
|
const outdir = 'dist'
|
||||||
|
|
||||||
@@ -8,48 +9,6 @@ const outdir = 'dist'
|
|||||||
const { rmSync } = await import('fs')
|
const { rmSync } = await import('fs')
|
||||||
rmSync(outdir, { recursive: true, force: true })
|
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
|
// Collect FEATURE_* env vars → Bun.build features
|
||||||
const envFeatures = Object.keys(process.env)
|
const envFeatures = Object.keys(process.env)
|
||||||
.filter(k => k.startsWith('FEATURE_'))
|
.filter(k => k.startsWith('FEATURE_'))
|
||||||
|
|||||||
153
bun.lock
153
bun.lock
@@ -6,7 +6,8 @@
|
|||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -17,23 +18,24 @@
|
|||||||
"@ant/computer-use-swift": "workspace:*",
|
"@ant/computer-use-swift": "workspace:*",
|
||||||
"@ant/model-provider": "workspace:*",
|
"@ant/model-provider": "workspace:*",
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
"@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/foundry-sdk": "^0.2.3",
|
||||||
"@anthropic-ai/mcpb": "^2.1.2",
|
"@anthropic-ai/mcpb": "^2.1.2",
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||||
"@anthropic/ink": "workspace:*",
|
"@anthropic/ink": "workspace:*",
|
||||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||||
"@aws-sdk/client-sts": "^3.1020.0",
|
"@aws-sdk/client-sts": "^3.1032.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.972.28",
|
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||||
"@azure/identity": "^4.13.1",
|
"@azure/identity": "^4.13.1",
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
"@claude-code-best/builtin-tools": "workspace:*",
|
"@claude-code-best/builtin-tools": "workspace:*",
|
||||||
"@claude-code-best/mcp-client": "workspace:*",
|
"@claude-code-best/mcp-client": "workspace:*",
|
||||||
|
"@claude-code-best/weixin": "workspace:*",
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
"@commander-js/extra-typings": "^14.0.0",
|
||||||
"@growthbook/growthbook": "^1.6.5",
|
"@growthbook/growthbook": "^1.6.5",
|
||||||
"@langfuse/otel": "^5.1.0",
|
"@langfuse/otel": "^5.1.0",
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/api-logs": "^0.214.0",
|
"@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-grpc": "^0.214.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||||
@@ -52,14 +54,14 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^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-logs": "^0.214.0",
|
||||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@sentry/node": "^10.47.0",
|
"@sentry/node": "^10.49.0",
|
||||||
"@smithy/core": "^3.23.13",
|
"@smithy/core": "^3.23.15",
|
||||||
"@smithy/node-http-handler": "^4.5.1",
|
"@smithy/node-http-handler": "^4.5.3",
|
||||||
"@types/bun": "^1.3.12",
|
"@types/bun": "^1.3.12",
|
||||||
"@types/cacache": "^20.0.1",
|
"@types/cacache": "^20.0.1",
|
||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
@@ -81,7 +83,7 @@
|
|||||||
"asciichart": "^1.5.25",
|
"asciichart": "^1.5.25",
|
||||||
"audio-capture-napi": "workspace:*",
|
"audio-capture-napi": "workspace:*",
|
||||||
"auto-bind": "^5.0.1",
|
"auto-bind": "^5.0.1",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.15.0",
|
||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"cacache": "^20.0.4",
|
"cacache": "^20.0.4",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -96,31 +98,30 @@
|
|||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.3.0",
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"https-proxy-agent": "^8.0.0",
|
"https-proxy-agent": "^8.0.0",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"image-processor-napi": "workspace:*",
|
"image-processor-napi": "workspace:*",
|
||||||
"indent-string": "^5.0.0",
|
"indent-string": "^5.0.0",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"knip": "^6.1.1",
|
"knip": "^6.4.1",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.18.1",
|
||||||
"lru-cache": "^11.2.7",
|
"lru-cache": "^11.3.5",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.6",
|
||||||
"modifiers-napi": "workspace:*",
|
"modifiers-napi": "workspace:*",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.34.0",
|
||||||
"p-map": "^7.0.4",
|
"p-map": "^7.0.4",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"plist": "^3.1.0",
|
"plist": "^3.1.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.5",
|
||||||
"react-compiler-runtime": "^1.0.0",
|
"react-compiler-runtime": "^1.0.0",
|
||||||
"react-reconciler": "^0.33.0",
|
"react-reconciler": "^0.33.0",
|
||||||
"rollup": "^4.60.1",
|
"rollup": "^4.60.2",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
@@ -129,10 +130,10 @@
|
|||||||
"strip-ansi": "^7.2.0",
|
"strip-ansi": "^7.2.0",
|
||||||
"supports-hyperlinks": "^4.4.0",
|
"supports-hyperlinks": "^4.4.0",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.4",
|
||||||
"type-fest": "^5.5.0",
|
"type-fest": "^5.6.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"undici": "^7.24.6",
|
"undici": "^7.25.0",
|
||||||
"url-handler-napi": "workspace:*",
|
"url-handler-napi": "workspace:*",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
@@ -194,13 +195,13 @@
|
|||||||
},
|
},
|
||||||
"packages/acp-link": {
|
"packages/acp-link": {
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "1.0.1",
|
"version": "2.0.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acp-link": "dist/cli/bin.js",
|
"acp-link": "dist/cli/bin.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^2.0.0",
|
||||||
"@hono/node-ws": "^1.0.5",
|
"@hono/node-ws": "^1.0.5",
|
||||||
"@stricli/auto-complete": "^1.2.4",
|
"@stricli/auto-complete": "^1.2.4",
|
||||||
"@stricli/core": "^1.2.4",
|
"@stricli/core": "^1.2.4",
|
||||||
@@ -210,6 +211,7 @@
|
|||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.12",
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
},
|
},
|
||||||
@@ -262,6 +264,10 @@
|
|||||||
"name": "modifiers-napi",
|
"name": "modifiers-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/pokemon": {
|
||||||
|
"name": "@claude-code-best/pokemon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
"packages/remote-control-server": {
|
"packages/remote-control-server": {
|
||||||
"name": "@anthropic/remote-control-server",
|
"name": "@anthropic/remote-control-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -320,6 +326,13 @@
|
|||||||
"name": "url-handler-napi",
|
"name": "url-handler-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/weixin": {
|
||||||
|
"name": "@claude-code-best/weixin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
||||||
@@ -560,10 +573,14 @@
|
|||||||
|
|
||||||
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
|
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
|
||||||
|
|
||||||
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.8", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.8.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-f7J1e4PQ6qxXzdHwL7QRrMZ4lPfD/L1MWxWDbyHmHY7jaW2GL6WcArKpk/fApg3V/q0racqUWzXHQdpE/HJZqg=="],
|
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@3.0.1", "", { "dependencies": { "@hono/node-server": "^1.19.13", "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "hono": "^4.12.12", "is-admin": "^4.0.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-ozeLHVOdckTUsWKJneJAL+CclrUlwVyBpfzFxgsrSL9f0LvjlJXE7+VcF5OmjDPwmZy6QNorvtg3/8NT2cIlzA=="],
|
||||||
|
|
||||||
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
||||||
|
|
||||||
|
"@claude-code-best/pokemon": ["@claude-code-best/pokemon@workspace:packages/pokemon"],
|
||||||
|
|
||||||
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -624,22 +641,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
|
|
||||||
|
|
||||||
"@fastify/cors": ["@fastify/cors@11.2.0", "https://registry.npmmirror.com/@fastify/cors/-/cors-11.2.0.tgz", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
|
|
||||||
|
|
||||||
"@fastify/error": ["@fastify/error@4.2.0", "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
|
||||||
|
|
||||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
|
||||||
|
|
||||||
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.1.tgz", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
|
|
||||||
|
|
||||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
|
||||||
|
|
||||||
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
|
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
|
||||||
|
|
||||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
"@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||||
@@ -654,7 +657,7 @@
|
|||||||
|
|
||||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "https://registry.npmmirror.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "https://registry.npmmirror.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
"@hono/node-server": ["@hono/node-server@2.0.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-n3GfHwwCvHCkGmOwKfxUPOlbfzuO64Sbc5XC4NGPIXxkuOnJrdgExdRKmHfF924r914WRJPT397GdqLvdYTeyQ=="],
|
||||||
|
|
||||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "https://registry.npmmirror.com/@hono/node-ws/-/node-ws-1.3.0.tgz", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
"@hono/node-ws": ["@hono/node-ws@1.3.0", "https://registry.npmmirror.com/@hono/node-ws/-/node-ws-1.3.0.tgz", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||||
|
|
||||||
@@ -1514,8 +1517,6 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||||
|
|
||||||
"abstract-logging": ["abstract-logging@2.0.1", "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
@@ -1556,8 +1557,6 @@
|
|||||||
|
|
||||||
"auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
"auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||||
|
|
||||||
"avvio": ["avvio@9.2.0", "https://registry.npmmirror.com/avvio/-/avvio-9.2.0.tgz", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
|
|
||||||
|
|
||||||
"axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
"axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||||
|
|
||||||
"bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
"bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
@@ -1624,8 +1623,6 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
"chrome-mcp-shared": ["chrome-mcp-shared@1.0.2", "https://registry.npmmirror.com/chrome-mcp-shared/-/chrome-mcp-shared-1.0.2.tgz", { "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "zod": "^3.24.4" } }, "sha512-v+6HBmcgXrIfyVbkkrVgfFDzqOfDutI8yZM0yA8k7SiicqL1MfBoqnsOy5idYNvxyQymxCxXNuTmajn8xaGsgQ=="],
|
|
||||||
|
|
||||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
|
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
|
||||||
|
|
||||||
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
@@ -1856,16 +1853,10 @@
|
|||||||
|
|
||||||
"fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
|
"fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||||
|
|
||||||
"fast-json-stringify": ["fast-json-stringify@6.3.0", "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
|
|
||||||
|
|
||||||
"fast-querystring": ["fast-querystring@1.1.2", "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
|
||||||
|
|
||||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||||
@@ -1874,10 +1865,6 @@
|
|||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
"fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||||
|
|
||||||
"fastify": ["fastify@5.8.4", "https://registry.npmmirror.com/fastify/-/fastify-5.8.4.tgz", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
|
|
||||||
|
|
||||||
"fastify-plugin": ["fastify-plugin@5.1.0", "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
|
||||||
|
|
||||||
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
|
"fd-package-json": ["fd-package-json@2.0.0", "https://registry.npmmirror.com/fd-package-json/-/fd-package-json-2.0.0.tgz", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
|
||||||
@@ -1894,8 +1881,6 @@
|
|||||||
|
|
||||||
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
"find-my-way": ["find-my-way@9.5.0", "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.5.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
|
||||||
|
|
||||||
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
"flora-colossus": ["flora-colossus@2.0.0", "https://registry.npmmirror.com/flora-colossus/-/flora-colossus-2.0.0.tgz", { "dependencies": { "debug": "^4.3.4", "fs-extra": "^10.1.0" } }, "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA=="],
|
"flora-colossus": ["flora-colossus@2.0.0", "https://registry.npmmirror.com/flora-colossus/-/flora-colossus-2.0.0.tgz", { "dependencies": { "debug": "^4.3.4", "fs-extra": "^10.1.0" } }, "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA=="],
|
||||||
@@ -2094,8 +2079,6 @@
|
|||||||
|
|
||||||
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
"json-schema": ["json-schema@0.4.0", "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
|
||||||
|
|
||||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
@@ -2126,8 +2109,6 @@
|
|||||||
|
|
||||||
"layout-base": ["layout-base@1.0.2", "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
"layout-base": ["layout-base@1.0.2", "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
||||||
|
|
||||||
"light-my-request": ["light-my-request@6.6.0", "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
@@ -2552,14 +2533,10 @@
|
|||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"ret": ["ret@0.5.0", "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
|
||||||
|
|
||||||
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
|
||||||
|
|
||||||
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||||
|
|
||||||
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||||
@@ -2578,8 +2555,6 @@
|
|||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safe-regex2": ["safe-regex2@5.1.0", "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.0.tgz", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
|
|
||||||
|
|
||||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
@@ -2598,8 +2573,6 @@
|
|||||||
|
|
||||||
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
"sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
@@ -2690,8 +2663,6 @@
|
|||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
"toad-cache": ["toad-cache@3.7.0", "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.0.tgz", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
@@ -3052,7 +3023,7 @@
|
|||||||
|
|
||||||
"@claude-code-best/agent-tools/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"@claude-code-best/agent-tools/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"@claude-code-best/mcp-chrome-bridge/pino": ["pino@9.14.0", "https://registry.npmmirror.com/pino/-/pino-9.14.0.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
"@claude-code-best/mcp-chrome-bridge/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
||||||
|
|
||||||
"@claude-code-best/mcp-client/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"@claude-code-best/mcp-client/lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
@@ -3064,16 +3035,18 @@
|
|||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||||
|
|
||||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
|
||||||
|
|
||||||
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
|
"@hono/node-ws/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
||||||
|
|
||||||
"@inquirer/core/@types/node": ["@types/node@22.19.17", "https://registry.npmmirror.com/@types/node/-/node-22.19.17.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
"@inquirer/core/@types/node": ["@types/node@22.19.17", "https://registry.npmmirror.com/@types/node/-/node-22.19.17.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||||
|
|
||||||
"@inquirer/core/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"@inquirer/core/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.13", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.13.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
|
||||||
|
|
||||||
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
"@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||||
|
|
||||||
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
"@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||||
@@ -3326,8 +3299,6 @@
|
|||||||
|
|
||||||
"cacache/lru-cache": ["lru-cache@11.3.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.3.tgz", {}, "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ=="],
|
"cacache/lru-cache": ["lru-cache@11.3.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.3.tgz", {}, "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ=="],
|
||||||
|
|
||||||
"chrome-mcp-shared/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
||||||
|
|
||||||
"cli-highlight/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"cli-highlight/chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"cli-highlight/highlight.js": ["highlight.js@10.7.3", "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
"cli-highlight/highlight.js": ["highlight.js@10.7.3", "https://registry.npmmirror.com/highlight.js/-/highlight.js-10.7.3.tgz", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||||
@@ -3350,8 +3321,6 @@
|
|||||||
|
|
||||||
"external-editor/iconv-lite": ["iconv-lite@0.4.24", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
"external-editor/iconv-lite": ["iconv-lite@0.4.24", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
"fastify/pino": ["pino@9.14.0", "https://registry.npmmirror.com/pino/-/pino-9.14.0.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
|
||||||
|
|
||||||
"form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
@@ -3370,10 +3339,6 @@
|
|||||||
|
|
||||||
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||||
|
|
||||||
"light-my-request/cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
|
||||||
|
|
||||||
"light-my-request/process-warning": ["process-warning@4.0.1", "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
|
||||||
|
|
||||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||||
|
|
||||||
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
@@ -3622,10 +3587,6 @@
|
|||||||
|
|
||||||
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"@claude-code-best/mcp-chrome-bridge/pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
|
||||||
|
|
||||||
"@claude-code-best/mcp-chrome-bridge/pino/thread-stream": ["thread-stream@3.1.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
|
"@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="],
|
||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
"@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "https://registry.npmmirror.com/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="],
|
||||||
@@ -3708,10 +3669,6 @@
|
|||||||
|
|
||||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||||
|
|
||||||
"fastify/pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
|
||||||
|
|
||||||
"fastify/pino/thread-stream": ["thread-stream@3.1.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
|
||||||
|
|
||||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Cla
|
|||||||
- **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
|
- **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
|
||||||
- **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
|
- **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
|
||||||
|
|
||||||
|
本仓库现在内置了 **微信 WeChat channel**,不需要单独安装外部 marketplace 插件。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启用频道监听(plugin 格式)
|
# 启用频道监听(plugin 格式)
|
||||||
ccb --channels plugin:feishu@claude-code-feishu-channel
|
ccb --channels plugin:feishu@claude-code-feishu-channel
|
||||||
|
|
||||||
|
# 启用内置微信 channel
|
||||||
|
ccb weixin login
|
||||||
|
ccb --channels plugin:weixin@builtin
|
||||||
|
|
||||||
# 启用频道监听(server 格式)
|
# 启用频道监听(server 格式)
|
||||||
ccb --channels server:my-slack-bridge
|
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` |
|
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
|
||||||
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
|
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
|
||||||
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
|
| **飞书 (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 会话。
|
||||||
|
|
||||||
## 相关文件
|
## 相关文件
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.5.0",
|
"version": "1.8.0",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
@@ -53,16 +53,19 @@
|
|||||||
"format": "biome format --write src/",
|
"format": "biome format --write src/",
|
||||||
"prepare": "git config core.hooksPath .githooks",
|
"prepare": "git config core.hooksPath .githooks",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
|
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
|
||||||
"check:unused": "knip-bun",
|
"check:unused": "knip-bun",
|
||||||
"health": "bun run scripts/health-check.ts",
|
"health": "bun run scripts/health-check.ts",
|
||||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||||
"docs:dev": "npx mintlify dev",
|
"docs:dev": "npx mintlify dev",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test:all": "bun run typecheck && bun test",
|
||||||
"rcs": "bun run scripts/rcs.ts"
|
"rcs": "bun run scripts/rcs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -90,6 +93,7 @@
|
|||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
"@claude-code-best/builtin-tools": "workspace:*",
|
"@claude-code-best/builtin-tools": "workspace:*",
|
||||||
"@claude-code-best/mcp-client": "workspace:*",
|
"@claude-code-best/mcp-client": "workspace:*",
|
||||||
|
"@claude-code-best/weixin": "workspace:*",
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
"@commander-js/extra-typings": "^14.0.0",
|
||||||
"@growthbook/growthbook": "^1.6.5",
|
"@growthbook/growthbook": "^1.6.5",
|
||||||
"@langfuse/otel": "^5.1.0",
|
"@langfuse/otel": "^5.1.0",
|
||||||
@@ -156,7 +160,6 @@
|
|||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"https-proxy-agent": "^8.0.0",
|
"https-proxy-agent": "^8.0.0",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"image-processor-napi": "workspace:*",
|
"image-processor-napi": "workspace:*",
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ acp-link can register to a Remote Control Server (RCS) for remote access. Set th
|
|||||||
|
|
||||||
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
|
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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||||
"author": "claude-code-best",
|
"author": "claude-code-best",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -15,15 +15,18 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
"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"
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1",
|
||||||
|
"@types/bun": "^1.3.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^2.0.0",
|
||||||
"@hono/node-ws": "^1.0.5",
|
"@hono/node-ws": "^1.0.5",
|
||||||
"@stricli/auto-complete": "^1.2.4",
|
"@stricli/auto-complete": "^1.2.4",
|
||||||
"@stricli/core": "^1.2.4",
|
"@stricli/core": "^1.2.4",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const command = buildCommand({
|
|||||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||||
"Use -- to pass arguments to the agent:\n" +
|
"Use -- to pass arguments to the agent:\n" +
|
||||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\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.",
|
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -40,6 +42,11 @@ export const command = buildCommand({
|
|||||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
manager: {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "Start Manager Web UI (no proxy)",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
group: {
|
group: {
|
||||||
kind: "parsed",
|
kind: "parsed",
|
||||||
parse: (value: string) => {
|
parse: (value: string) => {
|
||||||
@@ -59,12 +66,12 @@ export const command = buildCommand({
|
|||||||
parse: String,
|
parse: String,
|
||||||
placeholder: "command",
|
placeholder: "command",
|
||||||
},
|
},
|
||||||
minimum: 1,
|
minimum: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
func: async function (
|
func: async function (
|
||||||
this: LocalContext,
|
this: LocalContext,
|
||||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; group: string | undefined },
|
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
||||||
...args: readonly string[]
|
...args: readonly string[]
|
||||||
) {
|
) {
|
||||||
const port = flags.port;
|
const port = flags.port;
|
||||||
@@ -72,7 +79,21 @@ export const command = buildCommand({
|
|||||||
const debug = flags.debug;
|
const debug = flags.debug;
|
||||||
const noAuth = flags["no-auth"];
|
const noAuth = flags["no-auth"];
|
||||||
const https = flags.https;
|
const https = flags.https;
|
||||||
|
const manager = flags.manager;
|
||||||
const group = flags.group;
|
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 [command, ...agentArgs] = args;
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
|||||||
345
packages/acp-link/src/manager/html.ts
Normal file
345
packages/acp-link/src/manager/html.ts
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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">▶</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>`;
|
||||||
44
packages/acp-link/src/manager/index.ts
Normal file
44
packages/acp-link/src/manager/index.ts
Normal 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(() => {});
|
||||||
|
}
|
||||||
233
packages/acp-link/src/manager/manager.ts
Normal file
233
packages/acp-link/src/manager/manager.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
153
packages/acp-link/src/manager/routes.ts
Normal file
153
packages/acp-link/src/manager/routes.ts
Normal 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;
|
||||||
|
}
|
||||||
34
packages/acp-link/src/manager/types.ts
Normal file
34
packages/acp-link/src/manager/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -883,20 +883,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
authEnabled: !!AUTH_TOKEN,
|
authEnabled: !!AUTH_TOKEN,
|
||||||
}, "started");
|
}, "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
|
// Keep the server running
|
||||||
await new Promise(() => {});
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "esnext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Node.js module resolution
|
// Node.js module resolution
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "bundler",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
// Output
|
// Output
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"types": ["bun"],
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
|||||||
@@ -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 = {
|
type AudioCaptureNapi = {
|
||||||
startRecording(
|
startRecording(
|
||||||
@@ -41,7 +47,7 @@ function loadModule(): AudioCaptureNapi | null {
|
|||||||
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
|
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
cachedModule = require(
|
cachedModule = nodeRequire(
|
||||||
process.env.AUDIO_CAPTURE_NODE_PATH,
|
process.env.AUDIO_CAPTURE_NODE_PATH,
|
||||||
) as AudioCaptureNapi
|
) as AudioCaptureNapi
|
||||||
return cachedModule
|
return cachedModule
|
||||||
@@ -63,7 +69,7 @@ function loadModule(): AudioCaptureNapi | null {
|
|||||||
for (const p of fallbacks) {
|
for (const p of fallbacks) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
cachedModule = require(p) as AudioCaptureNapi
|
cachedModule = nodeRequire(p) as AudioCaptureNapi
|
||||||
return cachedModule
|
return cachedModule
|
||||||
} catch {
|
} catch {
|
||||||
// try next
|
// try next
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||||
import { queryModelWithStreaming } from 'src/services/api/claude.js'
|
import { queryModelWithStreaming } from 'src/services/api/claude.js'
|
||||||
|
import { createTrace, endTrace, isLangfuseEnabled } from 'src/services/langfuse/index.js'
|
||||||
|
import { getSessionId } from 'src/bootstrap/state.js'
|
||||||
|
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||||
import { createUserMessage } from 'src/utils/messages.js'
|
import { createUserMessage } from 'src/utils/messages.js'
|
||||||
import { getMainLoopModel, getSmallFastModel } from 'src/utils/model/model.js'
|
import { getMainLoopModel, getSmallFastModel } from 'src/utils/model/model.js'
|
||||||
import { jsonParse } from 'src/utils/slowOperations.js'
|
import { jsonParse } from 'src/utils/slowOperations.js'
|
||||||
@@ -38,6 +41,15 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
|||||||
const toolSchema = makeToolSchema({ allowedDomains, blockedDomains })
|
const toolSchema = makeToolSchema({ allowedDomains, blockedDomains })
|
||||||
|
|
||||||
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE('tengu_plum_vx3', false)
|
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE('tengu_plum_vx3', false)
|
||||||
|
const model = useHaiku ? getSmallFastModel() : getMainLoopModel()
|
||||||
|
const langfuseTrace = isLangfuseEnabled()
|
||||||
|
? createTrace({
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model,
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
name: 'web-search-tool',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const queryStream = queryModelWithStreaming({
|
const queryStream = queryModelWithStreaming({
|
||||||
messages: [userMessage],
|
messages: [userMessage],
|
||||||
@@ -58,7 +70,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
|||||||
alwaysAskRules: {},
|
alwaysAskRules: {},
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: false,
|
||||||
}),
|
}),
|
||||||
model: useHaiku ? getSmallFastModel() : getMainLoopModel(),
|
model,
|
||||||
toolChoice: useHaiku ? { type: 'tool' as const, name: 'web_search' } : undefined,
|
toolChoice: useHaiku ? { type: 'tool' as const, name: 'web_search' } : undefined,
|
||||||
isNonInteractiveSession: false,
|
isNonInteractiveSession: false,
|
||||||
hasAppendSystemPrompt: false,
|
hasAppendSystemPrompt: false,
|
||||||
@@ -68,6 +80,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
|||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
agentId: undefined,
|
agentId: undefined,
|
||||||
effortValue: undefined,
|
effortValue: undefined,
|
||||||
|
langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,6 +161,8 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endTrace(langfuseTrace)
|
||||||
|
|
||||||
// Extract SearchResult[] from content blocks
|
// Extract SearchResult[] from content blocks
|
||||||
return extractSearchResults(allContentBlocks)
|
return extractSearchResults(allContentBlocks)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,16 @@
|
|||||||
* getSyntaxTheme always returns the default for the given Claude theme.
|
* getSyntaxTheme always returns the default for the given Claude theme.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
import { diffArrays } from 'diff'
|
import { diffArrays } from 'diff'
|
||||||
import type * as hljsNamespace from 'highlight.js'
|
import type * as hljsNamespace from 'highlight.js'
|
||||||
import { basename, extname } from 'path'
|
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
|
// Lazy: defers loading highlight.js until first render. The full bundle
|
||||||
// registers 190+ language grammars at require time (~50MB, 100-200ms on
|
// registers 190+ language grammars at require time (~50MB, 100-200ms on
|
||||||
// macOS, several× that on Windows). With a top-level import, any caller
|
// 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
|
let cachedHljs: HLJSApi | null = null
|
||||||
function hljs(): HLJSApi {
|
function hljs(): HLJSApi {
|
||||||
if (cachedHljs) return cachedHljs
|
if (cachedHljs) return cachedHljs
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
const mod = nodeRequire('highlight.js')
|
||||||
const mod = require('highlight.js')
|
|
||||||
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
|
// 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.
|
// in .default; under node CJS the module IS the API. Check at runtime.
|
||||||
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFileSync, unlinkSync } from 'node:fs'
|
||||||
import sharpModule from 'sharp'
|
import sharpModule from 'sharp'
|
||||||
|
|
||||||
export const sharp = sharpModule
|
export const sharp = sharpModule
|
||||||
@@ -62,13 +63,11 @@ return "${tmpPath}"
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = Bun.file(tmpPath)
|
const file = Bun.file(tmpPath)
|
||||||
// Use synchronous read via Node compat
|
const buffer: Buffer = readFileSync(tmpPath)
|
||||||
const fs = require('fs')
|
|
||||||
const buffer: Buffer = fs.readFileSync(tmpPath)
|
|
||||||
|
|
||||||
// Clean up temp file
|
// Clean up temp file
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(tmpPath)
|
unlinkSync(tmpPath)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup errors
|
// ignore cleanup errors
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/weixin/package.json
Normal file
11
packages/weixin/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/weixin/src/__tests__/accounts.test.ts
Normal file
54
packages/weixin/src/__tests__/accounts.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
packages/weixin/src/__tests__/media.test.ts
Normal file
90
packages/weixin/src/__tests__/media.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
packages/weixin/src/__tests__/monitor.test.ts
Normal file
22
packages/weixin/src/__tests__/monitor.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
packages/weixin/src/__tests__/pairing.test.ts
Normal file
78
packages/weixin/src/__tests__/pairing.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
packages/weixin/src/__tests__/permissions.test.ts
Normal file
43
packages/weixin/src/__tests__/permissions.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
32
packages/weixin/src/__tests__/send.test.ts
Normal file
32
packages/weixin/src/__tests__/send.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
57
packages/weixin/src/accounts.ts
Normal file
57
packages/weixin/src/accounts.ts
Normal 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
148
packages/weixin/src/api.ts
Normal 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
119
packages/weixin/src/cli.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
115
packages/weixin/src/index.ts
Normal file
115
packages/weixin/src/index.ts
Normal 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'
|
||||||
134
packages/weixin/src/login.ts
Normal file
134
packages/weixin/src/login.ts
Normal 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.' }
|
||||||
|
}
|
||||||
163
packages/weixin/src/media.ts
Normal file
163
packages/weixin/src/media.ts
Normal 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
|
||||||
|
}
|
||||||
303
packages/weixin/src/monitor.ts
Normal file
303
packages/weixin/src/monitor.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
packages/weixin/src/pairing.ts
Normal file
101
packages/weixin/src/pairing.ts
Normal 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
|
||||||
|
}
|
||||||
83
packages/weixin/src/permissions.ts
Normal file
83
packages/weixin/src/permissions.ts
Normal 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
180
packages/weixin/src/send.ts
Normal 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 }
|
||||||
|
}
|
||||||
353
packages/weixin/src/server.ts
Normal file
353
packages/weixin/src/server.ts
Normal 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()
|
||||||
|
}
|
||||||
178
packages/weixin/src/types.ts
Normal file
178
packages/weixin/src/types.ts
Normal 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
|
||||||
|
}
|
||||||
5
packages/weixin/tsconfig.json
Normal file
5
packages/weixin/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
336
scripts/check-bundle-integrity.ts
Normal file
336
scripts/check-bundle-integrity.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* 构建产物完整性检查脚本
|
||||||
|
*
|
||||||
|
* 检查 Bun.build({ splitting: true }) 输出的 dist/ 目录中是否存在:
|
||||||
|
* 1. 引用了不存在的 chunk 文件(断链)
|
||||||
|
* 2. 通过 __require() 或 import() 引用的第三方模块(非 Node.js 内置),在生产环境中会找不到
|
||||||
|
* 3. 缺失的静态 import 依赖(跨 chunk 引用目标不存在)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* bun scripts/check-bundle-integrity.ts # 检查当前 dist/
|
||||||
|
* bun scripts/check-bundle-integrity.ts ./dist # 指定目录
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir, readFile } from "fs/promises"
|
||||||
|
import { join, resolve, dirname } from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
// ─── 从 package.json 读取 dependencies 作为白名单 ────────────────
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf-8'))
|
||||||
|
const PKG_DEPS = new Set(Object.keys(pkg.dependencies ?? {}))
|
||||||
|
|
||||||
|
// ─── Node.js 内置模块白名单 ────────────────────────────────────────
|
||||||
|
const NODE_BUILTINS = new Set([
|
||||||
|
"assert",
|
||||||
|
"async_hooks",
|
||||||
|
"buffer",
|
||||||
|
"child_process",
|
||||||
|
"cluster",
|
||||||
|
"console",
|
||||||
|
"constants",
|
||||||
|
"crypto",
|
||||||
|
"dgram",
|
||||||
|
"diagnostics_channel",
|
||||||
|
"dns",
|
||||||
|
"domain",
|
||||||
|
"events",
|
||||||
|
"fs",
|
||||||
|
"fs/promises",
|
||||||
|
"http",
|
||||||
|
"http2",
|
||||||
|
"https",
|
||||||
|
"inspector",
|
||||||
|
"module",
|
||||||
|
"net",
|
||||||
|
"os",
|
||||||
|
"path",
|
||||||
|
"perf_hooks",
|
||||||
|
"process",
|
||||||
|
"punycode",
|
||||||
|
"querystring",
|
||||||
|
"readline",
|
||||||
|
"repl",
|
||||||
|
"stream",
|
||||||
|
"string_decoder",
|
||||||
|
"sys",
|
||||||
|
"timers",
|
||||||
|
"tls",
|
||||||
|
"tty",
|
||||||
|
"url",
|
||||||
|
"util",
|
||||||
|
"v8",
|
||||||
|
"vm",
|
||||||
|
"worker_threads",
|
||||||
|
"zlib",
|
||||||
|
"node:test",
|
||||||
|
])
|
||||||
|
|
||||||
|
// Node 18+ 内置但不在传统列表中的模块
|
||||||
|
const NODE_18_PLUS_BUILTINS = new Set(["undici"])
|
||||||
|
|
||||||
|
// Bun 专用模块(仅在 Bun 运行时可用,Node.js 环境会失败)
|
||||||
|
const BUN_MODULES = new Set(["bun", "bun:ffi", "bun:test", "bun:sqlite"])
|
||||||
|
|
||||||
|
// macOS JXA / native 框架(通过 ObjC.import,非真正的 require)
|
||||||
|
const NATIVE_FRAMEWORKS = new Set(["AppKit", "CoreGraphics", "Foundation", "UIKit"])
|
||||||
|
|
||||||
|
// ─── 模式 ──────────────────────────────────────────────────────────
|
||||||
|
// 匹配 import { ... } from "./chunk-xxxxx.js" 或 import"./chunk-xxxxx.js"
|
||||||
|
const STATIC_IMPORT_RE = /(?:from\s+|import\s+)"(\.\/[^"]+\.js)"/g
|
||||||
|
// 匹配 __require("xxx")
|
||||||
|
const REQUIRE_RE = /__require\("([^"]+)"\)/g
|
||||||
|
// 匹配动态 import("xxx"),排除 ./chunk-xxx.js 的内部引用
|
||||||
|
const DYNAMIC_IMPORT_RE = /import\("([^"]+)"\)/g
|
||||||
|
// 匹配 nodeRequire("xxx")(createRequire 创建的 require 别名)
|
||||||
|
const NODE_REQUIRE_RE = /nodeRequire\("([^"]+)"\)/g
|
||||||
|
|
||||||
|
interface Finding {
|
||||||
|
type: "broken-chunk-ref" | "third-party-require" | "third-party-import" | "third-party-node-require" | "bun-runtime-only"
|
||||||
|
severity: "error" | "warning"
|
||||||
|
file: string
|
||||||
|
line: number
|
||||||
|
module: string
|
||||||
|
snippet: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const distDir = resolve(process.argv[2] || "./dist")
|
||||||
|
|
||||||
|
console.log(`\n🔍 检查构建产物完整性: ${distDir}\n`)
|
||||||
|
|
||||||
|
// 1. 列出所有 chunk 文件
|
||||||
|
let files: string[]
|
||||||
|
try {
|
||||||
|
files = (await readdir(distDir)).filter((f) => f.endsWith(".js"))
|
||||||
|
} catch {
|
||||||
|
console.error(`❌ 无法读取目录: ${distDir}`)
|
||||||
|
console.error(" 请先运行 bun run build")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSet = new Set(files)
|
||||||
|
console.log(`📦 找到 ${files.length} 个 JS 文件\n`)
|
||||||
|
|
||||||
|
const findings: Finding[] = []
|
||||||
|
|
||||||
|
// 2. 逐文件扫描
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(distDir, file)
|
||||||
|
const content = await readFile(filePath, "utf-8")
|
||||||
|
const lines = content.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNum = i + 1
|
||||||
|
|
||||||
|
// 2a. 检查静态 chunk 引用是否断链
|
||||||
|
const staticImportMatches = line.matchAll(STATIC_IMPORT_RE)
|
||||||
|
for (const m of staticImportMatches) {
|
||||||
|
const ref = m[1]
|
||||||
|
// 提取文件名部分(去掉 ./)
|
||||||
|
const refFile = ref.replace(/^\.\//, "")
|
||||||
|
if (!fileSet.has(refFile)) {
|
||||||
|
findings.push({
|
||||||
|
type: "broken-chunk-ref",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: ref,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. 检查 __require 中的第三方模块
|
||||||
|
const requireMatches = line.matchAll(REQUIRE_RE)
|
||||||
|
for (const m of requireMatches) {
|
||||||
|
const mod = m[1]
|
||||||
|
// 跳过 ObjC.import(JXA 语法,不是真正的 require)
|
||||||
|
if (NATIVE_FRAMEWORKS.has(mod)) continue
|
||||||
|
if (NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith("node:")) continue
|
||||||
|
if (BUN_MODULES.has(mod)) {
|
||||||
|
findings.push({
|
||||||
|
type: "bun-runtime-only",
|
||||||
|
severity: "warning",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 第三方模块 — 在生产环境(全局 npm install)中找不到
|
||||||
|
findings.push({
|
||||||
|
type: "third-party-require",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. 检查动态 import() 中的第三方模块
|
||||||
|
const dynImportMatches = line.matchAll(DYNAMIC_IMPORT_RE)
|
||||||
|
for (const m of dynImportMatches) {
|
||||||
|
const mod = m[1]
|
||||||
|
// 跳过内部 chunk 引用和相对路径
|
||||||
|
if (mod.startsWith("./") || mod.startsWith("../")) continue
|
||||||
|
// 跳过 ObjC.import
|
||||||
|
if (NATIVE_FRAMEWORKS.has(mod)) continue
|
||||||
|
if (NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith("node:")) continue
|
||||||
|
if (BUN_MODULES.has(mod)) {
|
||||||
|
// bun:test 等只在 Bun 运行时可用,Node.js 运行时会失败
|
||||||
|
findings.push({
|
||||||
|
type: "bun-runtime-only",
|
||||||
|
severity: "warning",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 第三方动态 import
|
||||||
|
findings.push({
|
||||||
|
type: "third-party-import",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2d. 检查 nodeRequire("xxx") 中的第三方模块(createRequire 别名)
|
||||||
|
const nodeRequireMatches = line.matchAll(NODE_REQUIRE_RE)
|
||||||
|
for (const m of nodeRequireMatches) {
|
||||||
|
const mod = m[1]
|
||||||
|
if (NATIVE_FRAMEWORKS.has(mod)) continue
|
||||||
|
if (NODE_BUILTINS.has(mod) || NODE_18_PLUS_BUILTINS.has(mod) || PKG_DEPS.has(mod) || mod.startsWith("node:")) continue
|
||||||
|
if (BUN_MODULES.has(mod)) {
|
||||||
|
findings.push({
|
||||||
|
type: "bun-runtime-only",
|
||||||
|
severity: "warning",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
findings.push({
|
||||||
|
type: "third-party-node-require",
|
||||||
|
severity: "error",
|
||||||
|
file,
|
||||||
|
line: lineNum,
|
||||||
|
module: mod,
|
||||||
|
snippet: line.trim().slice(0, 120),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 汇总报告
|
||||||
|
const errors = findings.filter((f) => f.severity === "error")
|
||||||
|
const warnings = findings.filter((f) => f.severity === "warning")
|
||||||
|
|
||||||
|
// 按 type 分组
|
||||||
|
const brokenRefs = errors.filter((f) => f.type === "broken-chunk-ref")
|
||||||
|
const thirdPartyRequires = errors.filter((f) => f.type === "third-party-require")
|
||||||
|
const thirdPartyImports = errors.filter((f) => f.type === "third-party-import")
|
||||||
|
const thirdPartyNodeRequires = errors.filter((f) => f.type === "third-party-node-require")
|
||||||
|
const bunRuntimeOnly = warnings.filter((f) => f.type === "bun-runtime-only")
|
||||||
|
|
||||||
|
if (brokenRefs.length > 0) {
|
||||||
|
console.log("❌ 断裂的 chunk 引用(引用了不存在的文件):")
|
||||||
|
for (const f of brokenRefs) {
|
||||||
|
console.log(` ${f.file}:${f.line} → ${f.module}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thirdPartyRequires.length > 0) {
|
||||||
|
console.log("❌ 通过 __require() 引用的第三方模块(生产环境会找不到):")
|
||||||
|
const grouped = groupByModule(thirdPartyRequires)
|
||||||
|
for (const [mod, items] of grouped) {
|
||||||
|
console.log(` "${mod}" — 出现 ${items.length} 次:`)
|
||||||
|
for (const f of items.slice(0, 5)) {
|
||||||
|
console.log(` ${f.file}:${f.line}`)
|
||||||
|
}
|
||||||
|
if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thirdPartyImports.length > 0) {
|
||||||
|
console.log("❌ 通过 import() 动态引用的第三方模块(生产环境会找不到):")
|
||||||
|
const grouped = groupByModule(thirdPartyImports)
|
||||||
|
for (const [mod, items] of grouped) {
|
||||||
|
console.log(` "${mod}" — 出现 ${items.length} 次:`)
|
||||||
|
for (const f of items.slice(0, 5)) {
|
||||||
|
console.log(` ${f.file}:${f.line}`)
|
||||||
|
}
|
||||||
|
if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thirdPartyNodeRequires.length > 0) {
|
||||||
|
console.log("❌ 通过 nodeRequire() 引用的第三方模块(绕过打包,生产环境会找不到):")
|
||||||
|
const grouped = groupByModule(thirdPartyNodeRequires)
|
||||||
|
for (const [mod, items] of grouped) {
|
||||||
|
console.log(` "${mod}" — 出现 ${items.length} 次:`)
|
||||||
|
for (const f of items.slice(0, 5)) {
|
||||||
|
console.log(` ${f.file}:${f.line}`)
|
||||||
|
}
|
||||||
|
if (items.length > 5) console.log(` ... 还有 ${items.length - 5} 处`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bunRuntimeOnly.length > 0) {
|
||||||
|
console.log("⚠️ Bun 运行时专用模块(Node.js 环境会失败):")
|
||||||
|
const grouped = groupByModule(bunRuntimeOnly)
|
||||||
|
for (const [mod, items] of grouped) {
|
||||||
|
console.log(` "${mod}" — 出现 ${items.length} 次`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 总结
|
||||||
|
console.log("─".repeat(50))
|
||||||
|
if (errors.length === 0 && warnings.length === 0) {
|
||||||
|
console.log("✅ 构建产物完整性检查通过,未发现问题。")
|
||||||
|
} else {
|
||||||
|
console.log(`📊 总计: ${errors.length} 个错误, ${warnings.length} 个警告`)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`\n💡 修复建议:
|
||||||
|
- 第三方模块问题:在 build.ts 中通过 external 选项排除,或确保它们被正确打包到 chunk 中
|
||||||
|
- 断链问题:检查 build 时是否有文件被意外删除或构建不完整
|
||||||
|
- Bun 专用模块:确保运行时使用 bun 而非 node`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(errors.length > 0 ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByModule(items: Finding[]): Map<string, Finding[]> {
|
||||||
|
const map = new Map<string, Finding[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const list = map.get(item.module) || []
|
||||||
|
list.push(item)
|
||||||
|
map.set(item.module, list)
|
||||||
|
}
|
||||||
|
// 按出现次数降序
|
||||||
|
return new Map([...map.entries()].sort((a, b) => b[1].length - a[1].length))
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err)
|
||||||
|
process.exit(2)
|
||||||
|
})
|
||||||
@@ -16,3 +16,52 @@ export function getMacroDefines(): Record<string, string> {
|
|||||||
"MACRO.VERSION_CHANGELOG": JSON.stringify(""),
|
"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;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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
|
// Resolve project root from this script's location
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
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.
|
// Bun --feature flags: enable feature() gates at runtime.
|
||||||
// Default features enabled in dev mode.
|
// Uses the shared DEFAULT_BUILD_FEATURES list from defines.ts.
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
|
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
|
||||||
// e.g. FEATURE_PROACTIVE=1 bun run dev
|
// e.g. FEATURE_PROACTIVE=1 bun run dev
|
||||||
@@ -62,7 +30,7 @@ const envFeatures = Object.entries(process.env)
|
|||||||
.filter(([k]) => k.startsWith("FEATURE_"))
|
.filter(([k]) => k.startsWith("FEATURE_"))
|
||||||
.map(([k]) => k.replace("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]);
|
const featureArgs = allFeatures.flatMap((name) => ["--feature", name]);
|
||||||
|
|
||||||
// If BUN_INSPECT is set, pass --inspect-wait to the child process
|
// If BUN_INSPECT is set, pass --inspect-wait to the child process
|
||||||
|
|||||||
@@ -1,41 +1,5 @@
|
|||||||
import type { Plugin } from "rollup";
|
import type { Plugin } from "rollup";
|
||||||
|
import { DEFAULT_BUILD_FEATURES } from "./defines.ts";
|
||||||
/**
|
|
||||||
* 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",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect enabled feature flags from defaults + env vars.
|
* Collect enabled feature flags from defaults + env vars.
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const getEmptyToolPermissionContext: () => ToolPermissionContext =
|
|||||||
alwaysAllowRules: {},
|
alwaysAllowRules: {},
|
||||||
alwaysDenyRules: {},
|
alwaysDenyRules: {},
|
||||||
alwaysAskRules: {},
|
alwaysAskRules: {},
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type CompactProgressEvent =
|
export type CompactProgressEvent =
|
||||||
@@ -277,6 +277,8 @@ export type ToolUseContext = {
|
|||||||
criticalSystemReminder_EXPERIMENTAL?: string
|
criticalSystemReminder_EXPERIMENTAL?: string
|
||||||
/** Langfuse root trace span for this query turn. Passed down to tool execution for observability. */
|
/** Langfuse root trace span for this query turn. Passed down to tool execution for observability. */
|
||||||
langfuseTrace?: LangfuseSpan | null
|
langfuseTrace?: LangfuseSpan | null
|
||||||
|
/** Langfuse root trace span for the outer/main agent trace. Used when subagents need to nest observations under the parent agent trace. */
|
||||||
|
langfuseRootTrace?: LangfuseSpan | null
|
||||||
/** Langfuse batch span wrapping a concurrent tool group. When set, tool observations are nested under it. */
|
/** Langfuse batch span wrapping a concurrent tool group. When set, tool observations are nested under it. */
|
||||||
langfuseBatchSpan?: LangfuseSpan | null
|
langfuseBatchSpan?: LangfuseSpan | null
|
||||||
/** When true, preserve toolUseResult on messages even for subagents.
|
/** When true, preserve toolUseResult on messages even for subagents.
|
||||||
|
|||||||
@@ -166,9 +166,9 @@ describe('getEmptyToolPermissionContext', () => {
|
|||||||
expect(ctx.alwaysAskRules).toEqual({})
|
expect(ctx.alwaysAskRules).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns isBypassPermissionsModeAvailable as false', () => {
|
test('returns isBypassPermissionsModeAvailable as true', () => {
|
||||||
const ctx = getEmptyToolPermissionContext()
|
const ctx = getEmptyToolPermissionContext()
|
||||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false)
|
expect(ctx.isBypassPermissionsModeAvailable).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { errorMessage } from '../../utils/errors.js'
|
import { errorMessage } from '../../utils/errors.js'
|
||||||
import {
|
import {
|
||||||
getMainLoopModel,
|
getMainLoopModel,
|
||||||
|
getSmallFastModel,
|
||||||
parseUserSpecifiedModel,
|
parseUserSpecifiedModel,
|
||||||
} from '../../utils/model/model.js'
|
} from '../../utils/model/model.js'
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
getDefaultExternalAutoModeRules,
|
getDefaultExternalAutoModeRules,
|
||||||
} from '../../utils/permissions/yoloClassifier.js'
|
} from '../../utils/permissions/yoloClassifier.js'
|
||||||
import { getAutoModeConfig } from '../../utils/settings/settings.js'
|
import { getAutoModeConfig } from '../../utils/settings/settings.js'
|
||||||
|
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
|
||||||
import { sideQuery } from '../../utils/sideQuery.js'
|
import { sideQuery } from '../../utils/sideQuery.js'
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||||
|
|
||||||
@@ -90,7 +92,9 @@ export async function autoModeCritiqueHandler(options: {
|
|||||||
|
|
||||||
const model = options.model
|
const model = options.model
|
||||||
? parseUserSpecifiedModel(options.model)
|
? parseUserSpecifiedModel(options.model)
|
||||||
: getMainLoopModel()
|
: isPoorModeActive()
|
||||||
|
? getSmallFastModel()
|
||||||
|
: getMainLoopModel()
|
||||||
|
|
||||||
const defaults = getDefaultExternalAutoModeRules()
|
const defaults = getDefaultExternalAutoModeRules()
|
||||||
const classifierPrompt = buildDefaultExternalSystemPrompt()
|
const classifierPrompt = buildDefaultExternalSystemPrompt()
|
||||||
|
|||||||
166
src/cli/updateCCB.ts
Normal file
166
src/cli/updateCCB.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* `ccb update` — Check and install the latest version of claude-code-best.
|
||||||
|
*
|
||||||
|
* Detection strategy:
|
||||||
|
* 1. If `bun` is available and the current installation was done via bun → use `bun update -g`
|
||||||
|
* 2. Otherwise → use `npm install -g`
|
||||||
|
*/
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
import { execFileNoThrowWithCwd } from '../utils/execFileNoThrow.js'
|
||||||
|
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
|
||||||
|
import { writeToStdout } from '../utils/process.js'
|
||||||
|
|
||||||
|
const PACKAGE_NAME = 'claude-code-best'
|
||||||
|
|
||||||
|
function getCurrentVersion(): string {
|
||||||
|
// Read version from the nearest package.json (walks up from this file)
|
||||||
|
try {
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
// In dev: src/cli/updateCCB.ts → ../../package.json
|
||||||
|
// In build: dist/chunks/xxx.js → ../../package.json (may not exist)
|
||||||
|
const pkgPath = join(__dirname, '..', '..', 'package.json')
|
||||||
|
if (existsSync(pkgPath)) {
|
||||||
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||||
|
if (pkg.version) return pkg.version
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
return MACRO.VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommandAvailable(cmd: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`which ${cmd} 2>/dev/null`, { stdio: 'pipe' })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the current installation was done via bun.
|
||||||
|
* Checks if the binary path contains "bun" or if bun's global install dir has our package.
|
||||||
|
*/
|
||||||
|
function isBunInstallation(): boolean {
|
||||||
|
// Check if the running binary is under bun's global install path
|
||||||
|
const execPath = process.execPath
|
||||||
|
if (execPath.includes('bun')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bun's global install directory
|
||||||
|
const bunGlobalDir = join(homedir(), '.bun', 'install', 'global')
|
||||||
|
if (existsSync(join(bunGlobalDir, 'node_modules', PACKAGE_NAME))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest version from npm registry.
|
||||||
|
*/
|
||||||
|
async function getLatestVersion(): Promise<string | null> {
|
||||||
|
const result = await execFileNoThrowWithCwd(
|
||||||
|
'npm',
|
||||||
|
['view', `${PACKAGE_NAME}@latest`, 'version', '--prefer-online'],
|
||||||
|
{ abortSignal: AbortSignal.timeout(10_000), cwd: homedir() },
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
logForDebugging(`npm view failed: ${result.stderr}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return result.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two semver strings. Returns true if a >= b.
|
||||||
|
*/
|
||||||
|
function gte(a: string, b: string): boolean {
|
||||||
|
const parseVer = (v: string) => v.replace(/^\D/, '').split('.').map(Number)
|
||||||
|
const pa = parseVer(a)
|
||||||
|
const pb = parseVer(b)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true
|
||||||
|
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCCB(): Promise<void> {
|
||||||
|
const currentVersion = getCurrentVersion()
|
||||||
|
writeToStdout(`Current version: ${currentVersion}\n`)
|
||||||
|
|
||||||
|
// Determine package manager
|
||||||
|
const hasBun = isCommandAvailable('bun')
|
||||||
|
const useBun = isBunInstallation()
|
||||||
|
const pkgManager = useBun && hasBun ? 'bun' : 'npm'
|
||||||
|
|
||||||
|
writeToStdout(`Package manager: ${pkgManager}\n`)
|
||||||
|
writeToStdout('Checking for updates...\n')
|
||||||
|
|
||||||
|
// Get latest version
|
||||||
|
const latestVersion = await getLatestVersion()
|
||||||
|
if (!latestVersion) {
|
||||||
|
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
|
||||||
|
process.stderr.write('Unable to fetch latest version from npm registry.\n')
|
||||||
|
await gracefulShutdown(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already up to date?
|
||||||
|
if (latestVersion === currentVersion || gte(currentVersion, latestVersion)) {
|
||||||
|
writeToStdout(chalk.green(`ccb is up to date (${currentVersion})`) + '\n')
|
||||||
|
await gracefulShutdown(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToStdout(
|
||||||
|
`New version available: ${latestVersion} (current: ${currentVersion})\n`,
|
||||||
|
)
|
||||||
|
writeToStdout(`Installing update via ${pkgManager}...\n`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pkgManager === 'bun') {
|
||||||
|
execSync(`bun update -g ${PACKAGE_NAME}`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: homedir(),
|
||||||
|
timeout: 120_000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
execSync(`npm install -g ${PACKAGE_NAME}@latest`, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: homedir(),
|
||||||
|
timeout: 120_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToStdout(
|
||||||
|
chalk.green(
|
||||||
|
`Successfully updated from ${currentVersion} to ${latestVersion}`,
|
||||||
|
) + '\n',
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(chalk.red('Update failed') + '\n')
|
||||||
|
process.stderr.write(`${error}\n`)
|
||||||
|
process.stderr.write('\n')
|
||||||
|
process.stderr.write('Try manually updating with:\n')
|
||||||
|
if (pkgManager === 'bun') {
|
||||||
|
process.stderr.write(chalk.bold(` bun update -g ${PACKAGE_NAME}`) + '\n')
|
||||||
|
} else {
|
||||||
|
process.stderr.write(
|
||||||
|
chalk.bold(` npm install -g ${PACKAGE_NAME}@latest`) + '\n',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await gracefulShutdown(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await gracefulShutdown(0)
|
||||||
|
}
|
||||||
@@ -18,9 +18,7 @@ import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
|||||||
import { stripSignatureBlocks } from '../../utils/messages.js'
|
import { stripSignatureBlocks } from '../../utils/messages.js'
|
||||||
import {
|
import {
|
||||||
checkAndDisableAutoModeIfNeeded,
|
checkAndDisableAutoModeIfNeeded,
|
||||||
checkAndDisableBypassPermissionsIfNeeded,
|
|
||||||
resetAutoModeGateCheck,
|
resetAutoModeGateCheck,
|
||||||
resetBypassPermissionsCheck,
|
|
||||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
||||||
import { resetUserCache } from '../../utils/user.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)
|
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
|
||||||
void enrollTrustedDevice()
|
void enrollTrustedDevice()
|
||||||
// Reset killswitch gate checks and re-run with new org
|
// Reset killswitch gate checks and re-run with new org
|
||||||
resetBypassPermissionsCheck()
|
resetAutoModeGateCheck()
|
||||||
const appState = context.getAppState()
|
const appState = context.getAppState()
|
||||||
void checkAndDisableBypassPermissionsIfNeeded(
|
void checkAndDisableAutoModeIfNeeded(
|
||||||
appState.toolPermissionContext,
|
appState.toolPermissionContext,
|
||||||
context.setAppState,
|
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)
|
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
|
||||||
context.setAppState(prev => ({
|
context.setAppState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAllowedChannels,
|
getAllowedChannels,
|
||||||
getHasDevChannels,
|
getHasDevChannels,
|
||||||
} from '../../bootstrap/state.js'
|
} from '../../bootstrap/state.js'
|
||||||
|
import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js'
|
||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
||||||
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
||||||
@@ -75,25 +76,39 @@ function formatEntry(c: ChannelEntry): string {
|
|||||||
|
|
||||||
type Unmatched = { entry: ChannelEntry; why: string }
|
type Unmatched = { entry: ChannelEntry; why: string }
|
||||||
|
|
||||||
function findUnmatched(
|
type FindUnmatchedDeps = {
|
||||||
|
configuredServerNames?: ReadonlySet<string>
|
||||||
|
installedPluginIds?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUnmatched(
|
||||||
entries: readonly ChannelEntry[],
|
entries: readonly ChannelEntry[],
|
||||||
|
deps?: FindUnmatchedDeps,
|
||||||
): Unmatched[] {
|
): Unmatched[] {
|
||||||
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
||||||
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
||||||
// redo that walk per entry.
|
// redo that walk per entry.
|
||||||
const scopes = ['enterprise', 'user', 'project', 'local'] as const
|
const configured = deps?.configuredServerNames ?? (() => {
|
||||||
const configured = new Set<string>()
|
const scopes = ['enterprise', 'user', 'project', 'local'] as const
|
||||||
for (const scope of scopes) {
|
const names = new Set<string>()
|
||||||
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
for (const scope of scopes) {
|
||||||
configured.add(name)
|
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
||||||
|
names.add(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return names
|
||||||
|
})()
|
||||||
|
|
||||||
// Plugin-kind installed check: installed_plugins.json keys are
|
// Plugin-kind installed check: installed_plugins.json keys are
|
||||||
// `name@marketplace`. loadInstalledPluginsV2 is cached.
|
// `name@marketplace`. loadInstalledPluginsV2 is cached.
|
||||||
const installedPluginIds = new Set(
|
const installedPluginIds = deps?.installedPluginIds ?? (() => {
|
||||||
Object.keys(loadInstalledPluginsV2().plugins),
|
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[] = []
|
const out: Unmatched[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|||||||
17
src/components/LogoV2/__tests__/ChannelsNotice.test.ts
Normal file
17
src/components/LogoV2/__tests__/ChannelsNotice.test.ts
Normal 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -151,16 +151,14 @@ import {
|
|||||||
isOpus1mMergeEnabled,
|
isOpus1mMergeEnabled,
|
||||||
modelDisplayString,
|
modelDisplayString,
|
||||||
} from '../../utils/model/model.js'
|
} from '../../utils/model/model.js'
|
||||||
import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'
|
|
||||||
import {
|
import {
|
||||||
cyclePermissionMode,
|
cyclePermissionMode,
|
||||||
getNextPermissionMode,
|
getNextPermissionMode,
|
||||||
} from '../../utils/permissions/getNextPermissionMode.js'
|
} from '../../utils/permissions/getNextPermissionMode.js'
|
||||||
import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'
|
|
||||||
import { getPlatform } from '../../utils/platform.js'
|
import { getPlatform } from '../../utils/platform.js'
|
||||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
||||||
import { editPromptInEditor } from '../../utils/promptEditor.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 { findBtwTriggerPositions } from '../../utils/sideQuestion.js'
|
||||||
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'
|
||||||
import {
|
import {
|
||||||
@@ -187,7 +185,7 @@ import {
|
|||||||
findUltraplanTriggerPositions,
|
findUltraplanTriggerPositions,
|
||||||
findUltrareviewTriggerPositions,
|
findUltrareviewTriggerPositions,
|
||||||
} from '../../utils/ultraplan/keyword.js'
|
} 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 { BridgeDialog } from '../BridgeDialog.js'
|
||||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
|
||||||
import {
|
import {
|
||||||
@@ -571,10 +569,6 @@ function PromptInput({
|
|||||||
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
|
const [showHistoryPicker, setShowHistoryPicker] = useState(false)
|
||||||
const [showFastModePicker, setShowFastModePicker] = useState(false)
|
const [showFastModePicker, setShowFastModePicker] = useState(false)
|
||||||
const [showThinkingToggle, setShowThinkingToggle] = 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
|
// Check if cursor is on the first line of input
|
||||||
const isCursorOnFirstLine = useMemo(() => {
|
const isCursorOnFirstLine = useMemo(() => {
|
||||||
@@ -1883,86 +1877,11 @@ function PromptInput({
|
|||||||
|
|
||||||
// Compute the next mode without triggering side effects first
|
// Compute the next mode without triggering side effects first
|
||||||
logForDebugging(
|
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)
|
const nextMode = getNextPermissionMode(toolPermissionContext, teamContext)
|
||||||
|
|
||||||
// Check if user is entering auto mode for the first time. Gated on the
|
// Call cyclePermissionMode to apply side effects (e.g. strip
|
||||||
// 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
|
|
||||||
// dangerous permissions, activate classifier)
|
// dangerous permissions, activate classifier)
|
||||||
const { context: preparedContext } = cyclePermissionMode(
|
const { context: preparedContext } = cyclePermissionMode(
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
@@ -2007,91 +1926,10 @@ function PromptInput({
|
|||||||
}, [
|
}, [
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
teamContext,
|
teamContext,
|
||||||
viewingAgentTaskId,
|
|
||||||
viewedTeammate,
|
viewedTeammate,
|
||||||
setAppState,
|
setAppState,
|
||||||
setToolPermissionContext,
|
setToolPermissionContext,
|
||||||
helpOpen,
|
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
|
// 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
|
// Portal dialog to DialogOverlay in fullscreen so it escapes the bottom
|
||||||
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
|
// slot's overflowY:hidden clip (same pattern as SuggestionsOverlay).
|
||||||
// Must be called before early returns below to satisfy rules-of-hooks.
|
// Must be called before early returns below to satisfy rules-of-hooks.
|
||||||
// Memoized so the portal useEffect doesn't churn on every PromptInput render.
|
useSetPromptOverlayDialog(null)
|
||||||
const autoModeOptInDialog = useMemo(
|
|
||||||
() =>
|
|
||||||
feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? (
|
|
||||||
<AutoModeOptInDialog
|
|
||||||
onAccept={handleAutoModeOptInAccept}
|
|
||||||
onDecline={handleAutoModeOptInDecline}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
[showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline],
|
|
||||||
)
|
|
||||||
useSetPromptOverlayDialog(
|
|
||||||
isFullscreenEnvEnabled() ? autoModeOptInDialog : null,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showBashesDialog) {
|
if (showBashesDialog) {
|
||||||
return (
|
return (
|
||||||
@@ -3077,7 +2902,6 @@ function PromptInput({
|
|||||||
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
|
isFullscreenEnvEnabled() ? handleOpenTasksDialog : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isFullscreenEnvEnabled() ? null : autoModeOptInDialog}
|
|
||||||
{isFullscreenEnvEnabled() ? (
|
{isFullscreenEnvEnabled() ? (
|
||||||
// position=absolute takes zero layout height so the spinner
|
// position=absolute takes zero layout height so the spinner
|
||||||
// doesn't shift when a notification appears/disappears. Yoga
|
// doesn't shift when a notification appears/disappears. Yoga
|
||||||
@@ -3098,7 +2922,7 @@ function PromptInput({
|
|||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
marginTop={briefOwnsGap ? -2 : -1}
|
marginTop={briefOwnsGap ? -2 : -1}
|
||||||
height={suggestions.length === 0 && !showAutoModeOptIn ? 1 : 0}
|
height={suggestions.length === 0 ? 1 : 0}
|
||||||
width="100%"
|
width="100%"
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
logEvent,
|
logEvent,
|
||||||
} from '../../services/analytics/index.js'
|
} from '../../services/analytics/index.js'
|
||||||
|
import { createTrace, endTrace, isLangfuseEnabled } from '../../services/langfuse/index.js'
|
||||||
|
import { getSessionId } from '../../bootstrap/state.js'
|
||||||
|
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||||
import { jsonParse } from '../../utils/slowOperations.js'
|
import { jsonParse } from '../../utils/slowOperations.js'
|
||||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||||
|
|
||||||
@@ -146,6 +149,15 @@ export async function generateAgent(
|
|||||||
? AGENT_CREATION_SYSTEM_PROMPT + AGENT_MEMORY_INSTRUCTIONS
|
? AGENT_CREATION_SYSTEM_PROMPT + AGENT_MEMORY_INSTRUCTIONS
|
||||||
: AGENT_CREATION_SYSTEM_PROMPT
|
: AGENT_CREATION_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
const langfuseTrace = isLangfuseEnabled()
|
||||||
|
? createTrace({
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model,
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
name: 'agent-creation',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const response = await queryModelWithoutStreaming({
|
const response = await queryModelWithoutStreaming({
|
||||||
messages: normalizeMessagesForAPI(messagesWithContext),
|
messages: normalizeMessagesForAPI(messagesWithContext),
|
||||||
systemPrompt: asSystemPrompt([systemPrompt]),
|
systemPrompt: asSystemPrompt([systemPrompt]),
|
||||||
@@ -161,9 +173,12 @@ export async function generateAgent(
|
|||||||
hasAppendSystemPrompt: false,
|
hasAppendSystemPrompt: false,
|
||||||
querySource: 'agent_creation',
|
querySource: 'agent_creation',
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
|
langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
endTrace(langfuseTrace)
|
||||||
|
|
||||||
const textBlocks = (Array.isArray(response.message.content) ? response.message.content : []).filter(
|
const textBlocks = (Array.isArray(response.message.content) ? response.message.content : []).filter(
|
||||||
(block): block is ContentBlock & { type: 'text' } => block.type === 'text',
|
(block): block is ContentBlock & { type: 'text' } => block.type === 'text',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,6 +140,31 @@ async function main(): Promise<void> {
|
|||||||
return
|
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).
|
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||||
// Must come before the daemon subcommand check: spawned per-worker, so
|
// Must come before the daemon subcommand check: spawned per-worker, so
|
||||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { CHANNEL_TAG } from 'src/constants/xml.js'
|
||||||
import { logForDebugging } from 'src/utils/debug.js'
|
import { logForDebugging } from 'src/utils/debug.js'
|
||||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||||
@@ -46,6 +47,76 @@ type InteractivePermissionParams = {
|
|||||||
channelCallbacks?: ChannelPermissionCallbacks
|
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.
|
* Handles the interactive (main-agent) permission flow.
|
||||||
*
|
*
|
||||||
@@ -420,6 +491,17 @@ function handleInteractivePermission(
|
|||||||
description,
|
description,
|
||||||
input_preview: truncateForPreview(displayInput),
|
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) {
|
for (const client of channelClients) {
|
||||||
if (client.type !== 'connected') continue // refine for TS
|
if (client.type !== 'connected') continue // refine for TS
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import type { PermissionMode } from './utils/permissions/PermissionMode.js'
|
|||||||
import { getBaseRenderOptions } from './utils/renderOptions.js'
|
import { getBaseRenderOptions } from './utils/renderOptions.js'
|
||||||
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
|
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
|
||||||
import {
|
import {
|
||||||
hasAutoModeOptIn,
|
|
||||||
hasSkipDangerousModePermissionPrompt,
|
hasSkipDangerousModePermissionPrompt,
|
||||||
} from './utils/settings/settings.js'
|
} 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
|
// --dangerously-load-development-channels confirmation. On accept, append
|
||||||
// dev channels to any --channels list already set in main.tsx. Org policy
|
// dev channels to any --channels list already set in main.tsx. Org policy
|
||||||
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
|
||||||
|
|||||||
22
src/main.tsx
22
src/main.tsx
@@ -242,7 +242,6 @@ import {
|
|||||||
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
|
import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js";
|
||||||
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
|
import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js";
|
||||||
import {
|
import {
|
||||||
checkAndDisableBypassPermissions,
|
|
||||||
getAutoModeEnabledStateIfCached,
|
getAutoModeEnabledStateIfCached,
|
||||||
initializeToolPermissionContext,
|
initializeToolPermissionContext,
|
||||||
initialPermissionModeFromCLI,
|
initialPermissionModeFromCLI,
|
||||||
@@ -3910,19 +3909,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
onChangeAppState,
|
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.
|
// 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")) {
|
if (feature("TRANSCRIPT_CLASSIFIER")) {
|
||||||
void verifyAutoModeGateAccess(
|
void verifyAutoModeGateAccess(
|
||||||
toolPermissionContext,
|
toolPermissionContext,
|
||||||
@@ -6564,6 +6551,15 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// claude update — update ccb to the latest version via npm or bun
|
||||||
|
program
|
||||||
|
.command("update")
|
||||||
|
.description("Update claude-code-best (ccb) to the latest version")
|
||||||
|
.action(async () => {
|
||||||
|
const { updateCCB } = await import("./cli/updateCCB.js");
|
||||||
|
await updateCCB();
|
||||||
|
});
|
||||||
|
|
||||||
// ant-only commands
|
// ant-only commands
|
||||||
if (process.env.USER_TYPE === "ant") {
|
if (process.env.USER_TYPE === "ant") {
|
||||||
const validateLogId = (value: string) => {
|
const validateLogId = (value: string) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logForDebugging } from '../utils/debug.js'
|
|||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import { getDefaultSonnetModel } from '../utils/model/model.js'
|
import { getDefaultSonnetModel } from '../utils/model/model.js'
|
||||||
import { sideQuery } from '../utils/sideQuery.js'
|
import { sideQuery } from '../utils/sideQuery.js'
|
||||||
|
import type { LangfuseSpan } from '../services/langfuse/index.js'
|
||||||
import { jsonParse } from '../utils/slowOperations.js'
|
import { jsonParse } from '../utils/slowOperations.js'
|
||||||
import {
|
import {
|
||||||
formatMemoryManifest,
|
formatMemoryManifest,
|
||||||
@@ -42,6 +43,7 @@ export async function findRelevantMemories(
|
|||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
recentTools: readonly string[] = [],
|
recentTools: readonly string[] = [],
|
||||||
alreadySurfaced: ReadonlySet<string> = new Set(),
|
alreadySurfaced: ReadonlySet<string> = new Set(),
|
||||||
|
parentSpan?: LangfuseSpan | null,
|
||||||
): Promise<RelevantMemory[]> {
|
): Promise<RelevantMemory[]> {
|
||||||
const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
|
const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
|
||||||
m => !alreadySurfaced.has(m.filePath),
|
m => !alreadySurfaced.has(m.filePath),
|
||||||
@@ -55,6 +57,7 @@ export async function findRelevantMemories(
|
|||||||
memories,
|
memories,
|
||||||
signal,
|
signal,
|
||||||
recentTools,
|
recentTools,
|
||||||
|
parentSpan,
|
||||||
)
|
)
|
||||||
const byFilename = new Map(memories.map(m => [m.filename, m]))
|
const byFilename = new Map(memories.map(m => [m.filename, m]))
|
||||||
const selected = selectedFilenames
|
const selected = selectedFilenames
|
||||||
@@ -79,6 +82,7 @@ async function selectRelevantMemories(
|
|||||||
memories: MemoryHeader[],
|
memories: MemoryHeader[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
recentTools: readonly string[],
|
recentTools: readonly string[],
|
||||||
|
parentSpan?: LangfuseSpan | null,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const validFilenames = new Set(memories.map(m => m.filename))
|
const validFilenames = new Set(memories.map(m => m.filename))
|
||||||
|
|
||||||
@@ -119,6 +123,8 @@ async function selectRelevantMemories(
|
|||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
querySource: 'memdir_relevance',
|
querySource: 'memdir_relevance',
|
||||||
|
optional: true,
|
||||||
|
parentSpan,
|
||||||
})
|
})
|
||||||
|
|
||||||
const textBlock = result.content.find(block => block.type === 'text')
|
const textBlock = result.content.find(block => block.type === 'text')
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
* 2. Call registerBuiltinPlugin() with the plugin definition here
|
* 2. Call registerBuiltinPlugin() with the plugin definition here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { registerWeixinBuiltinPlugin } from './weixin.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize built-in plugins. Called during CLI startup.
|
* Initialize built-in plugins. Called during CLI startup.
|
||||||
*/
|
*/
|
||||||
export function initBuiltinPlugins(): void {
|
export function initBuiltinPlugins(): void {
|
||||||
// No built-in plugins registered yet — this is the scaffolding for
|
registerWeixinBuiltinPlugin()
|
||||||
// migrating bundled skills that should be user-toggleable.
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/plugins/bundled/weixin.ts
Normal file
21
src/plugins/bundled/weixin.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -235,6 +235,9 @@ export async function* query(
|
|||||||
// When called as a sub-agent, langfuseTrace is already set by runAgent()
|
// When called as a sub-agent, langfuseTrace is already set by runAgent()
|
||||||
// — reuse it instead of creating an independent trace.
|
// — reuse it instead of creating an independent trace.
|
||||||
const ownsTrace = !params.toolUseContext.langfuseTrace
|
const ownsTrace = !params.toolUseContext.langfuseTrace
|
||||||
|
logForDebugging(
|
||||||
|
`[query] ownsTrace=${ownsTrace} incoming langfuseTrace=${params.toolUseContext.langfuseTrace ? 'present' : 'null/undefined'} isLangfuseEnabled=${isLangfuseEnabled()}`,
|
||||||
|
)
|
||||||
const langfuseTrace = params.toolUseContext.langfuseTrace
|
const langfuseTrace = params.toolUseContext.langfuseTrace
|
||||||
?? (isLangfuseEnabled()
|
?? (isLangfuseEnabled()
|
||||||
? createTrace({
|
? createTrace({
|
||||||
|
|||||||
@@ -422,9 +422,7 @@ import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInCh
|
|||||||
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js';
|
||||||
import type { Theme } from 'src/utils/theme.js';
|
import type { Theme } from 'src/utils/theme.js';
|
||||||
import {
|
import {
|
||||||
checkAndDisableBypassPermissionsIfNeeded,
|
|
||||||
checkAndDisableAutoModeIfNeeded,
|
checkAndDisableAutoModeIfNeeded,
|
||||||
useKickOffCheckAndDisableBypassPermissionsIfNeeded,
|
|
||||||
useKickOffCheckAndDisableAutoModeIfNeeded,
|
useKickOffCheckAndDisableAutoModeIfNeeded,
|
||||||
} from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
} from 'src/utils/permissions/bypassPermissionsKillswitch.js';
|
||||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.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 { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js';
|
||||||
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
|
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js';
|
||||||
import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.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 { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js';
|
||||||
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
|
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js';
|
||||||
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
|
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js';
|
||||||
@@ -948,7 +945,6 @@ export function REPL({
|
|||||||
[toolPermissionContext, proactiveActive, isBriefOnly],
|
[toolPermissionContext, proactiveActive, isBriefOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKickOffCheckAndDisableBypassPermissionsIfNeeded();
|
|
||||||
useKickOffCheckAndDisableAutoModeIfNeeded();
|
useKickOffCheckAndDisableAutoModeIfNeeded();
|
||||||
|
|
||||||
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
|
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(
|
||||||
@@ -1006,7 +1002,6 @@ export function REPL({
|
|||||||
useCanSwitchToExistingSubscription();
|
useCanSwitchToExistingSubscription();
|
||||||
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
|
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
|
||||||
useMcpConnectivityStatus({ mcpClients });
|
useMcpConnectivityStatus({ mcpClients });
|
||||||
useAutoModeUnavailableNotification();
|
|
||||||
usePluginInstallationStatus();
|
usePluginInstallationStatus();
|
||||||
usePluginAutoupdateNotification();
|
usePluginAutoupdateNotification();
|
||||||
useSettingsErrors();
|
useSettingsErrors();
|
||||||
@@ -3314,8 +3309,8 @@ export function REPL({
|
|||||||
queryCheckpoint('query_context_loading_start');
|
queryCheckpoint('query_context_loading_start');
|
||||||
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||||
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
// IMPORTANT: do this after setMessages() above, to avoid UI jank
|
||||||
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
|
undefined,
|
||||||
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
|
// Fast-mode circuit breaker check
|
||||||
feature('TRANSCRIPT_CLASSIFIER')
|
feature('TRANSCRIPT_CLASSIFIER')
|
||||||
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
|
? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const mockGetDefaultAppState = mock(() => ({
|
|||||||
alwaysAllowRules: { user: [], project: [], local: [] },
|
alwaysAllowRules: { user: [], project: [], local: [] },
|
||||||
alwaysDenyRules: { user: [], project: [], local: [] },
|
alwaysDenyRules: { user: [], project: [], local: [] },
|
||||||
alwaysAskRules: { user: [], project: [], local: [] },
|
alwaysAskRules: { user: [], project: [], local: [] },
|
||||||
isBypassPermissionsModeAvailable: false,
|
isBypassPermissionsModeAvailable: true,
|
||||||
},
|
},
|
||||||
fastMode: false,
|
fastMode: false,
|
||||||
settings: {},
|
settings: {},
|
||||||
@@ -627,6 +627,23 @@ describe('AcpAgent', () => {
|
|||||||
agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any),
|
agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any),
|
||||||
).rejects.toThrow('Session not found')
|
).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', () => {
|
describe('setSessionConfigOption', () => {
|
||||||
|
|||||||
@@ -519,12 +519,15 @@ export class AcpAgent implements Agent {
|
|||||||
|
|
||||||
const queryEngine = new QueryEngine(engineConfig)
|
const queryEngine = new QueryEngine(engineConfig)
|
||||||
|
|
||||||
// Build modes
|
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
|
||||||
const availableModes = [
|
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: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit 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: '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" },
|
{ id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { getSmallFastModel } from '../utils/model/model.js'
|
|||||||
import { asSystemPrompt } from '../utils/systemPromptType.js'
|
import { asSystemPrompt } from '../utils/systemPromptType.js'
|
||||||
import { getResolvedLanguage } from '../utils/language.js'
|
import { getResolvedLanguage } from '../utils/language.js'
|
||||||
import { queryModelWithoutStreaming } from './api/claude.js'
|
import { queryModelWithoutStreaming } from './api/claude.js'
|
||||||
|
import { createTrace, endTrace, isLangfuseEnabled } from './langfuse/index.js'
|
||||||
|
import { getSessionId } from '../bootstrap/state.js'
|
||||||
|
import { getAPIProvider } from '../utils/model/providers.js'
|
||||||
import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
|
import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
|
||||||
|
|
||||||
// Recap only needs recent context — truncate to avoid "prompt too long" on
|
// Recap only needs recent context — truncate to avoid "prompt too long" on
|
||||||
@@ -42,6 +45,16 @@ export async function generateAwaySummary(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const model = getSmallFastModel()
|
||||||
|
const langfuseTrace = isLangfuseEnabled()
|
||||||
|
? createTrace({
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model,
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
name: 'away-summary',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const memory = await getSessionMemoryContent()
|
const memory = await getSessionMemoryContent()
|
||||||
const recent = messages.slice(-RECENT_MESSAGE_WINDOW)
|
const recent = messages.slice(-RECENT_MESSAGE_WINDOW)
|
||||||
@@ -54,7 +67,7 @@ export async function generateAwaySummary(
|
|||||||
signal,
|
signal,
|
||||||
options: {
|
options: {
|
||||||
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
|
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
|
||||||
model: getSmallFastModel(),
|
model,
|
||||||
toolChoice: undefined,
|
toolChoice: undefined,
|
||||||
isNonInteractiveSession: false,
|
isNonInteractiveSession: false,
|
||||||
hasAppendSystemPrompt: false,
|
hasAppendSystemPrompt: false,
|
||||||
@@ -62,6 +75,7 @@ export async function generateAwaySummary(
|
|||||||
querySource: 'away_summary',
|
querySource: 'away_summary',
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
skipCacheWrite: true,
|
skipCacheWrite: true,
|
||||||
|
langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,14 +83,17 @@ export async function generateAwaySummary(
|
|||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[awaySummary] API error: ${getAssistantMessageText(response)}`,
|
`[awaySummary] API error: ${getAssistantMessageText(response)}`,
|
||||||
)
|
)
|
||||||
|
endTrace(langfuseTrace, undefined, 'error')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
endTrace(langfuseTrace)
|
||||||
return getAssistantMessageText(response)
|
return getAssistantMessageText(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof APIUserAbortError || signal.aborted) {
|
if (err instanceof APIUserAbortError || signal.aborted) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
logForDebugging(`[awaySummary] generation failed: ${err}`)
|
logForDebugging(`[awaySummary] generation failed: ${err}`)
|
||||||
|
endTrace(langfuseTrace, undefined, 'error')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1326,6 +1326,7 @@ async function streamCompactSummary({
|
|||||||
agents: context.options.agentDefinitions.activeAgents,
|
agents: context.options.agentDefinitions.activeAgents,
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
effortValue: appState.effortValue,
|
effortValue: appState.effortValue,
|
||||||
|
langfuseTrace: context.langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const streamIter = streamingGen[Symbol.asyncIterator]()
|
const streamIter = streamingGen[Symbol.asyncIterator]()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js'
|
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 type { LangfuseSpan } from './tracing.js'
|
||||||
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'
|
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'
|
||||||
|
|||||||
@@ -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(
|
export function endTrace(
|
||||||
rootSpan: LangfuseSpan | null,
|
rootSpan: LangfuseSpan | null,
|
||||||
output?: unknown,
|
output?: unknown,
|
||||||
|
|||||||
17
src/services/mcp/__tests__/channelAllowlist.test.ts
Normal file
17
src/services/mcp/__tests__/channelAllowlist.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ mock.module("src/services/analytics/growthbook.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
filterPermissionRelayClients,
|
||||||
shortRequestId,
|
shortRequestId,
|
||||||
truncateForPreview,
|
truncateForPreview,
|
||||||
PERMISSION_REPLY_RE,
|
PERMISSION_REPLY_RE,
|
||||||
@@ -160,3 +161,34 @@ describe("createChannelPermissionCallbacks", () => {
|
|||||||
expect(received?.behavior).toBe("deny");
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
|
import { BUILTIN_MARKETPLACE_NAME } from '../../plugins/builtinPlugins.js'
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
||||||
@@ -68,6 +69,9 @@ export function isChannelAllowlisted(
|
|||||||
if (!pluginSource) return false
|
if (!pluginSource) return false
|
||||||
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
||||||
if (!marketplace) return false
|
if (!marketplace) return false
|
||||||
|
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return getChannelAllowlist().some(
|
return getChannelAllowlist().some(
|
||||||
e => e.plugin === name && e.marketplace === marketplace,
|
e => e.plugin === name && e.marketplace === marketplace,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,33 @@ export type ChannelPermissionRequestParams = {
|
|||||||
* input is in the local terminal dialog; this is a phone-sized
|
* input is in the local terminal dialog; this is a phone-sized
|
||||||
* preview. Server decides whether/how to show it. */
|
* preview. Server decides whether/how to show it. */
|
||||||
input_preview: string
|
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
|
* Meta keys become XML attribute NAMES — a crafted key like
|
||||||
* `x="" injected="y` would break out of the attribute structure. Only
|
* `x="" injected="y` would break out of the attribute structure. Only
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
|||||||
* don't apply until restart.
|
* don't apply until restart.
|
||||||
*/
|
*/
|
||||||
export function isChannelPermissionRelayEnabled(): boolean {
|
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 = {
|
export type ChannelPermissionResponse = {
|
||||||
@@ -188,8 +188,8 @@ export function filterPermissionRelayClients<
|
|||||||
(c): c is T & { type: 'connected' } =>
|
(c): c is T & { type: 'connected' } =>
|
||||||
c.type === 'connected' &&
|
c.type === 'connected' &&
|
||||||
isInAllowlist(c.name) &&
|
isInAllowlist(c.name) &&
|
||||||
c.capabilities?.experimental?.['claude/channel'] !== undefined &&
|
Boolean(c.capabilities?.experimental?.['claude/channel']) &&
|
||||||
c.capabilities?.experimental?.['claude/channel/permission'] !== undefined,
|
Boolean(c.capabilities?.experimental?.['claude/channel/permission']),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ export function useManageMCPConnections(
|
|||||||
if (
|
if (
|
||||||
client.capabilities?.experimental?.[
|
client.capabilities?.experimental?.[
|
||||||
'claude/channel/permission'
|
'claude/channel/permission'
|
||||||
] !== undefined
|
]
|
||||||
) {
|
) {
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
ChannelPermissionNotificationSchema(),
|
ChannelPermissionNotificationSchema(),
|
||||||
|
|||||||
@@ -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.`,
|
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
|
||||||
cooldownSessions: 5,
|
cooldownSessions: 5,
|
||||||
isRelevant: async () => {
|
isRelevant: async () => {
|
||||||
if (process.env.USER_TYPE === 'ant') return false
|
|
||||||
const config = getGlobalConfig()
|
const config = getGlobalConfig()
|
||||||
// Show to users who haven't used plan mode recently (7+ days)
|
// Show to users who haven't used plan mode recently (7+ days)
|
||||||
const daysSinceLastUse = config.lastPlanModeUse
|
const daysSinceLastUse = config.lastPlanModeUse
|
||||||
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
|
|||||||
{
|
{
|
||||||
id: 'shift-tab',
|
id: 'shift-tab',
|
||||||
content: async () =>
|
content: async () =>
|
||||||
process.env.USER_TYPE === 'ant'
|
`Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default, accept edits, plan, auto, and bypass modes`,
|
||||||
? `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`,
|
|
||||||
cooldownSessions: 10,
|
cooldownSessions: 10,
|
||||||
isRelevant: async () => true,
|
isRelevant: async () => true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { jsonStringify } from '../utils/slowOperations.js'
|
|||||||
import { isToolReferenceBlock } from '../utils/toolSearch.js'
|
import { isToolReferenceBlock } from '../utils/toolSearch.js'
|
||||||
import { getAPIMetadata, getExtraBodyParams } from './api/claude.js'
|
import { getAPIMetadata, getExtraBodyParams } from './api/claude.js'
|
||||||
import { getAnthropicClient } from './api/client.js'
|
import { getAnthropicClient } from './api/client.js'
|
||||||
|
import { createTrace, endTrace, isLangfuseEnabled, recordLLMObservation } from './langfuse/index.js'
|
||||||
|
import { getSessionId } from '../bootstrap/state.js'
|
||||||
import { withTokenCountVCR } from './vcr.js'
|
import { withTokenCountVCR } from './vcr.js'
|
||||||
|
|
||||||
// Minimal values for token counting with thinking enabled
|
// Minimal values for token counting with thinking enabled
|
||||||
@@ -309,6 +311,15 @@ export async function countTokensViaHaikuFallback(
|
|||||||
: betas
|
: betas
|
||||||
|
|
||||||
// biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support
|
// biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support
|
||||||
|
const apiStart = Date.now()
|
||||||
|
const langfuseTrace = isLangfuseEnabled()
|
||||||
|
? createTrace({
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model: normalizeModelStringForAPI(model),
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
name: 'token-estimation',
|
||||||
|
})
|
||||||
|
: null
|
||||||
const response = await anthropic.beta.messages.create({
|
const response = await anthropic.beta.messages.create({
|
||||||
model: normalizeModelStringForAPI(model),
|
model: normalizeModelStringForAPI(model),
|
||||||
max_tokens: containsThinking ? TOKEN_COUNT_MAX_TOKENS : 1,
|
max_tokens: containsThinking ? TOKEN_COUNT_MAX_TOKENS : 1,
|
||||||
@@ -331,6 +342,22 @@ export async function countTokensViaHaikuFallback(
|
|||||||
const cacheCreationTokens = usage.cache_creation_input_tokens || 0
|
const cacheCreationTokens = usage.cache_creation_input_tokens || 0
|
||||||
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
const cacheReadTokens = usage.cache_read_input_tokens || 0
|
||||||
|
|
||||||
|
recordLLMObservation(langfuseTrace, {
|
||||||
|
model: normalizeModelStringForAPI(model),
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
input: messagesToSend,
|
||||||
|
output: response.content,
|
||||||
|
usage: {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: cacheCreationTokens || undefined,
|
||||||
|
cache_read_input_tokens: cacheReadTokens || undefined,
|
||||||
|
},
|
||||||
|
startTime: new Date(apiStart),
|
||||||
|
endTime: new Date(),
|
||||||
|
})
|
||||||
|
endTrace(langfuseTrace)
|
||||||
|
|
||||||
return inputTokens + cacheCreationTokens + cacheReadTokens
|
return inputTokens + cacheCreationTokens + cacheReadTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
notifySessionMetadataChanged,
|
notifySessionMetadataChanged,
|
||||||
type SessionExternalMetadata,
|
type SessionExternalMetadata,
|
||||||
} from '../utils/sessionState.js'
|
} from '../utils/sessionState.js'
|
||||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
|
||||||
import type { AppState } from './AppStateStore.js'
|
import type { AppState } from './AppStateStore.js'
|
||||||
|
|
||||||
// Inverse of the push below — restore on worker restart.
|
// Inverse of the push below — restore on worker restart.
|
||||||
@@ -91,23 +90,11 @@ export function onChangeAppState({
|
|||||||
notifyPermissionModeChanged(newMode)
|
notifyPermissionModeChanged(newMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainLoopModel: remove it from settings?
|
// mainLoopModel: session-scoped only (do NOT persist to userSettings).
|
||||||
if (
|
// Writing to settings.json would leak model changes into other running
|
||||||
newState.mainLoopModel !== oldState.mainLoopModel &&
|
// sessions (anthropics/claude-code#37596). Each process keeps its own
|
||||||
newState.mainLoopModel === null
|
// model override in memory via setMainLoopModelOverride.
|
||||||
) {
|
if (newState.mainLoopModel !== oldState.mainLoopModel) {
|
||||||
// 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 })
|
|
||||||
setMainLoopModelOverride(newState.mainLoopModel)
|
setMainLoopModelOverride(newState.mainLoopModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -457,9 +457,14 @@ describe("buildClassifierUnavailableMessage", () => {
|
|||||||
expect(msg).toContain("classifier-v1");
|
expect(msg).toContain("classifier-v1");
|
||||||
expect(msg).toContain("unavailable");
|
expect(msg).toContain("unavailable");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tells the model to wait and retry later", () => {
|
||||||
|
const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
|
||||||
|
expect(msg).toContain("Wait briefly and then try this action again.");
|
||||||
|
expect(msg).toContain("come back to it later");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── normalizeMessages ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("normalizeMessages", () => {
|
describe("normalizeMessages", () => {
|
||||||
test("splits multi-block assistant message into individual messages", () => {
|
test("splits multi-block assistant message into individual messages", () => {
|
||||||
|
|||||||
@@ -2201,6 +2201,7 @@ async function getRelevantMemoryAttachments(
|
|||||||
recentTools: readonly string[],
|
recentTools: readonly string[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
alreadySurfaced: ReadonlySet<string>,
|
alreadySurfaced: ReadonlySet<string>,
|
||||||
|
parentSpan?: unknown,
|
||||||
): Promise<Attachment[]> {
|
): Promise<Attachment[]> {
|
||||||
// If an agent is @-mentioned, search only its memory dir (isolation).
|
// If an agent is @-mentioned, search only its memory dir (isolation).
|
||||||
// Otherwise search the auto-memory dir.
|
// Otherwise search the auto-memory dir.
|
||||||
@@ -2221,6 +2222,7 @@ async function getRelevantMemoryAttachments(
|
|||||||
signal,
|
signal,
|
||||||
recentTools,
|
recentTools,
|
||||||
alreadySurfaced,
|
alreadySurfaced,
|
||||||
|
parentSpan as Parameters<typeof findRelevantMemories>[5],
|
||||||
).catch(() => []),
|
).catch(() => []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2370,6 +2372,12 @@ export function startRelevantMemoryPrefetch(
|
|||||||
return undefined
|
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)
|
const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta)
|
||||||
if (!lastUserMessage) {
|
if (!lastUserMessage) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -2397,6 +2405,7 @@ export function startRelevantMemoryPrefetch(
|
|||||||
collectRecentSuccessfulTools(messages, lastUserMessage),
|
collectRecentSuccessfulTools(messages, lastUserMessage),
|
||||||
controller.signal,
|
controller.signal,
|
||||||
surfaced.paths,
|
surfaced.paths,
|
||||||
|
toolUseContext.langfuseTrace,
|
||||||
).catch(e => {
|
).catch(e => {
|
||||||
if (!isAbortError(e)) {
|
if (!isAbortError(e)) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ export function calculateContextPercentages(
|
|||||||
currentUsage.cache_creation_input_tokens +
|
currentUsage.cache_creation_input_tokens +
|
||||||
currentUsage.cache_read_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(
|
const usedPercentage = Math.round(
|
||||||
(totalInputTokens / contextWindowSize) * 100,
|
(totalInputTokens / contextWindowSize) * 100,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -374,6 +374,10 @@ export function createSubagentContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Preserve the parent Langfuse trace separately so nested side queries
|
||||||
|
// like auto_mode can attach to the main agent trace instead of the
|
||||||
|
// subagent's own trace.
|
||||||
|
langfuseRootTrace: parentContext.langfuseTrace,
|
||||||
// Mutable state - cloned by default to maintain isolation
|
// Mutable state - cloned by default to maintain isolation
|
||||||
// Clone overrides.readFileState if provided, otherwise clone from parent
|
// Clone overrides.readFileState if provided, otherwise clone from parent
|
||||||
readFileState: cloneFileStateCache(
|
readFileState: cloneFileStateCache(
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function createApiQueryHook<TResult>(
|
|||||||
querySource: config.name,
|
querySource: config.name,
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
agentId: context.toolUseContext.agentId,
|
agentId: context.toolUseContext.agentId,
|
||||||
|
langfuseTrace: context.toolUseContext.langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Your response must be a JSON object matching one of the following schemas:
|
|||||||
querySource: 'hook_prompt',
|
querySource: 'hook_prompt',
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
agentId: toolUseContext.agentId,
|
agentId: toolUseContext.agentId,
|
||||||
|
langfuseTrace: toolUseContext.langfuseTrace,
|
||||||
outputFormat: {
|
outputFormat: {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
logEvent,
|
logEvent,
|
||||||
} from '../../services/analytics/index.js'
|
} from '../../services/analytics/index.js'
|
||||||
import { queryModelWithoutStreaming } from '../../services/api/claude.js'
|
import { queryModelWithoutStreaming } from '../../services/api/claude.js'
|
||||||
|
import { createTrace, endTrace, isLangfuseEnabled } from '../../services/langfuse/index.js'
|
||||||
|
import { getSessionId } from '../../bootstrap/state.js'
|
||||||
|
import { getAPIProvider } from '../model/providers.js'
|
||||||
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
import { getEmptyToolPermissionContext } from '../../Tool.js'
|
||||||
import type { Message } from '../../types/message.js'
|
import type { Message } from '../../types/message.js'
|
||||||
import { createAbortController } from '../abortController.js'
|
import { createAbortController } from '../abortController.js'
|
||||||
@@ -209,6 +212,16 @@ export async function applySkillImprovement(
|
|||||||
|
|
||||||
const updateList = updates.map(u => `- ${u.section}: ${u.change}`).join('\n')
|
const updateList = updates.map(u => `- ${u.section}: ${u.change}`).join('\n')
|
||||||
|
|
||||||
|
const model = getSmallFastModel()
|
||||||
|
const langfuseTrace = isLangfuseEnabled()
|
||||||
|
? createTrace({
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model,
|
||||||
|
provider: getAPIProvider(),
|
||||||
|
name: 'skill-improvement-apply',
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const response = await queryModelWithoutStreaming({
|
const response = await queryModelWithoutStreaming({
|
||||||
messages: [
|
messages: [
|
||||||
createUserMessage({
|
createUserMessage({
|
||||||
@@ -238,7 +251,7 @@ Rules:
|
|||||||
signal: createAbortController().signal,
|
signal: createAbortController().signal,
|
||||||
options: {
|
options: {
|
||||||
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
|
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
|
||||||
model: getSmallFastModel(),
|
model,
|
||||||
toolChoice: undefined,
|
toolChoice: undefined,
|
||||||
isNonInteractiveSession: false,
|
isNonInteractiveSession: false,
|
||||||
hasAppendSystemPrompt: false,
|
hasAppendSystemPrompt: false,
|
||||||
@@ -246,9 +259,12 @@ Rules:
|
|||||||
agents: [],
|
agents: [],
|
||||||
querySource: 'skill_improvement_apply',
|
querySource: 'skill_improvement_apply',
|
||||||
mcpTools: [],
|
mcpTools: [],
|
||||||
|
langfuseTrace,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
endTrace(langfuseTrace)
|
||||||
|
|
||||||
const responseText = extractTextContent(Array.isArray(response.message.content) ? response.message.content : []).trim()
|
const responseText = extractTextContent(Array.isArray(response.message.content) ? response.message.content : []).trim()
|
||||||
|
|
||||||
const updatedContent = extractTag(responseText, 'updated_file')
|
const updatedContent = extractTag(responseText, 'updated_file')
|
||||||
|
|||||||
78
src/utils/model/__tests__/model-alias-recursion.test.ts
Normal file
78
src/utils/model/__tests__/model-alias-recursion.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { isModelAlias } from "../aliases";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicate the guard used in getDefault*Model to verify it catches
|
||||||
|
* all alias forms that would cause recursion.
|
||||||
|
*/
|
||||||
|
function isAliasOrAliasWithSuffix(value: string): boolean {
|
||||||
|
const base = value.replace(/\[1m\]$/i, "").trim();
|
||||||
|
return isModelAlias(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isAliasOrAliasWithSuffix", () => {
|
||||||
|
test("detects bare 'opus' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("opus")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'opus[1m]' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("opus[1m]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'sonnet' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("sonnet")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'sonnet[1m]' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("sonnet[1m]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'haiku' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("haiku")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'haiku[1m]' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("haiku[1m]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'opusplan' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("opusplan")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects 'best' alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("best")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through concrete model IDs", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-opus-4-6")).toBe(false);
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-sonnet-4-6")).toBe(false);
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-haiku-4-5-20251001")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through concrete model IDs with [1m] suffix", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-opus-4-6[1m]")).toBe(false);
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-sonnet-4-6[1m]")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through 3P provider model IDs", () => {
|
||||||
|
expect(
|
||||||
|
isAliasOrAliasWithSuffix("us.anthropic.claude-opus-4-6-v1:0"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isAliasOrAliasWithSuffix("claude-opus-4-6@20251001")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through arbitrary custom model names", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("my-custom-model")).toBe(false);
|
||||||
|
expect(isAliasOrAliasWithSuffix("gpt-4o")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles whitespace around alias", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix(" opus ")).toBe(true);
|
||||||
|
expect(isAliasOrAliasWithSuffix(" opus[1m] ")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles case insensitivity of [1m] suffix", () => {
|
||||||
|
expect(isAliasOrAliasWithSuffix("opus[1M]")).toBe(true);
|
||||||
|
expect(isAliasOrAliasWithSuffix("sonnet[1M]")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,18 @@ import { getAPIProvider } from './providers.js'
|
|||||||
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
||||||
import { isModelAllowed } from './modelAllowlist.js'
|
import { isModelAllowed } from './modelAllowlist.js'
|
||||||
import { type ModelAlias, isModelAlias } from './aliases.js'
|
import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the value is a model alias or a model alias with a suffix
|
||||||
|
* like [1m] (e.g. "opus", "opus[1m]", "sonnet", "haiku[1m]").
|
||||||
|
* Used to guard against infinite recursion when getDefault*Model() falls back
|
||||||
|
* to the user-specified setting — an alias like "opus[1m]" would cause
|
||||||
|
* parseUserSpecifiedModel → getDefaultOpusModel → parseUserSpecifiedModel loop.
|
||||||
|
*/
|
||||||
|
function isAliasOrAliasWithSuffix(value: string): boolean {
|
||||||
|
const base = value.replace(/\[1m\]$/i, '').trim()
|
||||||
|
return isModelAlias(base)
|
||||||
|
}
|
||||||
import { capitalize } from '../stringUtils.js'
|
import { capitalize } from '../stringUtils.js'
|
||||||
|
|
||||||
export type ModelShortName = string
|
export type ModelShortName = string
|
||||||
@@ -126,6 +138,14 @@ export function getDefaultOpusModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
|
||||||
return 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.
|
||||||
|
// Skip if the user setting is a model alias (e.g. "opus", "opus[1m]") to
|
||||||
|
// avoid infinite recursion: parseUserSpecifiedModel(alias) → getDefaultOpusModel().
|
||||||
|
const userSpecifiedOpus = getUserSpecifiedModelSetting()
|
||||||
|
if (userSpecifiedOpus && !isAliasOrAliasWithSuffix(userSpecifiedOpus)) {
|
||||||
|
return parseUserSpecifiedModel(userSpecifiedOpus)
|
||||||
|
}
|
||||||
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
||||||
// even when values match, since 3P availability lags firstParty and
|
// even when values match, since 3P availability lags firstParty and
|
||||||
// these will diverge again at the next model launch.
|
// these will diverge again at the next model launch.
|
||||||
@@ -153,6 +173,14 @@ export function getDefaultSonnetModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
|
||||||
return 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.
|
||||||
|
// Skip if the user setting is a model alias to avoid infinite recursion.
|
||||||
|
const userSpecified = getUserSpecifiedModelSetting()
|
||||||
|
if (userSpecified && !isAliasOrAliasWithSuffix(userSpecified)) {
|
||||||
|
return parseUserSpecifiedModel(userSpecified)
|
||||||
|
}
|
||||||
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||||
if (provider !== 'firstParty') {
|
if (provider !== 'firstParty') {
|
||||||
return getModelStrings().sonnet45
|
return getModelStrings().sonnet45
|
||||||
@@ -175,6 +203,13 @@ export function getDefaultHaikuModel(): ModelName {
|
|||||||
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
|
||||||
return 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.
|
||||||
|
// Skip if the user setting is a model alias to avoid infinite recursion.
|
||||||
|
const userSpecifiedHaiku = getUserSpecifiedModelSetting()
|
||||||
|
if (userSpecifiedHaiku && !isAliasOrAliasWithSuffix(userSpecifiedHaiku)) {
|
||||||
|
return parseUserSpecifiedModel(userSpecifiedHaiku)
|
||||||
|
}
|
||||||
|
|
||||||
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||||
return getModelStrings().haiku45
|
return getModelStrings().haiku45
|
||||||
|
|||||||
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal file
204
src/utils/permissions/__tests__/getNextPermissionMode.test.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal file
148
src/utils/permissions/__tests__/permissionSetup.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,153 +1,136 @@
|
|||||||
import { mock, describe, expect, test } from "bun:test";
|
import { mock, describe, expect, test } from 'bun:test'
|
||||||
|
import { createFileStateCacheWithSizeLimit } from '../../../utils/fileStateCache.js'
|
||||||
|
import { createSubagentContext } from '../../../utils/forkedAgent.js'
|
||||||
|
import { getEmptyToolPermissionContext } from '../../../Tool.js'
|
||||||
|
|
||||||
// Mock log.ts to cut the heavy dependency chain
|
mock.module('src/utils/log.ts', () => ({
|
||||||
mock.module("src/utils/log.ts", () => ({
|
|
||||||
logError: () => {},
|
logError: () => {},
|
||||||
logToFile: () => {},
|
logToFile: () => {},
|
||||||
getLogDisplayTitle: () => "",
|
getLogDisplayTitle: () => '',
|
||||||
logEvent: () => {},
|
logEvent: () => {},
|
||||||
logMCPError: () => {},
|
logMCPError: () => {},
|
||||||
logMCPDebug: () => {},
|
logMCPDebug: () => {},
|
||||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'),
|
||||||
getLogFilePath: () => "/tmp/mock-log",
|
getLogFilePath: () => '/tmp/mock-log',
|
||||||
attachErrorLogSink: () => {},
|
attachErrorLogSink: () => {},
|
||||||
getInMemoryErrors: () => [],
|
getInMemoryErrors: () => [],
|
||||||
loadErrorLogs: async () => [],
|
loadErrorLogs: async () => [],
|
||||||
getErrorLogByIndex: async () => null,
|
getErrorLogByIndex: async () => null,
|
||||||
captureAPIRequest: () => {},
|
captureAPIRequest: () => {},
|
||||||
_resetErrorLogForTesting: () => {},
|
_resetErrorLogForTesting: () => {},
|
||||||
}));
|
}))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getDenyRuleForTool,
|
getDenyRuleForTool,
|
||||||
getAskRuleForTool,
|
getAskRuleForTool,
|
||||||
getDenyRuleForAgent,
|
getDenyRuleForAgent,
|
||||||
filterDeniedAgents,
|
filterDeniedAgents,
|
||||||
} = await import("../permissions");
|
} = await import('../permissions')
|
||||||
|
|
||||||
import { getEmptyToolPermissionContext } from "../../../Tool";
|
function makeContext(opts: { denyRules?: string[]; askRules?: string[] }) {
|
||||||
|
const ctx = getEmptyToolPermissionContext()
|
||||||
// ─── Helper ─────────────────────────────────────────────────────────────
|
const deny: Record<string, string[]> = {}
|
||||||
|
const ask: Record<string, string[]> = {}
|
||||||
function makeContext(opts: {
|
if (opts.denyRules?.length) deny.localSettings = opts.denyRules
|
||||||
denyRules?: string[];
|
if (opts.askRules?.length) ask.localSettings = opts.askRules
|
||||||
askRules?: string[];
|
return { ...ctx, alwaysDenyRules: deny, alwaysAskRules: ask } as any
|
||||||
}) {
|
|
||||||
const ctx = getEmptyToolPermissionContext();
|
|
||||||
const deny: Record<string, string[]> = {};
|
|
||||||
const ask: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
// alwaysDenyRules stores raw rule strings — getDenyRules() calls
|
|
||||||
// permissionRuleValueFromString internally
|
|
||||||
if (opts.denyRules?.length) {
|
|
||||||
deny["localSettings"] = opts.denyRules;
|
|
||||||
}
|
|
||||||
if (opts.askRules?.length) {
|
|
||||||
ask["localSettings"] = opts.askRules;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...ctx,
|
|
||||||
alwaysDenyRules: deny,
|
|
||||||
alwaysAskRules: ask,
|
|
||||||
} as any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
||||||
return { name, mcpInfo };
|
return { name, mcpInfo }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getDenyRuleForTool ─────────────────────────────────────────────────
|
describe('getDenyRuleForTool', () => {
|
||||||
|
test('returns null when no deny rules', () => {
|
||||||
|
const ctx = makeContext({})
|
||||||
|
expect(getDenyRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||||
|
})
|
||||||
|
test('returns matching deny rule for tool', () => {
|
||||||
|
const ctx = makeContext({ denyRules: ['Bash'] })
|
||||||
|
const result = getDenyRuleForTool(ctx, makeTool('Bash'))
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result!.ruleValue.toolName).toBe('Bash')
|
||||||
|
})
|
||||||
|
test('returns null for non-matching tool', () => {
|
||||||
|
const ctx = makeContext({ denyRules: ['Bash'] })
|
||||||
|
expect(getDenyRuleForTool(ctx, makeTool('Read'))).toBeNull()
|
||||||
|
})
|
||||||
|
test('rule with content does not match whole-tool deny', () => {
|
||||||
|
const ctx = makeContext({ denyRules: ['Bash(rm -rf)'] })
|
||||||
|
const result = getDenyRuleForTool(ctx, makeTool('Bash'))
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("getDenyRuleForTool", () => {
|
describe('getAskRuleForTool', () => {
|
||||||
test("returns null when no deny rules", () => {
|
test('returns null when no ask rules', () => {
|
||||||
const ctx = makeContext({});
|
const ctx = makeContext({})
|
||||||
expect(getDenyRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
expect(getAskRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||||
});
|
})
|
||||||
|
test('returns matching ask rule', () => {
|
||||||
|
const ctx = makeContext({ askRules: ['Write'] })
|
||||||
|
const result = getAskRuleForTool(ctx, makeTool('Write'))
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
})
|
||||||
|
test('returns null for non-matching tool', () => {
|
||||||
|
const ctx = makeContext({ askRules: ['Write'] })
|
||||||
|
expect(getAskRuleForTool(ctx, makeTool('Bash'))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("returns matching deny rule for tool", () => {
|
describe('getDenyRuleForAgent', () => {
|
||||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
test('returns null when no deny rules', () => {
|
||||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
const ctx = makeContext({})
|
||||||
expect(result).not.toBeNull();
|
expect(getDenyRuleForAgent(ctx, 'Agent', 'Explore')).toBeNull()
|
||||||
expect(result!.ruleValue.toolName).toBe("Bash");
|
})
|
||||||
});
|
test('returns matching deny rule for agent type', () => {
|
||||||
|
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||||
|
const result = getDenyRuleForAgent(ctx, 'Agent', 'Explore')
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
})
|
||||||
|
test('returns null for non-matching agent type', () => {
|
||||||
|
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||||
|
expect(getDenyRuleForAgent(ctx, 'Agent', 'Research')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("returns null for non-matching tool", () => {
|
describe('Langfuse trace propagation', () => {
|
||||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
test('subagent context preserves parent trace for nested side queries', () => {
|
||||||
expect(getDenyRuleForTool(ctx, makeTool("Read"))).toBeNull();
|
const parentTrace = { id: 'parent-trace' } as never
|
||||||
});
|
const parentContext = {
|
||||||
|
...getEmptyToolPermissionContext(),
|
||||||
|
messages: [],
|
||||||
|
abortController: new AbortController(),
|
||||||
|
readFileState: createFileStateCacheWithSizeLimit(1),
|
||||||
|
getAppState: () => ({ toolPermissionContext: getEmptyToolPermissionContext() }),
|
||||||
|
setAppState: () => {},
|
||||||
|
updateFileHistoryState: () => {},
|
||||||
|
updateAttributionState: () => {},
|
||||||
|
setInProgressToolUseIDs: () => {},
|
||||||
|
setResponseLength: () => {},
|
||||||
|
langfuseTrace: parentTrace,
|
||||||
|
} as never
|
||||||
|
const subagentContext = createSubagentContext(parentContext)
|
||||||
|
expect(subagentContext.langfuseRootTrace).toBe(parentTrace)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("rule with content does not match whole-tool deny", () => {
|
describe('filterDeniedAgents', () => {
|
||||||
// getDenyRuleForTool uses toolMatchesRule which requires ruleContent === undefined
|
test('returns all agents when no deny rules', () => {
|
||||||
// Rules like "Bash(rm -rf)" only match specific invocations, not the entire tool
|
const ctx = makeContext({})
|
||||||
const ctx = makeContext({ denyRules: ["Bash(rm -rf)"] });
|
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
expect(filterDeniedAgents(agents, ctx, 'Agent')).toEqual(agents)
|
||||||
expect(result).toBeNull();
|
})
|
||||||
});
|
test('filters out denied agent type', () => {
|
||||||
});
|
const ctx = makeContext({ denyRules: ['Agent(Explore)'] })
|
||||||
|
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||||
// ─── getAskRuleForTool ──────────────────────────────────────────────────
|
const result = filterDeniedAgents(agents, ctx, 'Agent')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
describe("getAskRuleForTool", () => {
|
expect(result[0]!.agentType).toBe('Research')
|
||||||
test("returns null when no ask rules", () => {
|
})
|
||||||
const ctx = makeContext({});
|
test('returns empty array when all agents denied', () => {
|
||||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
const ctx = makeContext({ denyRules: ['Agent(Explore)', 'Agent(Research)'] })
|
||||||
});
|
const agents = [{ agentType: 'Explore' }, { agentType: 'Research' }]
|
||||||
|
expect(filterDeniedAgents(agents, ctx, 'Agent')).toEqual([])
|
||||||
test("returns matching ask rule", () => {
|
})
|
||||||
const ctx = makeContext({ askRules: ["Write"] });
|
})
|
||||||
const result = getAskRuleForTool(ctx, makeTool("Write"));
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for non-matching tool", () => {
|
|
||||||
const ctx = makeContext({ askRules: ["Write"] });
|
|
||||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── getDenyRuleForAgent ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("getDenyRuleForAgent", () => {
|
|
||||||
test("returns null when no deny rules", () => {
|
|
||||||
const ctx = makeContext({});
|
|
||||||
expect(getDenyRuleForAgent(ctx, "Agent", "Explore")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns matching deny rule for agent type", () => {
|
|
||||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
||||||
const result = getDenyRuleForAgent(ctx, "Agent", "Explore");
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for non-matching agent type", () => {
|
|
||||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
||||||
expect(getDenyRuleForAgent(ctx, "Agent", "Research")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── filterDeniedAgents ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("filterDeniedAgents", () => {
|
|
||||||
test("returns all agents when no deny rules", () => {
|
|
||||||
const ctx = makeContext({});
|
|
||||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
||||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual(agents);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("filters out denied agent type", () => {
|
|
||||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
|
||||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
||||||
const result = filterDeniedAgents(agents, ctx, "Agent");
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]!.agentType).toBe("Research");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns empty array when all agents denied", () => {
|
|
||||||
const ctx = makeContext({
|
|
||||||
denyRules: ["Agent(Explore)", "Agent(Research)"],
|
|
||||||
});
|
|
||||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
|
||||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,79 +1,44 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import { useNotifications } from 'src/context/notifications.js'
|
||||||
type AppState,
|
import { toError } from '../../utils/errors.js'
|
||||||
useAppState,
|
import { logError } from '../../utils/log.js'
|
||||||
useAppStateStore,
|
|
||||||
useSetAppState,
|
|
||||||
} from 'src/state/AppState.js'
|
|
||||||
import type { ToolPermissionContext } from 'src/Tool.js'
|
|
||||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||||
|
import { useAppState, useAppStateStore, useSetAppState } from '../../state/AppState.js'
|
||||||
|
import type { ToolPermissionContext } from '../../Tool.js'
|
||||||
import {
|
import {
|
||||||
createDisabledBypassPermissionsContext,
|
|
||||||
shouldDisableBypassPermissions,
|
|
||||||
verifyAutoModeGateAccess,
|
verifyAutoModeGateAccess,
|
||||||
} from './permissionSetup.js'
|
} from './permissionSetup.js'
|
||||||
|
|
||||||
let bypassPermissionsCheckRan = false
|
/**
|
||||||
|
* No-op — bypass permissions is always available.
|
||||||
|
*/
|
||||||
export async function checkAndDisableBypassPermissionsIfNeeded(
|
export async function checkAndDisableBypassPermissionsIfNeeded(
|
||||||
toolPermissionContext: ToolPermissionContext,
|
_toolPermissionContext: ToolPermissionContext,
|
||||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
_setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Check if bypassPermissions should be disabled based on Statsig gate
|
// Bypass permissions is always available — no gate check needed
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset the run-once flag for checkAndDisableBypassPermissionsIfNeeded.
|
* Reset stub — kept for interface compatibility.
|
||||||
* Call this after /login so the gate check re-runs with the new org.
|
|
||||||
*/
|
*/
|
||||||
export function resetBypassPermissionsCheck(): void {
|
export function resetBypassPermissionsCheck(): void {
|
||||||
bypassPermissionsCheckRan = false
|
// No-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op hook — bypass permissions is always available.
|
||||||
|
*/
|
||||||
export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void {
|
export function useKickOffCheckAndDisableBypassPermissionsIfNeeded(): void {
|
||||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
|
// No-op
|
||||||
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
|
|
||||||
}, [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let autoModeCheckRan = false
|
let autoModeCheckRan = false
|
||||||
|
|
||||||
export async function checkAndDisableAutoModeIfNeeded(
|
export async function checkAndDisableAutoModeIfNeeded(
|
||||||
toolPermissionContext: ToolPermissionContext,
|
toolPermissionContext: ToolPermissionContext,
|
||||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
setAppState: (f: (prev: import('../../state/AppState.js').AppState) => import('../../state/AppState.js').AppState) => void,
|
||||||
fastMode?: boolean,
|
fastMode?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
@@ -87,10 +52,6 @@ export async function checkAndDisableAutoModeIfNeeded(
|
|||||||
fastMode,
|
fastMode,
|
||||||
)
|
)
|
||||||
setAppState(prev => {
|
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 nextCtx = updateContext(prev.toolPermissionContext)
|
||||||
const newState =
|
const newState =
|
||||||
nextCtx === prev.toolPermissionContext
|
nextCtx === prev.toolPermissionContext
|
||||||
@@ -133,11 +94,6 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
|
|||||||
const isFirstRunRef = useRef(true)
|
const isFirstRunRef = useRef(true)
|
||||||
|
|
||||||
// Runs on mount (startup check) AND whenever the model or fast mode changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (getIsRemoteMode()) return
|
if (getIsRemoteMode()) return
|
||||||
if (isFirstRunRef.current) {
|
if (isFirstRunRef.current) {
|
||||||
@@ -149,7 +105,9 @@ export function useKickOffCheckAndDisableAutoModeIfNeeded(): void {
|
|||||||
store.getState().toolPermissionContext,
|
store.getState().toolPermissionContext,
|
||||||
setAppState,
|
setAppState,
|
||||||
fastMode,
|
fastMode,
|
||||||
)
|
).catch(error => {
|
||||||
|
logError(new Error('Auto mode gate check failed', { cause: toError(error) }))
|
||||||
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mainLoopModel, mainLoopModelForSession, fastMode])
|
}, [mainLoopModel, mainLoopModelForSession, fastMode])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import type { ToolPermissionContext } from '../../Tool.js'
|
import type { ToolPermissionContext } from '../../Tool.js'
|
||||||
import { logForDebugging } from '../debug.js'
|
import { logForDebugging } from '../debug.js'
|
||||||
import type { PermissionMode } from './PermissionMode.js'
|
import type { PermissionMode } from './PermissionMode.js'
|
||||||
import {
|
import { transitionPermissionMode } from './permissionSetup.js'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the next permission mode when cycling through modes with Shift+Tab.
|
* 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(
|
export function getNextPermissionMode(
|
||||||
toolPermissionContext: ToolPermissionContext,
|
toolPermissionContext: ToolPermissionContext,
|
||||||
@@ -37,43 +15,29 @@ export function getNextPermissionMode(
|
|||||||
): PermissionMode {
|
): PermissionMode {
|
||||||
switch (toolPermissionContext.mode) {
|
switch (toolPermissionContext.mode) {
|
||||||
case 'default':
|
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'
|
return 'acceptEdits'
|
||||||
|
|
||||||
case 'acceptEdits':
|
case 'acceptEdits':
|
||||||
return 'plan'
|
return 'plan'
|
||||||
|
|
||||||
case 'plan':
|
case 'plan':
|
||||||
|
return 'auto'
|
||||||
|
|
||||||
|
case 'auto':
|
||||||
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
|
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
|
||||||
return 'bypassPermissions'
|
return 'bypassPermissions'
|
||||||
}
|
}
|
||||||
if (canCycleToAuto(toolPermissionContext)) {
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
case 'bypassPermissions':
|
case 'bypassPermissions':
|
||||||
if (canCycleToAuto(toolPermissionContext)) {
|
|
||||||
return 'auto'
|
|
||||||
}
|
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
case 'dontAsk':
|
case 'dontAsk':
|
||||||
// Not exposed in UI cycle yet, but return default if somehow reached
|
// Not exposed in UI cycle yet, but return default if somehow reached
|
||||||
return 'default'
|
return 'default'
|
||||||
|
|
||||||
|
|
||||||
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'
|
return 'default'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { logForDebugging } from '../debug.js'
|
|||||||
import { errorMessage } from '../errors.js'
|
import { errorMessage } from '../errors.js'
|
||||||
import { lazySchema } from '../lazySchema.js'
|
import { lazySchema } from '../lazySchema.js'
|
||||||
import { logError } from '../log.js'
|
import { logError } from '../log.js'
|
||||||
import { getMainLoopModel } from '../model/model.js'
|
import { getMainLoopModel, getSmallFastModel } from '../model/model.js'
|
||||||
|
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
|
||||||
import { sideQuery } from '../sideQuery.js'
|
import { sideQuery } from '../sideQuery.js'
|
||||||
import { jsonStringify } from '../slowOperations.js'
|
import { jsonStringify } from '../slowOperations.js'
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ ${conversationContext ? `\nRecent conversation context:\n${conversationContext}`
|
|||||||
|
|
||||||
Explain this command in context.`
|
Explain this command in context.`
|
||||||
|
|
||||||
const model = getMainLoopModel()
|
const model = isPoorModeActive() ? getSmallFastModel() : getMainLoopModel()
|
||||||
|
|
||||||
// Use sideQuery with forced tool choice for guaranteed structured output
|
// Use sideQuery with forced tool choice for guaranteed structured output
|
||||||
const response = await sideQuery({
|
const response = await sideQuery({
|
||||||
|
|||||||
@@ -799,10 +799,6 @@ export function initialPermissionModeFromCLI({
|
|||||||
result = { mode: 'default', notification }
|
result = { mode: 'default', notification }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
result = { mode: 'default', notification }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
|
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
|
||||||
autoModeStateModule?.setAutoModeActive(true)
|
autoModeStateModule?.setAutoModeActive(true)
|
||||||
}
|
}
|
||||||
@@ -927,20 +923,9 @@ export async function initializeToolPermissionContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if bypassPermissions mode is available (not disabled by Statsig gate or settings)
|
// Bypass permissions mode is available to all users
|
||||||
// Use cached values to avoid blocking on startup
|
const isBypassPermissionsModeAvailable = true
|
||||||
const growthBookDisableBypassPermissionsMode =
|
|
||||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
|
||||||
'tengu_disable_bypass_permissions_mode',
|
|
||||||
)
|
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
const settingsDisableBypassPermissionsMode =
|
|
||||||
settings.permissions?.disableBypassPermissionsMode === 'disable'
|
|
||||||
const isBypassPermissionsModeAvailable =
|
|
||||||
(permissionMode === 'bypassPermissions' ||
|
|
||||||
allowDangerouslySkipPermissions) &&
|
|
||||||
!growthBookDisableBypassPermissionsMode &&
|
|
||||||
!settingsDisableBypassPermissionsMode
|
|
||||||
|
|
||||||
// Load all permission rules from disk
|
// Load all permission rules from disk
|
||||||
const rulesFromDisk = loadAllPermissionRulesFromDisk()
|
const rulesFromDisk = loadAllPermissionRulesFromDisk()
|
||||||
@@ -984,7 +969,7 @@ export async function initializeToolPermissionContext({
|
|||||||
alwaysAskRules: {},
|
alwaysAskRules: {},
|
||||||
isBypassPermissionsModeAvailable,
|
isBypassPermissionsModeAvailable,
|
||||||
...(feature('TRANSCRIPT_CLASSIFIER')
|
...(feature('TRANSCRIPT_CLASSIFIER')
|
||||||
? { isAutoModeAvailable: isAutoModeGateEnabled() }
|
? { isAutoModeAvailable: true }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
rulesFromDisk,
|
rulesFromDisk,
|
||||||
@@ -1076,131 +1061,54 @@ export function getAutoModeUnavailableNotification(
|
|||||||
* kicking the user out of a mode they've already left during the await.
|
* kicking the user out of a mode they've already left during the await.
|
||||||
*/
|
*/
|
||||||
export async function verifyAutoModeGateAccess(
|
export async function verifyAutoModeGateAccess(
|
||||||
currentContext: ToolPermissionContext,
|
_currentContext: ToolPermissionContext,
|
||||||
// Runtime AppState.fastMode — passed from callers with AppState access so
|
// Runtime AppState.fastMode — passed from callers with AppState access so
|
||||||
// the disableFastMode circuit breaker reads current state, not stale
|
// the disableFastMode circuit breaker reads current state, not stale
|
||||||
// settings.fastMode (which is intentionally sticky across /model auto-
|
// settings.fastMode (which is intentionally sticky across /model auto-
|
||||||
// downgrades). Optional for callers without AppState (e.g. SDK init paths).
|
// downgrades). Optional for callers without AppState (e.g. SDK init paths).
|
||||||
fastMode?: boolean,
|
fastMode?: boolean,
|
||||||
): Promise<AutoModeGateCheckResult> {
|
): Promise<AutoModeGateCheckResult> {
|
||||||
// Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out)
|
// Only fast-mode circuit breaker remains. All other gates (GrowthBook,
|
||||||
// Fresh read of tengu_auto_mode_config.enabled — this async check runs once
|
// settings, model support, opt-in) have been removed.
|
||||||
// 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.
|
|
||||||
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
|
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
|
||||||
enabled?: AutoModeEnabledState
|
enabled?: AutoModeEnabledState
|
||||||
disableFastMode?: boolean
|
disableFastMode?: boolean
|
||||||
}>('tengu_auto_mode_config', {})
|
}>('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()
|
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 =
|
const disableFastModeBreakerFires =
|
||||||
!!autoModeConfig?.disableFastMode &&
|
!!autoModeConfig?.disableFastMode &&
|
||||||
(!!fastMode ||
|
(!!fastMode ||
|
||||||
(process.env.USER_TYPE === 'ant' &&
|
(process.env.USER_TYPE === 'ant' &&
|
||||||
mainModel.toLowerCase().includes('-fast')))
|
mainModel.toLowerCase().includes('-fast')))
|
||||||
const modelSupported =
|
|
||||||
modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires
|
// If fast-mode breaker fires, circuit-break auto mode
|
||||||
let carouselAvailable = false
|
autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires)
|
||||||
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
|
|
||||||
logForDebugging(
|
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).
|
if (!disableFastModeBreakerFires) {
|
||||||
const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false
|
// Auto mode available — no kick-out needed
|
||||||
|
return { updateContext: ctx => ctx }
|
||||||
// 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 (canEnterAuto) {
|
// Fast-mode breaker fired — kick out of auto if currently in it
|
||||||
return { updateContext: ctx => setAvailable(ctx, carouselAvailable) }
|
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 = (
|
const kickOutOfAutoIfNeeded = (
|
||||||
ctx: ToolPermissionContext,
|
ctx: ToolPermissionContext,
|
||||||
): ToolPermissionContext => {
|
): ToolPermissionContext => {
|
||||||
const inAuto = ctx.mode === 'auto'
|
const inAuto = ctx.mode === 'auto'
|
||||||
logForDebugging(
|
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 =
|
const inPlanWithAutoActive =
|
||||||
ctx.mode === 'plan' &&
|
ctx.mode === 'plan' &&
|
||||||
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
|
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
|
||||||
if (!inAuto && !inPlanWithAutoActive) {
|
if (!inAuto && !inPlanWithAutoActive) {
|
||||||
return setAvailable(ctx, false)
|
return { ...ctx, isAutoModeAvailable: false }
|
||||||
}
|
}
|
||||||
if (inAuto) {
|
if (inAuto) {
|
||||||
autoModeStateModule?.setAutoModeActive(false)
|
autoModeStateModule?.setAutoModeActive(false)
|
||||||
@@ -1214,8 +1122,6 @@ export async function verifyAutoModeGateAccess(
|
|||||||
isAutoModeAvailable: false,
|
isAutoModeAvailable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Plan with auto active: deactivate auto, restore permissions, defuse
|
|
||||||
// prePlanMode so ExitPlanMode goes to default.
|
|
||||||
autoModeStateModule?.setAutoModeActive(false)
|
autoModeStateModule?.setAutoModeActive(false)
|
||||||
setNeedsAutoModeExitAttachment(true)
|
setNeedsAutoModeExitAttachment(true)
|
||||||
return {
|
return {
|
||||||
@@ -1225,65 +1131,23 @@ export async function verifyAutoModeGateAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification decisions use the stale context — that's OK: we're deciding
|
return { updateContext: kickOutOfAutoIfNeeded, notification }
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
export function shouldDisableBypassPermissions(): Promise<boolean> {
|
||||||
return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')
|
return Promise.resolve(false)
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoModeDisabledBySettings(): boolean {
|
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
|
||||||
return (
|
|
||||||
(settings as { disableAutoMode?: 'disable' }).disableAutoMode ===
|
|
||||||
'disable' ||
|
|
||||||
(settings.permissions as { disableAutoMode?: 'disable' } | undefined)
|
|
||||||
?.disableAutoMode === 'disable'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if auto mode can be entered: circuit breaker is not active and settings
|
* Checks if auto mode can be entered: only fast-mode circuit breaker remains.
|
||||||
* have not disabled it. Synchronous.
|
* Synchronous.
|
||||||
*/
|
*/
|
||||||
export function isAutoModeGateEnabled(): boolean {
|
export function isAutoModeGateEnabled(): boolean {
|
||||||
|
// Auto mode is available to all users — only fast-mode circuit breaker remains
|
||||||
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false
|
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false
|
||||||
if (isAutoModeDisabledBySettings()) return false
|
|
||||||
if (!modelSupportsAutoMode(getMainLoopModel())) return false
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,11 +1156,9 @@ export function isAutoModeGateEnabled(): boolean {
|
|||||||
* Synchronous — uses state populated by verifyAutoModeGateAccess.
|
* Synchronous — uses state populated by verifyAutoModeGateAccess.
|
||||||
*/
|
*/
|
||||||
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
|
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
|
||||||
if (isAutoModeDisabledBySettings()) return 'settings'
|
|
||||||
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
|
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
|
||||||
return 'circuit-breaker'
|
return 'circuit-breaker'
|
||||||
}
|
}
|
||||||
if (!modelSupportsAutoMode(getMainLoopModel())) return 'model'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,8 +1172,7 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null
|
|||||||
*/
|
*/
|
||||||
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
|
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
|
||||||
|
|
||||||
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState =
|
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled'
|
||||||
feature('TRANSCRIPT_CLASSIFIER') ? 'enabled' : 'disabled'
|
|
||||||
|
|
||||||
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
|
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
|
||||||
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
|
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
|
||||||
@@ -1361,27 +1222,15 @@ export function getAutoModeEnabledStateIfCached():
|
|||||||
* dialog or by IDE/Desktop settings toggle)
|
* dialog or by IDE/Desktop settings toggle)
|
||||||
*/
|
*/
|
||||||
export function hasAutoModeOptInAnySource(): boolean {
|
export function hasAutoModeOptInAnySource(): boolean {
|
||||||
if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true
|
return true
|
||||||
return hasAutoModeOptIn()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if bypassPermissions mode is currently disabled by Statsig gate or settings.
|
* 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 {
|
export function isBypassPermissionsModeDisabled(): boolean {
|
||||||
const growthBookDisableBypassPermissionsMode =
|
return false
|
||||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
|
||||||
'tengu_disable_bypass_permissions_mode',
|
|
||||||
)
|
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
|
||||||
const settingsDisableBypassPermissionsMode =
|
|
||||||
settings.permissions?.disableBypassPermissionsMode === 'disable'
|
|
||||||
|
|
||||||
return (
|
|
||||||
growthBookDisableBypassPermissionsMode ||
|
|
||||||
settingsDisableBypassPermissionsMode
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1406,29 +1255,12 @@ export function createDisabledBypassPermissionsContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate
|
* No-op — bypass permissions is always available, no remote gate check needed.
|
||||||
* and returns an updated toolPermissionContext if needed
|
|
||||||
*/
|
*/
|
||||||
export async function checkAndDisableBypassPermissions(
|
export async function checkAndDisableBypassPermissions(
|
||||||
currentContext: ToolPermissionContext,
|
_currentContext: ToolPermissionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Only proceed if bypassPermissions mode is available
|
// Bypass permissions is always available — no gate check needed
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDefaultPermissionModeAuto(): boolean {
|
export function isDefaultPermissionModeAuto(): boolean {
|
||||||
@@ -1446,11 +1278,7 @@ export function isDefaultPermissionModeAuto(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function shouldPlanUseAutoMode(): boolean {
|
export function shouldPlanUseAutoMode(): boolean {
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
return (
|
return isAutoModeGateEnabled() && getUseAutoModeDuringPlan()
|
||||||
hasAutoModeOptIn() &&
|
|
||||||
isAutoModeGateEnabled() &&
|
|
||||||
getUseAutoModeDuringPlan()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,12 +690,16 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
|
|||||||
setClassifierChecking(toolUseID)
|
setClassifierChecking(toolUseID)
|
||||||
let classifierResult
|
let classifierResult
|
||||||
try {
|
try {
|
||||||
|
logForDebugging(
|
||||||
|
`[auto-mode] classifyYoloAction called with langfuseTrace=${context.langfuseTrace ? `id=${(context.langfuseTrace as unknown as Record<string, unknown>).id ?? 'present'}` : 'null/undefined'}`,
|
||||||
|
)
|
||||||
classifierResult = await classifyYoloAction(
|
classifierResult = await classifyYoloAction(
|
||||||
context.messages,
|
context.messages,
|
||||||
action,
|
action,
|
||||||
context.options.tools,
|
context.options.tools,
|
||||||
appState.toolPermissionContext,
|
appState.toolPermissionContext,
|
||||||
context.abortController.signal,
|
context.abortController.signal,
|
||||||
|
context.langfuseRootTrace ?? context.langfuseTrace,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
clearClassifierChecking(toolUseID)
|
clearClassifierChecking(toolUseID)
|
||||||
@@ -850,12 +854,30 @@ export const hasPermissionsToUseTool: CanUseToolFn = async (
|
|||||||
CLASSIFIER_FAIL_CLOSED_REFRESH_MS,
|
CLASSIFIER_FAIL_CLOSED_REFRESH_MS,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||||||
|
logForDebugging(
|
||||||
|
'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
behavior: 'deny',
|
||||||
|
decisionReason: {
|
||||||
|
type: 'classifier',
|
||||||
|
classifier: 'auto-mode',
|
||||||
|
reason: 'Classifier unavailable',
|
||||||
|
},
|
||||||
|
message: buildClassifierUnavailableMessage(
|
||||||
|
tool.name,
|
||||||
|
classifierResult.model,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
|
'Auto mode classifier unavailable, falling back to prompting with retry guidance (fail closed)',
|
||||||
{ level: 'warn' },
|
{ level: 'warn' },
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
behavior: 'deny',
|
behavior: 'ask',
|
||||||
decisionReason: {
|
decisionReason: {
|
||||||
type: 'classifier',
|
type: 'classifier',
|
||||||
classifier: 'auto-mode',
|
classifier: 'auto-mode',
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ import { errorMessage } from '../errors.js'
|
|||||||
import { lazySchema } from '../lazySchema.js'
|
import { lazySchema } from '../lazySchema.js'
|
||||||
import { extractTextContent } from '../messages.js'
|
import { extractTextContent } from '../messages.js'
|
||||||
import { resolveAntModel } from '../model/antModels.js'
|
import { resolveAntModel } from '../model/antModels.js'
|
||||||
import { getMainLoopModel } from '../model/model.js'
|
import { getDefaultSonnetModel, getMainLoopModel } from '../model/model.js'
|
||||||
|
import { isPoorModeActive } from '../../commands/poor/poorMode.js'
|
||||||
import { getAutoModeConfig } from '../settings/settings.js'
|
import { getAutoModeConfig } from '../settings/settings.js'
|
||||||
import { sideQuery } from '../sideQuery.js'
|
import { sideQuery } from '../sideQuery.js'
|
||||||
|
import type { LangfuseSpan } from '../../services/langfuse/index.js'
|
||||||
import { jsonStringify } from '../slowOperations.js'
|
import { jsonStringify } from '../slowOperations.js'
|
||||||
import { tokenCountWithEstimation } from '../tokens.js'
|
import { tokenCountWithEstimation } from '../tokens.js'
|
||||||
import {
|
import {
|
||||||
@@ -731,6 +733,7 @@ async function classifyYoloActionXml(
|
|||||||
action: string
|
action: string
|
||||||
},
|
},
|
||||||
mode: TwoStageMode,
|
mode: TwoStageMode,
|
||||||
|
parentSpan?: LangfuseSpan | null,
|
||||||
): Promise<YoloClassifierResult> {
|
): Promise<YoloClassifierResult> {
|
||||||
const classifierType =
|
const classifierType =
|
||||||
mode === 'both'
|
mode === 'both'
|
||||||
@@ -791,6 +794,7 @@ async function classifyYoloActionXml(
|
|||||||
signal,
|
signal,
|
||||||
...(mode !== 'fast' && { stop_sequences: ['</block>'] }),
|
...(mode !== 'fast' && { stop_sequences: ['</block>'] }),
|
||||||
querySource: 'auto_mode',
|
querySource: 'auto_mode',
|
||||||
|
parentSpan,
|
||||||
}
|
}
|
||||||
const stage1Raw = await sideQuery(stage1Opts)
|
const stage1Raw = await sideQuery(stage1Opts)
|
||||||
stage1DurationMs = Date.now() - stage1Start
|
stage1DurationMs = Date.now() - stage1Start
|
||||||
@@ -877,6 +881,7 @@ async function classifyYoloActionXml(
|
|||||||
maxRetries: getDefaultMaxRetries(),
|
maxRetries: getDefaultMaxRetries(),
|
||||||
signal,
|
signal,
|
||||||
querySource: 'auto_mode' as const,
|
querySource: 'auto_mode' as const,
|
||||||
|
parentSpan,
|
||||||
}
|
}
|
||||||
const stage2Raw = await sideQuery(stage2Opts)
|
const stage2Raw = await sideQuery(stage2Opts)
|
||||||
const stage2DurationMs = Date.now() - stage2Start
|
const stage2DurationMs = Date.now() - stage2Start
|
||||||
@@ -1015,6 +1020,7 @@ export async function classifyYoloAction(
|
|||||||
tools: Tools,
|
tools: Tools,
|
||||||
context: ToolPermissionContext,
|
context: ToolPermissionContext,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
parentSpan?: LangfuseSpan | null,
|
||||||
): Promise<YoloClassifierResult> {
|
): Promise<YoloClassifierResult> {
|
||||||
const lookup = buildToolLookup(tools)
|
const lookup = buildToolLookup(tools)
|
||||||
const actionCompact = toCompact(action, lookup)
|
const actionCompact = toCompact(action, lookup)
|
||||||
@@ -1126,6 +1132,7 @@ export async function classifyYoloAction(
|
|||||||
action: actionCompact,
|
action: actionCompact,
|
||||||
},
|
},
|
||||||
getTwoStageMode(),
|
getTwoStageMode(),
|
||||||
|
parentSpan,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model)
|
const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model)
|
||||||
@@ -1156,6 +1163,7 @@ export async function classifyYoloAction(
|
|||||||
maxRetries: getDefaultMaxRetries(),
|
maxRetries: getDefaultMaxRetries(),
|
||||||
signal,
|
signal,
|
||||||
querySource: 'auto_mode' as const,
|
querySource: 'auto_mode' as const,
|
||||||
|
parentSpan,
|
||||||
}
|
}
|
||||||
const result = await sideQuery(sideQueryOpts)
|
const result = await sideQuery(sideQueryOpts)
|
||||||
void maybeDumpAutoMode(sideQueryOpts, result, start)
|
void maybeDumpAutoMode(sideQueryOpts, result, start)
|
||||||
@@ -1343,6 +1351,10 @@ function getClassifierModel(): string {
|
|||||||
if (config?.model) {
|
if (config?.model) {
|
||||||
return config.model
|
return config.model
|
||||||
}
|
}
|
||||||
|
// Poor mode: downgrade classifier to Sonnet to reduce cost
|
||||||
|
if (isPoorModeActive()) {
|
||||||
|
return getDefaultSonnetModel()
|
||||||
|
}
|
||||||
return getMainLoopModel()
|
return getMainLoopModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -894,20 +894,8 @@ export function hasSkipDangerousModePermissionPrompt(): boolean {
|
|||||||
* a malicious project could otherwise auto-bypass the dialog (RCE risk).
|
* a malicious project could otherwise auto-bypass the dialog (RCE risk).
|
||||||
*/
|
*/
|
||||||
export function hasAutoModeOptIn(): boolean {
|
export function hasAutoModeOptIn(): boolean {
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
// Auto mode is available to all users — no opt-in needed
|
||||||
const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt
|
return true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type Anthropic from '@anthropic-ai/sdk'
|
|||||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js'
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js'
|
||||||
import {
|
import {
|
||||||
getLastApiCompletionTimestamp,
|
getLastApiCompletionTimestamp,
|
||||||
|
getSessionId,
|
||||||
setLastApiCompletionTimestamp,
|
setLastApiCompletionTimestamp,
|
||||||
} from '../bootstrap/state.js'
|
} from '../bootstrap/state.js'
|
||||||
import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js'
|
import { STRUCTURED_OUTPUTS_BETA_HEADER } from '../constants/betas.js'
|
||||||
@@ -14,8 +15,14 @@ import { logEvent } from '../services/analytics/index.js'
|
|||||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.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 { getAPIMetadata } from '../services/api/claude.js'
|
||||||
import { getAnthropicClient } from '../services/api/client.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 { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
|
||||||
|
import { logForDebugging } from './debug.js'
|
||||||
|
import { errorMessage } from './errors.js'
|
||||||
import { computeFingerprint } from './fingerprint.js'
|
import { computeFingerprint } from './fingerprint.js'
|
||||||
|
import { getAPIProvider } from './model/providers.js'
|
||||||
import { normalizeModelStringForAPI } from './model/model.js'
|
import { normalizeModelStringForAPI } from './model/model.js'
|
||||||
|
|
||||||
type MessageParam = Anthropic.MessageParam
|
type MessageParam = Anthropic.MessageParam
|
||||||
@@ -61,6 +68,11 @@ export type SideQueryOptions = {
|
|||||||
stop_sequences?: string[]
|
stop_sequences?: string[]
|
||||||
/** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */
|
/** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */
|
||||||
querySource: QuerySource
|
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 +189,65 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedModel = normalizeModelStringForAPI(model)
|
const normalizedModel = normalizeModelStringForAPI(model)
|
||||||
|
const provider = getAPIProvider()
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
// biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution
|
const traceName = `side-query:${opts.querySource}`
|
||||||
const response = await client.beta.messages.create(
|
|
||||||
{
|
// When parentSpan is provided, create a child span nested under the
|
||||||
model: normalizedModel,
|
// main agent trace; otherwise create a standalone root trace.
|
||||||
max_tokens,
|
const _ps = opts.parentSpan
|
||||||
system: systemBlocks,
|
// eslint-disable-next-line no-constant-condition
|
||||||
messages,
|
if (opts.querySource === 'auto_mode') {
|
||||||
...(tools && { tools }),
|
logForDebugging(
|
||||||
...(tool_choice && { tool_choice }),
|
`[sideQuery] auto_mode parentSpan=${_ps ? `id=${(_ps as unknown as Record<string, unknown>).id ?? 'present'}` : 'null/undefined'} querySource=${opts.querySource}`,
|
||||||
...(output_format && { output_config: { format: output_format } }),
|
)
|
||||||
...(temperature !== undefined && { temperature }),
|
}
|
||||||
...(stop_sequences && { stop_sequences }),
|
// When parentSpan is provided, create a child span nested under the
|
||||||
...(thinkingConfig && { thinking: thinkingConfig }),
|
// main agent trace. For auto_mode queries, we must always nest under
|
||||||
...(betas.length > 0 && { betas }),
|
// a parent span — never create a standalone root trace (agent type),
|
||||||
metadata: getAPIMetadata(),
|
// as auto_mode observations should appear as spans within the parent.
|
||||||
},
|
// For other query sources without a parent, create a standalone trace.
|
||||||
{ signal },
|
const langfuseTrace = _ps
|
||||||
)
|
? createChildSpan(_ps, {
|
||||||
|
name: traceName,
|
||||||
|
sessionId: getSessionId(),
|
||||||
|
model: normalizedModel,
|
||||||
|
provider,
|
||||||
|
querySource: opts.querySource,
|
||||||
|
})
|
||||||
|
: opts.querySource === 'auto_mode'
|
||||||
|
? null
|
||||||
|
: 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 =
|
const requestId =
|
||||||
(response as { _request_id?: string | null })._request_id ?? undefined
|
(response as { _request_id?: string | null })._request_id ?? undefined
|
||||||
@@ -218,5 +270,32 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
})
|
})
|
||||||
setLastApiCompletionTimestamp(now)
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,9 +150,17 @@ export function getCurrentUsage(messages: Message[]): {
|
|||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
const usage = message ? getTokenUsage(message) : undefined
|
const usage = message ? getTokenUsage(message) : undefined
|
||||||
if (usage) {
|
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 {
|
return {
|
||||||
input_tokens: usage.input_tokens,
|
input_tokens: usage.input_tokens ?? 0,
|
||||||
output_tokens: usage.output_tokens,
|
output_tokens: usage.output_tokens ?? 0,
|
||||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
|
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
|
||||||
"@claude-code-best/mcp-client": ["./packages/mcp-client/src/index.ts"],
|
"@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/*"],
|
||||||
"@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"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user