+
{/* ExitPlanMode */}
- {pendingPermission?.toolName === 'ExitPlanMode' && !permissionResolved && (
+ {pendingPermission?.toolName === 'ExitPlanMode' && !isResolved && (
}
toolUses={toolUses}
@@ -383,36 +446,32 @@ export function PermissionPrompt({
)}
{/* AskUserQuestion */}
- {pendingPermission?.toolName === 'AskUserQuestion' && !permissionResolved && (
+ {pendingPermission?.toolName === 'AskUserQuestion' && !isResolved && (
}
onSubmit={(decision, updatedInput) => onPermissionResponse(decision, updatedInput)}
/>
)}
- {pendingPermission?.toolName === 'AskUserQuestion' && permissionResolved && (
+ {pendingPermission?.toolName === 'AskUserQuestion' && isResolved && (
Answer submitted
)}
- {/* Generic confirmation for other tools */}
- {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && (pendingPermission || permissionResolved) && (
+ {/* Generic confirmation for other tools — only show when not yet resolved */}
+ {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && pendingPermission && !isResolved && (
- {pendingPermission?.toolName}
- {pendingPermission?.decisionReason && (
+ {pendingPermission.toolName}
+ {pendingPermission.decisionReason && (
— {pendingPermission.decisionReason}
)}
- {pendingPermission && (
-
- {formatToolInput(pendingPermission.toolInput)}
-
- )}
+
@@ -428,7 +487,7 @@ export function PermissionPrompt({
>
Allow Once
- {pendingPermission?.suggestions && pendingPermission.suggestions.length > 0 && (
+ {pendingPermission.suggestions && pendingPermission.suggestions.length > 0 && (
onPermissionResponse('allow_session')}
@@ -448,6 +507,16 @@ export function PermissionPrompt({
)}
+
+ {/* Resolved status for generic tools — minimal one-liner */}
+ {pendingPermission?.toolName !== 'AskUserQuestion' && pendingPermission?.toolName !== 'ExitPlanMode' && isResolved && (
+
+ {permissionResolved === 'allow' ? t('streaming.allowed') : t('streaming.denied')}
+
+ )}
);
}
diff --git a/src/components/layout/ProjectGroupHeader.tsx b/src/components/layout/ProjectGroupHeader.tsx
index baedd09e..45c614cf 100644
--- a/src/components/layout/ProjectGroupHeader.tsx
+++ b/src/components/layout/ProjectGroupHeader.tsx
@@ -16,7 +16,6 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslation } from '@/hooks/useTranslation';
-import { useClientPlatform } from '@/hooks/useClientPlatform';
interface ProjectGroupHeaderProps {
workingDirectory: string;
@@ -42,7 +41,6 @@ export function ProjectGroupHeader({
onCreateSession,
}: ProjectGroupHeaderProps) {
const { t } = useTranslation();
- const { fileManagerName } = useClientPlatform();
return (
@@ -53,20 +51,6 @@ export function ProjectGroupHeader({
"hover:bg-accent/50"
)}
onClick={onToggle}
- onDoubleClick={(e) => {
- e.stopPropagation();
- if (workingDirectory) {
- if (window.electronAPI?.shell?.openPath) {
- window.electronAPI.shell.openPath(workingDirectory);
- } else {
- fetch('/api/files/open', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: workingDirectory }),
- }).catch(() => {});
- }
- }
- }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@@ -115,7 +99,6 @@ export function ProjectGroupHeader({
{workingDirectory || t('chatList.noSessions')}
- {workingDirectory && {t('platform.openInFileManager', { fileManager: fileManagerName })}
}
);
diff --git a/src/components/settings/AssistantWorkspaceSection.tsx b/src/components/settings/AssistantWorkspaceSection.tsx
index 6378f165..2c6d3cab 100644
--- a/src/components/settings/AssistantWorkspaceSection.tsx
+++ b/src/components/settings/AssistantWorkspaceSection.tsx
@@ -436,7 +436,7 @@ export function AssistantWorkspaceSection() {
lastCheckInDate={workspace.state?.lastCheckInDate ?? null}
checkInDoneToday={checkInDoneToday}
creatingSession={creatingSession}
- autoTriggerEnabled={workspace.state?.dailyCheckInEnabled !== false}
+ autoTriggerEnabled={workspace.state?.dailyCheckInEnabled === true}
onStartCheckIn={handleStartCheckIn}
onAutoTriggerChange={async (enabled) => {
try {
diff --git a/src/hooks/useAssistantTrigger.ts b/src/hooks/useAssistantTrigger.ts
index c2522d52..9d3df9fc 100644
--- a/src/hooks/useAssistantTrigger.ts
+++ b/src/hooks/useAssistantTrigger.ts
@@ -166,7 +166,7 @@ export function useAssistantTrigger({
const today = getLocalDateString();
const needsOnboarding = !state.onboardingComplete;
- const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
+ const needsCheckIn = state.onboardingComplete && state.dailyCheckInEnabled === true && state.lastCheckInDate !== today;
if (!needsOnboarding && !needsCheckIn) return;
diff --git a/src/lib/assistant-workspace.ts b/src/lib/assistant-workspace.ts
index aaad3199..f17dcf22 100644
--- a/src/lib/assistant-workspace.ts
+++ b/src/lib/assistant-workspace.ts
@@ -6,7 +6,8 @@ import { getLocalDateString } from '@/lib/utils';
const DEFAULT_STATE: AssistantWorkspaceState = {
onboardingComplete: false,
lastCheckInDate: null,
- schemaVersion: 3,
+ schemaVersion: 4,
+ dailyCheckInEnabled: false,
};
const STATE_DIR = '.assistant';
@@ -160,6 +161,28 @@ export function migrateStateV2ToV3(dir: string): void {
saveState(dir, state);
}
+/**
+ * v3→v4 migration: reset dailyCheckInEnabled to false for all users.
+ * Previously the default was implicitly "enabled" (undefined treated as true).
+ * Now the default is explicitly false — users must opt-in.
+ */
+export function migrateStateV3ToV4(dir: string): void {
+ let state: AssistantWorkspaceState;
+ try {
+ const statePath = path.join(dir, STATE_DIR, STATE_FILE);
+ const raw = fs.readFileSync(statePath, 'utf-8');
+ state = JSON.parse(raw) as AssistantWorkspaceState;
+ } catch {
+ return;
+ }
+
+ if (state.schemaVersion >= 4) return;
+
+ state.dailyCheckInEnabled = false;
+ state.schemaVersion = 4;
+ saveState(dir, state);
+}
+
// ==========================================
// Root Docs
// ==========================================
@@ -316,6 +339,7 @@ export function initializeWorkspace(dir: string): string[] {
// Migrate existing state through all schema versions
migrateStateV1ToV2(dir);
migrateStateV2ToV3(dir);
+ migrateStateV3ToV4(dir);
}
// For existing directories, generate root docs and infer taxonomy
@@ -511,6 +535,10 @@ export function loadState(dir: string): AssistantWorkspaceState {
migrateStateV2ToV3(dir);
migrated = true;
}
+ if (state.schemaVersion < 4) {
+ migrateStateV3ToV4(dir);
+ migrated = true;
+ }
if (migrated) {
return loadState(dir); // Reload after migration
}
@@ -534,7 +562,7 @@ export function saveState(dir: string, state: AssistantWorkspaceState): void {
export function needsDailyCheckIn(state: AssistantWorkspaceState, now?: Date): boolean {
if (!state.onboardingComplete) return false;
- if (state.dailyCheckInEnabled === false) return false;
+ if (state.dailyCheckInEnabled !== true) return false;
const d = now ?? new Date();
const localToday = getLocalDateString(d);
if (state.lastCheckInDate === localToday) return false;
diff --git a/src/lib/onboarding-processor.ts b/src/lib/onboarding-processor.ts
index 5dfb81c2..400fb1f1 100644
--- a/src/lib/onboarding-processor.ts
+++ b/src/lib/onboarding-processor.ts
@@ -171,6 +171,6 @@ Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaTe
const state = loadState(workspacePath);
state.onboardingComplete = true;
state.lastCheckInDate = today;
- state.schemaVersion = 3;
+ state.schemaVersion = 4;
saveState(workspacePath, state);
}
From fcef32706adc56f0eaa487c79544bbefadcb585e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=83=AD=E6=B5=A9?=
Date: Sun, 22 Mar 2026 12:16:00 +0800
Subject: [PATCH 13/32] =?UTF-8?q?fix:=20chat=20latency=20remediation=20?=
=?UTF-8?q?=E2=80=94=20effort=20default,=20MCP=20persistent=20toggle,=20mo?=
=?UTF-8?q?de=20convergence?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1: Chat mode entry convergence
- Remove initialMode prop from ChatView, hardcode to 'code'
- Remove sessionMode state from chat/[id]/page.tsx and SplitColumn.tsx
- Simplify effectiveMode in chat route to always use 'code' / acceptEdits
- Comment out messageInput.modeCode/modePlan i18n keys (kept for bridge)
- DB schema and bridge /mode command unchanged
Phase 2: MCP persistent enable/disable toggle
- Add enabled?: boolean to MCPServerConfig type
- MCP route GET now reads project-level .mcp.json (tagged _source: 'project')
- Project server enabled state persisted via mcpServerOverrides in settings.json
- PUT handler routes project-source servers to overrides, not .mcp.json
- loadMcpServers() in chat route and bridge applies overrides and filters disabled
- McpServerList.tsx: add Switch toggle per server card, opacity-50 when disabled
- McpManager.tsx: add handlePersistentToggle with res.ok check and revert on failure
- i18n: add mcp.enabled / mcp.disabled keys
Phase 3: First-token latency optimization
- Pin .mcp.json chrome-devtools-mcp to @0.20.3 (was @latest, 10s+ cold start)
- Default effort to 'medium' when UI doesn't specify (prevents inheriting 'high'
from ~/.claude/settings.json via settingSources)
- Emit visible resume status event with notification: true before SDK resume wait
- Defer captureCapabilities() to first assistant message with 5-min TTL cache
- Add isCacheFresh() to agent-sdk-capabilities.ts
Tested: typecheck pass, 444/444 unit tests pass, Playwright UI verification
confirms no mode selector and MCP switch toggles visible.
---
.mcp.json | 3 +-
docs/exec-plans/README.md | 2 +
.../active/chat-latency-remediation.md | 176 +++++
.../active/weixin-bridge-channel.md | 599 ++++++++++++++++++
docs/research/README.md | 2 +
.../chat-latency-investigation-2026-03-20.md | 249 ++++++++
...t-latency-remediation-review-2026-03-22.md | 153 +++++
...eixin-openclaw-plugin-review-2026-03-22.md | 316 +++++++++
src/app/api/chat/route.ts | 36 +-
src/app/api/plugins/mcp/route.ts | 31 +-
src/app/chat/[id]/page.tsx | 5 +-
src/components/chat/ChatView.tsx | 6 +-
src/components/layout/SplitColumn.tsx | 3 -
src/components/plugins/McpManager.tsx | 21 +
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 | 13 +
src/lib/claude-client.ts | 33 +-
src/types/index.ts | 2 +
.../openclaw-weixin-cli-1.0.2.tgz" | Bin 0 -> 2375 bytes
.../weixin-openclaw-cli/package/LICENSE" | 21 +
.../weixin-openclaw-cli/package/cli.mjs" | 124 ++++
.../weixin-openclaw-cli/package/package.json" | 17 +
.../openclaw-weixin-1.0.2.tgz" | Bin 0 -> 47431 bytes
.../package/CHANGELOG.md" | 5 +
.../package/CHANGELOG.zh_CN.md" | 3 +
.../weixin-openclaw-package/package/LICENSE" | 21 +
.../package/README.md" | 271 ++++++++
.../package/README.zh_CN.md" | 271 ++++++++
.../weixin-openclaw-package/package/index.ts" | 27 +
.../package/openclaw.plugin.json" | 9 +
.../package/package.json" | 55 ++
.../package/src/api/api.ts" | 240 +++++++
.../package/src/api/config-cache.ts" | 79 +++
.../package/src/api/session-guard.ts" | 58 ++
.../package/src/api/types.ts" | 222 +++++++
.../package/src/auth/accounts.ts" | 289 +++++++++
.../package/src/auth/login-qr.ts" | 333 ++++++++++
.../package/src/auth/pairing.ts" | 120 ++++
.../package/src/cdn/aes-ecb.ts" | 21 +
.../package/src/cdn/cdn-upload.ts" | 77 +++
.../package/src/cdn/cdn-url.ts" | 17 +
.../package/src/cdn/pic-decrypt.ts" | 85 +++
.../package/src/cdn/upload.ts" | 155 +++++
.../package/src/channel.ts" | 380 +++++++++++
.../package/src/config/config-schema.ts" | 22 +
.../package/src/log-upload.ts" | 126 ++++
.../package/src/media/media-download.ts" | 141 +++++
.../package/src/media/mime.ts" | 76 +++
.../package/src/media/silk-transcode.ts" | 74 +++
.../package/src/messaging/debug-mode.ts" | 69 ++
.../package/src/messaging/error-notice.ts" | 31 +
.../package/src/messaging/inbound.ts" | 171 +++++
.../package/src/messaging/process-message.ts" | 481 ++++++++++++++
.../package/src/messaging/send-media.ts" | 72 +++
.../package/src/messaging/send.ts" | 267 ++++++++
.../package/src/messaging/slash-commands.ts" | 110 ++++
.../package/src/monitor/monitor.ts" | 221 +++++++
.../package/src/runtime.ts" | 70 ++
.../package/src/storage/state-dir.ts" | 11 +
.../package/src/storage/sync-buf.ts" | 81 +++
.../package/src/util/logger.ts" | 143 +++++
.../package/src/util/random.ts" | 17 +
.../package/src/util/redact.ts" | 46 ++
.../package/src/vendor.d.ts" | 25 +
67 files changed, 6796 insertions(+), 47 deletions(-)
create mode 100644 docs/exec-plans/active/chat-latency-remediation.md
create mode 100644 docs/exec-plans/active/weixin-bridge-channel.md
create mode 100644 docs/research/chat-latency-investigation-2026-03-20.md
create mode 100644 docs/research/chat-latency-remediation-review-2026-03-22.md
create mode 100644 docs/research/weixin-openclaw-plugin-review-2026-03-22.md
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/openclaw-weixin-cli-1.0.2.tgz"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/package/LICENSE"
create mode 100755 "\350\265\204\346\226\231/weixin-openclaw-cli/package/cli.mjs"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-cli/package/package.json"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/openclaw-weixin-1.0.2.tgz"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.md"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/CHANGELOG.zh_CN.md"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/LICENSE"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/README.md"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/README.zh_CN.md"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/index.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/openclaw.plugin.json"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/package.json"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/api.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/config-cache.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/session-guard.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/api/types.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/accounts.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/login-qr.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/auth/pairing.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/aes-ecb.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-upload.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/cdn-url.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/pic-decrypt.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/cdn/upload.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/channel.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/config/config-schema.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/log-upload.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/media-download.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/mime.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/media/silk-transcode.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/debug-mode.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/error-notice.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/inbound.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/process-message.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send-media.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/send.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/messaging/slash-commands.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/monitor/monitor.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/runtime.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/state-dir.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/storage/sync-buf.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/logger.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/random.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/util/redact.ts"
create mode 100644 "\350\265\204\346\226\231/weixin-openclaw-package/package/src/vendor.d.ts"
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/docs/exec-plans/README.md b/docs/exec-plans/README.md
index 740669d1..9644900d 100644
--- a/docs/exec-plans/README.md
+++ b/docs/exec-plans/README.md
@@ -44,8 +44,10 @@
| 文件 | 主题 | 状态 |
|------|------|------|
+| 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 待开始 |
### 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/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/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/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 42ab31ee..2cfa686a 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -30,6 +30,13 @@ function loadMcpServers(): Record | undefined {
...((settings.mcpServers || {}) as Record),
...((projectMcp.mcpServers || {}) as Record),
};
+ // Apply persistent enabled overrides for project-level servers
+ const settingsOverrides = (settings.mcpServerOverrides || {}) as Record;
+ for (const [name, override] of Object.entries(settingsOverrides)) {
+ if (merged[name] && override.enabled !== undefined) {
+ merged[name] = { ...merged[name], enabled: override.enabled };
+ }
+ }
// Resolve ${...} placeholders in env values against DB settings
for (const server of Object.values(merged)) {
if (server.env) {
@@ -42,6 +49,12 @@ function loadMcpServers(): Record | undefined {
}
}
}
+ // Filter out persistently disabled servers
+ for (const [name, server] of Object.entries(merged)) {
+ if (server.enabled === false) {
+ delete merged[name];
+ }
+ }
return Object.keys(merged).length > 0 ? merged : undefined;
} catch {
return undefined;
@@ -153,23 +166,10 @@ export async function POST(request: NextRequest) {
updateSessionProviderId(session_id, persistProviderId);
}
- // Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
- 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;
- }
+ // Desktop main chat always uses 'code' mode with acceptEdits permissions.
+ // Bridge sessions may override mode via conversation-engine independently.
+ const permissionMode = 'acceptEdits';
+ const systemPromptOverride: string | undefined = undefined;
const abortController = new AbortController();
@@ -386,7 +386,7 @@ Start by greeting the user and asking the first question.
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/plugins/mcp/route.ts b/src/app/api/plugins/mcp/route.ts
index acdd48aa..419c119b 100644
--- a/src/app/api/plugins/mcp/route.ts
+++ b/src/app/api/plugins/mcp/route.ts
@@ -47,15 +47,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 +94,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 +114,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)
diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx
index a14c3f04..a34b93a8 100644
--- a/src/app/chat/[id]/page.tsx
+++ b/src/app/chat/[id]/page.tsx
@@ -20,7 +20,6 @@ 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 { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel();
@@ -33,7 +32,6 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
setWorkingDirectory('');
setSessionModel('');
setSessionProviderId('');
- setSessionMode('');
setSessionInfoLoaded(false);
async function loadSession() {
@@ -53,7 +51,6 @@ 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');
}
} catch {
@@ -127,7 +124,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
return (
-
+
);
}
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx
index 4870077b..2c2be71b 100644
--- a/src/components/chat/ChatView.tsx
+++ b/src/components/chat/ChatView.tsx
@@ -31,12 +31,11 @@ interface ChatViewProps {
initialMessages?: Message[];
initialHasMore?: boolean;
modelName?: string;
- initialMode?: string;
providerId?: string;
initialPermissionProfile?: 'default' | 'full_access';
}
-export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) {
+export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, providerId, initialPermissionProfile }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setPendingApprovalSessionId } = usePanel();
const { t } = useTranslation();
const router = useRouter();
@@ -48,7 +47,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
const loadingMoreRef = useRef(false);
- const [mode, setMode] = useState(initialMode || 'code');
+ const [mode, setMode] = useState('code'); // Desktop chat always uses 'code'
const [currentModel, setCurrentModel] = useState(() => modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
const [currentProviderId, setCurrentProviderId] = useState(() => providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
const [selectedEffort, setSelectedEffort] = useState(undefined);
@@ -147,7 +146,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}
}, [initialMessages]);
- useEffect(() => { if (initialMode) setMode(initialMode); }, [initialMode]);
useEffect(() => { setHasMore(initialHasMore); }, [initialHasMore]);
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
diff --git a/src/components/layout/SplitColumn.tsx b/src/components/layout/SplitColumn.tsx
index e17b96aa..e774e447 100644
--- a/src/components/layout/SplitColumn.tsx
+++ b/src/components/layout/SplitColumn.tsx
@@ -25,7 +25,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
const [sessionModel, setSessionModel] = useState("");
const [sessionProviderId, setSessionProviderId] = useState("");
const [sessionInfoLoaded, setSessionInfoLoaded] = useState(false);
- const [sessionMode, setSessionMode] = useState("");
const [projectName, setProjectName] = useState("");
const [sessionWorkingDir, setSessionWorkingDir] = useState("");
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle } = usePanel();
@@ -46,7 +45,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
setSessionTitle(data.session.title || t("chat.newConversation"));
setSessionModel(data.session.model || "");
setSessionProviderId(data.session.provider_id || "");
- setSessionMode(data.session.mode || "code");
setProjectName(data.session.project_name || "");
setSessionWorkingDir(data.session.working_directory || "");
}
@@ -185,7 +183,6 @@ export function SplitColumn({ sessionId, isActive, onClose, onFocus }: SplitColu
initialMessages={messages}
initialHasMore={hasMore}
modelName={sessionModel}
- initialMode={sessionMode}
providerId={sessionProviderId}
/>
diff --git a/src/components/plugins/McpManager.tsx b/src/components/plugins/McpManager.tsx
index ee9954e6..55b85f7f 100644
--- a/src/components/plugins/McpManager.tsx
+++ b/src/components/plugins/McpManager.tsx
@@ -95,6 +95,26 @@ export function McpManager() {
setEditorOpen(true);
}
+ const handlePersistentToggle = useCallback(async (name: string, enabled: boolean) => {
+ const updated = { ...servers };
+ updated[name] = { ...updated[name], enabled };
+ setServers(updated);
+ try {
+ const res = await fetch('/api/plugins/mcp', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mcpServers: updated }),
+ });
+ if (!res.ok) {
+ throw new Error(`Server returned ${res.status}`);
+ }
+ } catch (err) {
+ console.error('Failed to toggle MCP server:', err);
+ // Revert on failure
+ fetchServers();
+ }
+ }, [servers, fetchServers]);
+
async function handleDelete(name: string) {
try {
const res = await fetch(`/api/plugins/mcp/${encodeURIComponent(name)}`, {
@@ -246,6 +266,7 @@ export function McpManager() {
servers={servers}
onEdit={handleEdit}
onDelete={handleDelete}
+ onToggleEnabled={handlePersistentToggle}
runtimeStatus={runtimeStatus}
activeSessionId={activeSessionId || undefined}
/>
diff --git a/src/components/plugins/McpServerList.tsx b/src/components/plugins/McpServerList.tsx
index c6999a95..aefe3574 100644
--- a/src/components/plugins/McpServerList.tsx
+++ b/src/components/plugins/McpServerList.tsx
@@ -4,6 +4,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Trash, PencilSimple, HardDrives, WifiHigh, Globe, ArrowsClockwise, SpinnerGap } from "@/components/ui/icon";
+import { Switch } from '@/components/ui/switch';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { MCPServer } from '@/types';
@@ -19,6 +20,7 @@ interface McpServerListProps {
servers: Record