diff --git a/.gitignore b/.gitignore
index 8d2ffe0f..5c6e4eef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,6 @@ next-env.d.ts
apps/site/.next/
apps/site/.source/
apps/site/node_modules/
+
+# claude code session data
+.claude/
diff --git a/.mcp.json b/.mcp.json
index ec1ba23f..d82ae698 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -5,7 +5,8 @@
"command": "npx",
"args": [
"-y",
- "chrome-devtools-mcp@latest"
+ "chrome-devtools-mcp@0.20.3",
+ "--headless"
],
"env": {}
}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..3557d25e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,86 @@
+# AGENTS.md
+
+CodePilot — Codex 的桌面 GUI 客户端,基于 Electron + Next.js。
+
+> 架构细节见 [ARCHITECTURE.md](./ARCHITECTURE.md),本文件只包含规则和流程。
+
+## 开发规则
+
+**提交前必须详尽测试:**
+- 每次提交代码前,必须在开发环境中充分测试所有改动的功能,确认无回归
+- 涉及前端 UI 的改动需要实际启动应用验证(`npm run dev` 或 `npm run electron:dev`)
+- 涉及构建/打包的改动需要完整执行一次打包流程验证产物可用
+- 涉及多平台的改动需要考虑各平台的差异性
+
+**UI 改动必须用 CDP 验证(chrome-devtools MCP):**
+- 修改组件、样式、布局后,必须通过 chrome-devtools MCP 实际验证效果
+- 验证流程:`npm run dev` 启动应用 → 用 CDP 打开 `http://localhost:3000` 对应页面 → 截图确认渲染正确 → 检查 console 无报错
+- 涉及交互的改动(按钮、表单、导航)需通过 CDP 模拟点击/输入并截图验证
+- 修改响应式布局时,用 CDP 的 device emulation 分别验证桌面和移动端视口
+
+**新增功能前必须详尽调研:**
+- 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践
+- 涉及 Electron API 需确认目标版本支持情况
+- 涉及第三方库需确认与现有依赖的兼容性
+- 涉及 Codex SDK 需确认 SDK 实际支持的功能和调用方式
+- 对不确定的技术点先做 POC 验证,不要直接在主代码中试错
+
+**Worktree 隔离规则:**
+- 如果任务设置了 Worktree,所有代码改动只能在该 Worktree 内进行
+- 严格禁止跨 Worktree 提交(不得在主目录提交 Worktree 的改动,反之亦然)
+- 严格禁止 `git push`,除非用户主动提出
+- 启动测试服务(`npm run dev` 等)只从当前 Worktree 启动,不得在其他目录启动
+- 合并回主分支必须由用户主动发起,不得自动合并
+- **端口隔离**:Worktree 启动 dev server 时使用非默认端口(如 `PORT=3001`),避免与主目录冲突
+- **禁止跨目录编辑**:属于 Worktree 任务范围的文件,只在该 Worktree 内编辑,不得在主目录修改
+- **合并前检查 untracked 文件**:合并回主分支前先 `git status` 确认无调试残留、临时文件等
+
+**Commit 信息规范:**
+- 标题行使用 conventional commits 格式(feat/fix/refactor/chore 等)
+- body 中按文件或功能分组,说明改了什么、为什么改、影响范围
+- 修复 bug 需说明根因;架构决策需简要说明理由
+
+## 自检命令
+
+**自检命令(pre-commit hook 会自动执行前三项):**
+- `npm run test` — typecheck + 单元测试(~4s,无需 dev server)
+- `npm run test:smoke` — 冒烟测试(~15s,需要 dev server)
+- `npm run test:e2e` — 完整 E2E(~60s+,需要 dev server)
+
+修改代码后,commit 前至少确保 `npm run test` 通过。
+涉及 UI 改动时额外运行 `npm run test:smoke`。
+
+## 改动自查
+
+完成代码修改后,在提交前确认:
+1. 改动是否涉及 i18n — 是否需要同步 `src/i18n/en.ts` 和 `zh.ts`
+2. 改动是否涉及数据库 — 是否需要在 `src/lib/db.ts` 更新 schema 迁移
+3. 改动是否涉及类型 — 是否需要更新 `src/types/index.ts`
+4. 改动是否涉及已有文档 — 是否需要更新 `docs/handover/` 中的交接文档
+
+## 发版
+
+**发版流程:** 更新 package.json version → `npm install` 同步 lock → 提交推送 → `git tag v{版本号} && git push origin v{版本号}` → CI 自动构建发布。不要手动创建 GitHub Release。
+
+**发版纪律:** 禁止自动发版。`git push` + `git tag` 必须等用户明确指示后才执行。commit 可以正常进行。
+
+**Release Notes 格式:** 标题 `CodePilot v{版本号}`,正文包含:更新内容、Downloads、Installation、Requirements、Changelog。
+
+**构建:** macOS 产出 DMG(arm64 + x64),Windows 产出 NSIS 安装包。`scripts/after-pack.js` 重编译 better-sqlite3 为 Electron ABI。构建前清理 `rm -rf release/ .next/`。
+
+## 执行计划
+
+**中大型功能(跨 3+ 模块、涉及 schema 变更、需分阶段交付)必须先写执行计划再开工。**
+- 活跃计划放 `docs/exec-plans/active/`,完成后移至 `completed/`
+- 纯调研/可行性分析放 `docs/research/`
+- 发现技术债务时记录到 `docs/exec-plans/tech-debt-tracker.md`
+- 模板和规范见 `docs/exec-plans/README.md`
+
+## 文档
+
+- [ARCHITECTURE.md](./ARCHITECTURE.md) — 项目架构、目录结构、数据流、新功能触及点
+- `docs/exec-plans/` — 执行计划(进度状态 + 决策日志 + 技术债务)
+- `docs/handover/` — 交接文档(架构、数据流、设计决策)
+- `docs/research/` — 调研文档(技术方案、可行性分析)
+
+**检索前先读对应目录的 README.md;增删文件后更新索引。**
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..197f3b11
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog
+
+## [0.38.4] - 2026-03-20
+
+### Bug Fixes
+- **#341**: Fixed Claude Code CLI not being recognized as a valid provider — `/api/setup` now checks `findClaudeBinary()` so the chat page no longer shows "no provider configured" when Claude Code CLI is available
+- **#343/#346**: Fixed session crash when switching provider/model — server-side PATCH handler now auto-clears stale `sdk_session_id` when provider or model changes, preventing resume failures
+- **#347**: Fixed default model always reverting to first model in list — introduced global default model setting that persists across sessions
+
+### New Features
+- **Global Default Model**: New setting in the provider page to choose a default model across all providers. New conversations automatically use this model. Existing conversations are not affected.
+- **Default Model Indicator**: The model selector in the chat input now shows a "Default" tag next to the configured default model
+
+### Improvements
+- **Error Classification**: Session state errors (stale session, resume failed) are now correctly classified instead of being reported as provider configuration problems
+- **Select Component**: Fixed Radix Select dropdown growing unbounded on scroll — now capped at 16rem with proper overflow scrolling
diff --git a/apps/site/package.json b/apps/site/package.json
index 420ae93a..c42dd34c 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -20,7 +20,7 @@
"fumadocs-mdx": "^11",
"fumadocs-ui": "^15",
"lucide-react": "^0.563.0",
- "next": "15.3.6",
+ "next": "15.5.14",
"react": "^19",
"react-dom": "^19",
"shadcn": "^4.0.2",
diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md
index 740669d1..43305b36 100644
--- a/docs/exec-plans/README.md
+++ b/docs/exec-plans/README.md
@@ -44,8 +44,11 @@
| 文件 | 主题 | 状态 |
|------|------|------|
+| active/chat-latency-remediation.md | 聊天链路提速 + 模式入口收敛 + MCP 持久开关 | Phase 0 完成,Phase 1-4 待开始 |
| active/context-storage-migration.md | 上下文共享与存储迁移 | Phase 0 部分完成,Phase 1-3 待开始 |
| active/site-and-docs.md | 官网 + 文档站(apps/site) | Phase 0-1 进行中 |
+| active/weixin-bridge-channel.md | 微信 Bridge 通道一次性交付方案 | One Shot 待开始 |
+| active/unified-context-layer.md | 统一上下文层 + 浮窗助理 + 产品架构演进 | Phase 1-3 已完成,Phase 4-5 待开始 |
### Completed
diff --git a/docs/exec-plans/active/chat-latency-remediation.md b/docs/exec-plans/active/chat-latency-remediation.md
new file mode 100644
index 00000000..e00f0efb
--- /dev/null
+++ b/docs/exec-plans/active/chat-latency-remediation.md
@@ -0,0 +1,176 @@
+# Chat Latency Remediation
+
+> 创建时间:2026-03-21
+> 最后更新:2026-03-21
+
+## 状态
+
+| Phase | 内容 | 状态 | 备注 |
+|-------|------|------|------|
+| Phase 0 | 范围确认与基线 | ✅ 已完成 | 用户已确认删模式入口、MCP 持久开关、resume 可见状态、capability 延后 |
+| Phase 1 | 聊天模式入口收敛 | 📋 待开始 | 先删前台入口并统一走 `code`,后台兼容先保留 |
+| Phase 2 | MCP 持久启停开关 | 📋 待开始 | 默认开启,用户可在 MCP 页面关闭单个 server |
+| Phase 3 | 首包延迟优化 | 📋 待开始 | effort/thinking 默认值、resume 可见状态、能力抓取延后 |
+| Phase 4 | 观测与验证 | 📋 待开始 | 时序埋点、回归验证、CDP 检查 |
+
+## 决策日志
+
+- 2026-03-21: 聊天界面的模式选择(如 CodePilot / Ask)可以删除,计划模式后续重做时再引入。
+- 2026-03-21: 本轮不做 `mode` 字段和桥接 `/mode` 命令的彻底清理,先删除桌面主聊天入口并把桌面聊天统一收敛到 `code`,以控制范围和回归风险。
+- 2026-03-21: MCP 需要做成持久启停开关,默认开启,用户可以在 MCP 页面关闭某个 server。
+- 2026-03-21: 恢复旧会话时允许增加可见状态提示,优先解决“空白等待像卡死”的体感问题。
+- 2026-03-21: `supportedModels` / `supportedCommands` / `accountInfo` / `mcpServerStatus` 可以移出首包关键路径。
+- 2026-03-21: 不采用“全局关闭 MCP”或“强制所有模型 low effort”这类以阉割功能换速度的方案。
+
+## 目标
+
+- 降低普通聊天的首个可见事件时间和首 token 时间。
+- 删除当前已经失去产品意义的模式入口,避免无效交互。
+- 保留工具、MCP、技能、恢复旧会话等能力,不靠砍功能提速。
+- 让 MCP 的启停变成用户可控且持久生效的配置。
+
+## 非目标
+
+- 不在本轮重做计划模式。
+- 不在本轮彻底移除数据库 `mode` 字段、桥接 `/mode` 命令和所有旧兼容分支。
+- 不在本轮重构 provider 体系或 assistant workspace。
+- 不通过全局禁用用户本机 Claude 配置来一次性解决所有延迟问题。
+
+## 交互与功能变化
+
+- 删除主聊天界面的模式选择入口;桌面聊天统一按 `code` 路径执行。
+- MCP 页面增加“持久启用/停用”开关;默认开启,关闭后该 server 不再注入新的聊天会话和桥接会话。
+- 恢复旧会话时显示显式状态文案,如“正在恢复上下文…”或“正在连接工具…”。
+- 除以上三点外,不新增额外交互,不减少模型、附件、工具和 MCP 的现有能力。
+
+## 详细设计
+
+### Phase 1: 聊天模式入口收敛
+
+**目标**
+
+- 去掉无效的 UI 模式切换,减少用户误解和无意义状态分支。
+
+**实现**
+
+- `src/components/chat/ChatView.tsx`
+ - 删除主聊天的 mode 状态、切换 UI、相关请求。
+ - 发消息时不再从前端传 `ask` / `plan`。
+- `src/app/chat/[id]/page.tsx`
+ - 页面加载时不再把 `session.mode` 作为主聊天 UI 状态源。
+- `src/components/layout/SplitColumn.tsx`
+ - 移除传递给聊天视图的 mode UI 状态,避免顶部状态和实际执行路径不一致。
+- `src/app/api/chat/route.ts`
+ - 主聊天路径把 `effectiveMode` 收敛到 `code`。
+ - 保留兼容注释,说明这是桌面主聊天的产品决策,不等于全系统彻底移除 `mode`。
+- `src/app/api/chat/mode/route.ts`
+ - 桌面 UI 不再调用。
+ - 可先保留接口以兼容旧版本客户端和桥接路径,后续再统一清理。
+- `src/i18n/en.ts` 与 `src/i18n/zh.ts`
+ - 清理主聊天里不再使用的模式文案。
+
+**风险控制**
+
+- 不建议本轮直接删除 DB schema、TypeScript union、bridge `/mode` 命令,否则会把“延迟治理”扩成“跨桌面 + 桥接 + 数据兼容”的大重构。
+- 旧 session 若仍带 `ask` / `plan`,桌面主聊天读取时统一按 `code` 执行;桥接路径暂不动。
+
+### Phase 2: MCP 持久启停开关
+
+**目标**
+
+- 把 MCP 开关从“当前活动会话里的临时 runtime toggle”升级为“配置层的持久开关”。
+
+**现状问题**
+
+- 现有 `src/app/api/plugins/mcp/toggle/route.ts` 只对当前活动 conversation 做 runtime toggle。
+- 主聊天和桥接加载 MCP 时,`src/app/api/chat/route.ts` 与 `src/lib/bridge/conversation-engine.ts` 会直接读取配置文件并全部注入,没有“持久禁用”的过滤层。
+
+**实现**
+
+- `src/types/index.ts`
+ - 给 `MCPServerConfig` 增加 `enabled?: boolean`,默认按 `true` 解释。
+- `src/app/api/plugins/mcp/route.ts`
+ - GET/PUT/POST 保留并透传 `enabled` 字段。
+ - 读取 `~/.claude.json` 和 `~/.claude/settings.json` 时合并 `_source`,但不丢失 `enabled`。
+- `src/components/plugins/McpServerList.tsx`
+ - 把“仅在 runtime disabled 时显示 Enable 按钮”的逻辑改成常驻开关。
+ - 配置禁用状态要和 runtime status 分开展示,避免“配置关掉”和“运行中失败”混为一谈。
+- `src/components/plugins/McpManager.tsx`
+ - 保存开关改为走配置更新,而不是仅调用 runtime toggle。
+- `src/app/api/chat/route.ts`
+ - `loadMcpServers()` 过滤 `enabled === false` 的 server。
+- `src/lib/bridge/conversation-engine.ts`
+ - 同步过滤 `enabled === false` 的 server,保证桌面和桥接行为一致。
+- `src/app/api/plugins/mcp/toggle/route.ts`
+ - 保留给“活动会话临时 reconnect / runtime toggle”使用,或者标记为仅 runtime 语义;不要拿它承载持久配置。
+
+**用户可见行为**
+
+- 默认不变,所有 server 仍然开启。
+- 用户手动关闭某个 server 后,它不会参与新的聊天初始化;这属于明确的用户控制,不是功能被系统静默阉割。
+
+### Phase 3: 首包延迟优化
+
+**目标**
+
+- 优先解决“所有模型都慢”和“恢复旧会话时长时间空白”这两个体感问题。
+
+**实现 A:runtime profile 显式默认值**
+
+- `src/lib/claude-client.ts`
+ - 当请求没有显式传 `thinking/effort` 时,由 CodePilot 统一设默认值,而不是继续继承用户本机 Claude 的高强度偏好。
+ - 推荐默认 `effort: 'medium'`;是否显式关闭 thinking 取决于当前 UI 有没有独立的 thinking 控制,避免引入额外行为分叉。
+- `src/app/api/chat/route.ts`
+ - 明确桌面主聊天的默认 runtime profile。
+ - 若未来需要区分“显式用户选择”和“应用默认值”,在请求体里增加来源标记,避免统计混淆。
+
+**实现 B:resume 可见状态**
+
+- `src/lib/claude-client.ts`
+ - 在执行 resume 校验前,先发一条前端可见的 status 事件。
+ - resume 失败时继续保留 fresh fallback,但不要让用户先经历整段静默等待。
+- `src/hooks/useSSEStream.ts`
+ - 接受新的可见状态消息并展示,不再把这类初始化状态都当作 internal-only 噪音过滤掉。
+
+**实现 C:能力抓取延后**
+
+- `src/lib/agent-sdk-capabilities.ts`
+ - 增加 TTL / freshness 判断,已有缓存时不在每轮 query 初始化后立即抓全套。
+- `src/lib/claude-client.ts`
+ - `captureCapabilities()` 改到首个 `system init` 之后、或首个文本事件之后执行,避免与首轮初始化竞争资源。
+
+**实现 D:项目自带 MCP 冷启动减负**
+
+- 项目级 [`.mcp.json`](/Users/guohao/Documents/code/codepilot/CodePilot/.mcp.json)
+ - 对项目自带的重型 server 避免使用 `npx -y ...@latest` 这类每次解析版本的冷启动方式。
+ - 优先固定版本;若条件允许,改为本地已安装命令或更轻的启动路径。
+- 保留现有 generative UI 的按需挂载策略,不把 widget MCP 重新拉回所有会话的默认初始化路径。
+
+## 验收标准
+
+- 主聊天界面不再出现 CodePilot / Ask 模式切换。
+- 新建或继续聊天时,桌面主聊天统一按 `code` 权限路径工作。
+- MCP 页面可以对单个 server 做持久启停,刷新页面后状态仍然正确。
+- 被关闭的 MCP server 不会出现在新聊天的初始化注入列表中。
+- 恢复旧会话时,用户能在模型首 token 前看到明确状态提示。
+- 普通文本聊天的首个可见状态时间明显早于当前版本,且不依赖关闭功能来实现。
+
+## 验证计划
+
+- `npm run test`
+ - 覆盖类型、单元测试、基础回归。
+- `npm run test:smoke`
+ - 验证主聊天发送、MCP 设置页、持久开关基本流程。
+- UI 变更必须跑开发环境并用 CDP 验证
+ - 启动 `npm run dev`
+ - 验证主聊天无模式切换入口
+ - 验证 MCP 页面开关可切换且刷新后保持
+ - 验证恢复旧会话时状态文案可见
+ - 检查 console 无新增报错
+
+## 建议实施顺序
+
+1. 先做 Phase 1,去掉无效模式入口并把桌面主聊天统一到 `code`。
+2. 再做 Phase 2,把 MCP 开关改成持久配置,并同步过滤聊天/桥接注入。
+3. 然后做 Phase 3 的三项低风险优化:显式 runtime 默认值、resume 可见状态、capability 延后。
+4. 最后补齐时序日志和回归验证,确认延迟改善是否达到预期。
diff --git a/docs/exec-plans/active/unified-context-layer.md b/docs/exec-plans/active/unified-context-layer.md
new file mode 100644
index 00000000..8ed66e7f
--- /dev/null
+++ b/docs/exec-plans/active/unified-context-layer.md
@@ -0,0 +1,255 @@
+# 统一上下文层 + 浮窗助理 + 产品架构演进
+
+> 创建时间:2026-03-24
+> 最后更新:2026-03-24
+
+## 状态
+
+| Phase | 内容 | 状态 | 备注 |
+|-------|------|------|------|
+| Phase 1 | 统一上下文层(Context Assembler + MCP Loader) | ✅ 已完成 | 已合入 worktree |
+| Phase 2 | Plan Mode 补全 | ✅ 已完成 | UI + 状态恢复 + 安全修复 |
+| Phase 3 | Bridge 能力升级 | ✅ 已完成 | CLI 工具上下文注入 |
+| Phase 4 | 浮窗助理(Electron 常驻 + 快捷键 + 语音 + 剪贴板) | 📋 待开始 | 需单独规划 |
+| Phase 5 | 代码任务通知(系统通知推送) | 📋 待开始 | 依赖 Phase 4 的交互范式确认 |
+
+---
+
+## 一、起因:项目架构散装问题
+
+### 问题描述
+
+CodePilot 发展至今,积累了 6 套独立的"连接器"系统,各有自己的发现机制、注入方式、调用链路和 UI 入口:
+
+| 系统 | 发现方式 | 注入方式 | UI 入口 |
+|------|---------|---------|---------|
+| Skills | 文件系统扫描 `.claude/skills/` | 展开为纯文本插入 prompt | `/` 弹出框 |
+| MCP | 读 3 个配置文件合并 | SDK 原生加载 | 设置页管理 |
+| CLI Tools | `which` + `--version` 探测 | system prompt XML 块 | 工具栏弹出框 |
+| Plugins | `~/.claude/plugins/` 目录扫描 | SDK 自动加载 | 设置页管理 |
+| Generative UI | 模型输出 code fence 检测 | system prompt 指令 | 消息流内 iframe |
+| Bridge | 适配器自注册 | 独立消息循环 | CLI skill 启停 |
+
+这些系统之间几乎没有共享抽象,导致三个结构性问题:
+
+1. **上下文组装碎片化**:`route.ts` 有 150 行内联组装逻辑(5 层 prompt),Bridge 只用裸 `session.system_prompt`。同一个用户装了 ffmpeg、配了 MCP、开了 widget,但从 Bridge 发消息时 Claude 不知道这些能力存在。
+2. **扩展的"身份"没有统一**:Skills、CLI Tools、MCP tools、Plugins 本质上都是"Claude 可以调用的能力",但注册方式、生命周期、UI、作用域全部不同。
+3. **各入口能力不对称**:Browser chat 有全功能,Bridge 缺 CLI/Widget,Assistant 又是另一套。
+
+### 参考:Moxt 的 AI-Native Workspace
+
+Moxt([moxt.ai](https://moxt.ai))的核心洞察:
+
+- **Less Content is More Context** — 重要的不是工具数量,而是 AI 能无摩擦调用。
+- 所有能力统一在"工作空间"概念下:文件系统、记忆、技能、AI 同事不是独立功能,而是同一空间的不同维度。
+- 用户不需要知道"这是 MCP 工具还是 Skill",只需说"帮我做这件事"。
+
+映射到 CodePilot:应有一个统一的能力层(Capability Layer),所有扩展注册进去,Claude 按需调用。
+
+---
+
+## 二、架构方向
+
+### 目标架构
+
+```
+┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
+│ 主窗口 │ │ 浮窗助理 │ │ Bridge │ │ 未来入口 │
+│ (完整UI) │ │ (快捷键 │ │ (IM远程) │ │ │
+│ │ │ +剪贴板 │ │ │ │ │
+│ │ │ +语音) │ │ │ │ │
+└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │ │
+ └─────────────┴─────────────┴──────────────┘
+ │
+ ┌─────────▼──────────┐
+ │ Context Assembler │
+ │ 按入口类型决定 │
+ │ 注入哪些能力层 │
+ └─────────┬──────────┘
+ │
+ ┌─────────▼──────────┐
+ │ SDK Gateway │
+ │ (streamClaude) │
+ │ 所有入口共享 │
+ └────────────────────┘
+```
+
+四个入口形态不同,但底层共享:
+- **主窗口**:完整开发体验,生成式 UI、文件树、全功能
+- **浮窗助理**:轻量、快进快出、剪贴板 + 语音驱动
+- **Bridge**:IM 远程,文字驱动
+- **助理空间**:长期记忆、个性化
+
+### 核心设计原则
+
+1. **按 mode 决定注入什么** — 助理模式不注入 CLI/widget(省上下文),代码模式不注入 workspace prompt
+2. **不重实现 SDK 已有功能** — plan mode、settingSources、MCP 加载全用 SDK 原生
+3. **入口增加不需要重写上下文逻辑** — 新增浮窗助理只需调 `assembleContext({ entryPoint: 'floating' })`
+
+---
+
+## 三、Phase 1-3 已完成:统一上下文层
+
+### 3.1 Context Assembler(`src/lib/context-assembler.ts`)
+
+从 `route.ts` 提取 150 行内联组装逻辑为纯 async 函数。按 `entryPoint` 条件注入 5 层 prompt:
+
+| 层 | Desktop | Bridge | 说明 |
+|----|---------|--------|------|
+| Workspace prompt | ✅ 如果 isAssistantProject | ✅ 如果 isAssistantProject | soul.md, user.md, memory.md 等 |
+| session.system_prompt + append | ✅ | ✅ | 会话自定义 prompt + 技能注入 |
+| Assistant project instructions | ✅ 如果 isAssistantProject | ✅ 如果 isAssistantProject | onboarding / daily check-in |
+| CLI tools context | ✅ | ✅ **(新增)** | 检测到的系统工具 XML 块 |
+| Widget system prompt | ✅ 如果 generative_ui_enabled | ❌ 永不注入 | IM 渲染不了 iframe |
+
+复用现有函数(loadWorkspaceFiles、assembleWorkspacePrompt、buildCliToolsContext 等),不重写。
+
+### 3.2 MCP Loader(`src/lib/mcp-loader.ts`)
+
+**问题**:`loadMcpServers()` 在 route.ts 和 conversation-engine.ts 中完全重复。且 SDK 通过 `settingSources: ['user', 'project', 'local']` 已自动加载所有 MCP 服务器,手动再传一遍是冗余的。
+
+**方案**:Delta 模式。
+- `loadCodePilotMcpServers()`:只返回有 `${...}` env placeholder 的服务器(需从 CodePilot DB 解析)。当前实际配置中无此类服务器,返回 undefined。
+- `loadAllMcpServers()`:全量合并,供 MCP Manager UI 展示。
+- 30 秒 TTL 缓存 + 配置变更时主动失效。
+
+**延迟影响**:大多数用户场景下不再传任何手动 MCP 配置给 SDK,消除冗余配置解析。
+
+### 3.3 Plan Mode 补全
+
+**之前的问题**:
+- `permissionMode` 硬编码为 `'acceptEdits'`,切到 Plan 不生效
+- mode 状态刷新即丢(hardcoded `useState('code')`)
+- 无 UI 切换入口
+
+**修复**:
+- 从请求体 `mode` 字段读取(优先)或从 DB `session.mode` 读取(fallback),消除竞态
+- Plan mode 优先于 full_access:`bypassPermissions = full_access && mode !== 'plan'`
+- `initialMode` prop 从 DB 恢复
+- ModeIndicator 组件(Tabs),Code/Plan 切换
+
+### 3.4 Bridge 升级
+
+- `conversation-engine.ts` 调用 `assembleContext({ entryPoint: 'bridge' })`,获得 CLI 工具上下文
+- 补齐 5 个缺失 SDK 选项:thinking(disabled)、effort(medium)、generativeUI(false)、fileCheckpointing(false)、context1m(false)
+
+### 3.5 UI 统一
+
+Action bar 三个组件(ModeIndicator、ImageGenToggle、ChatPermissionSelector)统一为:
+- 圆角矩形(`rounded-md`)
+- 相同高度(`h-7` = 28px)
+- 相同字号(`text-xs`)
+- 各自带语义图标(Code / NotePencil / PaintBrush / Lock)
+
+---
+
+## 四、Phase 4 待开始:浮窗助理
+
+### 4.1 产品定位
+
+参考 Perplexity 桌面版的"常驻后台 + 快捷键拉起"模式,CodePilot 的浮窗助理定位为:
+
+**始终在身边的助理,不需要打开主窗口就能执行任务。**
+
+```
+用户在任何 app 里工作
+ → 复制了一段文字/截了一张图(进剪贴板)
+ → 按全局快捷键(如 Cmd+Shift+Space)
+ → 浮窗从右上角弹出
+ → 自动读取剪贴板内容,显示在输入区作为上下文
+ → 用户语音说指令(或打字)
+ → Claude 执行,结果语音播报
+ → 用户关掉浮窗,继续工作
+```
+
+全程不需要切换到 CodePilot 主窗口。
+
+### 4.2 核心决策
+
+| 决策 | 结论 | 理由 |
+|------|------|------|
+| 浮窗用哪个 workspace? | 固定在助理 workspace | 助理任务不需要项目上下文,避免上下文爆炸 |
+| 新建还是继续对话? | 用户选择 | 可以新建,也可以在助理已有对话上继续 |
+| 代码任务怎么办? | 回主窗口 | 代码需要审查,通过系统通知告知 |
+| 语音方案? | 双轨 | 本地 Whisper(愿意下模型的用户)+ API(配置不够的用户) |
+| TTS 回复? | 仅浮窗助理 | 代码任务不需要语音播报 |
+
+### 4.3 技术方案
+
+**块 1:Electron 常驻 + 全局快捷键 + 浮窗**
+- `Tray` 做常驻(菜单栏图标)
+- `globalShortcut.register()` 注册全局快捷键
+- 独立 `BrowserWindow`,小尺寸,置顶,圆角
+- Esc 或失焦自动收起(隐藏不关闭)
+
+**块 2:剪贴板感知**
+- 弹出时 `clipboard.readText()` + `clipboard.readImage()`
+- 有文字:显示为上下文预览卡片
+- 有图片:显示缩略图,作为 vision 输入
+- 空:直接显示输入框
+
+**块 3:语音输入/输出**
+- 抽象接口:
+ ```ts
+ interface SpeechProvider {
+ transcribe(audio: AudioBuffer): Promise // STT
+ speak(text: string): Promise // TTS
+ readonly type: 'local' | 'api'
+ readonly ready: boolean
+ }
+ ```
+- 本地:Whisper + 系统 TTS
+- API:OpenAI Whisper API + TTS API
+- 用户设置里选,或自动降级(本地模型没下载就 fallback 到 API)
+
+### 4.4 上下文隔离
+
+浮窗助理始终在助理模式:
+- Context Assembler 按 `entryPoint: 'floating'` 组装,只注入 workspace prompt + session prompt
+- 不注入 CLI tools、widget prompt、项目文件上下文
+- 上下文小 → 响应快 → 适合快进快出
+
+---
+
+## 五、Phase 5 待开始:代码任务通知
+
+代码任务留在主窗口,但通过系统通知推送关键事件:
+
+```
+助理浮窗: 语音输入 → AI 处理 → 语音/文字回复(快进快出)
+代码任务: 主窗口操作 → 需要注意力时推系统通知 → 用户点通知回到主窗口
+```
+
+通知场景:
+- "任务完成:已创建 3 个文件"
+- "需要审批:Claude 想执行 `rm -rf dist/`"
+- 点击通知 → 跳转到主窗口对应会话
+
+技术:Electron `Notification` API,简单直接。
+
+---
+
+## 六、技术债务 & 后续优化
+
+| 项目 | 说明 | 优先级 |
+|------|------|--------|
+| Skills/Commands 双轨发现 | SDK 自动发现 + CodePilot 手动扫描不同步,UI 显示的能力 ≠ Claude 实际知道的 | P1 |
+| Widget prompt 始终注入 | ~150 tokens,不大但无关对话也在。已做 keyword-gated MCP,prompt 本身可进一步按需注入 | P2 |
+| Bridge 缺 thinking/effort UI | 目前 Bridge 固定 thinking=disabled, effort=medium,未来可加 binding 级别配置 | P3 |
+| 统一能力注册表 | 长期目标:`AgentCapabilityRegistry` 让所有扩展统一注册,UI 有统一"能力面板" | P3 |
+| ConversationContext 一等实体 | 封装 provider + model + tools + skills + files + cwd,消除重复解析逻辑 | P3 |
+
+---
+
+## 七、相关文件
+
+| 文件 | 用途 |
+|------|------|
+| `src/lib/context-assembler.ts` | 统一上下文组装 |
+| `src/lib/mcp-loader.ts` | 智能 MCP 加载器 |
+| `src/components/chat/ModeIndicator.tsx` | Plan/Code 模式切换 UI |
+| `src/app/api/chat/route.ts` | 主聊天端点(已重构) |
+| `src/lib/bridge/conversation-engine.ts` | Bridge SDK 调用(已升级) |
+| `src/__tests__/unit/context-assembler.test.ts` | 上下文组装测试 |
+| `src/__tests__/unit/mcp-loader.test.ts` | MCP 加载器测试 |
diff --git a/docs/exec-plans/active/weixin-bridge-channel.md b/docs/exec-plans/active/weixin-bridge-channel.md
new file mode 100644
index 00000000..e4df452a
--- /dev/null
+++ b/docs/exec-plans/active/weixin-bridge-channel.md
@@ -0,0 +1,599 @@
+# Weixin Bridge Channel Integration
+
+> 创建时间:2026-03-22
+> 最后更新:2026-03-22
+
+## 状态
+
+| Phase | 内容 | 状态 | 备注 |
+|-------|------|------|------|
+| One Shot | 微信 Bridge 通道端到端实现 | 📋 待开始 | 按本文一次性交付,不拆分成多轮功能阶段 |
+
+## 决策日志
+
+- 2026-03-22: 不直接依赖 `@tencent-weixin/openclaw-weixin` 运行时;只把它当协议样本和参考实现,因为它深度绑定 `openclaw/plugin-sdk` 与 OpenClaw runtime。
+- 2026-03-22: 微信通道按 CodePilot 现有 `BaseChannelAdapter` 原生实现,而不是走当前 CodePilot `ChannelPlugin` 接口;后者接口能力不足以承载微信的多账号登录、长轮询和 per-account 状态管理。
+- 2026-03-22: 多账号路由不修改 `channel_bindings` schema,改用合成 `chatId` 解决隔离问题,格式固定为 `weixin::::`。
+- 2026-03-22: `channel_offsets` 继续复用,但 offset key 不再等于单纯 channel name,而是 `weixin:`,其 `offset_value` 保存 `get_updates_buf` 原文。
+- 2026-03-22: `context_token` 不能照搬 OpenClaw 插件的内存 `Map`,必须持久化进 SQLite,否则 Bridge 自动启动、冷启动回复和多账号轮询都会不稳定。
+- 2026-03-22: 本轮不做群聊、不做 OpenClaw 飞书插件里的平台工具集、不做 OpenClaw command/tool runtime 复刻;聚焦 CodePilot Bridge 通道能力本身。
+- 2026-03-22: 权限审批走文本命令降级路径 `/perm allow|allow_session|deny `,不做微信内联按钮。
+
+## 目标
+
+- 在 CodePilot 中新增可真实使用的微信 Bridge 通道。
+- 支持二维码登录、多账号在线、私聊消息长轮询、文本收发、typing 指示、入站媒体解析。
+- 让微信通道完整接入现有 `Bridge -> Router -> ConversationEngine -> Delivery` 主链路。
+- 提供桌面设置页、账号列表、连接/断开和运行状态展示。
+- 让 Claude Code 可以基于本计划和本地参考代码,一次性完成可交付实现,而不是先做半成品 POC。
+
+## 非目标
+
+- 不直接把 OpenClaw 微信 npm 包作为 CodePilot 运行时依赖。
+- 不实现群聊、群策略、线程会话、@mention 触发。
+- 不移植 OpenClaw 飞书插件的 doc/wiki/drive/task/calendar 工具族。
+- 不在本轮做 AI 主动发送媒体的完整产品交互;若实现者顺手补齐底层能力可以接受,但不能因此拖慢主链路交付。
+
+## 先读这些上下文
+
+Claude Code 在开工前,必须先通读以下本地资料,再写代码:
+
+### 1. CodePilot 现有架构
+
+- `AGENTS.md`
+- `ARCHITECTURE.md`
+- `docs/handover/bridge-system.md`
+- `docs/research/mobile-remote-control-overall-plan.md`
+- `src/lib/bridge/channel-adapter.ts`
+- `src/lib/bridge/bridge-manager.ts`
+- `src/lib/bridge/channel-router.ts`
+- `src/lib/bridge/conversation-engine.ts`
+- `src/lib/db.ts`
+- `src/app/api/bridge/settings/route.ts`
+- `src/components/bridge/BridgeSection.tsx`
+- `src/components/bridge/BridgeLayout.tsx`
+
+### 2. OpenClaw 微信插件参考
+
+- `docs/research/weixin-openclaw-plugin-review-2026-03-22.md`
+- `资料/weixin-openclaw-cli/package/cli.mjs`
+- `资料/weixin-openclaw-package/package/index.ts`
+- `资料/weixin-openclaw-package/package/openclaw.plugin.json`
+- `资料/weixin-openclaw-package/package/src/channel.ts`
+- `资料/weixin-openclaw-package/package/src/api/api.ts`
+- `资料/weixin-openclaw-package/package/src/api/types.ts`
+- `资料/weixin-openclaw-package/package/src/auth/login-qr.ts`
+- `资料/weixin-openclaw-package/package/src/auth/accounts.ts`
+- `资料/weixin-openclaw-package/package/src/monitor/monitor.ts`
+- `资料/weixin-openclaw-package/package/src/messaging/inbound.ts`
+- `资料/weixin-openclaw-package/package/src/messaging/process-message.ts`
+- `资料/weixin-openclaw-package/package/src/messaging/send.ts`
+- `资料/weixin-openclaw-package/package/src/messaging/send-media.ts`
+- `资料/weixin-openclaw-package/package/src/cdn/upload.ts`
+- `资料/weixin-openclaw-package/package/src/media/media-download.ts`
+- `资料/weixin-openclaw-package/package/README.zh_CN.md`
+
+### 3. OpenClaw 飞书插件参考
+
+- `资料/feishu-openclaw-plugin/package/index.js`
+- `资料/feishu-openclaw-plugin/package/openclaw.plugin.json`
+- `资料/feishu-openclaw-plugin/package/src/commands/index.js`
+
+飞书插件在本任务中的作用不是“代码复用”,而是“组织方式参考”:诊断命令、onboarding、插件命令入口、能力分层。不要试图把其 OpenClaw runtime 逻辑直接复制进 CodePilot。
+
+## 单次交付约束
+
+- 这次交付必须一次性打通:数据层、适配器层、设置 API、Bridge UI、基础测试、文档。
+- 不允许停在“只接协议 helper”或“只做设置页”。
+- 代码写完后必须执行至少:
+ - `npm run test`
+ - `npm run test:smoke`
+ - 启动 `PORT=3001 npm run dev`
+ - 用 CDP 打开 Bridge 页面验证微信设置 UI、账号列表和连接流程界面
+- 若因缺少真实微信账号无法做真人扫码联调,必须在结果里明确说明“已完成代码、自测和模拟验证,但真实扫码登录未实测”,不能假装已经验证过。
+
+## 总体设计
+
+### 1. 总体实现形态
+
+采用 **CodePilot 原生 `BaseChannelAdapter` 实现**:
+
+- 新建微信协议 helper、登录 helper、媒体 helper。
+- 新建 `WeixinAdapter` 负责多账号长轮询、消息标准化、文本出站、typing 指示和状态跟踪。
+- 不引入 `openclaw/plugin-sdk`,不在运行时调用 OpenClaw 包导出的任何插件接口。
+
+### 2. 多账号模型
+
+微信与 Telegram/QQ 最大不同在于:
+
+- 一个 CodePilot 实例可能同时登录多个微信 bot 账号。
+- 同一个 `peerUserId` 在不同 bot 账号下必须拥有不同上下文和不同 binding。
+
+因此必须采用合成路由键:
+
+- `syntheticChatId = "weixin::::"`
+- `address.channelType = "weixin"`
+- `address.chatId = syntheticChatId`
+- `address.userId = peerUserId`
+- `address.displayName = peerUserId`
+
+这样做的好处:
+
+- 复用现有 `channel_bindings` 唯一键 `(channel_type, chat_id)`,无需改表。
+- 同一 `peerUserId` 在不同 bot 账号下会自然落到不同 `chat_session`。
+- `bridge-manager` 和 `channel-router` 无需感知“多账号”概念,只处理普通地址。
+
+必须新增一个 helper,例如:
+
+- `encodeWeixinChatId(accountId, peerUserId): string`
+- `decodeWeixinChatId(chatId): { accountId: string; peerUserId: string }`
+
+所有微信 adapter、API、UI、permission fallback、日志都必须统一使用这套 helper,禁止散落字符串拼接。
+
+### 3. 数据持久化设计
+
+#### 3.1 继续复用的表
+
+- `channel_bindings`
+ 继续保存微信会话绑定,key 为合成 `chatId`
+- `channel_offsets`
+ 继续保存 `get_updates_buf`
+ key 格式:`weixin:`
+- `channel_audit_logs`
+ 继续记录入站/出站消息摘要
+
+#### 3.2 必须新增的表
+
+新增 `weixin_accounts`:
+
+- `account_id TEXT PRIMARY KEY`
+- `user_id TEXT NOT NULL DEFAULT ''`
+- `base_url TEXT NOT NULL DEFAULT ''`
+- `cdn_base_url TEXT NOT NULL DEFAULT ''`
+- `token TEXT NOT NULL DEFAULT ''`
+- `name TEXT NOT NULL DEFAULT ''`
+- `enabled INTEGER NOT NULL DEFAULT 1`
+- `last_login_at TEXT`
+- `created_at TEXT NOT NULL DEFAULT (datetime('now'))`
+- `updated_at TEXT NOT NULL DEFAULT (datetime('now'))`
+
+用途:
+
+- 保存二维码登录后的 bot token 与账号配置
+- 支撑账号列表 UI
+- 支撑多账号轮询启动/停止
+
+新增 `weixin_context_tokens`:
+
+- `account_id TEXT NOT NULL`
+- `peer_user_id TEXT NOT NULL`
+- `context_token TEXT NOT NULL`
+- `updated_at TEXT NOT NULL DEFAULT (datetime('now'))`
+- `PRIMARY KEY(account_id, peer_user_id)`
+
+用途:
+
+- 持久化最近一次可用的 `context_token`
+- 供出站回复、Bridge 自动恢复、账号重启后续答使用
+
+不新增 sync buf 专用表,原因:
+
+- `channel_offsets.offset_value` 已经是 `TEXT`
+- `get_updates_buf` 本身就是字符串
+- 只要 offset key 改成 per-account 即可
+
+#### 3.3 DB 层 helper
+
+在 `src/lib/db.ts` 中新增 helper:
+
+- `listWeixinAccounts()`
+- `getWeixinAccount(accountId)`
+- `upsertWeixinAccount(...)`
+- `deleteWeixinAccount(accountId)`
+- `setWeixinAccountEnabled(accountId, enabled)`
+- `getWeixinContextToken(accountId, peerUserId)`
+- `upsertWeixinContextToken(accountId, peerUserId, token)`
+- `deleteWeixinContextTokensByAccount(accountId)`
+
+同时补类型:
+
+- `src/types/index.ts`
+ - `WeixinAccount`
+ - `WeixinContextTokenRecord`
+
+### 4. 微信协议层设计
+
+创建独立 helper,不把协议逻辑塞进 adapter 主文件:
+
+- `src/lib/bridge/adapters/weixin/weixin-types.ts`
+- `src/lib/bridge/adapters/weixin/weixin-api.ts`
+- `src/lib/bridge/adapters/weixin/weixin-auth.ts`
+- `src/lib/bridge/adapters/weixin/weixin-media.ts`
+- `src/lib/bridge/adapters/weixin/weixin-ids.ts`
+- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts`
+
+#### 4.1 API helper
+
+`weixin-api.ts` 直接按 OpenClaw 插件协议实现:
+
+- `getUpdates`
+- `sendMessage`
+- `getUploadUrl`
+- `getConfig`
+- `sendTyping`
+- `startLoginQr`
+- `pollLoginQrStatus`
+
+必须复刻的协议细节:
+
+- `AuthorizationType: ilink_bot_token`
+- `Authorization: Bearer `
+- `X-WECHAT-UIN` 随机 uint32 base64
+- 请求 body 携带 `base_info.channel_version`
+- `getUpdates` 的客户端超时视为正常空轮询,不应报错终止
+- `errcode = -14` 进入 account 级 pause 状态
+
+#### 4.2 登录 helper
+
+`weixin-auth.ts` 负责:
+
+- 生成二维码登录会话
+- 长轮询扫码状态
+- 登录成功后写入 `weixin_accounts`
+- 提供 API route 所需的 in-memory active session store
+
+实现要求:
+
+- 参考 OpenClaw 的 `activeLogins` 思路,但状态放在 CodePilot 服务端的 `globalThis` 挂载,避免 Next dev HMR 丢失
+- session 有 TTL
+- 支持二维码过期后刷新
+- `accountId` 正规化使用安全字符串,不在 DB key 中保留危险字符
+
+### 5. 媒体处理设计
+
+#### 5.1 入站媒体
+
+必须支持:
+
+- 图片
+- 文件
+- 视频
+- 语音
+
+处理流程参考 OpenClaw 微信插件:
+
+1. 从 `item_list` 找可下载媒体
+2. 从 CDN 拉密文
+3. AES-128-ECB 解密
+4. 生成 `FileAttachment`
+5. 交给现有 `conversation-engine.ts`
+
+建议实现方式:
+
+- 下载后的本地落盘可以先走临时文件,再转成 CodePilot 现有 `FileAttachment { data(base64), name, type }`
+- 如果有现成统一媒体保存 helper,可复用;不要引入额外 native 依赖
+- 语音优先尝试转 WAV;若无稳定转码链路,允许先按原始音频附件传入,但要明确 MIME
+
+#### 5.2 出站媒体
+
+本轮可选两种实现方式,优先采用 A:
+
+- A. 先只实现文本出站,把媒体 helper 写好但不接到 `OutboundMessage`
+- B. 若实现者有余力,可为微信 adapter 补齐 `sendImage/sendFile/sendVideo` 底层能力,并预留接口给未来 Bridge message tool 使用
+
+无论选 A 还是 B,都必须把以下基础层写好:
+
+- AES-128-ECB 加密/解密
+- `getUploadUrl`
+- CDN PUT 上传
+- download param 回填
+
+原因:
+
+- 入站媒体已经需要解密
+- 将来补出站媒体时不应再重新拆协议
+
+### 6. Adapter 设计
+
+创建 `src/lib/bridge/adapters/weixin-adapter.ts`,并在 `src/lib/bridge/adapters/index.ts` 注册。
+
+#### 6.1 生命周期
+
+`WeixinAdapter` 是单实例、多 worker 模型:
+
+- `start()`
+ - 读取 `bridge_weixin_enabled`
+ - 拉取 DB 里所有 `enabled = true` 的微信账号
+ - 每个账号启动一个长轮询 worker
+- `stop()`
+ - abort 所有 worker
+ - 清理 waiters、queue、typing timers、pause 状态
+- `isRunning()`
+ - 只要至少一个 account worker 正在运行就返回 true
+
+#### 6.2 worker 模型
+
+每个账号一个 polling loop:
+
+1. 从 `channel_offsets` 取 `weixin:` 的 `get_updates_buf`
+2. 循环调用 `getUpdates`
+3. 成功时持久化新 `get_updates_buf`
+4. 逐条标准化消息并入队
+5. `errcode=-14` 时暂停该账号 1 小时
+
+#### 6.3 标准化为 `InboundMessage`
+
+对于每条微信消息:
+
+- `messageId`
+ 优先 `message_id`,没有则退化为 `seq` 或生成值
+- `address.channelType = "weixin"`
+- `address.chatId = encodeWeixinChatId(accountId, from_user_id)`
+- `address.userId = from_user_id`
+- `text`
+ 从 `item_list` 提取文本,引用消息按 OpenClaw 的 quoted-text 思路展开
+- `attachments`
+ 若有媒体则转成 `FileAttachment[]`
+- `raw`
+ 保留原始消息和 accountId,便于调试
+
+同时必须:
+
+- 把 `context_token` 写入 `weixin_context_tokens`
+- 写审计日志
+- 对 message_id 做 account 级 dedupe,避免重复入队
+
+#### 6.4 出站发送
+
+`send(message: OutboundMessage)` 必须:
+
+1. 从 `message.address.chatId` decode 出 `accountId` 和 `peerUserId`
+2. 读取该 peer 最近一次 `context_token`
+3. 若没有 token,返回明确错误,不要静默失败
+4. 调 `sendMessage`
+
+文本内容处理:
+
+- 微信不支持我们当前桥接里的 HTML/Markdown 交互格式
+- 统一走 plain text,必要时做轻量 markdown strip,参考 OpenClaw `markdownToPlainText`
+
+#### 6.5 typing 指示
+
+实现 `onMessageStart` / `onMessageEnd`:
+
+- `onMessageStart(chatId)`
+ - decode account + peer
+ - 读取或缓存 `typing_ticket`
+ - 调 `sendTyping(status=1)`
+- `onMessageEnd(chatId)`
+ - `sendTyping(status=2)`
+
+typing ticket 获取逻辑:
+
+- 按 `accountId + peerUserId` 或至少按 `peerUserId` 缓存
+- 带退避策略,失败后不要阻塞主消息链路
+
+#### 6.6 preview / permission
+
+- 不实现 `getPreviewCapabilities`
+- 不实现 callback query
+- `permission-broker.ts` 必须把 `weixin` 归类到“无按钮渠道”,与 `qq` 同类
+- `/perm` 文本审批链路保持可用
+
+### 7. API 路由设计
+
+新增:
+
+- `src/app/api/settings/weixin/route.ts`
+ - GET/PUT 全局微信配置
+- `src/app/api/settings/weixin/accounts/route.ts`
+ - GET 账号列表
+- `src/app/api/settings/weixin/accounts/[accountId]/route.ts`
+ - DELETE 断开账号
+ - PATCH 启停账号
+- `src/app/api/settings/weixin/login/start/route.ts`
+ - 生成二维码
+- `src/app/api/settings/weixin/login/wait/route.ts`
+ - 轮询登录结果
+
+建议保留在 settings 表中的全局 key:
+
+- `bridge_weixin_enabled`
+- `bridge_weixin_base_url`
+- `bridge_weixin_cdn_base_url`
+- `bridge_weixin_image_enabled`
+- `bridge_weixin_media_enabled`
+- `bridge_weixin_log_upload_url`
+
+说明:
+
+- 多账号 token 不要进 `settings` 表
+- token 必须只进 `weixin_accounts`
+
+同时更新:
+
+- `src/app/api/bridge/settings/route.ts`
+ - 把上述全局 key 加入白名单
+
+### 8. Bridge UI 设计
+
+新增:
+
+- `src/components/bridge/WeixinBridgeSection.tsx`
+
+修改:
+
+- `src/components/bridge/BridgeLayout.tsx`
+ - 增加 `weixin` section
+- `src/components/bridge/BridgeSection.tsx`
+ - 增加 channel 总开关
+- `src/i18n/en.ts`
+- `src/i18n/zh.ts`
+
+UI 必须包含:
+
+- 微信总开关
+- base URL / CDN base URL 配置
+- “连接微信账号”按钮
+- 当前二维码展示区或轮询状态展示
+- 已登录账号列表
+- 每账号 enabled 开关
+- 每账号断开按钮
+- 基础状态提示:已连接 / 轮询中 / session paused / 最后错误
+
+不要求:
+
+- 花哨动效
+- 复杂诊断页
+
+但必须满足:
+
+- 信息完整
+- 状态可见
+- 刷新后与 DB 一致
+
+### 9. 文本审批降级
+
+修改 `src/lib/bridge/permission-broker.ts`:
+
+- 当前 `supportsButtons = adapter.channelType !== 'qq'`
+- 必须改成明确把 `weixin` 也归到“无按钮渠道”
+
+例如:
+
+- `const supportsButtons = !['qq', 'weixin'].includes(adapter.channelType)`
+
+否则微信收到的权限消息会被当作按钮卡片发送,但 adapter 根本不会处理回调。
+
+### 10. Bridge 帮助与可见状态
+
+建议同步更新:
+
+- `src/lib/bridge/bridge-manager.ts`
+ - `/help` 文案中增加 `weixin`
+ - 若有 channel-specific 帮助入口,可增加简单说明
+
+不是必须新增 `/weixin` 命令组,但至少总帮助里要让用户知道微信已接入。
+
+### 11. 文档更新
+
+实现完成后必须更新:
+
+- `docs/handover/bridge-system.md`
+ - 增加 Weixin 架构和数据流说明
+- `docs/handover/README.md`
+ - 如 handover 文档新增内容需要索引则同步更新
+- `docs/research/weixin-openclaw-plugin-review-2026-03-22.md`
+ - 如实现过程中发现协议差异或实测补充,可追加结论
+
+## 建议修改文件清单
+
+### 必改
+
+- `src/lib/db.ts`
+- `src/types/index.ts`
+- `src/lib/bridge/adapters/index.ts`
+- `src/lib/bridge/adapters/weixin-adapter.ts`
+- `src/lib/bridge/permission-broker.ts`
+- `src/app/api/bridge/settings/route.ts`
+- `src/components/bridge/BridgeLayout.tsx`
+- `src/components/bridge/BridgeSection.tsx`
+- `src/components/bridge/WeixinBridgeSection.tsx`
+- `src/i18n/en.ts`
+- `src/i18n/zh.ts`
+
+### 强烈建议新建
+
+- `src/lib/bridge/adapters/weixin/weixin-types.ts`
+- `src/lib/bridge/adapters/weixin/weixin-ids.ts`
+- `src/lib/bridge/adapters/weixin/weixin-api.ts`
+- `src/lib/bridge/adapters/weixin/weixin-auth.ts`
+- `src/lib/bridge/adapters/weixin/weixin-media.ts`
+- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts`
+- `src/app/api/settings/weixin/route.ts`
+- `src/app/api/settings/weixin/accounts/route.ts`
+- `src/app/api/settings/weixin/accounts/[accountId]/route.ts`
+- `src/app/api/settings/weixin/login/start/route.ts`
+- `src/app/api/settings/weixin/login/wait/route.ts`
+- `src/__tests__/unit/weixin-*.test.ts`
+
+## 推荐编码顺序
+
+下面是单次交付内部的编码顺序,不是阶段拆分:
+
+1. 先写 DB 迁移和 helper,确保账户、token、context token 有可靠存储。
+2. 再写协议 helper 和 ID helper,把登录、轮询、headers、加密逻辑固定下来。
+3. 然后实现 adapter 并注册,先打通文本收发和多账号轮询。
+4. 接着补入站媒体解密和 `FileAttachment` 转换。
+5. 然后加 settings API 和 Weixin Bridge UI。
+6. 最后补 permission fallback、i18n、文档和测试。
+
+## 验收标准
+
+- Bridge 首页可见微信通道开关。
+- Bridge 侧边栏可进入独立微信设置页。
+- 用户可以发起二维码登录,并看到二维码或二维码链接。
+- 登录成功后账号写入 DB,并在设置页列表中可见。
+- 启动 Bridge 后,已启用的微信账号会开始长轮询。
+- 私聊文本消息能创建或命中正确的 CodePilot session,并收到回复。
+- 同一个 `peerUserId` 在不同微信账号下不会串 session。
+- 入站图片至少能作为 `FileAttachment` 进入 `conversation-engine.ts`。
+- `context_token` 在应用重启后仍可用于对同一 peer 的正常回复。
+- `permission_request` 在微信中能走 `/perm` 文本审批,不会卡死。
+- 停用账号后对应 worker 停止,不再收消息。
+- 删除账号后 token、context token 和其 offset 能被清理。
+
+## 必跑验证
+
+### 自动化
+
+- `npm run test`
+- `npm run test:smoke`
+
+至少补以下单元测试:
+
+- `encodeWeixinChatId/decodeWeixinChatId`
+- `weixin-api` 头部和 timeout 行为
+- `weixin-session-guard`
+- `weixin_accounts` / `weixin_context_tokens` DB helper
+- `permission-broker` 对 `weixin` 的无按钮分支
+- `context_token` 持久化读取
+
+### 手动
+
+- `PORT=3001 npm run dev`
+- 打开 Bridge 页面
+- 验证微信 section 可进入
+- 验证设置保存、刷新后回显
+- 验证“连接账号”按钮打开二维码区域
+- 验证账号列表启停/删除交互
+- 检查浏览器 console 无报错
+
+### CDP
+
+UI 改动必须用 CDP:
+
+- 打开 `http://localhost:3001/bridge`
+- 截图 Bridge 首页微信开关状态
+- 截图微信设置页
+- 若二维码区域能显示,截图登录面板
+- 检查 console 无错误
+
+## 实现时禁止事项
+
+- 不要把 `@tencent-weixin/openclaw-weixin` 直接加到生产依赖后强行调用其插件入口。
+- 不要把 token 继续存在 `settings` 表的平铺 key 中。
+- 不要把 `context_token` 只存在内存里。
+- 不要为多账号去破坏现有 `channel_bindings` 唯一键模型;优先使用合成 `chatId`。
+- 不要因为微信无按钮就跳过权限审批;必须接 `/perm` 降级路径。
+- 不要只做静态 UI,不接入真实 adapter 生命周期。
+
+## Claude Code 输出要求
+
+Claude Code 实施完成后,输出里必须明确说明:
+
+- 改了哪些核心文件
+- 是否完成真实扫码联调
+- 跑了哪些测试
+- CDP 验证覆盖了哪些页面
+- 尚未验证的风险点是什么
diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md
index ee938020..c27977a2 100644
--- a/docs/exec-plans/tech-debt-tracker.md
+++ b/docs/exec-plans/tech-debt-tracker.md
@@ -12,6 +12,7 @@
| 2 | `conversation-registry` / `permission-registry` 运行态依赖内存 Map,重启后丢失 | 中 | 长时间运行的会话、Bridge 模式 | 2026-02-26 |
| 3 | 消息 fallback 上下文固定最近 50 条,无动态 token 预算截断 | 低 | 长会话上下文质量 | 2026-02-26 |
| 4 | `context-storage-migration` Phase 0 剩余:`projects` 表未建、`canUpdateSdkCwd` 未实现 | 低 | 多项目隔离 | 2026-03-04 |
+| 5 | Bridge 的 `/mode plan` 在会话权限档位为 `full_access` 时仍会被 `bypassPermissions` 覆盖,导致 Plan 语义失效;需让 bridge 与桌面聊天一致,显式以 Plan 优先于 full_access | 中 | Bridge 远程会话的权限/安全语义 | 2026-03-25 |
## 已解决
diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md
index 8520396d..b48f4fc2 100644
--- a/docs/handover/bridge-system.md
+++ b/docs/handover/bridge-system.md
@@ -2,7 +2,7 @@
## 核心思路
-让用户通过 Telegram(后续可扩展 Discord/飞书等)远程操控 CodePilot 中的 Claude 会话。复用现有 `streamClaude()` 管线,在服务端消费 SSE 流,而非通过浏览器。
+让用户通过 Telegram、Discord、飞书、微信等 IM 通道远程操控 CodePilot 中的 Claude 会话。Bridge 复用现有 `streamClaude()` 管线,在服务端直接消费 SSE 流,而不是依赖浏览器标签页。
## 目录结构
@@ -24,6 +24,13 @@ src/lib/bridge/
│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册
│ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换
│ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage
+│ ├── weixin-adapter.ts # 微信多账号长轮询 + batch ack + 纯文本出站 + 自注册
+│ ├── weixin/
+│ │ ├── weixin-api.ts # 微信 ilink 协议客户端(getupdates/sendmessage/sendtyping/getconfig)
+│ │ ├── weixin-auth.ts # 二维码登录 + HMR 安全会话存储
+│ │ ├── weixin-media.ts # AES-128-ECB 媒体解密/上传
+│ │ ├── weixin-ids.ts # synthetic chatId 编解码(weixin::::)
+│ │ └── weixin-session-guard.ts # errcode -14 暂停保护
│ ├── feishu-adapter.ts # 薄代理 → ChannelPluginAdapter(FeishuChannelPlugin)
│ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册
├── markdown/
@@ -110,6 +117,77 @@ Discord 消息 → discord.js Client (Gateway WebSocket)
- **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式)
- **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动
+### 微信(Native BaseChannelAdapter + 多账号长轮询)
+
+```
+微信消息 → WeixinAdapter.runPollLoop(account)
+ → getupdates(long-poll, get_updates_buf)
+ → context_token 落库(weixin_context_tokens)
+ → 媒体解密(AES-128-ECB,可按设置关闭)
+ → synthetic chatId = weixin::::
+ → enqueue(InboundMessage, updateId=batchId)
+ → BridgeManager.runAdapterLoop() → handleMessage()
+ → channel-router.resolve() 自愈坏 cwd / stale sdkSessionId
+ → conversation-engine.processMessage() 用有效 cwd 调 streamClaude()
+ → permission_request → `/perm allow|allow_session|deny ` 文本降级
+ → deliverResponse() 纯文本分片(4096 chars, 最多 5 段)
+ → sendmessage({ msg, base_info })
+ → handleMessage() finally
+ → adapter.acknowledgeUpdate(batchId)
+ → batch sealed + remaining=0
+ → channel_offsets["weixin:"] = get_updates_buf
+```
+
+**关键文件**
+- `src/lib/bridge/adapters/weixin-adapter.ts`:微信主 adapter。每个启用账号一个 poll worker,负责入站标准化、batch ack、typing、纯文本出站。
+- `src/lib/bridge/adapters/weixin/weixin-api.ts`:协议客户端。对齐 OpenClaw 微信插件协议,但不把其 npm 包作为运行时依赖。
+- `src/lib/bridge/adapters/weixin/weixin-auth.ts`:二维码登录,使用 `globalThis` 保存活跃登录会话以穿过 Next.js HMR。
+- `src/lib/bridge/adapters/weixin/weixin-media.ts`:微信 CDN 媒体下载/上传的 AES-128-ECB 加解密。
+- `src/lib/bridge/adapters/weixin/weixin-ids.ts`:`weixin::::` synthetic chatId 编解码。
+- `src/lib/bridge/adapters/weixin/weixin-session-guard.ts`:`errcode = -14` 会话失效时暂停账号 60 分钟,避免无限重试。
+
+**为什么用 synthetic chatId**
+- 微信 bridge 需要多账号并存,但 `channel_bindings` 表没有单独的 account 维度。
+- 方案是把账号隔离编码进 chatId:`weixin::::`。
+- 这样 `channel-router`、`permission-broker`、`delivery-layer` 和审计日志都可以继续复用原来的单 chat 抽象,不需要额外改 schema。
+
+**数据持久化**
+- `weixin_accounts`:账号凭据、bot token、base URL、启用状态、最后登录时间。
+- `weixin_context_tokens`:按 `(account_id, peer_user_id)` 持久化 `context_token`。这是微信主动回消息的硬前置条件,不能只放内存。
+- `channel_offsets`:使用 key `weixin:` 保存每个账号各自的 `get_updates_buf`。
+
+**二维码登录与运行时刷新**
+- `/api/settings/weixin/login/start` 生成二维码;服务端读取微信返回的 `qrcode_img_content` URL,再用 `qrcode` 渲染为 data URL,前端无需额外跳转或依赖外链图片。
+- `/api/settings/weixin/login/wait` 轮询扫码状态。状态变成 `confirmed` 后,账号会落库到 `weixin_accounts`,并在 bridge 正在运行时自动调用 `bridge-manager.restart()`,让新账号立即加入 worker 池。
+- 账号启用/停用/删除也会走同样的 restart 流程;如果 DB 改动成功但 runtime 重启失败,API 会显式返回错误,前端 toast 告知“已保存但运行态未切换”。
+
+**出站协议与成功判定**
+- `sendmessage` 请求体必须是 `{ msg, base_info }`,其中 `msg` 包含 `to_user_id`、`client_id`、`message_type`、`message_state`、`item_list`、`context_token`。
+- 不能依赖服务端返回 `message_id` 判成功。当前实现本地生成 `client_id`,HTTP 成功即视为投递成功,并把 `client_id` 作为 bridge 层的 `messageId`。
+- 微信只支持纯文本出站,所以 `bridge-manager.deliverResponse()` 会先做纯文本分片。Markdown / HTML 不走专门渲染器。
+
+**cursor / ack 语义**
+- 微信 worker 读到 `get_updates_buf` 后不会立即写库,而是先给本批消息分配 `batchId`。
+- 每条消息处理完成后,`bridge-manager.handleMessage()` 在 `finally` 中调用 `adapter.acknowledgeUpdate(batchId)`。
+- 只有当该 batch 被 `sealed` 且 `remaining = 0` 时,才真正把 cursor 提交到 `channel_offsets`。
+- 这样即使 Claude 处理、权限审批或微信出站在中途失败,也不会把上游 cursor 提前推进,避免静默丢消息。
+
+**cwd 自愈与 resume 清理**
+- 微信实现过程中补齐了 bridge 的 cwd 自愈链:`session.sdk_cwd` → `binding.workingDirectory` → `session.working_directory` → `bridge_default_work_dir` → `HOME/process cwd`。
+- `channel-router.resolve()` 会在每次消息到来时校验目录是否存在,并在回退到默认目录/Home 时清空 binding/session 上的 `sdk_session_id`,避免拿坏会话强行 resume。
+- `conversation-engine` 与 `claude-client` 也会再做一层防线:如果 cwd 已回退,不再尝试 resume 旧 Claude session。
+
+**typing / 媒体 / 权限**
+- typing 是 best-effort:先用 `getconfig(ilink_user_id, context_token)` 取 `typing_ticket`,再调用 `sendtyping`。失败不影响主流程。
+- 入站媒体可按 `bridge_weixin_media_enabled` 开关控制。开启时会把图片/文件/视频/语音下载、解密并转换成 `FileAttachment`,复用现有 vision / 文件上下文管线。
+- 微信没有按钮和消息编辑能力,权限审批统一降级为文本命令:`/perm allow|allow_session|deny `。
+
+**当前限制**
+- 仅支持私聊,不支持群聊语义。
+- 不支持流式预览;微信端无法像 Telegram/飞书那样持续编辑同一条消息。
+- 当前版本只做文本出站,AI 主动发图/发文件尚未接通。
+- 真实扫码联调依赖具备 ilink bot 权限的微信账号。
+
### Telegram
```
@@ -225,16 +303,24 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
**19. Telegram 通知模式互斥**
`telegram-bot.ts` 的通知功能(UI 会话通知)与 bridge 模式互斥。通过 `globalThis.__codepilot_bridge_mode_active` 标志协调(存 globalThis 防 HMR 重置)。Bridge 启动时设 `true`,4 个 notify 函数检查此标志后提前返回。
+**20. 微信 `context_token` 必须持久化**
+微信不是“只靠 chatId 就能主动回消息”的协议。`sendmessage` 依赖最近一次入站消息带来的 `context_token`,所以必须把 `(account_id, peer_user_id) -> context_token` 持久化到 `weixin_context_tokens`。只放内存会在进程重启后导致“能收消息、不能回消息”。
+
+**21. 坏 cwd 不能继续 resume Claude 会话**
+Bridge 绑定和 chat session 中可能残留已经删除的 `working_directory` / `sdk_cwd`。一旦用坏目录继续携带旧 `sdk_session_id` 调 `streamClaude()`,Claude 子进程会在错误项目上下文里瞬间退出。当前修复要求在 cwd 回退时同步清空 binding/session 的 `sdk_session_id`,并把修正后的 cwd 回写 DB。
+
## 设置项(settings 表)
| Key | 说明 |
|-----|------|
| remote_bridge_enabled | 总开关 |
| bridge_telegram_enabled | Telegram 通道开关 |
+| bridge_weixin_enabled | 微信通道开关 |
+| bridge_weixin_media_enabled | 微信入站媒体下载开关(默认 true;关闭后只收文本) |
| bridge_auto_start | 服务启动时自动拉起桥接 |
| bridge_default_work_dir | 新建会话默认工作目录 |
| bridge_default_model | 新建会话默认模型 |
-| bridge_default_provider_id | 新建会话默认服务商 |
+| bridge_default_provider_id | 新建会话默认服务商(Bridge 系统独立设置,与全局默认模型的 `global_default_model_provider` 分离;Bridge 会话使用此值而非全局默认) |
| telegram_bridge_allowed_users | 白名单用户 ID(逗号分隔) |
| bridge_telegram_image_enabled | Telegram 图片接收开关(默认 true,设为 false 关闭) |
| bridge_telegram_max_image_size | 图片大小上限(字节,默认 20MB) |
@@ -262,6 +348,11 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
| /api/bridge | POST | `{ action: 'start' \| 'stop' \| 'auto-start' }` |
| /api/bridge/channels | GET | 列出活跃通道(支持 `?active=true/false` 过滤) |
| /api/bridge/settings | GET/PUT | 读写 bridge 设置 |
+| /api/settings/weixin | GET/PUT | 读写微信全局设置(当前仅开关和媒体选项) |
+| /api/settings/weixin/accounts | GET | 列出微信账号(token 脱敏,只返回 `has_token`) |
+| /api/settings/weixin/accounts/[accountId] | PATCH/DELETE | 启停或删除微信账号;bridge 运行中会同步 restart |
+| /api/settings/weixin/login/start | POST | 创建二维码登录会话并返回二维码图片 |
+| /api/settings/weixin/login/wait | POST | 轮询二维码状态;确认后自动尝试重启 bridge |
## Telegram 命令
@@ -281,10 +372,16 @@ Claude 的回复是 Markdown 格式,Telegram 仅支持有限 HTML 标签(b/i
- `src/lib/telegram-bot.ts` — 通知模式(UI 发起会话的通知),与 bridge 模式互斥
- `src/lib/permission-registry.ts` — 权限 Promise 注册表,bridge 和 UI 共用
- `src/lib/claude-client.ts` — streamClaude(),bridge 和 UI 共用
-- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/飞书通道开关
-- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram + Feishu 入口)
+- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge),含 Telegram/微信/飞书通道开关
+- `src/components/bridge/BridgeLayout.tsx` — 侧边栏导航(Telegram / 微信 / 飞书 入口)
- `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram)
+- `src/components/bridge/WeixinBridgeSection.tsx` — 微信设置 UI:账号列表、二维码登录、运行态错误提示(/bridge#weixin)
- `src/components/bridge/FeishuBridgeSection.tsx` — 飞书设置 UI:凭据 + 访问与行为(2 卡片 2 保存按钮 + 脏状态追踪)
+- `src/app/api/settings/weixin/route.ts` — 微信全局设置 API(当前仅 `bridge_weixin_enabled` / `bridge_weixin_media_enabled`)
+- `src/app/api/settings/weixin/accounts/route.ts` — 微信账号列表 API
+- `src/app/api/settings/weixin/accounts/[accountId]/route.ts` — 微信账号启停/删除 API(带 bridge restart 语义)
+- `src/app/api/settings/weixin/login/start/route.ts` — 微信二维码登录启动 API
+- `src/app/api/settings/weixin/login/wait/route.ts` — 微信二维码状态轮询 API(confirmed 后自动 restart bridge)
- `src/app/api/settings/feishu/route.ts` — 飞书设置读写 API(简化后 10 个 key)
- `src/app/api/settings/feishu/verify/route.ts` — 飞书凭据验证 API(测试 token 获取 + bot info)
- `src/lib/channels/` — V2 ChannelPlugin 架构(见目录结构)
diff --git a/docs/handover/global-default-model.md b/docs/handover/global-default-model.md
new file mode 100644
index 00000000..97ac3ffd
--- /dev/null
+++ b/docs/handover/global-default-model.md
@@ -0,0 +1,58 @@
+# 全局默认模型
+
+## 概述
+v0.38.4 引入全局默认模型机制,替代了之前的"默认服务商"概念。用户在设置页选择一个模型(自带所属 provider),新对话自动使用该模型。已有对话不受影响。
+
+## 数据存储
+- `global_default_model` — settings 表,存储默认模型 ID(如 'kimi-k2.5')
+- `global_default_model_provider` — settings 表,存储默认模型所属的 provider ID
+- `default_provider_id` — settings 表,legacy 兼容字段,由 setDefaultProviderId() 同步写入
+
+## 读写函数
+- `getDefaultProviderId()` — 优先读 global_default_model_provider,fallback 到 legacy default_provider_id
+- `setDefaultProviderId(id)` — 三写:default_provider_id + global_default_model_provider + 清空 global_default_model
+- `getProviderOptions('__global__')` / `setProviderOptions('__global__')` — 读写全局默认模型设置
+
+## 新对话模型选择优先级
+1. 全局默认模型(global_default_model + global_default_model_provider 都有效)
+2. 全局默认 provider 但 model 被清空(如 doctor repair 后)→ 该 provider 的第一个可用模型
+3. localStorage 的 last-model / last-provider-id
+4. groups[0] 的第一个模型
+
+## 已有对话
+- 已有对话始终使用 session 自己存储的 model 和 provider_id
+- MessageInput 的自动纠正逻辑只 fallback 到 modelOptions[0],不使用全局默认模型
+- 全局默认模型变化不会影响已有对话
+
+## provider-resolver 中的归属校验
+- env 分支和 DB provider 分支都会检查 global_default_model_provider 是否与当前 provider ID 一致
+- 不一致时忽略全局默认模型,防止 A 服务商的模型串到 B 服务商
+
+## 竞态防护
+- 新对话页 currentModel/currentProviderId 初始为空
+- modelReady 状态门控:fetch 完成前禁止发送
+- provider-changed 事件触发时先 setModelReady(false),fetch 完成后所有分支都 setModelReady(true)
+
+## UI
+- 设置页:连接诊断卡片内,分割线下方,左边标题+描述,右边 select(w-[160px])
+- 聊天输入框:当前模型是默认模型时显示"默认"/"Default" tag
+- 模型选择下拉框:默认模型旁标"默认"/"Default" tag
+
+## 关键文件
+- src/lib/db.ts — getDefaultProviderId, setDefaultProviderId, getProviderOptions('__global__'), setProviderOptions('__global__')
+- src/lib/provider-resolver.ts — 模型解析优先级链中的归属校验
+- src/app/chat/page.tsx — 新对话初始化 + checkProvider + modelReady 门控
+- src/components/settings/ProviderManager.tsx — 全局默认模型 select UI + handleGlobalDefaultModelChange
+- src/components/chat/ModelSelectorDropdown.tsx — "默认" tag 显示
+- src/hooks/useProviderModels.ts — globalDefaultModel / globalDefaultProvider
+- src/components/chat/MessageInput.tsx — 自动纠正逻辑(不使用全局默认)
+
+## 与 Bridge 系统的关系
+Bridge 系统使用独立的 bridge_default_provider_id,与全局默认模型分离。
+
+## 测试覆盖
+- src/__tests__/unit/provider-resolver.test.ts — Global Default Model describe block(7 条测试)
+ - env provider 归属正确/不正确
+ - DB provider 归属正确/不正确
+ - explicit model / session model 覆盖
+- src/__tests__/unit/stale-default-provider.test.ts — stale default 清理(隔离 global_default_model_provider)
diff --git a/docs/handover/provider-error-doctor.md b/docs/handover/provider-error-doctor.md
index 1aa8fa3a..e8691fcf 100644
--- a/docs/handover/provider-error-doctor.md
+++ b/docs/handover/provider-error-doctor.md
@@ -58,8 +58,9 @@ provider-changed 事件 → 同步 localStorage
onProviderModelChange → 写入 localStorage
```
-**新增 provider 自动设默认流程:**
-- 首个 provider → 自动设为默认
+**全局默认模型机制:**
+- 不再有独立的"默认服务商"概念,改为使用全局默认模型(`global_default_model` + `global_default_model_provider`)决定新对话的 provider 和 model
+- 首个 provider → 自动设为全局默认模型的 provider
- 已有 provider → `confirm()` 询问是否切换
- 写入 DB (`set-default` API) + localStorage
@@ -100,7 +101,7 @@ GET /api/doctor → runDiagnosis()
| 动作 | 触发条件 | 效果 |
|------|---------|------|
-| set-default-provider | provider.no-default | 设置第一个 provider 为默认 |
+| set-default-provider | provider.no-default | 设置第一个 provider 为默认;同时写入 `default_provider_id` 和 `global_default_model_provider`,并清空 `global_default_model`(旧 model 不再有效) |
| apply-provider-to-session | auth.resolved-no-creds | 将默认 provider 赋给无 provider 的 session |
| clear-stale-resume | features.stale-session-id | 清理所有 stale sdk_session_id |
| switch-auth-style | auth.style-mismatch | 在 provider extra_env 中切换 API_KEY ↔ AUTH_TOKEN |
@@ -134,6 +135,7 @@ GET /api/doctor → runDiagnosis()
| `src/app/api/doctor/repair/route.ts` | 修复 API(5 种动作) |
| `src/app/api/doctor/export/route.ts` | 脱敏日志导出 |
| `src/app/api/providers/set-default/route.ts` | 设置默认 provider |
+| `src/lib/db.ts` | `getDefaultProviderId()` / `setDefaultProviderId()` — 默认服务商读写 |
| `src/components/settings/ProviderDoctorDialog.tsx` | 诊断 UI |
| `src/components/settings/ProviderManager.tsx` | 诊断入口(设置项样式) |
diff --git a/docs/research/README.md b/docs/research/README.md
index 112c8809..79f78f92 100644
--- a/docs/research/README.md
+++ b/docs/research/README.md
@@ -8,6 +8,8 @@
| 文件 | 主题 |
|------|------|
+| chat-latency-investigation-2026-03-20.md | 聊天响应变慢问题排查报告(用户设置 / MCP / resume 链路) |
| chat-sdk-integration-feasibility.md | Vercel Chat SDK 集成可行性调研 |
| context-storage-migration-plan.md | 上下文共享与存储迁移设计(详细方案;执行跟踪见 `docs/exec-plans/active/context-storage-migration.md`) |
| mobile-remote-control-overall-plan.md | 移动端远程控制整体方案(Host / Controller / Lease / 多设备控制) |
+| weixin-openclaw-plugin-review-2026-03-22.md | OpenClaw 微信插件拆包与 CodePilot 逆向集成可行性调研 |
diff --git a/docs/research/chat-latency-investigation-2026-03-20.md b/docs/research/chat-latency-investigation-2026-03-20.md
new file mode 100644
index 00000000..6b1e0886
--- /dev/null
+++ b/docs/research/chat-latency-investigation-2026-03-20.md
@@ -0,0 +1,249 @@
+# Chat Latency Investigation (2026-03-20)
+
+## 结论
+
+这次“所有模型都要等十几秒才开始回”的问题,主因不是前端渲染,也不是 assistant workspace 的索引逻辑,而是聊天请求在真正进入模型生成前,继承了过重的 Claude Code 运行环境:
+
+1. **CodePilot 对所有 provider 都加载用户级 Claude 设置**,当前机器的 `~/.claude/settings.json` 开着:
+ - `alwaysThinkingEnabled: true`
+ - `effortLevel: "high"`
+2. **每次请求还会带上 MCP / plugin / hook 生态**,其中项目级 `.mcp.json` 里有:
+ - `chrome-devtools`: `npx -y chrome-devtools-mcp@latest`
+3. **resume 路径在发出任何可见 SSE 之前就会阻塞等待第一条 SDK 消息**,导致用户体感是“完全没反应”。
+
+综合判断:
+
+- **最高优先级根因**:用户级 Claude 设置泄漏进 CodePilot,会把所有 provider 都拉进高 effort / always-thinking 路径。
+- **第二优先级放大器**:MCP server 初始化过重,尤其是 `npx -y ...@latest` 这种每次可能触发包解析/网络探测的配置。
+- **第三优先级体感问题**:resume / init 阶段没有尽早给 UI 可见状态,放大了“卡住”的感觉。
+
+## 证据
+
+### 1. 所有 provider 都会加载用户级 Claude 设置
+
+代码里对已配置 provider 的 `settingSources` 固定返回:
+
+- `['user', 'project', 'local']`
+
+位置:
+
+- [`src/lib/provider-resolver.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/provider-resolver.ts)
+
+同时 SDK 只在显式传入时才追加 `--effort`:
+
+- [`src/lib/claude-client.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/claude-client.ts)
+- [`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`](/Users/guohao/Documents/code/codepilot/CodePilot/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs)
+
+本机实际用户设置:
+
+- `~/.claude/settings.json`
+ - `alwaysThinkingEnabled: true`
+ - `effortLevel: "high"`
+ - `enabledPlugins.code-simplifier@claude-plugins-official: true`
+ - 多个 hooks(`SessionStart` / `UserPromptSubmit` / `Stop` / `PreCompact`)
+ - `statusLine.command: "npx -y ccstatusline@latest"`
+
+这意味着:
+
+- 即使 CodePilot UI 没主动把 effort 选到 high,只要没有显式覆盖,SDK 很可能仍继承用户级默认值。
+- 这是**跨模型、跨 provider**都成立的统一慢路径。
+
+### 2. assistant workspace 不是主要瓶颈
+
+当前活跃 assistant workspace:
+
+- `/Users/guohao/Documents/op7418的仓库`
+
+我对这条链路做了本机拆分测量,结果如下:
+
+- `walkDir`: 12.6ms
+- `read manifest`: 1.8ms
+- `parse manifest`: 2.1ms
+- `read chunks`: 54.8ms
+- `parse chunks`: 40.6ms
+- `build manifest map`: 0.3ms
+- `build chunks map`: 4.5ms
+- `stat compare`: 3.1ms
+
+总量级大约 **120ms**。
+
+另外:
+
+- workspace 总文件数:915
+- 可索引的 `.md/.txt/.markdown` 文件:592
+- `.assistant/index/chunks.jsonl` 大小:21MB
+
+结论:
+
+- assistant workspace 的增量索引和检索确实有成本,但**远远不够解释“十几秒”**。
+
+相关位置:
+
+- [`src/app/api/chat/route.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/app/api/chat/route.ts)
+- [`src/lib/assistant-workspace.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/assistant-workspace.ts)
+- [`src/lib/workspace-indexer.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/workspace-indexer.ts)
+- [`src/lib/workspace-retrieval.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/workspace-retrieval.ts)
+
+### 3. CLI 工具探测也不是主要瓶颈
+
+`buildCliToolsContext()` 最终依赖 `detectAllCliTools()`;我按当前 catalog 和 extra bins 做了本机近似测量:
+
+- 总耗时:**427.7ms**
+
+这也不是十几秒级。
+
+相关位置:
+
+- [`src/lib/cli-tools-context.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/cli-tools-context.ts)
+- [`src/lib/cli-tools-detect.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/cli-tools-detect.ts)
+
+### 4. MCP 配置明显存在重启动风险
+
+当前项目 `.mcp.json`:
+
+- `chrome-devtools`
+ - `command: "npx"`
+ - `args: ["-y", "chrome-devtools-mcp@latest"]`
+
+当前用户全局 `~/.claude.json`:
+
+- 全局 `mcpServers.deepwiki`
+
+当前 `~/.claude.json` 项目配置(`/Users/guohao`)里还有:
+
+- `figma-dev-mode-mcp-server`
+- `context7`
+
+所以真实聊天环境里,SDK 可能同时面对多组 MCP 来源:
+
+1. CodePilot 显式注入的 `loadMcpServers()`
+2. SDK 通过 `settingSources: ['user','project','local']` 自己再读到的用户/项目配置
+
+尤其危险的是:
+
+- `chrome-devtools-mcp@latest` 不是固定版本
+- 它通过 `npx -y` 启动,天然可能触发 npm 解析/校验/联网
+
+本地探针里,执行:
+
+```bash
+npx -y chrome-devtools-mcp@latest --help
+```
+
+在 10 秒以上仍没有返回结果,说明这个启动路径本身就非常可疑。
+
+相关位置:
+
+- [`src/app/api/chat/route.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/app/api/chat/route.ts)
+- [`.mcp.json`](/Users/guohao/Documents/code/codepilot/CodePilot/.mcp.json)
+
+### 5. resume 路径会放大“没反应”的体感
+
+在 resume 分支里,代码会先:
+
+1. `query(...)`
+2. 立即 `await iter.next()`
+3. 拿到第一条消息后才继续往前走
+
+也就是说:
+
+- 在 SDK 第一条消息回来之前,前端收不到任何可见 SSE
+- 如果 resume 初始化、MCP handshake、plugin/hook 处理慢,UI 会表现为“发送后长时间完全静止”
+
+而且内部 resume fallback status 又被 `_internal` 过滤掉,用户无感知:
+
+- [`src/lib/claude-client.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/lib/claude-client.ts)
+- [`src/hooks/useSSEStream.ts`](/Users/guohao/Documents/code/codepilot/CodePilot/src/hooks/useSSEStream.ts)
+- [`docs/handover/provider-error-doctor.md`](/Users/guohao/Documents/code/codepilot/CodePilot/docs/handover/provider-error-doctor.md)
+
+### 6. 数据库里的真实会话也符合“统一变慢”
+
+我抽了几条最近的简单会话看消息落库时间:
+
+- `e4528e...`:`你好` -> 助手消息落库,约 9 秒
+- `624de8...`:`你好` -> 助手消息落库,约 14 秒
+- `a2d0cb...`:`妹有,结束` -> 助手消息落库,约 23 秒
+
+这些会话分布在不同 provider / 不同目录下,不是单一模型或单一 workspace 才会慢。
+
+说明:
+
+- 问题更像**统一的 SDK 启动/配置层**,不是某个模型本身。
+
+## 排查结论排序
+
+### P0
+
+**用户级 Claude 设置泄漏到 CodePilot**
+
+- 症状匹配度最高
+- 影响范围最广
+- 和“所有模型都慢”完全一致
+
+### P1
+
+**MCP 初始化过重,特别是 `chrome-devtools-mcp@latest`**
+
+- 会统一拖慢首个可见响应
+- 和当前项目配置直接相关
+
+### P2
+
+**resume 逻辑在首条消息前阻塞,放大感知延迟**
+
+- 不一定是根因
+- 但会显著恶化体感,并隐藏内部 fallback
+
+### P3
+
+**启动后立刻 `captureCapabilities()`**
+
+- 会额外打 SDK 控制请求:
+ - `supportedModels()`
+ - `supportedCommands()`
+ - `accountInfo()`
+ - `mcpServerStatus()`
+- 更像放大器,不像唯一根因
+
+## 建议修复
+
+### 方案 A:先止血
+
+1. **不要让 CodePilot 默认继承 `~/.claude/settings.json` 的 thinking / effort**
+ - 最稳妥:对 provider 会话去掉 `user` setting source
+ - 或者在 CodePilot 里显式传默认值,例如:
+ - `thinking: { type: 'disabled' }` 或明确的 UI 默认
+ - `effort: 'low'` / `'medium'`
+2. **把项目 `.mcp.json` 的 `chrome-devtools-mcp@latest` 改成固定版本**
+ - 避免每次走 `@latest`
+3. **先临时关闭聊天自动注入的 MCP**
+ - 至少给 plain chat 一个“无 MCP 快速路径”
+
+### 方案 B:改善体感
+
+1. 在 `streamClaude()` 一开始就先发一个本地 `status`
+ - 比如 `Connecting...`
+2. resume 分支不要在 UI 完全无反馈的情况下等待 `iter.next()`
+3. 不要把 resume fallback 全部标成 `_internal`
+ - 至少在 debug 模式或状态栏里可见
+
+### 方案 C:进一步优化
+
+1. 把 `captureCapabilities()` 延后到首个 assistant event 之后
+2. 给 `/api/chat` 增加服务端耗时日志
+ - `preflight_ms`
+ - `sdk_init_ms`
+ - `first_event_ms`
+ - `first_text_ms`
+ - `complete_ms`
+
+## 我这次没有做的事
+
+- 没有直接对真实 provider 发线上请求测“首 token 时间”,因为当前沙箱环境不适合安全地跑外部网络压测。
+- 所以这份报告里的“主因”是基于:
+ - 代码链路
+ - 本机配置
+ - 本地耗时拆分
+ - 数据库中的真实会话时间
+
+但证据已经足够把范围缩到:**不是前端,不是 assistant workspace,不是 CLI tools context,而是 SDK 继承设置 + MCP 启动 + resume 可见性**。
diff --git a/docs/research/chat-latency-remediation-review-2026-03-22.md b/docs/research/chat-latency-remediation-review-2026-03-22.md
new file mode 100644
index 00000000..1a2ebdae
--- /dev/null
+++ b/docs/research/chat-latency-remediation-review-2026-03-22.md
@@ -0,0 +1,153 @@
+# Chat Latency Remediation — Code Review Report
+
+> Date: 2026-03-22
+> Author: Claude (implementation) / pending Codex review
+> Related: [investigation](./chat-latency-investigation-2026-03-20.md) | [exec plan](../exec-plans/active/chat-latency-remediation.md)
+
+## Summary
+
+15 files changed, +100 / -45 lines. Covers exec plan Phase 1 (mode convergence) + Phase 2 (MCP persistent toggle) + Phase 3 (first-token latency optimization). Phase 4 (observability) deferred.
+
+## Changes by Category
+
+### P0 Fix: Prevent user-level Claude settings from injecting high effort
+
+**File:** `src/lib/claude-client.ts`
+
+```diff
+- if (effort) {
+- queryOptions.effort = effort;
+- }
++ queryOptions.effort = effort || 'medium';
+```
+
+- **Why:** `~/.claude/settings.json` has `effortLevel: "high"` + `alwaysThinkingEnabled: true`. SDK inherits these via `settingSources: ['user', 'project', 'local']`. Without explicit override, all providers run at high effort.
+- **Behavior change:** Default effort is now `medium` for all chats. User-selected effort from UI still takes priority.
+- **Not changed:** `thinking` config not overridden this round — more complex interaction with UI states.
+
+### P1 Fix: MCP persistent enable/disable toggle
+
+| File | Change |
+|------|--------|
+| `src/types/index.ts` | Added `enabled?: boolean` to `MCPServerConfig` |
+| `src/app/api/chat/route.ts` | `loadMcpServers()` filters `enabled === false` |
+| `src/lib/bridge/conversation-engine.ts` | Same filter in bridge's `loadMcpServers()` |
+| `src/app/api/plugins/mcp/route.ts` | No change needed — PUT handler's `{ _source, ...cleanServer }` already preserves `enabled` |
+| `src/components/plugins/McpServerList.tsx` | Added `Switch` toggle per server card; `opacity-50` when disabled |
+| `src/components/plugins/McpManager.tsx` | Added `handlePersistentToggle()` → `PUT /api/plugins/mcp` |
+| `src/i18n/en.ts` + `zh.ts` | Added `mcp.enabled` / `mcp.disabled` keys |
+
+- **Semantics:** `enabled: undefined` and `enabled: true` both mean enabled. Only explicit `false` = disabled. Zero-migration.
+- **Scope:** Persistent toggle writes to `~/.claude/settings.json` or `~/.claude.json` (respects `_source`). Runtime toggle (`/api/plugins/mcp/toggle`) kept separate for live reconnect.
+- **Note:** Project-level `.mcp.json` servers (like `chrome-devtools`) are not shown in the MCP management UI — they're only managed by editing the file. The filter still applies to them.
+
+### P1 Fix: Pin .mcp.json version
+
+```diff
+- "chrome-devtools-mcp@latest"
++ "chrome-devtools-mcp@0.20.3",
++ "--headless"
+```
+
+- **Why:** `npx -y ...@latest` triggers npm registry check on every cold start (measured 10s+).
+- **Note:** `--headless` flag was already in the original but lost in a previous edit — restored here.
+
+### P2 Fix: Resume visible status event
+
+**File:** `src/lib/claude-client.ts`
+
+```typescript
+if (shouldResume) {
+ controller.enqueue(formatSSE({
+ type: 'status',
+ data: JSON.stringify({
+ title: 'Resuming session',
+ message: 'Reconnecting to previous conversation...',
+ }),
+ }));
+ queryOptions.resume = sdkSessionId;
+}
+```
+
+- **Why:** Resume path blocks on `await iter.next()` with zero UI feedback. Users see "sent message, nothing happening" for 10+ seconds.
+- **No `_internal: true`** — event passes through `useSSEStream.ts` filter and is shown to user.
+
+### P3 Fix: Defer capability capture
+
+**File:** `src/lib/agent-sdk-capabilities.ts`
+
+```typescript
+const CACHE_TTL_MS = 5 * 60 * 1000;
+export function isCacheFresh(providerId: string = 'env'): boolean { ... }
+```
+
+**File:** `src/lib/claude-client.ts`
+
+- `captureCapabilities()` moved from immediately after `registerConversation()` to inside the `for await` loop's first `'assistant'` case.
+- Skipped entirely if cache is fresh (within 5-minute TTL).
+- **Trade-off:** Model selector may show stale data for up to 5 minutes after provider switch. Acceptable because users rarely switch providers mid-conversation.
+
+### Phase 1: Chat mode entry convergence
+
+| File | Change |
+|------|--------|
+| `src/components/chat/ChatView.tsx` | Removed `initialMode` prop; hardcoded `useState('code')`; removed `useEffect` syncing `initialMode` |
+| `src/app/chat/[id]/page.tsx` | Removed `sessionMode` state; removed `initialMode` prop passing |
+| `src/components/layout/SplitColumn.tsx` | Same as above |
+| `src/app/api/chat/route.ts` | Replaced `effectiveMode` switch with hardcoded `permissionMode = 'acceptEdits'`; `enableFileCheckpointing` defaults to `true` |
+| `src/i18n/en.ts` + `zh.ts` | Commented out `messageInput.modeCode` / `messageInput.modePlan` (not deleted, in case bridge UI references them) |
+
+- **Kept:** `handleModeChange` callback in ChatView (SDK can still push mode changes). `mode/route.ts` API kept for bridge. DB schema unchanged.
+- **Kept:** `mode` field in request body parsing (bridge still sends it).
+
+## What Was NOT Changed
+
+- DB `mode` column schema (`CHECK(mode IN ('code', 'plan', 'ask'))`)
+- Bridge `/mode` command and `conversation-engine.ts` mode handling
+- `thinking` config override (only `effort` overridden)
+- `settingSources` in `provider-resolver.ts` (SDK still reads user config for plugins/hooks)
+- `useSSEStream.ts` `_internal` filter (no change needed)
+- Runtime MCP toggle route (`/api/plugins/mcp/toggle`)
+
+## Test Results
+
+- **Typecheck:** Pass
+- **Unit tests:** 444/444 pass, 0 fail
+- **Playwright verification (headless):**
+ - Chat page: No mode selector (Plan button count: 0) ✅
+ - MCP page: 2 switch toggles visible, both `checked` state ✅
+ - Server cards display correctly with toggle ✅
+
+## Risk Areas for Review
+
+1. **`effort || 'medium'` override** — If SDK internally applies effort before our explicit value, we might double-set. Verify SDK precedence: explicit `queryOptions.effort` > `settingSources` inheritance.
+
+2. **`handlePersistentToggle` optimistic update** — Sets state before API call, reverts via `fetchServers()` on failure. Race condition possible if user toggles rapidly. Low severity — worst case is a stale UI state that corrects on next fetch.
+
+3. **i18n commented keys** — `messageInput.modeCode` / `messageInput.modePlan` are commented out, not deleted. If `TranslationKey` type is derived from the object keys, the type is already removed. Any runtime reference to these keys would return the key itself. Grep confirms no runtime references exist outside i18n files.
+
+4. **`enableFileCheckpointing` default changed** — From `effectiveMode === 'code'` to `true`. Since desktop chat is now always 'code', this is semantically identical. But if the request body explicitly sends `enableFileCheckpointing: false`, it's still respected.
+
+5. **Bridge path unaffected** — `conversation-engine.ts` has its own mode resolution at lines 189-194. The `route.ts` mode convergence only affects desktop `/api/chat`. Verify bridge still receives and applies `mode` from IM messages.
+
+## Diff Stats
+
+```
+15 files changed, 100 insertions(+), 45 deletions(-)
+
+.mcp.json 3 ++-
+src/app/api/chat/route.ts 29 +++-------
+src/app/chat/[id]/page.tsx 5 +---
+src/components/chat/ChatView.tsx 6 ++--
+src/components/layout/SplitColumn.tsx 3 ---
+src/components/plugins/McpManager.tsx 18 +++++++
+src/components/plugins/McpServerList.tsx 15 +++++++-
+src/i18n/en.ts 6 ++--
+src/i18n/zh.ts 6 ++--
+src/lib/agent-sdk-capabilities.ts 12 ++++++
+src/lib/bridge/conversation-engine.ts 6 +++
+src/lib/claude-client.ts 32 +++++++++++------
+src/types/index.ts 2 ++
+docs/exec-plans/README.md 1 +
+docs/research/README.md 1 +
+```
diff --git a/docs/research/weixin-openclaw-plugin-review-2026-03-22.md b/docs/research/weixin-openclaw-plugin-review-2026-03-22.md
new file mode 100644
index 00000000..5cbbb2e3
--- /dev/null
+++ b/docs/research/weixin-openclaw-plugin-review-2026-03-22.md
@@ -0,0 +1,316 @@
+# OpenClaw 微信插件拆包与 CodePilot 逆向集成可行性调研
+
+> 调研时间:2026-03-22
+> 调研方式:静态拆包 + 本地架构比对,未做真实微信账号登录联调
+> 样本位置:
+> - `资料/weixin-openclaw-cli/package/`
+> - `资料/weixin-openclaw-package/package/`
+
+## 一句话结论
+
+可以集成,但不建议把 `@tencent-weixin/openclaw-weixin` 这个 npm 包直接塞进 CodePilot 运行。
+
+更合适的做法是:把它当成协议说明和参考实现,按 CodePilot 现有 `Bridge` 架构原生实现一个 `weixin` adapter。
+
+原因很简单:
+
+- **协议层可复用度高**:二维码登录、长轮询、发消息、媒体上传下载都写得很清楚。
+- **运行时层耦合很深**:真实业务逻辑大量绑定 `openclaw/plugin-sdk`、OpenClaw 的 routing/session/reply runtime、`~/.openclaw` 状态目录和 pairing 文件格式。
+
+## 一、本次拉取到的包
+
+### 1. CLI 包
+
+- 包名:`@tencent-weixin/openclaw-weixin-cli`
+- latest:`1.0.2`
+- npm registry 时间:`2026-03-21T14:50:38.585Z`
+- 体积很小,解包后只有 `cli.mjs`、`package.json`、`LICENSE`
+
+### 2. 真正的渠道包
+
+- 包名:`@tencent-weixin/openclaw-weixin`
+- latest:`1.0.2`
+- npm registry 时间:`2026-03-21T15:43:24.503Z`
+- License:`MIT`
+- 发布内容直接带 TypeScript 源码,便于静态分析
+
+## 二、CLI 实际只做了什么
+
+CLI 没有任何微信协议逻辑,只是一个薄安装器,流程只有四步:
+
+1. 检查本机是否存在 `openclaw` CLI
+2. 执行 `openclaw plugins install "@tencent-weixin/openclaw-weixin"`
+3. 执行 `openclaw channels login --channel openclaw-weixin`
+4. 执行 `openclaw gateway restart`
+
+这意味着:
+
+- `npx -y @tencent-weixin/openclaw-weixin-cli install` 本身没有可“嵌入”的核心价值
+- 真正值得研究的是 `@tencent-weixin/openclaw-weixin`
+
+## 三、真实插件的结构
+
+### 1. OpenClaw 插件壳
+
+插件入口是 `index.ts`,通过 `openclaw/plugin-sdk` 注册:
+
+- `api.registerChannel({ plugin: weixinPlugin })`
+- `api.registerCli(...)`
+- `setWeixinRuntime(api.runtime)`
+
+说明它不是一个独立 Node SDK,而是 **OpenClaw 插件运行时中的一个渠道扩展**。
+
+### 2. 最有价值的可复用层
+
+真正可借鉴的是下面这几层:
+
+- `src/auth/login-qr.ts`
+ 负责二维码登录:获取二维码、轮询扫码状态、拿到 `bot_token`
+- `src/api/api.ts`
+ 负责所有 HTTP JSON API 调用、头部构造、long-poll timeout、错误处理
+- `src/monitor/monitor.ts`
+ 负责 `getUpdates` 长轮询、同步游标保存、会话过期处理
+- `src/media/` + `src/cdn/`
+ 负责微信媒体下载/解密/上传/加密
+- `src/auth/accounts.ts`
+ 负责多账号状态落盘
+
+### 3. 深耦合、不能直接拿来跑的层
+
+下面这些部分直接绑在 OpenClaw runtime 上:
+
+- `ChannelPlugin` / `OpenClawPluginApi`
+- `PluginRuntime["channel"]`
+- `resolveSenderCommandAuthorizationWithRuntime`
+- `reply.createReplyDispatcherWithTyping`
+- `routing.resolveAgentRoute`
+- `session.recordInboundSession`
+- pairing 的 `allowFrom` 文件协议
+
+结论:
+
+- **直接安装到 CodePilot 里当依赖运行:不现实**
+- **按协议和实现样本重写一个 CodePilot 原生 adapter:可行**
+
+## 四、微信后端协议已经暴露得足够清楚
+
+从 README 和源码可以确认,插件与后端的主链路是 HTTP JSON API:
+
+### 1. 登录相关
+
+- `GET ilink/bot/get_bot_qrcode?bot_type=3`
+- `GET ilink/bot/get_qrcode_status?qrcode=...`
+
+扫码确认后服务端返回:
+
+- `bot_token`
+- `ilink_bot_id`
+- `ilink_user_id`
+- `baseurl`
+
+### 2. 消息主链路
+
+- `POST ilink/bot/getupdates`
+- `POST ilink/bot/sendmessage`
+- `POST ilink/bot/getuploadurl`
+- `POST ilink/bot/getconfig`
+- `POST ilink/bot/sendtyping`
+
+### 3. 关键请求头
+
+- `AuthorizationType: ilink_bot_token`
+- `Authorization: Bearer `
+- `X-WECHAT-UIN: <随机 uint32 的 base64>`
+- 可选 `SKRouteTag`
+
+### 4. 关键状态字段
+
+- `get_updates_buf`
+ 长轮询同步游标,必须本地持久化
+- `context_token`
+ 回复时必须带回去,否则消息无法正确关联会话
+- `typing_ticket`
+ 由 `getconfig` 返回,供 `sendtyping` 使用
+- `errcode = -14`
+ 表示 session 过期,插件会暂停该账号 1 小时
+
+## 五、媒体链路也已经写明白了
+
+微信媒体不是普通 URL 直传,而是:
+
+1. 本地文件计算明文大小和 MD5
+2. 用 AES-128-ECB 计算密文大小
+3. 调 `getuploadurl` 获取 `upload_param`
+4. 本地 AES-128-ECB 加密
+5. PUT 上传到 CDN
+6. 把 `encrypt_query_param` + `aes_key` 回填到消息体
+
+入站下载同理:
+
+1. 从消息里拿 `encrypt_query_param` + `aes_key`
+2. 从 CDN 拉密文
+3. 本地 AES-128-ECB 解密
+4. 图片/文件/视频直接保存
+5. 语音再尝试 SILK -> WAV 转码
+
+这部分对 CodePilot 的意义很大:
+
+- 我们不用猜协议
+- 也不需要 native 依赖
+- 直接用 Node `crypto` + `fetch` 就能复刻
+
+## 六、它当前的限制和隐藏坑
+
+### 1. 只支持私聊
+
+插件明确声明:
+
+- `capabilities.chatTypes = ["direct"]`
+
+当前没有群聊、线程或群策略实现。
+
+### 2. `context_token` 只存在内存 Map
+
+源码里 `contextTokenStore` 是一个进程内 `Map`。
+
+这意味着:
+
+- 当前会话内的正常回复没问题
+- 进程重启后,历史 peer 的 `context_token` 会丢
+- 任何“冷启动主动推送”都可能失败
+
+这对 CodePilot 很关键,因为我们已经有:
+
+- 持久会话
+- bridge 自动启动
+- 未来的 scheduled task / automation
+
+如果做原生集成,**必须把最近一次可用的 `context_token` 持久化到 DB**,不能照搬它的内存实现。
+
+### 3. 多账号能力和我们当前设置模型不完全匹配
+
+OpenClaw 微信插件支持:
+
+- 多个微信号同时在线
+- 每个账号独立 token
+- 每个账号独立 sync buf
+
+而 CodePilot 当前 bridge 设置还是以平面 key-value 为主,例如:
+
+- `bridge_qq_app_id`
+- `bridge_feishu_app_secret`
+
+这不阻塞集成,但意味着微信这条线至少要新增:
+
+- 每账号凭证存储
+- 每账号同步游标
+- 每账号状态展示
+
+### 4. 直接依赖方式会卡在 OpenClaw runtime
+
+即便忽略协议层,直接复用包也会卡在这些点:
+
+- 缺失 `openclaw/plugin-sdk`
+- 缺失 OpenClaw 的 runtime object
+- 缺失它的 session/routing/reply/pairing 体系
+- 状态文件路径默认写在 `~/.openclaw`
+
+所以“npm install 然后 require 进 CodePilot”这条路不值得走。
+
+## 七、与 CodePilot 现状的匹配度
+
+### 1. 能直接复用的现有能力
+
+CodePilot 已有这些基础能力,能接住微信适配器:
+
+- `src/lib/bridge/` 的统一 adapter 生命周期
+- `consumeOne()` 队列消费模型
+- `conversation-engine.ts` 的图片附件入站处理
+- `channel_bindings` / `channel_offsets` / 审计日志
+- 现有 Telegram/QQ 的图片下载经验
+
+### 2. 明确缺口
+
+需要新增或改造的点:
+
+- 微信专用设置页和 API
+- 二维码登录 API / UI
+- 每账号状态与账号列表
+- `context_token` 持久化
+- `get_updates_buf` 的账号级存储键设计
+- 微信媒体加解密层
+- 如果想支持“AI 主动发送图片/文件”,需要扩展当前 `OutboundMessage`
+
+## 八、可行的集成路线
+
+### 方案 A:直接嵌 npm 包
+
+不推荐。
+
+原因:
+
+- 运行时耦合太深
+- 会把 OpenClaw runtime 假设硬塞进 CodePilot
+- 后续调试成本会很高
+
+### 方案 B:原生实现 `weixin` adapter
+
+推荐。
+
+建议拆成下面几块:
+
+1. `src/lib/bridge/adapters/weixin-api.ts`
+ 封装 `getupdates/sendmessage/getuploadurl/getconfig/sendtyping`
+2. `src/lib/bridge/adapters/weixin-auth.ts`
+ 封装二维码登录、token 保存、多账号
+3. `src/lib/bridge/adapters/weixin-media.ts`
+ 封装 CDN 下载/上传 + AES-128-ECB 加解密
+4. `src/lib/bridge/adapters/weixin-adapter.ts`
+ 实现 `BaseChannelAdapter`
+5. `src/app/api/settings/weixin/*`
+ 设置、校验、登录、状态 API
+6. `src/components/bridge/WeixinBridgeSection.tsx`
+ 登录/账号/状态 UI
+
+### MVP 建议范围
+
+第一阶段先做:
+
+- 私聊
+- 文本入站/出站
+- 二维码登录
+- `get_updates_buf` 持久化
+- `context_token` 持久化
+- 入站图片
+
+第二阶段再补:
+
+- 文件/视频/语音
+- 出站媒体
+- 多账号管理 UI
+- 诊断 / logs upload
+
+## 九、最终判断
+
+**能做,而且可做性不低。**
+
+但这个“能做”的前提是:
+
+- **把腾讯这套包当协议参考,不当运行时依赖**
+- **用 CodePilot 自己的 bridge 架构重写微信 adapter**
+
+如果只问“是否值得往前推进一个 POC”,我的结论是:
+
+- **值得**
+- **适合先做一个 text-only + QR login 的 POC**
+- **不建议直接在主产品里硬接 OpenClaw 插件包本体**
+
+## 十、这次调研没有覆盖的内容
+
+下面这些仍然需要后续实测确认:
+
+- 真机扫码登录是否稳定
+- `context_token` 生命周期和失效条件
+- session 过期后恢复策略是否只能等 1 小时
+- 图片/视频上传在真实账号下的大小限制
+- 多账号并发长轮询时的速率限制
diff --git a/package-lock.json b/package-lock.json
index 58087fef..ed584417 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
{
"name": "codepilot",
- "version": "0.38.1",
+ "version": "0.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codepilot",
- "version": "0.38.1",
+ "version": "0.39.0",
+ "license": "BUSL-1.1",
"workspaces": [
"apps/*",
"packages/*"
@@ -26,7 +27,8 @@
"@streamdown/cjk": "^1.0.1",
"@streamdown/code": "^1.0.1",
"@streamdown/math": "^1.0.1",
- "@streamdown/mermaid": "^1.0.1",
+ "@streamdown/mermaid": "1.0.1",
+ "@types/qrcode": "^1.5.6",
"ai": "^6.0.73",
"ansi-to-react": "^6.2.6",
"better-sqlite3": "^12.6.2",
@@ -40,8 +42,9 @@
"morphdom": "^2.7.8",
"motion": "^12.33.0",
"nanoid": "^5.1.6",
- "next": "16.1.6",
+ "next": "16.2.1",
"next-themes": "^0.4.6",
+ "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -73,17 +76,17 @@
"@types/ws": "^8.18.1",
"concurrently": "^9.2.1",
"electron": "^40.2.1",
- "electron-builder": "^26.7.0",
+ "electron-builder": "^26.8.1",
"esbuild": "^0.27.3",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "16.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
- "wait-on": "^9.0.3"
+ "wait-on": "^9.0.4"
}
},
"apps/site": {
@@ -100,7 +103,7 @@
"fumadocs-mdx": "^11",
"fumadocs-ui": "^15",
"lucide-react": "^0.563.0",
- "next": "15.3.6",
+ "next": "15.5.14",
"react": "^19",
"react-dom": "^19",
"shadcn": "^4.0.2",
@@ -587,15 +590,15 @@
}
},
"apps/site/node_modules/@next/env": {
- "version": "15.3.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.6.tgz",
- "integrity": "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.14.tgz",
+ "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
"license": "MIT"
},
"apps/site/node_modules/@next/swc-darwin-arm64": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
- "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
+ "integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
"cpu": [
"arm64"
],
@@ -609,9 +612,9 @@
}
},
"apps/site/node_modules/@next/swc-darwin-x64": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
- "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
+ "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
"cpu": [
"x64"
],
@@ -625,9 +628,9 @@
}
},
"apps/site/node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
- "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
+ "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
"cpu": [
"arm64"
],
@@ -641,9 +644,9 @@
}
},
"apps/site/node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
- "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
+ "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
"cpu": [
"arm64"
],
@@ -657,9 +660,9 @@
}
},
"apps/site/node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
- "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
+ "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
"cpu": [
"x64"
],
@@ -673,9 +676,9 @@
}
},
"apps/site/node_modules/@next/swc-linux-x64-musl": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
- "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
+ "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
"cpu": [
"x64"
],
@@ -689,9 +692,9 @@
}
},
"apps/site/node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
- "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
+ "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
"cpu": [
"arm64"
],
@@ -705,9 +708,9 @@
}
},
"apps/site/node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.3.5",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
- "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
+ "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
"cpu": [
"x64"
],
@@ -954,16 +957,13 @@
}
},
"apps/site/node_modules/next": {
- "version": "15.3.6",
- "resolved": "https://registry.npmjs.org/next/-/next-15.3.6.tgz",
- "integrity": "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==",
- "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
+ "version": "15.5.14",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
+ "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"license": "MIT",
"dependencies": {
- "@next/env": "15.3.6",
- "@swc/counter": "0.1.3",
+ "@next/env": "15.5.14",
"@swc/helpers": "0.5.15",
- "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -975,19 +975,19 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "15.3.5",
- "@next/swc-darwin-x64": "15.3.5",
- "@next/swc-linux-arm64-gnu": "15.3.5",
- "@next/swc-linux-arm64-musl": "15.3.5",
- "@next/swc-linux-x64-gnu": "15.3.5",
- "@next/swc-linux-x64-musl": "15.3.5",
- "@next/swc-win32-arm64-msvc": "15.3.5",
- "@next/swc-win32-x64-msvc": "15.3.5",
- "sharp": "^0.34.1"
+ "@next/swc-darwin-arm64": "15.5.14",
+ "@next/swc-darwin-x64": "15.5.14",
+ "@next/swc-linux-arm64-gnu": "15.5.14",
+ "@next/swc-linux-arm64-musl": "15.5.14",
+ "@next/swc-linux-x64-gnu": "15.5.14",
+ "@next/swc-linux-x64-musl": "15.5.14",
+ "@next/swc-win32-arm64-msvc": "15.5.14",
+ "@next/swc-win32-x64-msvc": "15.5.14",
+ "sharp": "^0.34.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
- "@playwright/test": "^1.41.2",
+ "@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
@@ -2036,54 +2036,42 @@
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
- "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
+ "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
"license": "Apache-2.0",
"dependencies": {
- "@chevrotain/gast": "11.0.3",
- "@chevrotain/types": "11.0.3",
- "lodash-es": "4.17.21"
+ "@chevrotain/gast": "11.1.2",
+ "@chevrotain/types": "11.1.2",
+ "lodash-es": "4.17.23"
}
},
- "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
- "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
- "license": "MIT"
- },
"node_modules/@chevrotain/gast": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
- "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
+ "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
"license": "Apache-2.0",
"dependencies": {
- "@chevrotain/types": "11.0.3",
- "lodash-es": "4.17.21"
+ "@chevrotain/types": "11.1.2",
+ "lodash-es": "4.17.23"
}
},
- "node_modules/@chevrotain/gast/node_modules/lodash-es": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
- "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
- "license": "MIT"
- },
"node_modules/@chevrotain/regexp-to-ast": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
- "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
+ "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
- "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
+ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
- "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
+ "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"license": "Apache-2.0"
},
"node_modules/@codepilot/site": {
@@ -2758,9 +2746,9 @@
}
},
"node_modules/@electron/rebuild/node_modules/node-abi": {
- "version": "4.26.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.26.0.tgz",
- "integrity": "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==",
+ "version": "4.28.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
+ "integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2813,9 +2801,9 @@
}
},
"node_modules/@electron/universal/node_modules/fs-extra": {
- "version": "11.3.3",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
- "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
+ "version": "11.3.4",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
+ "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2841,13 +2829,13 @@
}
},
"node_modules/@electron/universal/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2866,72 +2854,6 @@
"node": ">= 10.0.0"
}
},
- "node_modules/@electron/windows-sign": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
- "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "optional": true,
- "peer": true,
- "dependencies": {
- "cross-dirname": "^0.1.0",
- "debug": "^4.3.4",
- "fs-extra": "^11.1.1",
- "minimist": "^1.2.8",
- "postject": "^1.0.0-alpha.6"
- },
- "bin": {
- "electron-windows-sign": "bin/electron-windows-sign.js"
- },
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/fs-extra": {
- "version": "11.3.3",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz",
- "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
- },
- "engines": {
- "node": ">=14.14"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/jsonfile": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
- "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "universalify": "^2.0.0"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/@electron/windows-sign/node_modules/universalify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
- "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 10.0.0"
- }
- },
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -4549,29 +4471,6 @@
}
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
- "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5113,12 +5012,12 @@
}
},
"node_modules/@mermaid-js/parser": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
- "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz",
+ "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==",
"license": "MIT",
"dependencies": {
- "langium": "3.3.1"
+ "langium": "^4.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
@@ -5214,15 +5113,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
- "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
+ "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
- "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz",
+ "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5230,9 +5129,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
- "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
+ "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
"cpu": [
"arm64"
],
@@ -5246,9 +5145,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
- "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
+ "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
"cpu": [
"x64"
],
@@ -5262,9 +5161,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
- "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
+ "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
"cpu": [
"arm64"
],
@@ -5278,9 +5177,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
- "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
+ "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
"cpu": [
"arm64"
],
@@ -5294,9 +5193,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
- "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
+ "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
"cpu": [
"x64"
],
@@ -5310,9 +5209,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
- "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
+ "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
"cpu": [
"x64"
],
@@ -5326,9 +5225,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
- "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
+ "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
"cpu": [
"arm64"
],
@@ -5342,9 +5241,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
- "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
+ "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
"cpu": [
"x64"
],
@@ -8282,12 +8181,6 @@
"react": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/@swc/counter": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
- "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
- "license": "Apache-2.0"
- },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -9192,6 +9085,15 @@
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@@ -9501,13 +9403,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -9846,6 +9748,16 @@
"win32"
]
},
+ "node_modules/@upsetjs/venn.js": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
+ "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "d3-selection": "^3.0.0",
+ "d3-transition": "^3.0.1"
+ }
+ },
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
@@ -9951,9 +9863,9 @@
}
},
"node_modules/acorn": {
- "version": "8.15.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
- "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -10022,9 +9934,9 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10250,9 +10162,9 @@
"license": "MIT"
},
"node_modules/app-builder-lib": {
- "version": "26.7.0",
- "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.7.0.tgz",
- "integrity": "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz",
+ "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10267,7 +10179,7 @@
"@malept/flatpak-bundler": "^0.4.0",
"@types/fs-extra": "9.0.13",
"async-exit-hook": "^2.0.1",
- "builder-util": "26.4.1",
+ "builder-util": "26.8.1",
"builder-util-runtime": "9.5.1",
"chromium-pickle-js": "^0.2.0",
"ci-info": "4.3.1",
@@ -10275,7 +10187,7 @@
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6",
"ejs": "^3.1.8",
- "electron-publish": "26.6.0",
+ "electron-publish": "26.8.1",
"fs-extra": "^10.1.0",
"hosted-git-info": "^4.1.0",
"isbinaryfile": "^5.0.0",
@@ -10297,8 +10209,8 @@
"node": ">=14.0.0"
},
"peerDependencies": {
- "dmg-builder": "26.7.0",
- "electron-builder-squirrel-windows": "26.7.0"
+ "dmg-builder": "26.8.1",
+ "electron-builder-squirrel-windows": "26.8.1"
}
},
"node_modules/app-builder-lib/node_modules/@electron/get": {
@@ -10348,6 +10260,29 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/app-builder-lib/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/app-builder-lib/node_modules/brace-expansion": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/app-builder-lib/node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
@@ -10403,26 +10338,26 @@
}
},
"node_modules/app-builder-lib/node_modules/isexe": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz",
- "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+ "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
- "node": ">=20"
+ "node": ">=18"
}
},
"node_modules/app-builder-lib/node_modules/minimatch": {
- "version": "10.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
- "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "@isaacs/brace-expansion": "^5.0.1"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -10791,13 +10726,13 @@
}
},
"node_modules/axios": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
- "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
+ "version": "1.13.6",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
+ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -11068,9 +11003,9 @@
"license": "MIT"
},
"node_modules/builder-util": {
- "version": "26.4.1",
- "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz",
- "integrity": "sha512-FlgH43XZ50w3UtS1RVGDWOz8v9qMXPC7upMtKMtBEnYdt1OVoS61NYhKm/4x+cIaWqJTXua0+VVPI+fSPGXNIw==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz",
+ "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11158,17 +11093,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/busboy": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
- "dependencies": {
- "streamsearch": "^1.1.0"
- },
- "engines": {
- "node": ">=10.16.0"
- }
- },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -11242,13 +11166,13 @@
"license": "ISC"
},
"node_modules/cacache/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -11343,6 +11267,15 @@
"node": ">=6"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001768",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz",
@@ -11431,17 +11364,17 @@
}
},
"node_modules/chevrotain": {
- "version": "11.0.3",
- "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
- "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
+ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"license": "Apache-2.0",
"dependencies": {
- "@chevrotain/cst-dts-gen": "11.0.3",
- "@chevrotain/gast": "11.0.3",
- "@chevrotain/regexp-to-ast": "11.0.3",
- "@chevrotain/types": "11.0.3",
- "@chevrotain/utils": "11.0.3",
- "lodash-es": "4.17.21"
+ "@chevrotain/cst-dts-gen": "11.1.2",
+ "@chevrotain/gast": "11.1.2",
+ "@chevrotain/regexp-to-ast": "11.1.2",
+ "@chevrotain/types": "11.1.2",
+ "@chevrotain/utils": "11.1.2",
+ "lodash-es": "4.17.23"
}
},
"node_modules/chevrotain-allstar": {
@@ -11456,12 +11389,6 @@
"chevrotain": "^11.0.0"
}
},
- "node_modules/chevrotain/node_modules/lodash-es": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
- "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
- "license": "MIT"
- },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -11918,15 +11845,6 @@
"buffer": "^5.1.0"
}
},
- "node_modules/cross-dirname": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
- "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true
- },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -12458,9 +12376,9 @@
}
},
"node_modules/dagre-d3-es": {
- "version": "7.0.13",
- "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
- "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
+ "version": "7.0.14",
+ "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
+ "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@@ -12560,6 +12478,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -12823,6 +12750,12 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -12871,14 +12804,14 @@
}
},
"node_modules/dmg-builder": {
- "version": "26.7.0",
- "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.7.0.tgz",
- "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz",
+ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "app-builder-lib": "26.7.0",
- "builder-util": "26.4.1",
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
"fs-extra": "^10.1.0",
"iconv-lite": "^0.6.2",
"js-yaml": "^4.1.0"
@@ -13091,18 +13024,18 @@
}
},
"node_modules/electron-builder": {
- "version": "26.7.0",
- "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.7.0.tgz",
- "integrity": "sha512-LoXbCvSFxLesPneQ/fM7FB4OheIDA2tjqCdUkKlObV5ZKGhYgi5VHPHO/6UUOUodAlg7SrkPx7BZJPby+Vrtbg==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz",
+ "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "app-builder-lib": "26.7.0",
- "builder-util": "26.4.1",
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
"builder-util-runtime": "9.5.1",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
- "dmg-builder": "26.7.0",
+ "dmg-builder": "26.8.1",
"fs-extra": "^10.1.0",
"lazy-val": "^1.0.5",
"simple-update-notifier": "2.0.0",
@@ -13117,15 +13050,15 @@
}
},
"node_modules/electron-builder-squirrel-windows": {
- "version": "26.7.0",
- "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.7.0.tgz",
- "integrity": "sha512-3EqkQK+q0kGshdPSKEPb2p5F75TENMKu6Fe5aTdeaPfdzFK4Yjp5L0d6S7K8iyvqIsGQ/ei4bnpyX9wt+kVCKQ==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz",
+ "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
- "app-builder-lib": "26.7.0",
- "builder-util": "26.4.1",
+ "app-builder-lib": "26.8.1",
+ "builder-util": "26.8.1",
"electron-winstaller": "5.4.0"
}
},
@@ -13168,14 +13101,14 @@
}
},
"node_modules/electron-publish": {
- "version": "26.6.0",
- "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.6.0.tgz",
- "integrity": "sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==",
+ "version": "26.8.1",
+ "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
+ "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/fs-extra": "^9.0.11",
- "builder-util": "26.4.1",
+ "builder-util": "26.8.1",
"builder-util-runtime": "9.5.1",
"chalk": "^4.1.2",
"form-data": "^4.0.5",
@@ -13811,13 +13744,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
- "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz",
+ "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.1.6",
+ "@next/eslint-plugin-next": "16.2.1",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -14733,9 +14666,9 @@
"license": "MIT"
},
"node_modules/filelist": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
- "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -14753,9 +14686,9 @@
}
},
"node_modules/filelist/node_modules/minimatch": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
- "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -14849,9 +14782,9 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -16009,9 +15942,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.6",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.6.tgz",
- "integrity": "sha512-KljEp+MeEEEIOT75qBo1UjqqB29fRMtlDEwCxcexOzdkUq6LR/vRvHk5pdROcxyOYyW1niq7Gb5pFVGy5R1eBw==",
+ "version": "4.12.8",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
+ "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -17326,19 +17259,20 @@
}
},
"node_modules/langium": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
- "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
+ "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
"license": "MIT",
"dependencies": {
- "chevrotain": "~11.0.3",
- "chevrotain-allstar": "~0.3.0",
+ "chevrotain": "~11.1.1",
+ "chevrotain-allstar": "~0.3.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
- "vscode-uri": "~3.0.8"
+ "vscode-uri": "~3.1.0"
},
"engines": {
- "node": ">=16.0.0"
+ "node": ">=20.10.0",
+ "npm": ">=10.2.3"
}
},
"node_modules/language-subtag-registry": {
@@ -18832,27 +18766,28 @@
}
},
"node_modules/mermaid": {
- "version": "11.12.2",
- "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
- "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
+ "version": "11.13.0",
+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz",
+ "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
- "@iconify/utils": "^3.0.1",
- "@mermaid-js/parser": "^0.6.3",
+ "@iconify/utils": "^3.0.2",
+ "@mermaid-js/parser": "^1.0.1",
"@types/d3": "^7.4.3",
- "cytoscape": "^3.29.3",
+ "@upsetjs/venn.js": "^2.0.0",
+ "cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
- "dagre-d3-es": "7.0.13",
- "dayjs": "^1.11.18",
- "dompurify": "^3.2.5",
- "katex": "^0.16.22",
+ "dagre-d3-es": "7.0.14",
+ "dayjs": "^1.11.19",
+ "dompurify": "^3.3.1",
+ "katex": "^0.16.25",
"khroma": "^2.1.0",
- "lodash-es": "^4.17.21",
- "marked": "^16.2.1",
+ "lodash-es": "^4.17.23",
+ "marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@@ -19760,9 +19695,9 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -19968,15 +19903,15 @@
"license": "MIT"
},
"node_modules/mlly": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
- "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"license": "MIT",
"dependencies": {
- "acorn": "^8.15.0",
+ "acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
- "ufo": "^1.6.1"
+ "ufo": "^1.6.3"
}
},
"node_modules/morphdom": {
@@ -20182,14 +20117,14 @@
}
},
"node_modules/next": {
- "version": "16.1.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
- "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
+ "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.1.6",
+ "@next/env": "16.2.1",
"@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.8.3",
+ "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -20201,15 +20136,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.1.6",
- "@next/swc-darwin-x64": "16.1.6",
- "@next/swc-linux-arm64-gnu": "16.1.6",
- "@next/swc-linux-arm64-musl": "16.1.6",
- "@next/swc-linux-x64-gnu": "16.1.6",
- "@next/swc-linux-x64-musl": "16.1.6",
- "@next/swc-win32-arm64-msvc": "16.1.6",
- "@next/swc-win32-x64-msvc": "16.1.6",
- "sharp": "^0.34.4"
+ "@next/swc-darwin-arm64": "16.2.1",
+ "@next/swc-darwin-x64": "16.2.1",
+ "@next/swc-linux-arm64-gnu": "16.2.1",
+ "@next/swc-linux-arm64-musl": "16.2.1",
+ "@next/swc-linux-x64-gnu": "16.2.1",
+ "@next/swc-linux-x64-musl": "16.2.1",
+ "@next/swc-win32-arm64-msvc": "16.2.1",
+ "@next/swc-win32-x64-msvc": "16.2.1",
+ "sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -20409,13 +20344,13 @@
}
},
"node_modules/node-gyp/node_modules/isexe": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz",
- "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+ "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
- "node": ">=20"
+ "node": ">=18"
}
},
"node_modules/node-gyp/node_modules/semver": {
@@ -20891,6 +20826,15 @@
"node": ">= 4"
}
},
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -21007,7 +20951,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -21192,6 +21135,15 @@
"node": ">=10.4.0"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -21289,36 +21241,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/postject": {
- "version": "1.0.0-alpha.6",
- "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
- "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "commander": "^9.4.0"
- },
- "bin": {
- "postject": "dist/cli.js"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/postject/node_modules/commander": {
- "version": "9.5.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
- "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "engines": {
- "node": "^12.20.0 || >=14"
- }
- },
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -21552,6 +21474,141 @@
"node": ">=6"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/qrcode/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -22928,6 +22985,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@@ -23100,9 +23163,9 @@
}
},
"node_modules/robust-predicates": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
- "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/roughjs": {
@@ -23266,9 +23329,9 @@
"license": "MIT"
},
"node_modules/sanitize-filename": {
- "version": "1.6.3",
- "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
- "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz",
+ "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==",
"dev": true,
"license": "WTFPL OR ISC",
"dependencies": {
@@ -23414,6 +23477,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -24395,14 +24464,6 @@
"node": ">= 20"
}
},
- "node_modules/streamsearch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
@@ -24828,9 +24889,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.7",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
- "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
+ "version": "7.5.12",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.12.tgz",
+ "integrity": "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -25527,9 +25588,9 @@
}
},
"node_modules/undici": {
- "version": "6.21.3",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
- "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
+ "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -26096,21 +26157,21 @@
"license": "MIT"
},
"node_modules/vscode-uri": {
- "version": "3.0.8",
- "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
- "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/wait-on": {
- "version": "9.0.3",
- "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz",
- "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==",
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
+ "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "axios": "^1.13.2",
- "joi": "^18.0.1",
- "lodash": "^4.17.21",
+ "axios": "^1.13.5",
+ "joi": "^18.0.2",
+ "lodash": "^4.17.23",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
},
@@ -26232,6 +26293,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
diff --git a/package.json b/package.json
index 6f1dd647..689be345 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codepilot",
- "version": "0.38.1",
+ "version": "0.39.0",
"private": true,
"license": "BUSL-1.1",
"workspaces": [
@@ -53,7 +53,8 @@
"@streamdown/cjk": "^1.0.1",
"@streamdown/code": "^1.0.1",
"@streamdown/math": "^1.0.1",
- "@streamdown/mermaid": "^1.0.1",
+ "@streamdown/mermaid": "1.0.1",
+ "@types/qrcode": "^1.5.6",
"ai": "^6.0.73",
"ansi-to-react": "^6.2.6",
"better-sqlite3": "^12.6.2",
@@ -67,8 +68,9 @@
"morphdom": "^2.7.8",
"motion": "^12.33.0",
"nanoid": "^5.1.6",
- "next": "16.1.6",
+ "next": "16.2.1",
"next-themes": "^0.4.6",
+ "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -100,16 +102,26 @@
"@types/ws": "^8.18.1",
"concurrently": "^9.2.1",
"electron": "^40.2.1",
- "electron-builder": "^26.7.0",
+ "electron-builder": "^26.8.1",
"esbuild": "^0.27.3",
"eslint": "^9",
- "eslint-config-next": "16.1.6",
+ "eslint-config-next": "16.2.1",
"husky": "^9.1.7",
"lint-staged": "^16.3.2",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
- "wait-on": "^9.0.3"
+ "wait-on": "^9.0.4"
+ },
+ "overrides": {
+ "@mermaid-js/parser": "1.0.1",
+ "axios": "1.13.6",
+ "flatted": "3.4.2",
+ "hono": "4.12.8",
+ "lodash-es": "4.17.23",
+ "mermaid": "11.13.0",
+ "tar": "7.5.12",
+ "undici": "6.24.1"
}
}
diff --git a/src/__tests__/unit/assistant-workspace.test.ts b/src/__tests__/unit/assistant-workspace.test.ts
index 2b2107d7..423834ce 100644
--- a/src/__tests__/unit/assistant-workspace.test.ts
+++ b/src/__tests__/unit/assistant-workspace.test.ts
@@ -60,7 +60,7 @@ describe('Assistant Workspace', () => {
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
assert.equal(state.onboardingComplete, false);
assert.equal(state.lastCheckInDate, null);
- assert.equal(state.schemaVersion, 3);
+ assert.equal(state.schemaVersion, 4);
});
it('should create all 4 template files', () => {
@@ -129,19 +129,24 @@ describe('Assistant Workspace', () => {
});
it('should trigger check-in if onboarding done and no check-in today', () => {
- const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 2 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 2, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state), true);
});
it('should not trigger check-in if already done today', () => {
const today = getLocalDateString();
- const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 };
+ const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state), false);
});
it('onboarding day should skip daily check-in (lastCheckInDate set)', () => {
const today = getLocalDateString();
- const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 };
+ const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2, dailyCheckInEnabled: true };
+ assert.equal(needsDailyCheckIn(state), false);
+ });
+
+ it('should not trigger check-in if dailyCheckInEnabled is not set (default off)', () => {
+ const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 3 };
assert.equal(needsDailyCheckIn(state), false);
});
});
@@ -263,20 +268,20 @@ describe('Assistant Workspace', () => {
migrateStateV1ToV2(workDir);
const state = loadState(workDir);
- assert.equal(state.schemaVersion, 3);
+ assert.equal(state.schemaVersion, 4);
assert.ok(fs.existsSync(path.join(workDir, 'memory', 'daily')));
assert.ok(fs.existsSync(path.join(workDir, 'Inbox')));
});
- it('should not re-migrate v3 state', () => {
+ it('should not re-migrate v4 state', () => {
initializeWorkspace(workDir);
const state = loadState(workDir);
- assert.equal(state.schemaVersion, 3);
+ assert.equal(state.schemaVersion, 4);
// Should not throw or change anything
migrateStateV1ToV2(workDir);
const reloaded = loadState(workDir);
- assert.equal(reloaded.schemaVersion, 3);
+ assert.equal(reloaded.schemaVersion, 4);
});
});
diff --git a/src/__tests__/unit/context-assembler.test.ts b/src/__tests__/unit/context-assembler.test.ts
new file mode 100644
index 00000000..313ebfa8
--- /dev/null
+++ b/src/__tests__/unit/context-assembler.test.ts
@@ -0,0 +1,164 @@
+/**
+ * Unit tests for context-assembler.
+ *
+ * Run with: npx tsx --test src/__tests__/unit/context-assembler.test.ts
+ *
+ * Tests verify:
+ * 1. Desktop entry point includes widget prompt
+ * 2. Bridge entry point does NOT include widget prompt
+ * 3. Workspace prompt only injected for assistant project sessions
+ * 4. Widget MCP keyword detection
+ * 5. generative_ui_enabled=false skips widget even on desktop
+ */
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import type { ChatSession } from '../../types';
+
+function makeSession(overrides: Partial = {}): ChatSession {
+ return {
+ id: 'test-session',
+ title: 'Test',
+ model: 'sonnet',
+ working_directory: '/Users/test/project',
+ system_prompt: 'You are a helpful assistant.',
+ created_at: '2024-01-01',
+ updated_at: '2024-01-01',
+ sdk_session_id: '',
+ mode: 'code',
+ provider_id: '',
+ sdk_cwd: '',
+ permission_profile: 'default',
+ project_name: '',
+ status: 'active',
+ provider_name: '',
+ runtime_status: 'idle',
+ runtime_updated_at: '',
+ runtime_error: '',
+ ...overrides,
+ };
+}
+
+describe('assembleContext', () => {
+
+ it('desktop: includes session prompt and enables generativeUI', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'desktop',
+ userPrompt: 'hello',
+ });
+
+ assert.ok(result.systemPrompt?.includes('You are a helpful assistant.'));
+ assert.equal(result.generativeUIEnabled, true);
+ assert.equal(result.isAssistantProject, false);
+ });
+
+ it('bridge: does NOT enable generativeUI or widget MCP', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'bridge',
+ userPrompt: 'hello',
+ });
+
+ assert.equal(result.generativeUIEnabled, false);
+ assert.equal(result.needsWidgetMcp, false);
+ });
+
+ it('includes systemPromptAppend when provided', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'desktop',
+ userPrompt: 'hello',
+ systemPromptAppend: 'EXTRA INSTRUCTIONS HERE',
+ });
+
+ assert.ok(result.systemPrompt?.includes('EXTRA INSTRUCTIONS HERE'));
+ assert.ok(result.systemPrompt?.includes('You are a helpful assistant.'));
+ });
+
+ it('non-workspace session: isAssistantProject is false', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession({ working_directory: '/Users/test/project' }),
+ entryPoint: 'desktop',
+ userPrompt: 'hello',
+ });
+
+ assert.equal(result.isAssistantProject, false);
+ assert.equal(result.assistantProjectInstructions, '');
+ });
+
+ it('widget MCP detection: keyword triggers needsWidgetMcp on desktop', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'desktop',
+ userPrompt: '帮我画一个可视化图表',
+ });
+
+ assert.equal(result.needsWidgetMcp, true);
+ });
+
+ it('widget MCP detection: no keyword means no widget MCP', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'desktop',
+ userPrompt: '帮我写一个函数',
+ });
+
+ assert.equal(result.needsWidgetMcp, false);
+ });
+
+ it('widget MCP detection: conversation history with show-widget', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'desktop',
+ userPrompt: '继续',
+ conversationHistory: [
+ { role: 'assistant', content: '```show-widget\n{"title":"test"}\n```' },
+ ],
+ });
+
+ assert.equal(result.needsWidgetMcp, true);
+ });
+
+ it('bridge: widget MCP is never enabled even with keywords', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession(),
+ entryPoint: 'bridge',
+ userPrompt: '帮我画一个可视化图表',
+ });
+
+ assert.equal(result.needsWidgetMcp, false);
+ assert.equal(result.generativeUIEnabled, false);
+ });
+
+ it('session with empty system_prompt: does not throw', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession({ system_prompt: '' }),
+ entryPoint: 'bridge',
+ userPrompt: 'hello',
+ });
+
+ // Should not throw — prompt may be undefined or contain only CLI context
+ assert.ok(true);
+ });
+
+ it('prompt ordering: session prompt present in result', async () => {
+ const { assembleContext } = await import('../../lib/context-assembler');
+ const result = await assembleContext({
+ session: makeSession({ system_prompt: '<>' }),
+ entryPoint: 'desktop',
+ userPrompt: 'hello',
+ });
+
+ assert.ok(result.systemPrompt?.includes('<>'));
+ });
+});
diff --git a/src/__tests__/unit/mcp-loader.test.ts b/src/__tests__/unit/mcp-loader.test.ts
new file mode 100644
index 00000000..4c4cd31e
--- /dev/null
+++ b/src/__tests__/unit/mcp-loader.test.ts
@@ -0,0 +1,78 @@
+/**
+ * Unit tests for mcp-loader.
+ *
+ * Run with: npx tsx --test src/__tests__/unit/mcp-loader.test.ts
+ *
+ * Tests verify:
+ * 1. loadCodePilotMcpServers returns undefined when no servers have ${...} placeholders
+ * 2. loadCodePilotMcpServers returns only servers with resolved placeholders
+ * 3. loadAllMcpServers returns all merged servers
+ * 4. Cache invalidation works
+ * 5. Disabled servers are filtered out
+ */
+
+import { describe, it, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+
+// Import the module — will use real filesystem
+// Tests are designed to work with the user's actual config
+import { loadCodePilotMcpServers, loadAllMcpServers, invalidateMcpCache } from '../../lib/mcp-loader';
+
+afterEach(() => {
+ invalidateMcpCache();
+});
+
+describe('mcp-loader', () => {
+
+ it('loadCodePilotMcpServers: returns undefined when no servers have placeholders', () => {
+ // Most real configs don't have ${...} placeholders
+ // This test verifies the common case returns undefined (= let SDK handle everything)
+ const result = loadCodePilotMcpServers();
+ // Either undefined (no placeholders) or a map (has placeholders)
+ // Both are valid; we just verify it doesn't throw
+ if (result === undefined) {
+ assert.equal(result, undefined);
+ } else {
+ assert.ok(typeof result === 'object');
+ // Verify all returned servers have env values (they were resolved from placeholders)
+ for (const server of Object.values(result)) {
+ assert.ok(server.env, 'returned servers should have env property');
+ }
+ }
+ });
+
+ it('loadAllMcpServers: returns merged config or undefined', () => {
+ const result = loadAllMcpServers();
+ // Result depends on whether user has MCP servers configured
+ if (result !== undefined) {
+ assert.ok(typeof result === 'object');
+ for (const [name, server] of Object.entries(result)) {
+ assert.ok(typeof name === 'string');
+ // All servers should NOT be disabled (disabled ones are filtered out)
+ assert.notEqual(server.enabled, false);
+ }
+ }
+ });
+
+ it('cache: consecutive calls return same reference', () => {
+ const result1 = loadAllMcpServers();
+ const result2 = loadAllMcpServers();
+ // Same cache should be hit — references should be equal
+ // (unless there are no servers, in which case both are undefined)
+ if (result1 !== undefined && result2 !== undefined) {
+ assert.equal(result1, result2, 'cached results should be the same reference');
+ }
+ });
+
+ it('invalidateMcpCache: forces fresh read', () => {
+ const result1 = loadAllMcpServers();
+ invalidateMcpCache();
+ const result2 = loadAllMcpServers();
+ // After invalidation, a new object should be created
+ // (they may be deeply equal but should be different references)
+ if (result1 !== undefined && result2 !== undefined) {
+ // New cache entry means new object
+ assert.notEqual(result1, result2, 'invalidated cache should produce new reference');
+ }
+ });
+});
diff --git a/src/__tests__/unit/onboarding-completion.test.ts b/src/__tests__/unit/onboarding-completion.test.ts
index c28eec2e..13718425 100644
--- a/src/__tests__/unit/onboarding-completion.test.ts
+++ b/src/__tests__/unit/onboarding-completion.test.ts
@@ -243,7 +243,8 @@ describe('onboarding completion + workspace state integration', () => {
const state = loadState(workDir);
state.onboardingComplete = true;
state.lastCheckInDate = today;
- state.schemaVersion = 3;
+ state.schemaVersion = 4;
+ state.dailyCheckInEnabled = true;
saveState(workDir, state);
const reloaded = loadState(workDir);
@@ -261,7 +262,8 @@ describe('onboarding completion + workspace state integration', () => {
const state = loadState(workDir);
state.onboardingComplete = true;
state.lastCheckInDate = '2020-01-01'; // yesterday or earlier
- state.schemaVersion = 3;
+ state.schemaVersion = 4;
+ state.dailyCheckInEnabled = true;
saveState(workDir, state);
const reloaded = loadState(workDir);
diff --git a/src/__tests__/unit/provider-resolver.test.ts b/src/__tests__/unit/provider-resolver.test.ts
index 9365382b..5b8b45bc 100644
--- a/src/__tests__/unit/provider-resolver.test.ts
+++ b/src/__tests__/unit/provider-resolver.test.ts
@@ -691,26 +691,49 @@ describe('Env Provider AI SDK Consistency', () => {
});
it('toAiSdkConfig with env resolution produces valid anthropic config', () => {
- const resolved: ResolvedProvider = {
- provider: undefined,
- protocol: 'anthropic',
- authStyle: 'api_key',
- model: 'sonnet',
- upstreamModel: 'sonnet',
- modelDisplayName: undefined,
- headers: {},
- envOverrides: {},
- roleModels: {},
- hasCredentials: true,
- availableModels: [],
- settingSources: ['user', 'project', 'local'],
+ // Isolate from real env vars AND DB settings that may be set on developer machines
+ const envSnapshot = {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
+ };
+ delete process.env.ANTHROPIC_API_KEY;
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
+ delete process.env.ANTHROPIC_BASE_URL;
+ const dbSnapshot = {
+ anthropic_auth_token: getSetting('anthropic_auth_token'),
+ anthropic_base_url: getSetting('anthropic_base_url'),
};
- const config = toAiSdkConfig(resolved);
- assert.equal(config.sdkType, 'anthropic');
- assert.equal(config.modelId, 'sonnet');
- // No apiKey/baseUrl — SDK will read from process.env
- assert.equal(config.apiKey, undefined);
- assert.equal(config.baseUrl, undefined);
+ setSetting('anthropic_auth_token', '');
+ setSetting('anthropic_base_url', '');
+ try {
+ const resolved: ResolvedProvider = {
+ provider: undefined,
+ protocol: 'anthropic',
+ authStyle: 'api_key',
+ model: 'sonnet',
+ upstreamModel: 'sonnet',
+ modelDisplayName: undefined,
+ headers: {},
+ envOverrides: {},
+ roleModels: {},
+ hasCredentials: true,
+ availableModels: [],
+ settingSources: ['user', 'project', 'local'],
+ };
+ const config = toAiSdkConfig(resolved);
+ assert.equal(config.sdkType, 'anthropic');
+ assert.equal(config.modelId, 'sonnet');
+ // No apiKey/baseUrl — SDK will read from process.env
+ assert.equal(config.apiKey, undefined);
+ assert.equal(config.baseUrl, undefined);
+ } finally {
+ for (const [k, v] of Object.entries(envSnapshot)) {
+ if (v !== undefined) process.env[k] = v; else delete process.env[k];
+ }
+ setSetting('anthropic_auth_token', dbSnapshot.anthropic_auth_token || '');
+ setSetting('anthropic_base_url', dbSnapshot.anthropic_base_url || '');
+ }
});
});
@@ -846,27 +869,50 @@ describe('Entry Point Resolution Contract', () => {
});
it('toAiSdkConfig for env mode does not require provider record', () => {
- // env mode: provider=undefined, hasCredentials=true
- // toAiSdkConfig must produce a valid config that relies on process.env for auth
- const resolved: ResolvedProvider = {
- provider: undefined,
- protocol: 'anthropic',
- authStyle: 'api_key',
- model: 'sonnet',
- upstreamModel: 'sonnet',
- modelDisplayName: undefined,
- headers: {},
- envOverrides: {},
- roleModels: {},
- hasCredentials: true,
- availableModels: [],
- settingSources: ['user', 'project', 'local'],
+ // Isolate from real env vars AND DB settings
+ const envSnapshot = {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
+ };
+ delete process.env.ANTHROPIC_API_KEY;
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
+ delete process.env.ANTHROPIC_BASE_URL;
+ const dbSnapshot = {
+ anthropic_auth_token: getSetting('anthropic_auth_token'),
+ anthropic_base_url: getSetting('anthropic_base_url'),
};
- const config = toAiSdkConfig(resolved);
- assert.equal(config.sdkType, 'anthropic');
- assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env');
- assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env');
- assert.equal(config.modelId, 'sonnet');
+ setSetting('anthropic_auth_token', '');
+ setSetting('anthropic_base_url', '');
+ try {
+ // env mode: provider=undefined, hasCredentials=true
+ // toAiSdkConfig must produce a valid config that relies on process.env for auth
+ const resolved: ResolvedProvider = {
+ provider: undefined,
+ protocol: 'anthropic',
+ authStyle: 'api_key',
+ model: 'sonnet',
+ upstreamModel: 'sonnet',
+ modelDisplayName: undefined,
+ headers: {},
+ envOverrides: {},
+ roleModels: {},
+ hasCredentials: true,
+ availableModels: [],
+ settingSources: ['user', 'project', 'local'],
+ };
+ const config = toAiSdkConfig(resolved);
+ assert.equal(config.sdkType, 'anthropic');
+ assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env');
+ assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env');
+ assert.equal(config.modelId, 'sonnet');
+ } finally {
+ for (const [k, v] of Object.entries(envSnapshot)) {
+ if (v !== undefined) process.env[k] = v; else delete process.env[k];
+ }
+ setSetting('anthropic_auth_token', dbSnapshot.anthropic_auth_token || '');
+ setSetting('anthropic_base_url', dbSnapshot.anthropic_base_url || '');
+ }
});
it('upstream model mapping is consistent between AI SDK and Claude Code paths', () => {
@@ -907,3 +953,150 @@ describe('Entry Point Resolution Contract', () => {
assert.equal(aiConfig.modelId, ccEnv.ANTHROPIC_MODEL, 'AI SDK and Claude Code must use same upstream model');
});
});
+
+// ── Global Default Model Tests ──────────────────────────────────
+
+import { getSetting, setSetting } from '../../lib/db';
+
+describe('Global Default Model', () => {
+ // Save and restore settings around each test
+ let savedModel: string | null | undefined;
+ let savedProvider: string | null | undefined;
+
+ const setup = () => {
+ savedModel = getSetting('global_default_model');
+ savedProvider = getSetting('global_default_model_provider');
+ };
+ const teardown = () => {
+ setSetting('global_default_model', savedModel || '');
+ setSetting('global_default_model_provider', savedProvider || '');
+ };
+
+ // ── env provider branch ───────────────────────────────────────
+
+ it('env provider uses global default model when it belongs to env', () => {
+ setup();
+ try {
+ setSetting('global_default_model', 'opus');
+ setSetting('global_default_model_provider', 'env');
+
+ const resolved = resolveProvider({ providerId: 'env' });
+ assert.equal(resolved.model, 'opus', 'should use global default model for env provider');
+ } finally {
+ teardown();
+ }
+ });
+
+ it('env provider ignores global default model when it belongs to another provider', () => {
+ setup();
+ try {
+ setSetting('global_default_model', 'some-model');
+ setSetting('global_default_model_provider', 'some-other-provider-id');
+
+ const resolved = resolveProvider({ providerId: 'env' });
+ // Should NOT use 'some-model' because it belongs to a different provider
+ assert.notEqual(resolved.model, 'some-model',
+ 'should not apply global default from another provider');
+ } finally {
+ teardown();
+ }
+ });
+
+ it('explicit model overrides global default', () => {
+ setup();
+ try {
+ setSetting('global_default_model', 'opus');
+ setSetting('global_default_model_provider', 'env');
+
+ const resolved = resolveProvider({ providerId: 'env', model: 'haiku' });
+ assert.equal(resolved.model, 'haiku', 'explicit model should take priority');
+ } finally {
+ teardown();
+ }
+ });
+
+ it('session model overrides global default', () => {
+ setup();
+ try {
+ setSetting('global_default_model', 'opus');
+ setSetting('global_default_model_provider', 'env');
+
+ const resolved = resolveProvider({ providerId: 'env', sessionModel: 'sonnet' });
+ assert.equal(resolved.model, 'sonnet', 'session model should take priority');
+ } finally {
+ teardown();
+ }
+ });
+
+ // ── DB provider branch ────────────────────────────────────────
+
+ it('DB provider uses global default model when it belongs to that provider', () => {
+ setup();
+ const { createProvider, deleteProvider } = require('../../lib/db');
+ const provider = createProvider({
+ name: '__test_global_default__',
+ provider_type: 'anthropic',
+ base_url: 'https://api.anthropic.com',
+ api_key: 'test-key',
+ });
+ try {
+ setSetting('global_default_model', 'test-model-x');
+ setSetting('global_default_model_provider', provider.id);
+
+ const resolved = resolveProvider({ providerId: provider.id });
+ assert.equal(resolved.model, 'test-model-x',
+ 'DB provider should use global default when provider ID matches');
+ } finally {
+ deleteProvider(provider.id);
+ teardown();
+ }
+ });
+
+ it('DB provider ignores global default model when it belongs to a different provider', () => {
+ setup();
+ const { createProvider, deleteProvider } = require('../../lib/db');
+ const provider = createProvider({
+ name: '__test_global_default_cross__',
+ provider_type: 'anthropic',
+ base_url: 'https://api.anthropic.com',
+ api_key: 'test-key',
+ role_models_json: JSON.stringify({ default: 'own-default-model' }),
+ });
+ try {
+ setSetting('global_default_model', 'foreign-model');
+ setSetting('global_default_model_provider', 'some-completely-different-id');
+
+ const resolved = resolveProvider({ providerId: provider.id });
+ // Should fall through to roleModels.default, NOT use 'foreign-model'
+ assert.notEqual(resolved.model, 'foreign-model',
+ 'DB provider should not use global default from another provider');
+ assert.equal(resolved.model, 'own-default-model',
+ 'should fall through to roleModels.default');
+ } finally {
+ deleteProvider(provider.id);
+ teardown();
+ }
+ });
+
+ it('DB provider: session model overrides global default even when provider matches', () => {
+ setup();
+ const { createProvider, deleteProvider } = require('../../lib/db');
+ const provider = createProvider({
+ name: '__test_global_default_session__',
+ provider_type: 'anthropic',
+ base_url: 'https://api.anthropic.com',
+ api_key: 'test-key',
+ });
+ try {
+ setSetting('global_default_model', 'global-pick');
+ setSetting('global_default_model_provider', provider.id);
+
+ const resolved = resolveProvider({ providerId: provider.id, sessionModel: 'session-pick' });
+ assert.equal(resolved.model, 'session-pick',
+ 'session model should take priority over global default');
+ } finally {
+ deleteProvider(provider.id);
+ teardown();
+ }
+ });
+});
diff --git a/src/__tests__/unit/stale-default-provider.test.ts b/src/__tests__/unit/stale-default-provider.test.ts
index 7cf550d7..09dbf9f6 100644
--- a/src/__tests__/unit/stale-default-provider.test.ts
+++ b/src/__tests__/unit/stale-default-provider.test.ts
@@ -21,6 +21,8 @@ import {
createProvider,
deleteProvider,
getDb,
+ getSetting,
+ setSetting,
} from '../../lib/db';
import { resolveProvider } from '../../lib/provider-resolver';
@@ -57,17 +59,22 @@ function cleanupTestProviders() {
// ── Tests ───────────────────────────────────────────────────────
describe('Stale default_provider_id cleanup', () => {
- // Save and restore original default
+ // Save and restore original default + global default model provider
let originalDefault: string | undefined;
+ let originalGlobalProvider: string | undefined;
beforeEach(() => {
originalDefault = getDefaultProviderId();
+ originalGlobalProvider = getSetting('global_default_model_provider') || undefined;
+ // Clear global_default_model_provider so these tests exercise the legacy path
+ setSetting('global_default_model_provider', '');
cleanupTestProviders();
});
afterEach(() => {
cleanupTestProviders();
- // Restore original default
+ // Restore originals
+ setSetting('global_default_model_provider', originalGlobalProvider || '');
if (originalDefault) {
setDefaultProviderId(originalDefault);
}
diff --git a/src/__tests__/unit/timezone-boundaries.test.ts b/src/__tests__/unit/timezone-boundaries.test.ts
index b1919a17..b8505d3d 100644
--- a/src/__tests__/unit/timezone-boundaries.test.ts
+++ b/src/__tests__/unit/timezone-boundaries.test.ts
@@ -116,7 +116,7 @@ describe('needsDailyCheckIn timezone boundaries', () => {
it('UTC+9: check-in done as local 2026-03-10, still valid at 08:30 JST', () => {
setTZ('Asia/Tokyo');
const now = new Date('2026-03-10T00:30:00Z'); // 09:30 JST, still March 10 local
- const state = { onboardingComplete: true, lastCheckInDate: '2026-03-10', schemaVersion: 3 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2026-03-10', schemaVersion: 3, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state, now), false);
});
@@ -126,7 +126,7 @@ describe('needsDailyCheckIn timezone boundaries', () => {
// localToday = '2026-03-10', utcToday = '2026-03-10'
// stored '2026-03-09' matches neither → triggers
const now = new Date('2026-03-10T01:00:00Z');
- const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state, now), true);
});
@@ -137,7 +137,7 @@ describe('needsDailyCheckIn timezone boundaries', () => {
// stored '2026-03-09' matches utcToday → compat suppresses (correct:
// old code could have written this just hours ago during the same UTC day)
const now = new Date('2026-03-09T15:00:00Z');
- const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state, now), false);
});
@@ -149,7 +149,7 @@ describe('needsDailyCheckIn timezone boundaries', () => {
// DID check in today (local March 10, 1am), but old code wrote UTC date.
// The UTC fallback catches this: '2026-03-09' === utcToday → skip.
const now = new Date('2026-03-09T17:00:00Z');
- const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3, dailyCheckInEnabled: true };
// utcToday = '2026-03-09' matches stored → should NOT trigger
assert.equal(needsDailyCheckIn(state, now), false);
});
@@ -159,7 +159,7 @@ describe('needsDailyCheckIn timezone boundaries', () => {
const now = new Date('2026-03-10T02:00:00Z'); // local March 10 10:00
// localToday = '2026-03-10', utcToday = '2026-03-10'
// stored '2026-03-07' matches neither → triggers
- const state = { onboardingComplete: true, lastCheckInDate: '2026-03-07', schemaVersion: 3 };
+ const state = { onboardingComplete: true, lastCheckInDate: '2026-03-07', schemaVersion: 3, dailyCheckInEnabled: true };
assert.equal(needsDailyCheckIn(state, now), true);
});
});
@@ -245,7 +245,7 @@ describe('v2→v3 migration', () => {
assert.equal(raw.lastCheckInDate, '2026-03-10', 'should not change v3 state');
});
- it('loadState auto-migrates v2 state to v3', () => {
+ it('loadState auto-migrates v2 state to v4', () => {
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
// Also need daily dir for v1→v2 migration path
@@ -263,9 +263,11 @@ describe('v2→v3 migration', () => {
);
const state = loadState(workDir);
- assert.equal(state.schemaVersion, 3);
+ assert.equal(state.schemaVersion, 4);
// Past date should be preserved, not overwritten to today
assert.equal(state.lastCheckInDate, '2026-01-15');
+ // v3→v4 migration resets dailyCheckInEnabled to false
+ assert.equal(state.dailyCheckInEnabled, false);
});
it('should preserve null lastCheckInDate during migration', () => {
diff --git a/src/__tests__/unit/update-release.test.ts b/src/__tests__/unit/update-release.test.ts
new file mode 100644
index 00000000..6c80aa37
--- /dev/null
+++ b/src/__tests__/unit/update-release.test.ts
@@ -0,0 +1,68 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { selectRecommendedReleaseAsset, type ReleaseAsset } from '../../lib/update-release';
+
+const assets: ReleaseAsset[] = [
+ {
+ name: 'CodePilot-0.38.5-arm64.dmg',
+ browser_download_url: 'https://example.com/CodePilot-0.38.5-arm64.dmg',
+ },
+ {
+ name: 'CodePilot-0.38.5-x64.dmg',
+ browser_download_url: 'https://example.com/CodePilot-0.38.5-x64.dmg',
+ },
+ {
+ name: 'CodePilot-0.38.5-arm64.zip',
+ browser_download_url: 'https://example.com/CodePilot-0.38.5-arm64.zip',
+ },
+ {
+ name: 'CodePilot-0.38.5-x64.zip',
+ browser_download_url: 'https://example.com/CodePilot-0.38.5-x64.zip',
+ },
+ {
+ name: 'CodePilot-0.38.5.exe',
+ browser_download_url: 'https://example.com/CodePilot-0.38.5.exe',
+ },
+];
+
+describe('selectRecommendedReleaseAsset', () => {
+ it('prefers the arm64 dmg for Apple Silicon Macs', () => {
+ const selected = selectRecommendedReleaseAsset(assets, {
+ platform: 'darwin',
+ processArch: 'x64',
+ hostArch: 'arm64',
+ });
+
+ assert.equal(selected?.name, 'CodePilot-0.38.5-arm64.dmg');
+ });
+
+ it('prefers the x64 dmg for Intel Macs', () => {
+ const selected = selectRecommendedReleaseAsset(assets, {
+ platform: 'darwin',
+ processArch: 'x64',
+ hostArch: 'x64',
+ });
+
+ assert.equal(selected?.name, 'CodePilot-0.38.5-x64.dmg');
+ });
+
+ it('falls back to the windows installer on Windows', () => {
+ const selected = selectRecommendedReleaseAsset(assets, {
+ platform: 'win32',
+ processArch: 'x64',
+ hostArch: 'x64',
+ });
+
+ assert.equal(selected?.name, 'CodePilot-0.38.5.exe');
+ });
+
+ it('returns null when no matching asset exists', () => {
+ const selected = selectRecommendedReleaseAsset([], {
+ platform: 'darwin',
+ processArch: 'arm64',
+ hostArch: 'arm64',
+ });
+
+ assert.equal(selected, null);
+ });
+});
diff --git a/src/__tests__/unit/weixin-adapter-ack.test.ts b/src/__tests__/unit/weixin-adapter-ack.test.ts
new file mode 100644
index 00000000..e9df92d4
--- /dev/null
+++ b/src/__tests__/unit/weixin-adapter-ack.test.ts
@@ -0,0 +1,90 @@
+import { after, before, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-weixin-ack-test-'));
+process.env.CLAUDE_GUI_DATA_DIR = tmpDir;
+
+let WeixinAdapter: typeof import('../../lib/bridge/adapters/weixin-adapter').WeixinAdapter;
+let getChannelOffset: typeof import('../../lib/db').getChannelOffset;
+let closeDb: typeof import('../../lib/db').closeDb;
+
+before(async () => {
+ ({ WeixinAdapter } = await import('../../lib/bridge/adapters/weixin-adapter'));
+ ({ getChannelOffset, closeDb } = await import('../../lib/db'));
+});
+
+after(() => {
+ closeDb();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe('WeixinAdapter deferred cursor ack', () => {
+ it('attaches batch updateId to enqueued inbound messages', async () => {
+ const adapter = new WeixinAdapter() as any;
+ adapter.pendingCursors.set(42, {
+ offsetKey: 'weixin:acc-1',
+ cursor: 'cursor-42',
+ remaining: 0,
+ sealed: false,
+ });
+
+ await adapter.processMessage(
+ 'acc-1',
+ {
+ botToken: 'token',
+ ilinkBotId: 'acc-1',
+ baseUrl: 'https://example.test',
+ cdnBaseUrl: 'https://cdn.example.test',
+ },
+ {
+ from_user_id: 'peer-1',
+ message_id: 'msg-1',
+ item_list: [{ type: 1, text_item: { text: 'hello' } }],
+ },
+ 42,
+ );
+
+ const inbound = await adapter.consumeOne();
+ assert.ok(inbound);
+ assert.equal(inbound?.updateId, 42);
+ assert.equal(adapter.pendingCursors.get(42)?.remaining, 1);
+ });
+
+ it('commits cursor only after final acknowledgeUpdate', () => {
+ const adapter = new WeixinAdapter() as any;
+
+ adapter.pendingCursors.set(7, {
+ offsetKey: 'weixin:acc-7',
+ cursor: 'cursor-7',
+ remaining: 2,
+ sealed: true,
+ });
+
+ adapter.acknowledgeUpdate(7);
+ assert.equal(getChannelOffset('weixin:acc-7'), '0');
+
+ adapter.acknowledgeUpdate(7);
+ assert.equal(getChannelOffset('weixin:acc-7'), 'cursor-7');
+ });
+
+ it('does not commit cursor before the batch is sealed', () => {
+ const adapter = new WeixinAdapter() as any;
+
+ adapter.pendingCursors.set(8, {
+ offsetKey: 'weixin:acc-8',
+ cursor: 'cursor-8',
+ remaining: 1,
+ sealed: false,
+ });
+
+ adapter.acknowledgeUpdate(8);
+ assert.equal(getChannelOffset('weixin:acc-8'), '0');
+
+ adapter.pendingCursors.get(8).sealed = true;
+ adapter.maybeCommitPendingCursor(8);
+ assert.equal(getChannelOffset('weixin:acc-8'), 'cursor-8');
+ });
+});
diff --git a/src/__tests__/unit/weixin-api.test.ts b/src/__tests__/unit/weixin-api.test.ts
new file mode 100644
index 00000000..8bb06d6e
--- /dev/null
+++ b/src/__tests__/unit/weixin-api.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ sendTextMessage,
+ sendTyping,
+} from '../../lib/bridge/adapters/weixin/weixin-api';
+
+const creds = {
+ botToken: 'token',
+ ilinkBotId: 'bot-id',
+ baseUrl: 'https://example.test',
+ cdnBaseUrl: 'https://cdn.example.test',
+};
+
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+describe('weixin-api empty success bodies', () => {
+ it('sendTextMessage tolerates empty 200 response bodies', async () => {
+ let requestBody = '';
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ requestBody = String(init?.body || '');
+ return new Response('', { status: 200 });
+ }) as typeof fetch;
+
+ const { clientId } = await sendTextMessage(creds, 'peer-user', 'hello', 'ctx-token');
+
+ assert.match(clientId, /^codepilot-wx-/);
+ const parsed = JSON.parse(requestBody);
+ assert.equal(parsed.msg.to_user_id, 'peer-user');
+ assert.equal(parsed.msg.context_token, 'ctx-token');
+ assert.equal(parsed.msg.message_state, 2);
+ });
+
+ it('sendTyping tolerates empty 200 response bodies', async () => {
+ let requestBody = '';
+ globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
+ requestBody = String(init?.body || '');
+ return new Response('', { status: 200 });
+ }) as typeof fetch;
+
+ await assert.doesNotReject(() => sendTyping(creds, 'peer-user', 'ticket-1', 1));
+
+ const parsed = JSON.parse(requestBody);
+ assert.equal(parsed.ilink_user_id, 'peer-user');
+ assert.equal(parsed.typing_ticket, 'ticket-1');
+ assert.equal(parsed.status, 1);
+ });
+});
diff --git a/src/__tests__/unit/weixin-ids.test.ts b/src/__tests__/unit/weixin-ids.test.ts
new file mode 100644
index 00000000..f4a3ea9f
--- /dev/null
+++ b/src/__tests__/unit/weixin-ids.test.ts
@@ -0,0 +1,54 @@
+/**
+ * Unit tests for WeChat chat ID encoding/decoding.
+ *
+ * Run with: npx tsx src/__tests__/unit/weixin-ids.test.ts
+ */
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { encodeWeixinChatId, decodeWeixinChatId, isWeixinChatId } from '../../lib/bridge/adapters/weixin/weixin-ids';
+
+describe('encodeWeixinChatId', () => {
+ it('produces correct format', () => {
+ assert.equal(encodeWeixinChatId('acc123', 'user456'), 'weixin::acc123::user456');
+ });
+
+ it('handles special characters in IDs', () => {
+ assert.equal(encodeWeixinChatId('acc-im-bot', 'user@test'), 'weixin::acc-im-bot::user@test');
+ });
+});
+
+describe('decodeWeixinChatId', () => {
+ it('round-trips correctly', () => {
+ const encoded = encodeWeixinChatId('myAccount', 'peerUser');
+ const decoded = decodeWeixinChatId(encoded);
+ assert.deepEqual(decoded, { accountId: 'myAccount', peerUserId: 'peerUser' });
+ });
+
+ it('returns null for non-weixin chatId', () => {
+ assert.equal(decodeWeixinChatId('telegram:12345'), null);
+ assert.equal(decodeWeixinChatId('random_string'), null);
+ });
+
+ it('returns null for malformed weixin chatId', () => {
+ assert.equal(decodeWeixinChatId('weixin::'), null);
+ assert.equal(decodeWeixinChatId('weixin::acc'), null);
+ assert.equal(decodeWeixinChatId('weixin::::user'), null);
+ });
+
+ it('handles empty components', () => {
+ assert.equal(decodeWeixinChatId('weixin::::peer'), null);
+ assert.equal(decodeWeixinChatId('weixin::acc::'), null);
+ });
+});
+
+describe('isWeixinChatId', () => {
+ it('returns true for valid weixin chatIds', () => {
+ assert.equal(isWeixinChatId('weixin::acc::user'), true);
+ });
+
+ it('returns false for non-weixin chatIds', () => {
+ assert.equal(isWeixinChatId('telegram:123'), false);
+ assert.equal(isWeixinChatId('qq:456'), false);
+ });
+});
diff --git a/src/__tests__/unit/weixin-media.test.ts b/src/__tests__/unit/weixin-media.test.ts
new file mode 100644
index 00000000..164f79ba
--- /dev/null
+++ b/src/__tests__/unit/weixin-media.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Unit tests for WeChat media encryption/decryption.
+ *
+ * Run with: npx tsx src/__tests__/unit/weixin-media.test.ts
+ */
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { encryptMedia, decryptMedia, generateMediaKey, aesEcbPaddedSize } from '../../lib/bridge/adapters/weixin/weixin-media';
+
+describe('AES-128-ECB encrypt/decrypt', () => {
+ it('round-trips correctly', () => {
+ const key = generateMediaKey();
+ const plaintext = Buffer.from('Hello, WeChat media encryption!');
+ const encrypted = encryptMedia(plaintext, key);
+ const decrypted = decryptMedia(encrypted, key);
+ assert.equal(decrypted.toString(), plaintext.toString());
+ });
+
+ it('round-trips with various sizes', () => {
+ const key = generateMediaKey();
+
+ // Exactly 16 bytes (one block)
+ const data16 = Buffer.alloc(16, 0x42);
+ assert.deepEqual(decryptMedia(encryptMedia(data16, key), key), data16);
+
+ // 15 bytes (less than one block)
+ const data15 = Buffer.alloc(15, 0x43);
+ assert.deepEqual(decryptMedia(encryptMedia(data15, key), key), data15);
+
+ // 17 bytes (just over one block)
+ const data17 = Buffer.alloc(17, 0x44);
+ assert.deepEqual(decryptMedia(encryptMedia(data17, key), key), data17);
+
+ // Large data
+ const dataLarge = Buffer.alloc(1024, 0x45);
+ assert.deepEqual(decryptMedia(encryptMedia(dataLarge, key), key), dataLarge);
+
+ // Empty data should still work with PKCS7
+ const dataEmpty = Buffer.alloc(0);
+ assert.deepEqual(decryptMedia(encryptMedia(dataEmpty, key), key), dataEmpty);
+ });
+
+ it('produces correct ciphertext size', () => {
+ const key = generateMediaKey();
+ const data = Buffer.alloc(100, 0x46);
+ const encrypted = encryptMedia(data, key);
+ // AES-128-ECB with PKCS7: ceil((100+1)/16) * 16 = 112
+ assert.equal(encrypted.length, 112);
+ });
+});
+
+describe('generateMediaKey', () => {
+ it('generates 16-byte key', () => {
+ const key = generateMediaKey();
+ assert.equal(key.length, 16);
+ });
+
+ it('generates unique keys', () => {
+ const key1 = generateMediaKey();
+ const key2 = generateMediaKey();
+ assert.equal(key1.equals(key2), false);
+ });
+});
+
+describe('aesEcbPaddedSize', () => {
+ it('computes correct padded sizes', () => {
+ assert.equal(aesEcbPaddedSize(0), 16);
+ assert.equal(aesEcbPaddedSize(1), 16);
+ assert.equal(aesEcbPaddedSize(15), 16);
+ assert.equal(aesEcbPaddedSize(16), 32);
+ assert.equal(aesEcbPaddedSize(17), 32);
+ assert.equal(aesEcbPaddedSize(31), 32);
+ assert.equal(aesEcbPaddedSize(32), 48);
+ assert.equal(aesEcbPaddedSize(100), 112);
+ });
+});
diff --git a/src/__tests__/unit/weixin-session-guard.test.ts b/src/__tests__/unit/weixin-session-guard.test.ts
new file mode 100644
index 00000000..8bf51e62
--- /dev/null
+++ b/src/__tests__/unit/weixin-session-guard.test.ts
@@ -0,0 +1,55 @@
+/**
+ * Unit tests for WeChat session guard (account pause management).
+ *
+ * Run with: npx tsx src/__tests__/unit/weixin-session-guard.test.ts
+ */
+
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { isPaused, setPaused, clearPause, clearAllPauses, getPauseRemainingMs } from '../../lib/bridge/adapters/weixin/weixin-session-guard';
+
+describe('weixin-session-guard', () => {
+ beforeEach(() => {
+ clearAllPauses();
+ });
+
+ it('starts unpaused', () => {
+ assert.equal(isPaused('test-account'), false);
+ });
+
+ it('can be paused and detected', () => {
+ setPaused('test-account', 'Session expired');
+ assert.equal(isPaused('test-account'), true);
+ });
+
+ it('can be cleared', () => {
+ setPaused('test-account');
+ clearPause('test-account');
+ assert.equal(isPaused('test-account'), false);
+ });
+
+ it('tracks multiple accounts independently', () => {
+ setPaused('account-1');
+ assert.equal(isPaused('account-1'), true);
+ assert.equal(isPaused('account-2'), false);
+ });
+
+ it('clearAllPauses clears everything', () => {
+ setPaused('account-1');
+ setPaused('account-2');
+ clearAllPauses();
+ assert.equal(isPaused('account-1'), false);
+ assert.equal(isPaused('account-2'), false);
+ });
+
+ it('getPauseRemainingMs returns positive value when paused', () => {
+ setPaused('test-account');
+ const remaining = getPauseRemainingMs('test-account');
+ assert.ok(remaining > 0);
+ assert.ok(remaining <= 60 * 60 * 1000);
+ });
+
+ it('getPauseRemainingMs returns 0 when not paused', () => {
+ assert.equal(getPauseRemainingMs('test-account'), 0);
+ });
+});
diff --git a/src/__tests__/unit/working-directory.test.ts b/src/__tests__/unit/working-directory.test.ts
new file mode 100644
index 00000000..85f071df
--- /dev/null
+++ b/src/__tests__/unit/working-directory.test.ts
@@ -0,0 +1,65 @@
+import { after, before, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const TEST_ROOT = path.join(os.tmpdir(), `codepilot-working-dir-${Date.now()}`);
+const VALID_DIR = path.join(TEST_ROOT, 'valid-project');
+const HOME_DIR = path.join(TEST_ROOT, 'fake-home');
+
+describe('working-directory helpers', () => {
+ const originalHome = process.env.HOME;
+ let helpers: typeof import('../../lib/working-directory');
+
+ before(async () => {
+ fs.mkdirSync(VALID_DIR, { recursive: true });
+ fs.mkdirSync(HOME_DIR, { recursive: true });
+ process.env.HOME = HOME_DIR;
+ helpers = await import(path.resolve(__dirname, '../../lib/working-directory.ts'));
+ });
+
+ after(() => {
+ if (originalHome === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = originalHome;
+ }
+ fs.rmSync(TEST_ROOT, { recursive: true, force: true });
+ });
+
+ it('accepts an existing directory', () => {
+ assert.equal(helpers.isExistingDirectory(VALID_DIR), true);
+ });
+
+ it('rejects a missing directory', () => {
+ assert.equal(helpers.isExistingDirectory(path.join(TEST_ROOT, 'missing')), false);
+ });
+
+ it('picks the first valid candidate after skipping invalid ones', () => {
+ const resolved = helpers.resolveWorkingDirectory([
+ { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' },
+ { path: VALID_DIR, source: 'binding' },
+ ]);
+
+ assert.equal(resolved.path, VALID_DIR);
+ assert.equal(resolved.source, 'binding');
+ assert.deepEqual(resolved.invalidCandidates, [
+ { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' },
+ ]);
+ });
+
+ it('falls back to HOME when no candidates are valid', () => {
+ const resolved = helpers.resolveWorkingDirectory([
+ { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' },
+ { path: path.join(TEST_ROOT, 'missing-b'), source: 'setting' },
+ ]);
+
+ assert.equal(resolved.path, HOME_DIR);
+ assert.equal(resolved.source, 'home');
+ assert.deepEqual(resolved.invalidCandidates, [
+ { path: path.join(TEST_ROOT, 'missing-a'), source: 'requested' },
+ { path: path.join(TEST_ROOT, 'missing-b'), source: 'setting' },
+ ]);
+ });
+});
diff --git a/src/app/api/app/updates/route.ts b/src/app/api/app/updates/route.ts
index 9294f49e..19c34881 100644
--- a/src/app/api/app/updates/route.ts
+++ b/src/app/api/app/updates/route.ts
@@ -1,4 +1,6 @@
import { NextResponse } from "next/server";
+import { getRuntimeArchitectureInfo } from "@/lib/platform";
+import { selectRecommendedReleaseAsset, type ReleaseAsset } from "@/lib/update-release";
const GITHUB_REPO = "op7418/CodePilot";
@@ -15,6 +17,7 @@ function compareSemver(a: string, b: string): number {
export async function GET() {
try {
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
+ const runtimeInfo = getRuntimeArchitectureInfo();
const res = await fetch(
`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`,
@@ -34,6 +37,10 @@ export async function GET() {
const release = await res.json();
const latestVersion = (release.tag_name || "").replace(/^v/, "");
const updateAvailable = compareSemver(latestVersion, currentVersion) > 0;
+ const recommendedAsset = selectRecommendedReleaseAsset(
+ Array.isArray(release.assets) ? (release.assets as ReleaseAsset[]) : [],
+ runtimeInfo,
+ );
return NextResponse.json({
latestVersion,
@@ -43,6 +50,12 @@ export async function GET() {
releaseNotes: release.body || "",
publishedAt: release.published_at || "",
releaseUrl: release.html_url || "",
+ downloadUrl: recommendedAsset?.browser_download_url || release.html_url || "",
+ downloadAssetName: recommendedAsset?.name || "",
+ detectedPlatform: runtimeInfo.platform,
+ detectedArch: runtimeInfo.processArch,
+ hostArch: runtimeInfo.hostArch,
+ runningUnderRosetta: runtimeInfo.runningUnderRosetta,
});
} catch {
return NextResponse.json(
diff --git a/src/app/api/bridge/route.ts b/src/app/api/bridge/route.ts
index 14a530fc..007945c9 100644
--- a/src/app/api/bridge/route.ts
+++ b/src/app/api/bridge/route.ts
@@ -33,8 +33,8 @@ export async function POST(request: NextRequest) {
const { action } = body;
if (action === 'start') {
- await bridgeManager.start();
- return Response.json({ ok: true, status: bridgeManager.getStatus() });
+ const result = await bridgeManager.start();
+ return Response.json({ ok: result.started, reason: result.reason, status: bridgeManager.getStatus() });
} else if (action === 'stop') {
await bridgeManager.stop();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts
index 4787e210..d5511fd0 100644
--- a/src/app/api/bridge/settings/route.ts
+++ b/src/app/api/bridge/settings/route.ts
@@ -41,6 +41,8 @@ const BRIDGE_SETTING_KEYS = [
'bridge_qq_allowed_users',
'bridge_qq_image_enabled',
'bridge_qq_max_image_size',
+ 'bridge_weixin_enabled',
+ 'bridge_weixin_media_enabled',
] as const;
export async function GET() {
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 42ab31ee..b9a110be 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -4,50 +4,17 @@ import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessi
import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver';
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
import { extractCompletion } from '@/lib/onboarding-completion';
+import { loadCodePilotMcpServers } from '@/lib/mcp-loader';
+import { assembleContext } from '@/lib/context-assembler';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
-import type { MCPServerConfig } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-/** Read MCP server configs from ~/.claude.json, ~/.claude/settings.json, and project .mcp.json */
-function loadMcpServers(): Record | undefined {
- try {
- const readJson = (p: string): Record => {
- if (!fs.existsSync(p)) return {};
- try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
- };
- const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
- const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
- // Also read project-level .mcp.json
- const projectMcp = readJson(path.join(process.cwd(), '.mcp.json'));
- const merged = {
- ...((userConfig.mcpServers || {}) as Record),
- ...((settings.mcpServers || {}) as Record),
- ...((projectMcp.mcpServers || {}) as Record),
- };
- // Resolve ${...} placeholders in env values against DB settings
- for (const server of Object.values(merged)) {
- if (server.env) {
- for (const [key, value] of Object.entries(server.env)) {
- if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
- const settingKey = value.slice(2, -1);
- const resolved = getSetting(settingKey);
- server.env[key] = resolved || '';
- }
- }
- }
- }
- return Object.keys(merged).length > 0 ? merged : undefined;
- } catch {
- return undefined;
- }
-}
-
export async function POST(request: NextRequest) {
let activeSessionId: string | undefined;
let activeLockId: string | undefined;
@@ -153,23 +120,17 @@ export async function POST(request: NextRequest) {
updateSessionProviderId(session_id, persistProviderId);
}
- // Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
+ // Resolve permission mode from request body (sent by frontend on each message)
+ // or fall back to session's persisted mode from DB.
+ // Request body mode takes priority to avoid race condition: user switches mode
+ // then immediately sends — the PATCH may not have landed in DB yet.
const effectiveMode = mode || session.mode || 'code';
- let permissionMode: string;
- let systemPromptOverride: string | undefined;
- switch (effectiveMode) {
- case 'plan':
- permissionMode = 'plan';
- break;
- case 'ask':
- permissionMode = 'default';
- systemPromptOverride = (session.system_prompt || '') +
- '\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
- break;
- default: // 'code'
- permissionMode = 'acceptEdits';
- break;
- }
+ const permissionMode = effectiveMode === 'plan' ? 'plan' : 'acceptEdits';
+
+ // Plan mode takes precedence over full_access: if the user explicitly chose
+ // Plan, they expect no tool execution regardless of permission profile.
+ const bypassPermissions = session.permission_profile === 'full_access' && effectiveMode !== 'plan';
+ const systemPromptOverride: string | undefined = undefined;
const abortController = new AbortController();
@@ -195,153 +156,6 @@ export async function POST(request: NextRequest) {
})
: undefined;
- // Load assistant workspace prompt if configured
- let workspacePrompt = '';
- let assistantProjectInstructions = '';
- try {
- const workspacePath = getSetting('assistant_workspace_path');
- if (workspacePath) {
- const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace');
-
- // Only inject workspace files for assistant project sessions
- const sessionWd = session.working_directory || '';
- const isAssistantProject = sessionWd === workspacePath;
-
- if (isAssistantProject) {
- // Incremental reindex BEFORE search so current turn sees latest content
- try {
- const { indexWorkspace } = await import('@/lib/workspace-indexer');
- indexWorkspace(workspacePath);
- } catch {
- // indexer not available, skip
- }
-
- const files = loadWorkspaceFiles(workspacePath);
-
- // Retrieval: search workspace index for relevant context
- let retrievalResults: import('@/types').SearchResult[] | undefined;
- try {
- const { searchWorkspace, updateHotset } = await import('@/lib/workspace-retrieval');
- if (content.length > 10) {
- retrievalResults = searchWorkspace(workspacePath, content, { limit: 5 });
- if (retrievalResults.length > 0) {
- updateHotset(workspacePath, retrievalResults.map(r => r.path));
- }
- }
- } catch {
- // retrieval module not available, skip
- }
-
- workspacePrompt = assembleWorkspacePrompt(files, retrievalResults);
-
- const state = loadState(workspacePath);
-
- if (!state.onboardingComplete) {
- // First-time onboarding: instruct AI to ask onboarding questions
- assistantProjectInstructions = `
-You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile.
-
-Ask the following 13 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly.
-
-1. How should I address you?
-2. What name should I use for myself?
-3. Do you prefer "concise and direct" or "detailed explanations"?
-4. Do you prefer "minimal interruptions" or "proactive suggestions"?
-5. What are your three hard boundaries?
-6. What are your three most important current goals?
-7. Do you prefer output as "lists", "reports", or "conversation summaries"?
-8. What information may be written to long-term memory?
-9. What information must never be written to long-term memory?
-10. What three things should I do first when entering a project?
-11. How do you organize your materials? (by project / time / topic / mixed)
-12. Where should new information go by default?
-13. How should completed tasks be archived?
-
-After the user answers the LAST question (Q13), you MUST immediately output the completion block below. Do NOT wait for the user to say anything else. Do NOT ask for confirmation. Just output the block right after your response to Q13.
-
-CRITICAL FORMATTING RULES for the completion block:
-- Each value must be a single line (replace any newlines with spaces)
-- Escape all double quotes inside values with backslash: \\"
-- Do NOT use single quotes for JSON keys or values
-- Do NOT add trailing commas
-- The JSON must be on a SINGLE line
-
-\`\`\`onboarding-complete
-{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10","q11":"answer11","q12":"answer12","q13":"answer13"}
-\`\`\`
-
-After outputting the completion block, tell the user that the setup is complete and the system is now initializing their workspace. Keep this message brief and friendly.
-
-Do NOT try to write files yourself. The system will automatically generate soul.md, user.md, claude.md, memory.md, config.json, and taxonomy.json from your collected answers.
-
-Start by greeting the user and asking the first question.
-`;
- } else if (needsDailyCheckIn(state)) {
- // Daily check-in: instruct AI to ask 3 quick questions
- assistantProjectInstructions = `
-You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME:
-
-1. What did you work on or accomplish today?
-2. Any changes to your current priorities or goals?
-3. Anything you'd like me to remember going forward?
-
-After collecting all 3 answers, output a summary in exactly this format:
-
-\`\`\`checkin-complete
-{"q1":"answer1","q2":"answer2","q3":"answer3"}
-\`\`\`
-
-Do NOT try to write files yourself. The system will automatically write a daily memory entry and update user.md from your collected answers.
-
-Start by greeting the user and asking the first question.
-`;
- }
-
- }
- }
- } catch (e) {
- console.warn('[chat API] Failed to load assistant workspace:', e);
- }
-
- // Append per-request system prompt (e.g. skill injection for image generation)
- let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined;
- if (systemPromptAppend) {
- finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend;
- }
-
- // Workspace prompt goes first (base personality), session prompt after (task override)
- if (workspacePrompt) {
- finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || '');
- }
-
- // Assistant project instructions go after workspace prompt
- if (assistantProjectInstructions) {
- finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions;
- }
-
- // Inject available CLI tools context (best-effort, non-blocking)
- try {
- const { buildCliToolsContext } = await import('@/lib/cli-tools-context');
- const cliToolsCtx = await buildCliToolsContext();
- if (cliToolsCtx) {
- finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + cliToolsCtx;
- }
- } catch {
- // CLI tools context injection failed — don't block chat
- }
-
- // Inject widget (generative UI) system prompt — gated by user setting (default: enabled)
- const generativeUISetting = getSetting('generative_ui_enabled');
- const generativeUIEnabled = generativeUISetting !== 'false';
- if (generativeUIEnabled) {
- try {
- const { WIDGET_SYSTEM_PROMPT } = await import('@/lib/widget-guidelines');
- finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + WIDGET_SYSTEM_PROMPT;
- } catch {
- // Widget prompt injection failed — don't block chat
- }
- }
-
// Load recent conversation history from DB as fallback context.
// This is used when SDK session resume is unavailable or fails,
// so the model still has conversation context.
@@ -352,9 +166,23 @@ Start by greeting the user and asking the first question.
content: m.content,
}));
- // Load MCP servers from Claude config files so the SDK knows about them
- // even when settingSources skips 'user' (custom provider scenario).
- const mcpServers = loadMcpServers();
+ // Unified context assembly — extracts workspace, CLI tools, widget prompt
+ const assembled = await assembleContext({
+ session,
+ entryPoint: 'desktop',
+ userPrompt: content,
+ systemPromptAppend,
+ conversationHistory: historyMsgs,
+ imageAgentMode: !!systemPromptAppend,
+ });
+ const finalSystemPrompt = assembled.systemPrompt;
+ const generativeUIEnabled = assembled.generativeUIEnabled;
+ const assistantProjectInstructions = assembled.assistantProjectInstructions;
+ const isAssistantProject = assembled.isAssistantProject;
+
+ // Load only MCP servers needing CodePilot-specific processing (${...} env placeholders).
+ // All other MCP servers are auto-loaded by the SDK via settingSources.
+ const mcpServers = loadCodePilotMcpServers();
// Stream Claude response, using SDK session ID for resume if available
console.log('[chat API] streamClaude params:', {
@@ -381,12 +209,12 @@ Start by greeting the user and asking the first question.
sessionProviderId: session.provider_id || undefined,
mcpServers,
conversationHistory: historyMsgs,
- bypassPermissions: session.permission_profile === 'full_access',
+ bypassPermissions,
thinking: thinking as ClaudeStreamOptions['thinking'],
effort: effort as ClaudeStreamOptions['effort'],
context1m: context_1m,
generativeUI: generativeUIEnabled,
- enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'),
+ enableFileCheckpointing: enableFileCheckpointing ?? true,
autoTrigger: !!autoTrigger,
onRuntimeStatusChange: (status: string) => {
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
diff --git a/src/app/api/chat/sessions/[id]/route.ts b/src/app/api/chat/sessions/[id]/route.ts
index 32295981..d9322255 100644
--- a/src/app/api/chat/sessions/[id]/route.ts
+++ b/src/app/api/chat/sessions/[id]/route.ts
@@ -41,6 +41,12 @@ export async function PATCH(
if (body.mode) {
updateSessionMode(id, body.mode);
}
+ // Track whether provider or model actually changed — if so, the old
+ // sdk_session_id is stale and must be cleared to prevent resume failures
+ // against a different provider/model (fixes #343, #346).
+ const modelChanged = body.model !== undefined && body.model !== session.model;
+ const providerChanged = body.provider_id !== undefined && body.provider_id !== session.provider_id;
+
if (body.model !== undefined) {
updateSessionModel(id, body.model);
}
@@ -50,6 +56,20 @@ export async function PATCH(
if (body.sdk_session_id !== undefined) {
updateSdkSessionId(id, body.sdk_session_id);
}
+
+ // Server-side guard: when provider or model changed and the caller didn't
+ // explicitly set sdk_session_id in the same request, force-clear it so the
+ // next chat message starts a fresh SDK session instead of trying to resume
+ // the old one (which would fail with a different provider/model).
+ if ((modelChanged || providerChanged) && body.sdk_session_id === undefined) {
+ if (session.sdk_session_id) {
+ console.log(
+ `[session-api] Provider/model changed for session ${id}, clearing stale sdk_session_id`,
+ { modelChanged, providerChanged, oldSdkSessionId: session.sdk_session_id.slice(0, 8) + '...' }
+ );
+ }
+ updateSdkSessionId(id, '');
+ }
if (body.permission_profile !== undefined) {
if (body.permission_profile !== 'default' && body.permission_profile !== 'full_access') {
return Response.json({ error: 'permission_profile must be "default" or "full_access"' }, { status: 400 });
diff --git a/src/app/api/doctor/export/route.ts b/src/app/api/doctor/export/route.ts
index a9e41bff..d1f68c72 100644
--- a/src/app/api/doctor/export/route.ts
+++ b/src/app/api/doctor/export/route.ts
@@ -1,6 +1,6 @@
import os from 'os';
import { NextResponse } from 'next/server';
-import { runDiagnosis } from '@/lib/provider-doctor';
+import { getLastDiagnosisResult, runDiagnosis, getLastLiveProbeError } from '@/lib/provider-doctor';
import { getRecentLogs } from '@/lib/runtime-log';
import { resolveProvider } from '@/lib/provider-resolver';
@@ -100,15 +100,17 @@ function sanitizeValue(value: unknown): unknown {
export async function GET() {
try {
- // Gather data in parallel
- const [diagnosis, runtimeLogs] = await Promise.all([
- runDiagnosis(),
- getRecentLogs(),
- ]);
+ // Use cached diagnosis if available (avoid re-running live probe).
+ // Only run fresh diagnosis if Doctor hasn't been opened yet.
+ const diagnosis = getLastDiagnosisResult() ?? await runDiagnosis();
+ const runtimeLogs = getRecentLogs();
// Resolve current provider chain (no raw keys thanks to sanitization)
const providerResolution = resolveProvider();
+ // Capture live probe error (if any) for debugging
+ const liveProbeError = getLastLiveProbeError();
+
// Build the export package
const exportPackage = {
diagnosis: sanitizeValue(diagnosis),
@@ -125,6 +127,15 @@ export async function GET() {
providerName: providerResolution.provider?.name,
providerType: providerResolution.provider?.provider_type,
}),
+ liveProbeError: liveProbeError ? sanitizeValue({
+ category: liveProbeError.category,
+ userMessage: liveProbeError.userMessage,
+ actionHint: liveProbeError.actionHint,
+ retryable: liveProbeError.retryable,
+ providerName: liveProbeError.providerName,
+ details: liveProbeError.details,
+ rawMessage: liveProbeError.rawMessage,
+ }) : null,
exportedAt: new Date().toISOString(),
};
diff --git a/src/app/api/doctor/route.ts b/src/app/api/doctor/route.ts
index 2c3b04f2..ceebb7b3 100644
--- a/src/app/api/doctor/route.ts
+++ b/src/app/api/doctor/route.ts
@@ -1,12 +1,31 @@
-import { NextResponse } from 'next/server';
-import { runDiagnosis } from '@/lib/provider-doctor';
+import { NextRequest, NextResponse } from 'next/server';
+import { runDiagnosis, runLiveProbe, setLastDiagnosisResult } from '@/lib/provider-doctor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-export async function GET() {
+/**
+ * GET /api/doctor — run diagnostic probes.
+ * ?live=true — also run the live probe (spawns CLI, takes up to 15s).
+ * Without ?live, only runs fast static probes (~1s).
+ */
+export async function GET(request: NextRequest) {
try {
const result = await runDiagnosis();
+
+ // Live probe is opt-in to avoid blocking the Doctor UI
+ const wantLive = request.nextUrl.searchParams.get('live') === 'true';
+ if (wantLive) {
+ const liveResult = await runLiveProbe();
+ result.probes.push(liveResult);
+ // Recalculate overall severity
+ if (liveResult.severity === 'error') result.overallSeverity = 'error';
+ else if (liveResult.severity === 'warn' && result.overallSeverity === 'ok') result.overallSeverity = 'warn';
+ result.durationMs = Date.now() - new Date(result.timestamp).getTime();
+ // Update cache so export includes the live probe result
+ setLastDiagnosisResult(result);
+ }
+
return NextResponse.json(result);
} catch (error) {
console.error('[doctor] Diagnosis failed:', error);
diff --git a/src/app/api/files/raw/route.ts b/src/app/api/files/raw/route.ts
index 33dd3aeb..d9ed3b42 100644
--- a/src/app/api/files/raw/route.ts
+++ b/src/app/api/files/raw/route.ts
@@ -1,10 +1,16 @@
import { NextRequest } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
+import os from 'os';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
+function isPathSafe(base: string, target: string): boolean {
+ const normalizedBase = base.endsWith(path.sep) ? base : base + path.sep;
+ return target === base || target.startsWith(normalizedBase);
+}
+
const MIME_TYPES: Record = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
@@ -79,8 +85,8 @@ const MIME_TYPES: Record = {
};
/**
- * Serve raw file content from the user's home directory.
- * Security: only allows reading files within the user's home directory.
+ * Serve raw file content.
+ * Security: restricts to user's home directory to prevent arbitrary file reads.
*/
export async function GET(request: NextRequest) {
const filePath = request.nextUrl.searchParams.get('path');
@@ -93,6 +99,15 @@ export async function GET(request: NextRequest) {
}
const resolved = path.resolve(filePath);
+ const homeDir = os.homedir();
+
+ // Only allow reading files within the user's home directory
+ if (!isPathSafe(homeDir, resolved)) {
+ return new Response(JSON.stringify({ error: 'File is outside the allowed scope' }), {
+ status: 403,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
try {
await fs.access(resolved);
diff --git a/src/app/api/plugins/mcp/[name]/route.ts b/src/app/api/plugins/mcp/[name]/route.ts
index 4cb53cd9..70e26c62 100644
--- a/src/app/api/plugins/mcp/[name]/route.ts
+++ b/src/app/api/plugins/mcp/[name]/route.ts
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types';
+import { invalidateMcpCache } from '@/lib/mcp-loader';
function getSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
@@ -67,6 +68,7 @@ export async function DELETE(
);
}
+ invalidateMcpCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
diff --git a/src/app/api/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts
index acdd48aa..4560d971 100644
--- a/src/app/api/plugins/mcp/route.ts
+++ b/src/app/api/plugins/mcp/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
+import { invalidateMcpCache } from '@/lib/mcp-loader';
import type {
MCPServerConfig,
MCPConfigResponse,
@@ -47,15 +48,32 @@ export async function GET(): Promise;
const userConfigServers = (userConfig.mcpServers || {}) as Record;
- // Merge: ~/.claude/settings.json takes precedence over ~/.claude.json
+ // Also read project-level .mcp.json so the UI can display and toggle project servers
+ const projectMcp = readJsonFile(path.join(process.cwd(), '.mcp.json'));
+ const projectServers = (projectMcp.mcpServers || {}) as Record;
+
+ // Merge: settings.json > claude.json > project .mcp.json
// Tag each server with _source so UI knows where it came from
const mcpServers: Record = {};
+ for (const [name, server] of Object.entries(projectServers)) {
+ mcpServers[name] = { ...server, _source: 'project' };
+ }
for (const [name, server] of Object.entries(userConfigServers)) {
mcpServers[name] = { ...server, _source: 'claude.json' };
}
for (const [name, server] of Object.entries(settingsServers)) {
mcpServers[name] = { ...server, _source: 'settings.json' };
}
+
+ // For project-source servers, check if settings.json has an enabled override
+ // (project .mcp.json is read-only; we persist enabled state to settings.json)
+ const settingsOverrides = (settings.mcpServerOverrides || {}) as Record;
+ for (const [name, server] of Object.entries(mcpServers)) {
+ if (server._source === 'project' && settingsOverrides[name]?.enabled !== undefined) {
+ mcpServers[name] = { ...server, enabled: settingsOverrides[name].enabled };
+ }
+ }
+
return NextResponse.json({ mcpServers });
} catch (error) {
return NextResponse.json(
@@ -77,10 +95,17 @@ export async function PUT(
// Servers with _source='claude.json' → ~/.claude.json
const forSettings: Record = {};
const forUserConfig: Record = {};
+ let forProjectOverrides: Record | undefined;
for (const [name, server] of Object.entries(incoming)) {
const { _source, ...cleanServer } = server;
- if (_source === 'claude.json') {
+ if (_source === 'project') {
+ // Project servers are read-only — only persist enabled override to settings.json
+ if (cleanServer.enabled !== undefined) {
+ if (!forProjectOverrides) forProjectOverrides = {};
+ forProjectOverrides[name] = { enabled: cleanServer.enabled };
+ }
+ } else if (_source === 'claude.json') {
forUserConfig[name] = cleanServer;
} else {
forSettings[name] = cleanServer;
@@ -90,6 +115,9 @@ export async function PUT(
// Write settings.json
const settings = readSettings();
settings.mcpServers = forSettings;
+ if (forProjectOverrides) {
+ settings.mcpServerOverrides = forProjectOverrides;
+ }
writeSettings(settings);
// Write ~/.claude.json (only the mcpServers key, preserve other fields)
@@ -102,6 +130,7 @@ export async function PUT(
}
fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8');
+ invalidateMcpCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
@@ -148,6 +177,7 @@ export async function POST(
mcpServers[name] = server;
writeSettings(settings);
+ invalidateMcpCache();
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts
index bad8de18..740d97e3 100644
--- a/src/app/api/providers/models/route.ts
+++ b/src/app/api/providers/models/route.ts
@@ -151,7 +151,7 @@ export async function GET() {
}
// Add each role model to the list (default role first, so it appears at the top)
for (const entry of roleEntries) {
- if (!rawModels.some(m => m.value === entry.id)) {
+ if (!rawModels.some(m => m.value === entry.id || m.upstreamModelId === entry.id)) {
const label = entry.role === 'default' ? entry.id : `${entry.id} (${entry.role})`;
rawModels.unshift({ value: entry.id, label });
}
@@ -159,10 +159,12 @@ export async function GET() {
} catch { /* ignore */ }
// Legacy: inject ANTHROPIC_MODEL from env overrides if not already present
+ // Also check upstreamModelId to avoid duplicates (e.g. catalog has modelId='sonnet'
+ // with upstreamModelId='mimo-v2-pro', and env has ANTHROPIC_MODEL='mimo-v2-pro')
try {
const envOverrides = provider.env_overrides_json || provider.extra_env || '{}';
const envObj = JSON.parse(envOverrides);
- if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL)) {
+ if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL || m.upstreamModelId === envObj.ANTHROPIC_MODEL)) {
rawModels.unshift({ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL });
}
} catch { /* ignore */ }
@@ -176,7 +178,7 @@ export async function GET() {
});
// Detect SDK-proxy-only providers via preset match
- const preset = findPresetForLegacy(provider.base_url, provider.provider_type);
+ const preset = findPresetForLegacy(provider.base_url, provider.provider_type, protocol);
const sdkProxyOnly = preset?.sdkProxyOnly === true;
groups.push({
diff --git a/src/app/api/settings/weixin/accounts/[accountId]/route.ts b/src/app/api/settings/weixin/accounts/[accountId]/route.ts
new file mode 100644
index 00000000..7b841360
--- /dev/null
+++ b/src/app/api/settings/weixin/accounts/[accountId]/route.ts
@@ -0,0 +1,95 @@
+/**
+ * Single WeChat account operations.
+ * PATCH — enable/disable account
+ * DELETE — remove account
+ */
+
+import { NextResponse } from 'next/server';
+import { getWeixinAccount, setWeixinAccountEnabled, deleteWeixinAccount } from '@/lib/db';
+import { getStatus, restart } from '@/lib/bridge/bridge-manager';
+
+function isBenignRestartReason(reason: string): boolean {
+ if (reason === 'no_channels_enabled') {
+ return true;
+ }
+ if (!reason.startsWith('adapter_config_invalid: ')) {
+ return false;
+ }
+ const errors = reason
+ .slice('adapter_config_invalid: '.length)
+ .split('; ')
+ .map(item => item.trim())
+ .filter(Boolean);
+ return errors.length > 0 && errors.every(error =>
+ error.startsWith('weixin: No enabled WeChat accounts')
+ || error.startsWith('weixin: No WeChat accounts have valid tokens')
+ );
+}
+
+/** Restart bridge if running so worker pool reflects account changes. */
+async function restartIfRunning(): Promise {
+ if (!getStatus().running) {
+ return null;
+ }
+ try {
+ const result = await restart();
+ if (result.started || !result.reason || isBenignRestartReason(result.reason)) {
+ return null;
+ }
+ return result.reason;
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+}
+
+export async function PATCH(
+ request: Request,
+ { params }: { params: Promise<{ accountId: string }> },
+) {
+ try {
+ const { accountId } = await params;
+ const account = getWeixinAccount(accountId);
+ if (!account) {
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 });
+ }
+
+ const body = await request.json();
+ if (typeof body.enabled === 'boolean') {
+ setWeixinAccountEnabled(accountId, body.enabled);
+ const restartError = await restartIfRunning();
+ if (restartError) {
+ return NextResponse.json(
+ { ok: false, error: restartError, account_updated: true },
+ { status: 503 },
+ );
+ }
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to update account' }, { status: 500 });
+ }
+}
+
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ accountId: string }> },
+) {
+ try {
+ const { accountId } = await params;
+ const deleted = deleteWeixinAccount(accountId);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 });
+ }
+ const restartError = await restartIfRunning();
+ if (restartError) {
+ return NextResponse.json(
+ { ok: false, error: restartError, account_deleted: true },
+ { status: 503 },
+ );
+ }
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/settings/weixin/accounts/route.ts b/src/app/api/settings/weixin/accounts/route.ts
new file mode 100644
index 00000000..1b7b258d
--- /dev/null
+++ b/src/app/api/settings/weixin/accounts/route.ts
@@ -0,0 +1,28 @@
+/**
+ * WeChat accounts list API.
+ * GET — returns all accounts (token masked)
+ */
+
+import { NextResponse } from 'next/server';
+import { listWeixinAccounts } from '@/lib/db';
+
+export async function GET() {
+ try {
+ const accounts = listWeixinAccounts().map(a => ({
+ account_id: a.account_id,
+ user_id: a.user_id,
+ base_url: a.base_url,
+ cdn_base_url: a.cdn_base_url,
+ name: a.name,
+ enabled: a.enabled === 1,
+ last_login_at: a.last_login_at,
+ created_at: a.created_at,
+ updated_at: a.updated_at,
+ // Mask token for security
+ has_token: !!(a.token && a.token.length > 0),
+ }));
+ return NextResponse.json({ accounts });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to list accounts' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/settings/weixin/login/start/route.ts b/src/app/api/settings/weixin/login/start/route.ts
new file mode 100644
index 00000000..fd5c3b27
--- /dev/null
+++ b/src/app/api/settings/weixin/login/start/route.ts
@@ -0,0 +1,19 @@
+/**
+ * Start WeChat QR code login.
+ * POST — generates a QR code for scanning
+ */
+
+import { NextResponse } from 'next/server';
+import { startQrLoginSession } from '@/lib/bridge/adapters/weixin/weixin-auth';
+
+export async function POST() {
+ try {
+ const { sessionId, qrImage } = await startQrLoginSession();
+ return NextResponse.json({ session_id: sessionId, qr_image: qrImage });
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Failed to start QR login' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/settings/weixin/login/wait/route.ts b/src/app/api/settings/weixin/login/wait/route.ts
new file mode 100644
index 00000000..ec758499
--- /dev/null
+++ b/src/app/api/settings/weixin/login/wait/route.ts
@@ -0,0 +1,58 @@
+/**
+ * Poll WeChat QR login status.
+ * POST { session_id } — polls the QR code status
+ */
+
+import { NextResponse } from 'next/server';
+import { pollQrLoginStatus, cancelQrLoginSession } from '@/lib/bridge/adapters/weixin/weixin-auth';
+import { getStatus, restart } from '@/lib/bridge/bridge-manager';
+
+async function restartIfRunning(): Promise {
+ if (!getStatus().running) {
+ return null;
+ }
+ try {
+ const result = await restart();
+ return result.started ? null : (result.reason || 'Bridge restart failed');
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const sessionId = body.session_id;
+ if (!sessionId) {
+ return NextResponse.json({ error: 'Missing session_id' }, { status: 400 });
+ }
+
+ const session = await pollQrLoginStatus(sessionId);
+
+ // Clean up completed sessions
+ if (session.status === 'confirmed' || session.status === 'failed') {
+ // Don't delete immediately — let the client read the final status
+ setTimeout(() => cancelQrLoginSession(sessionId), 30_000);
+ }
+
+ // Auto-restart bridge when a new account is confirmed so the weixin
+ // adapter picks it up without manual stop/start.
+ let bridgeRestartError: string | undefined;
+ if (session.status === 'confirmed') {
+ bridgeRestartError = (await restartIfRunning()) || undefined;
+ }
+
+ return NextResponse.json({
+ status: session.status,
+ qr_image: session.qrImage || undefined,
+ account_id: session.accountId || undefined,
+ error: session.error || undefined,
+ bridge_restart_error: bridgeRestartError,
+ });
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Failed to poll login status' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/settings/weixin/route.ts b/src/app/api/settings/weixin/route.ts
new file mode 100644
index 00000000..32f4b590
--- /dev/null
+++ b/src/app/api/settings/weixin/route.ts
@@ -0,0 +1,45 @@
+/**
+ * WeChat global settings API.
+ * GET — returns current settings
+ * PUT — updates settings
+ */
+
+import { NextResponse } from 'next/server';
+import { getSetting, setSetting } from '@/lib/db';
+
+const WEIXIN_KEYS = [
+ 'bridge_weixin_enabled',
+ 'bridge_weixin_media_enabled',
+] as const;
+
+export async function GET() {
+ try {
+ const settings: Record = {};
+ for (const key of WEIXIN_KEYS) {
+ settings[key] = getSetting(key) || '';
+ }
+ return NextResponse.json({ settings });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 });
+ }
+}
+
+export async function PUT(request: Request) {
+ try {
+ const body = await request.json();
+ const { settings } = body as { settings?: Record };
+ if (!settings) {
+ return NextResponse.json({ error: 'Missing settings' }, { status: 400 });
+ }
+
+ for (const key of WEIXIN_KEYS) {
+ if (key in settings) {
+ setSetting(key, settings[key]);
+ }
+ }
+
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts
index ef9a50a1..40e2c8ce 100644
--- a/src/app/api/setup/route.ts
+++ b/src/app/api/setup/route.ts
@@ -34,9 +34,23 @@ export async function GET() {
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || appToken) {
provider = 'completed';
} else {
- // No real provider found — check if user previously skipped setup
- const providerSkipped = getSetting('setup_provider_skipped');
- provider = providerSkipped === 'true' ? 'skipped' : 'not-configured';
+ // Check if Claude Code CLI is available — it acts as a provider via SDK proxy
+ // (the built-in 'env' provider in /api/providers/models always lists Claude Code,
+ // so we must recognise the CLI as a valid provider to keep the UI consistent)
+ try {
+ const binary = findClaudeBinary();
+ if (binary) {
+ provider = 'completed';
+ }
+ } catch {
+ // CLI not found — continue to fallback
+ }
+
+ if (provider === 'not-configured') {
+ // No real provider found — check if user previously skipped setup
+ const providerSkipped = getSetting('setup_provider_skipped');
+ provider = providerSkipped === 'true' ? 'skipped' : 'not-configured';
+ }
}
}
diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx
index a14c3f04..14b4551e 100644
--- a/src/app/chat/[id]/page.tsx
+++ b/src/app/chat/[id]/page.tsx
@@ -20,9 +20,9 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const [error, setError] = useState(null);
const [sessionModel, setSessionModel] = useState('');
const [sessionProviderId, setSessionProviderId] = useState('');
- const [sessionMode, setSessionMode] = useState('');
const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false);
const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default');
+ const [sessionMode, setSessionMode] = useState<'code' | 'plan'>('code');
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel();
const { t } = useTranslation();
@@ -33,7 +33,6 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
setWorkingDirectory('');
setSessionModel('');
setSessionProviderId('');
- setSessionMode('');
setSessionInfoLoaded(false);
async function loadSession() {
@@ -53,8 +52,8 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
setPanelSessionTitle(title);
setSessionModel(data.session.model || '');
setSessionProviderId(data.session.provider_id || '');
- setSessionMode(data.session.mode || 'code');
setSessionPermissionProfile(data.session.permission_profile || 'default');
+ setSessionMode((data.session.mode as 'code' | 'plan') || 'code');
}
} catch {
// Session info load failed - panel will still work without directory
@@ -127,7 +126,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
return (
-
+
);
}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
index c4bc1abf..9163f4ac 100644
--- a/src/app/chat/page.tsx
+++ b/src/app/chat/page.tsx
@@ -6,6 +6,7 @@ import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestE
import { MessageList } from '@/components/chat/MessageList';
import { MessageInput } from '@/components/chat/MessageInput';
import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar';
+import { ModeIndicator } from '@/components/chat/ModeIndicator';
import { ChatPermissionSelector } from '@/components/chat/ChatPermissionSelector';
import { ImageGenToggle } from '@/components/chat/ImageGenToggle';
import { PermissionPrompt } from '@/components/chat/PermissionPrompt';
@@ -43,25 +44,27 @@ export default function NewChatPage() {
const [errorBanner, setErrorBanner] = useState<{ message: string; description?: string } | null>(null);
const [recentProjects, setRecentProjects] = useState([]);
const [hasProvider, setHasProvider] = useState(true); // assume true until checked
- const [mode] = useState('code');
+ const [mode, setMode] = useState('code');
+ // Model/provider start empty — populated by the async global-default fetch.
+ // This prevents the race where a user sends before the fetch completes and
+ // gets the stale localStorage model instead of the configured default.
+ const [modelReady, setModelReady] = useState(false);
const [currentModel, setCurrentModel] = useState(() => {
- if (typeof window === 'undefined') return 'sonnet';
+ if (typeof window === 'undefined') return '';
// One-time migration: clear stale model/provider from pre-0.38 installs
if (!localStorage.getItem('codepilot:migration-038')) {
localStorage.removeItem('codepilot:last-model');
localStorage.removeItem('codepilot:last-provider-id');
localStorage.setItem('codepilot:migration-038', '1');
- return 'sonnet';
}
- return localStorage.getItem('codepilot:last-model') || 'sonnet';
+ return '';
});
const [currentProviderId, setCurrentProviderId] = useState(() => {
if (typeof window === 'undefined') return '';
- // Migration already ran above (or was already done), just read
if (!localStorage.getItem('codepilot:migration-038')) {
return '';
}
- return localStorage.getItem('codepilot:last-provider-id') || '';
+ return '';
});
const [pendingPermission, setPendingPermission] = useState(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
@@ -91,34 +94,87 @@ export default function NewChatPage() {
return () => controller.abort();
}, [currentProviderId]);
- // Validate restored model/provider against actual available providers/models
+ // Validate restored model/provider against actual available providers/models.
+ // For NEW conversations, the global default model takes priority
+ // over localStorage's last-model (which is a cross-session global memory).
useEffect(() => {
let cancelled = false;
- fetch('/api/providers/models')
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (cancelled || !data?.groups || data.groups.length === 0) return;
- const groups = data.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>;
-
- // Validate provider
- const validProvider = groups.find(g => g.provider_id === currentProviderId);
- if (currentProviderId && !validProvider) {
- setCurrentProviderId('');
- localStorage.removeItem('codepilot:last-provider-id');
+
+ // Fetch models and global default in parallel
+ const modelsP = fetch('/api/providers/models').then(r => r.ok ? r.json() : null);
+ const globalP = fetch('/api/providers/options?providerId=__global__').then(r => r.ok ? r.json() : null);
+
+ Promise.all([modelsP, globalP]).then(([modelsData, globalData]) => {
+ if (cancelled || !modelsData?.groups || modelsData.groups.length === 0) {
+ // No provider data — fall back to localStorage best-effort
+ const savedModel = localStorage.getItem('codepilot:last-model') || 'sonnet';
+ const savedProvider = localStorage.getItem('codepilot:last-provider-id') || '';
+ setCurrentModel(savedModel);
+ setCurrentProviderId(savedProvider);
+ setModelReady(true);
+ return;
+ }
+ const groups = modelsData.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>;
+ const globalDefaultModel = globalData?.options?.default_model || '';
+ const globalDefaultProvider = globalData?.options?.default_model_provider || '';
+
+ // Apply global default for new conversations
+ // Case 1: both provider and model are set and valid
+ if (globalDefaultModel && globalDefaultProvider) {
+ const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider);
+ const modelValid = targetGroup?.models.some(m => m.value === globalDefaultModel);
+ if (modelValid) {
+ setCurrentModel(globalDefaultModel);
+ setCurrentProviderId(globalDefaultProvider);
+ setModelReady(true);
+ return;
}
+ }
+ // Case 2: provider is set but model was cleared (e.g. after doctor repair / provider delete)
+ // → use that provider's first available model
+ if (globalDefaultProvider && !globalDefaultModel) {
+ const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider);
+ if (targetGroup?.models?.length) {
+ setCurrentModel(targetGroup.models[0].value);
+ setCurrentProviderId(globalDefaultProvider);
+ setModelReady(true);
+ return;
+ }
+ }
- // Validate model against the resolved provider's model list
- const resolvedGroup = validProvider || groups[0];
- if (resolvedGroup?.models && resolvedGroup.models.length > 0) {
- const validModel = resolvedGroup.models.find(m => m.value === currentModel);
- if (!validModel) {
- const fallback = resolvedGroup.models[0].value;
- setCurrentModel(fallback);
- localStorage.setItem('codepilot:last-model', fallback);
- }
+ // No global default — use localStorage, validate against provider's list
+ const savedProvider = localStorage.getItem('codepilot:last-provider-id') || '';
+ const savedModel = localStorage.getItem('codepilot:last-model') || '';
+ const validProvider = groups.find(g => g.provider_id === savedProvider);
+ const resolvedGroup = validProvider || groups[0];
+ const resolvedPid = resolvedGroup?.provider_id || '';
+
+ if (validProvider) {
+ setCurrentProviderId(savedProvider);
+ } else {
+ setCurrentProviderId(resolvedPid);
+ }
+
+ if (resolvedGroup?.models && resolvedGroup.models.length > 0) {
+ const validModel = savedModel && resolvedGroup.models.some(m => m.value === savedModel);
+ if (validModel) {
+ setCurrentModel(savedModel);
+ } else {
+ setCurrentModel(resolvedGroup.models[0].value);
}
- })
- .catch(() => {});
+ } else {
+ setCurrentModel(savedModel || 'sonnet');
+ }
+ setModelReady(true);
+ }).catch(() => {
+ // Fetch failed — fall back to localStorage best-effort
+ const savedModel = localStorage.getItem('codepilot:last-model') || 'sonnet';
+ const savedProvider = localStorage.getItem('codepilot:last-provider-id') || '';
+ setCurrentModel(savedModel);
+ setCurrentProviderId(savedProvider);
+ setModelReady(true);
+ });
+
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run once on mount to validate initial values
@@ -188,6 +244,8 @@ export default function NewChatPage() {
// Check provider availability — only 'completed' counts, 'skipped' means user deferred but has no real credentials
useEffect(() => {
const checkProvider = () => {
+ // Lock sending while we re-resolve the model/provider
+ setModelReady(false);
fetch('/api/setup')
.then(r => r.ok ? r.json() : null)
.then(data => {
@@ -196,47 +254,84 @@ export default function NewChatPage() {
}
})
.catch(() => {});
- // Sync provider/model from localStorage, validating against available providers
+ // Sync provider/model, applying global default model for new conversations.
const savedProviderId = localStorage.getItem('codepilot:last-provider-id');
- const savedModel = localStorage.getItem('codepilot:last-model');
- fetch('/api/providers/models')
- .then(r => r.ok ? r.json() : null)
- .then(data => {
- if (!data?.groups || data.groups.length === 0) return;
- const groups = data.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>;
-
- // Validate and apply provider
- if (savedProviderId !== null) {
- const validProvider = groups.find(g => g.provider_id === savedProviderId);
- if (validProvider) {
- setCurrentProviderId(savedProviderId);
- } else {
- setCurrentProviderId('');
- localStorage.removeItem('codepilot:last-provider-id');
- }
+
+ // Fetch models + global default in parallel
+ const modelsP = fetch('/api/providers/models').then(r => r.ok ? r.json() : null);
+ const globalP = fetch('/api/providers/options?providerId=__global__').then(r => r.ok ? r.json() : null);
+
+ Promise.all([modelsP, globalP]).then(([modelsData, globalData]) => {
+ if (!modelsData?.groups || modelsData.groups.length === 0) {
+ setModelReady(true);
+ return;
+ }
+ const groups = modelsData.groups as Array<{ provider_id: string; models: Array<{ value: string }> }>;
+ const globalDefaultModel = globalData?.options?.default_model || '';
+ const globalDefaultProvider = globalData?.options?.default_model_provider || '';
+
+ // Validate and apply provider
+ if (savedProviderId !== null) {
+ const validProvider = groups.find(g => g.provider_id === savedProviderId);
+ if (validProvider) {
+ setCurrentProviderId(savedProviderId);
+ } else {
+ setCurrentProviderId('');
+ localStorage.removeItem('codepilot:last-provider-id');
}
+ }
- // Validate and apply model
- const resolvedPid = savedProviderId && groups.find(g => g.provider_id === savedProviderId)
- ? savedProviderId
- : groups[0]?.provider_id || '';
- const resolvedGroup = groups.find(g => g.provider_id === resolvedPid) || groups[0];
- if (savedModel && resolvedGroup?.models?.length > 0) {
- const validModel = resolvedGroup.models.find((m: { value: string }) => m.value === savedModel);
- if (validModel) {
- setCurrentModel(savedModel);
- } else {
- const fallback = resolvedGroup.models[0].value;
- setCurrentModel(fallback);
- localStorage.setItem('codepilot:last-model', fallback);
- }
+ // Apply global default for new conversations
+ // Case 1: both provider and model are set and valid
+ if (globalDefaultModel && globalDefaultProvider) {
+ const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider);
+ const modelValid = targetGroup?.models.some(m => m.value === globalDefaultModel);
+ if (modelValid) {
+ setCurrentModel(globalDefaultModel);
+ setCurrentProviderId(globalDefaultProvider);
+ setModelReady(true);
+ return;
}
- })
- .catch(() => {
- // On fetch failure, still apply localStorage values as-is (best effort)
- if (savedProviderId !== null) setCurrentProviderId(savedProviderId);
- if (savedModel) setCurrentModel(savedModel);
- });
+ }
+ // Case 2: provider is set but model was cleared (e.g. after doctor repair / provider delete)
+ // → use that provider's first available model
+ if (globalDefaultProvider && !globalDefaultModel) {
+ const targetGroup = groups.find(g => g.provider_id === globalDefaultProvider);
+ if (targetGroup?.models?.length) {
+ setCurrentModel(targetGroup.models[0].value);
+ setCurrentProviderId(globalDefaultProvider);
+ setModelReady(true);
+ return;
+ }
+ }
+
+ // No global default — validate current model
+ const resolvedPid = savedProviderId && groups.find(g => g.provider_id === savedProviderId)
+ ? savedProviderId
+ : groups[0]?.provider_id || '';
+ const resolvedGroup = groups.find(g => g.provider_id === resolvedPid) || groups[0];
+ setCurrentProviderId(resolvedPid);
+ if (resolvedGroup?.models?.length > 0) {
+ const savedModel = localStorage.getItem('codepilot:last-model');
+ const validModel = savedModel && resolvedGroup.models.some(
+ (m: { value: string }) => m.value === savedModel
+ );
+ if (validModel) {
+ setCurrentModel(savedModel);
+ } else {
+ const fallback = resolvedGroup.models[0].value;
+ setCurrentModel(fallback);
+ localStorage.setItem('codepilot:last-model', fallback);
+ }
+ }
+ setModelReady(true);
+ }).catch(() => {
+ // On fetch failure, still apply localStorage values as-is (best effort)
+ if (savedProviderId !== null) setCurrentProviderId(savedProviderId);
+ const savedModel = localStorage.getItem('codepilot:last-model');
+ if (savedModel) setCurrentModel(savedModel);
+ setModelReady(true);
+ });
};
checkProvider();
@@ -311,6 +406,9 @@ export default function NewChatPage() {
async (content: string, _files?: unknown, systemPromptAppend?: string, displayOverride?: string) => {
if (isStreaming) return;
+ // Wait for model/provider to be resolved from the global default before allowing send
+ if (!modelReady) return;
+
// Require a project directory before sending
if (!workingDir.trim()) {
setErrorBanner({ message: t('chat.empty.noDirectory') });
@@ -521,7 +619,7 @@ export default function NewChatPage() {
'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE',
]);
if (diagCategories.has(parsed.category)) {
- errorDisplay += '\n\n💡 Go to **Settings → Providers → Run Diagnostics** for detailed troubleshooting.';
+ errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/docs/providers).';
}
} else {
errorDisplay = event.data;
@@ -580,7 +678,7 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
- [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider]
+ [isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, context1m, setPendingApprovalSessionId, t, hasProvider, modelReady]
);
const handleCommand = useCallback((command: string) => {
@@ -660,7 +758,7 @@ export default function NewChatPage() {
onSend={sendFirstMessage}
onCommand={handleCommand}
onStop={stopStreaming}
- disabled={false}
+ disabled={!modelReady}
isStreaming={isStreaming}
modelName={currentModel}
onModelChange={setCurrentModel}
@@ -676,7 +774,7 @@ export default function NewChatPage() {
onEffortChange={setSelectedEffort}
/>
}
+ left={<>>}
center={
{
@@ -98,6 +101,7 @@ export function BridgeLayout() {
{activeSection === "feishu" && }
{activeSection === "discord" && }
{activeSection === "qq" && }
+ {activeSection === "weixin" && }
diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx
index d57815f9..a864b9b6 100644
--- a/src/components/bridge/BridgeSection.tsx
+++ b/src/components/bridge/BridgeSection.tsx
@@ -16,6 +16,7 @@ import {
import { SpinnerGap, CheckCircle, Warning, TelegramLogo, ChatTeardrop, GameController, ChatsCircle } from "@/components/ui/icon";
import { useTranslation } from "@/hooks/useTranslation";
import { useBridgeStatus } from "@/hooks/useBridgeStatus";
+import { showToast } from "@/hooks/useToast";
import { SettingsCard } from "@/components/patterns/SettingsCard";
import { FieldRow } from "@/components/patterns/FieldRow";
import { StatusBanner } from "@/components/patterns/StatusBanner";
@@ -27,6 +28,7 @@ interface BridgeSettings {
bridge_feishu_enabled: string;
bridge_discord_enabled: string;
bridge_qq_enabled: string;
+ bridge_weixin_enabled: string;
bridge_auto_start: string;
bridge_default_work_dir: string;
bridge_default_model: string;
@@ -39,6 +41,7 @@ const DEFAULT_SETTINGS: BridgeSettings = {
bridge_feishu_enabled: "",
bridge_discord_enabled: "",
bridge_qq_enabled: "",
+ bridge_weixin_enabled: "",
bridge_auto_start: "",
bridge_default_work_dir: "",
bridge_default_model: "",
@@ -133,6 +136,10 @@ export function BridgeSection() {
saveSettings({ bridge_qq_enabled: checked ? "true" : "" });
};
+ const handleToggleWeixin = (checked: boolean) => {
+ saveSettings({ bridge_weixin_enabled: checked ? "true" : "" });
+ };
+
const handleSaveDefaults = () => {
// Split composite "provider_id::model" value
const parts = model.split("::");
@@ -168,11 +175,28 @@ export function BridgeSection() {
saveSettings({ bridge_auto_start: checked ? "true" : "" });
};
+ const handleStartBridge = async () => {
+ const reason = await startBridge();
+ if (reason) {
+ const reasonMessages: Record = {
+ bridge_not_enabled: t("bridge.errorNotEnabled"),
+ no_channels_enabled: t("bridge.errorNoChannels"),
+ no_adapters_started: t("bridge.errorNoAdapters"),
+ network_error: t("bridge.errorNetwork"),
+ };
+ const message = reason.startsWith("adapter_config_invalid:")
+ ? t("bridge.errorAdapterConfig")
+ : reasonMessages[reason] ?? reason;
+ showToast({ type: "error", message });
+ }
+ };
+
const isEnabled = settings.remote_bridge_enabled === "true";
const isTelegramEnabled = settings.bridge_telegram_enabled === "true";
const isFeishuEnabled = settings.bridge_feishu_enabled === "true";
const isDiscordEnabled = settings.bridge_discord_enabled === "true";
const isQQEnabled = settings.bridge_qq_enabled === "true";
+ const isWeixinEnabled = settings.bridge_weixin_enabled === "true";
const isAutoStart = settings.bridge_auto_start === "true";
const isRunning = bridgeStatus?.running ?? false;
const adapterCount = bridgeStatus?.adapters?.length ?? 0;
@@ -242,7 +266,7 @@ export function BridgeSection() {
) : (