mcode
huangbh2020/mcode/AGENTS.md
本文件指导 AI agent(含本项目自身用 Claude Code 开发时)如何理解并参与 Mcode 的开发。先读本文,再动手。 Mcode(my Code)- 基于 Claude Agent SDK 构建的桌面端 GUI(Electron 三栏 IDE)。 核心理念:不重新实现 agent,只做 Claude 的交互界面。通过 Agent SDK 驱动 claude agent loop;本应用负责会话管理、实时渲染、工具审批、IDE 能力(文件/git/终端)。 改 SdkMessageAdapter 或涉及 SDK 输出解析时,必须先读 stream-json 文档——SDK 的 SDKMessage 类型本质上是对 CLI stream-json 的类型化封装,字段语义一一对应。 安全边界:renderer 不能 require() 任何 Node 模块。通往 Node 的唯一桥梁是 preload 暴露的 window.api,所有消息经 zod 校验后才放行。新增 IPC 通道时,必须在 packages/contracts/src/ipc.ts 定义 schema + 通道常量,并在 preload 白名单注册。
AGENTS.md34 starsChanged 41 days ago
- Reads credentials
- Installs packages
# AGENTS.md
本文件指导 AI agent(含本项目自身用 Claude Code 开发时)如何理解并参与 Mcode 的开发。先读本文,再动手。
---
## 项目是什么
**Mcode**(*my* Code)- 基于 Claude Agent SDK 构建的**桌面端 GUI**(Electron 三栏 IDE)。
核心理念:**不重新实现 agent,只做 Claude 的交互界面**。通过 Agent SDK 驱动 claude agent loop;本应用负责会话管理、实时渲染、工具审批、IDE 能力(文件/git/终端)。
- 使用 `@anthropic-ai/claude-agent-sdk`,内部管理 claude 二进制(项目不直接 spawn)
- 项目 MIT,可独立开源
- 架构受 [Synara](https://github.com/Emanuele-web04/synara) 启发,但用主流 TS 重写(无 effect-ts、无 bun)
- 内置 `AgentProvider` 抽象层,后续可扩展其他 agent 平台(OpenAI Codex、Gemini CLI 等)
---
## 权威文档(动手前必读)
| 主题 | 文档 |
|------|------|
| 技术栈、架构、踩坑记录 | [`docs/tech-stack.md`](docs/tech-stack.md) |
| claude stream-json 数据格式(旧 CLI 方式的 dump 记录,SDK 的 SDKMessage 与此对应) | [`docs/claude-stream-json.md`](docs/claude-stream-json.md) |
| Pi SDK 接入记录 | [`docs/pi-sdk-integration.md`](docs/pi-sdk-integration.md) |
| Claude Agent SDK 参考 | https://code.claude.com/docs/en/agent-sdk |
改 `SdkMessageAdapter` 或涉及 SDK 输出解析时,**必须**先读 stream-json 文档——SDK 的 `SDKMessage` 类型本质上是对 CLI stream-json 的类型化封装,字段语义一一对应。
---
## 进程架构(三进程)
```
Renderer (React 19, contextIsolation:true, nodeIntegration:false)
↕ Electron IPC(preload contextBridge + zod 校验)
Main (Node.js)
├── RuntimeManager 持 ProviderRegistry,构造 ProviderContext
│ └── AgentProvider ClaudeAgentSdkProvider(→ query() → SDKMessage → RuntimeEvent)
├── SessionManager 会话生命周期(SQLite via better-sqlite3)
└── IDE Services terminal / git / checkpoint(P4)
↕ @anthropic-ai/claude-agent-sdk (query)
claude 二进制(SDK 内打包,项目不直接 spawn)
```
**安全边界**:renderer 不能 `require()` 任何 Node 模块。通往 Node 的唯一桥梁是 preload 暴露的 `window.api`,所有消息经 zod 校验后才放行。新增 IPC 通道时,必须在 `packages/contracts/src/ipc.ts` 定义 schema + 通道常量,并在 preload 白名单注册。
---
## 目录地图
```
packages/contracts/src/ # 跨进程共享(无运行时逻辑)
runtime.ts # RuntimeEvent 联合 — provider 中立的归一化事件
session.ts # Project / Session / Message 领域类型
ipc.ts # zod schema + IPC 通道常量 + RPC 类型表
provider.ts # AgentProvider 接口 / ProviderContext / TurnHandle
apps/desktop/src/
main/ # 主进程
claude/
RuntimeManager.ts # ★ 会话↔provider 映射,构造 ProviderContext
ApprovalBridge.ts # 工具审批/AskUserQuestion 的 IPC 异步桥
providers/
registry.ts # ProviderRegistry 单例(启动时注册所有 provider)
claude-sdk/
ClaudeAgentSdkProvider.ts # AgentProvider 实现(query() 包装 + canUseTool 桥)
SdkMessageAdapter.ts # ★ SDKMessage → RuntimeEvent 归一化(改前读 SDK 文档)
pi-sdk/
PiAgentSdkProvider.ts # AgentProvider 实现(createAgentSession 包装 + 内联 Extension 注入)
mcodeExtension.ts # ★ 内联 Pi Extension:tool_call 权限/路径守卫 + AskUserQuestion 工具 + system prompt
PiMessageAdapter.ts # Pi SDK 事件 → RuntimeEvent 归一化
ipc/{claude,projects}.ts # IPC handler
lib/
logger.ts # 文件+stderr 日志(userData/logs/main.log)
askQuestion.ts # ★ 共享:parseQuestions / formatAnswersForModel / ASK_SYSTEM_PROMPT(Claude + Pi 共用)
store/{db,repositories}.ts # SQLite 持久化(better-sqlite3,2026-09-14 从 sql.js 迁移)
preload/index.ts # contextBridge 白名单 API
renderer/ # 前端(React)
stores/sessionStore.ts # ★ Zustand store,ingest RuntimeEvent → ChatMessage(locale 状态也在此)
lib/i18n/ # ★ 中英双语文案:core.ts(translate) + index.ts(useI18n) + zh|en/{common,layout,lib,chat-stream,chat-composer,ide,browser,settings,store}.ts
hooks/useClaudeEvents.ts # 订阅 IPC 事件流
components/{layout,chat}/ # UI(chat/ 下 ActivityCluster + ActivityConsole + activityShared = 活动区)
```
---
## 开发命令
```bash
# 启动开发(electron-vite,HMR)
cd D:\00-huangbh-project\my-claude-gui
pnpm dev
# 类型检查(改完代码先跑这个,最快定位问题)
cd apps/desktop && npx tsc --noEmit -p tsconfig.json
# 构建
pnpm build
```
### ⚠️ 启动前注意
异常退出后,5173 端口可能残留(TIME_WAIT)。若窗口没弹出,先在任务管理器结束所有 `electron.exe`,或等约 30 秒端口释放。
---
## 环境
- Node.js ≥ 22.13(pnpm 11 要求,本机 v25.9.0)
- pnpm ≥ 9(经 `corepack enable` 启用,本机 11.16.0)
- Claude Code CLI(本机装在 `D:\soft\nodejs\node_global`,非默认路径——`ClaudePathResolver` 已处理)
- `.npmrc` 配了国内 electron 镜像(直连 GitHub 会超时),任何人重装不会踩
- **`@anthropic-ai/claude-agent-sdk` 钉死精确版本 `0.3.238`(不带 `^`;2026-08-27 从 0.3.218 显式升级)**:防止 `^0.3.x` 在普通 `pnpm install` 时静默漂移(2026-08-23 曾意外漂到本版)。注意:本版捆绑 CLI 2.1.238 的 `sdkCompat.testedWrapperVersions` 名单止于 0.3.227、不含 wrapper 自身(该字段仅宿主元数据,`sdk.mjs` 不消费它);本次升级已过 checksum 比对 + 对话框 kind 存在性 + 冒烟(system/init 报 2.1.238)三项验证。升级要显式改版本号,升级前必查:changelog + issues 搜 "Stream closed"/permission;新包 manifest 的 testedWrapperVersions 要含 wrapper 自身;`grep -ac "permission_exit_plan_mode_v2" claude.exe` 确认对话框 kind 没改名;升级后回归计划审批/AskUserQuestion/工具审批/子代理收尾四条链路
- **Claude 的 UI「计划模式」在 provider 层翻译为 SDK `default` + `CLAUDE_PLAN_MODE_NUDGE` 引导模型走 EnterPlanMode 工具**(2026-08-26):CLI 的 plan permission-mode 在"上一轮后台子代理刚完成、新一轮立即 resume"的竞态下会把 ExitPlanMode 的审批请求在规则层秒拒(`toolDenialKind:"permission-rule"`,tool_result 显示 `Tool permission request failed: AbortError: Stream closed`,记入 permission_denials,宿主 canUseTool/onUserDialog 均不会被调用)——2.1.218 与 2.1.238 都复现,与 SDK 版本无关;default 模式下模型自调 EnterPlanMode→ExitPlanMode 的审批链路则一直可靠。安全性由宿主侧保持:ApprovalBridge 仍按配置级 "plan" 判定,所有写操作逐个弹审批(对齐 Pi 侧计划模式的设计)。adapter 已做降级呈现:ExitPlanMode 的通道故障显示琥珀色警告卡(`planApprovalBroken` 词条)
- **CLI 排障入口**:CLI 开了 `--debug`,自写日志按 SDK 会话落在 `~/.mcode/debug/<sessionId>.txt`(权限判定、hook、agent 生命周期都在里面,main.log 看不到的 CLI 内部行为来这里查)
---
## 编码约定
### TypeScript
- **strict 模式**,全量类型,禁 `any`(必要时用 `unknown` + 收窄)
- 工作区包用别名导入:`@contracts/*`、`@main/*`、`@renderer/*`
- 文件间用 `.js` 扩展名的相对导入(nodeNext 兼容):`import { x } from "./y.js"`
- 改完代码**先 typecheck**:`npx tsc --noEmit -p tsconfig.json`
### Zustand(renderer 状态)
- 选择器**必须返回稳定引用**。禁止 `useStore((s) => arr ?? [])`——每次渲染返回新 `[]` 会触发无限循环(已踩过)。用模块级常量:`const EMPTY: T[] = []`
- 动作(actions)放 store 内,组件只读 + 调用
### IPC
- 新通道:先在 `contracts/ipc.ts` 加 zod schema + `IPC` 常量 → preload 白名单注册 → main handler 用 `Schema.parse(raw)` 校验入参
- main→renderer 推送用 `sendToRenderer(IPC.XXX, msg)`,renderer 用 `api.on.xxx` 订阅
### 界面文案 i18n(中英双语,默认中文)
- 语言偏好:`settings` 表 `ui.locale` key(`UI_LOCALE_SETTING_KEY`,`"zh"|"en"`),sessionStore 的 `locale` 状态 + `setLocale` 持久化,启动时进 first-paint `getMany` 批量水合,切换即时生效(组件订阅 `useI18n()` 自动重渲染,并同步 `<html lang>`)
- 组件内:`import { useI18n } from "@renderer/lib/i18n/index.js"` → `const { t } = useI18n()` → `t("area.key")`;插值 `t("key", { n })` 对应词条里的 `{n}`
- 非 React 模块(store、lib 纯函数):用 `lib/i18n/core.ts` 的 `translate(locale, key, params)`(locale 从 `useSessionStore.getState().locale` 取)。**不要从 index.js 导入 translate 到 store/store 相关模块**——index 导入 store,会成环;core.ts 无依赖
- 词典:`lib/i18n/zh/` 与 `en/` 按功能分区(common/layout/lib/chat-stream/chat-composer/ide/browser/settings/store),zh 是源(`MessageId` 由 zh 键派生),en 镜像同一类型——**缺键过不了 typecheck**。新词条 zh/en 同步加,键用分区前缀
- **禁止硬编码新的用户可见中文/英文文案**;只翻 UI 文案,代码注释、console 日志、发给模型的 prompt、持久化标识符不进词典。模块级常量数组存 `labelKey: MessageId`,渲染时 `t()`
### claude 解析(SdkMessageAdapter)
- `SdkMessageAdapter.dispatch()` 将 SDK 的 `SDKMessage` 归一化为 `RuntimeEvent`
- 流是按 `message.type` 分发的 if/else 链,未知 type 静默忽略(向前兼容)
- stream_event 的 text/thinking 增量**只在 delta 渲染**;assistant 完整消息只补全 tool_use,不重发 text(避免重复)
- **渲染端增量缓冲(sessionStore 的 flushDeltas)按真实到达顺序应用**(2026-09-03):`DeltaEntry` 是有序 segment 列表(`appendDelta` 同类合并、换类开新段),不再是无序的 text/thinking 双槽——rAF 窗口横跨 text↔thinking 边界时,旧实现固定先 text 后 thinking,会把块跨边界对调(思考卡落在其后正文之后)。**纯空白 text 块三层防护**:模型在 `<think>` 段前后输出的裸换行经桥的 thinkTagSplitter 放行(只跳 length===0),在窗口边界被隔离成纯空白 text 块,渲染为空 Markdown 容器+块间距=空白行;防护 = ① MessageBlocks text 分支 `!block.text.trim()` 不渲染(护栏在 useDeferredValue 之后——流式块可能从纯空白长出正文,条件挂 hook 会崩 React);② toRecords 对 assistant 行剪除(用户行原样,编辑重发往返不受影响);③ fromRecords 水合同步剪除(pruneBlankTextBlocks,镜像 pruneUnchangedTurnFileBlocks,整条消息剪空则丢弃)——修复前的历史行同样被清理
- turn 结束判定:收到 `result` 消息时,**仅当没有运行中的子代理、也没有后台任务**才立即发 `turn.done`(CLI v2.1.198+ 子代理默认后台运行,主 agent 回合结束会先发一条中间 `result`,此时 turn 并未真正结束,后续会恢复继续流式);否则推迟到 `flushFinal()` 在 generator 真正结束时补发(reason 取最后一条 result)。`emitTurnDone` 去重,每 turn 恰好发一次。后台任务跟踪同时消费 SDK 的 `background_tasks_changed` 水平信号,避免漏掉 task_started 边沿事件
- **prompt 用不结束的 AsyncIterable 占住 stdin(settle 门控,2026-08-26)**:`buildPromptInput` 始终返回"yield 用户消息后 await 门控"的迭代器——SDK 对字符串 prompt(及一次性迭代器)会在**第一条 result 后关闭 stdin**(`isSingleUserTurn`/`streamInput` 的 endInput),CLI 进程随即退出,还在跑的后台子代理被孤儿化、独立继续写共享会话文件;下一轮在 ~300ms 窗口内 resume 会读到撕裂状态,此时**所有权限询问瞬时失败**(`Tool permission request failed: AbortError: Stream closed`,AskUserQuestion/ExitPlanMode 全灭,2.1.218/2.1.238 皆然)。占住 stdin 让 CLI 进程活到后台代理全部完成。**settle 条件(关键,踩过坑)**:result 已到 + 无 running 子代理 + 无后台任务 + **result 晚于最后一次代理活动边沿**(`lastResultAt > lastAgentActivityAt`)——CLI 有 task-notification 恢复机制:代理完成后注入合成 user 消息并**继续主循环**(更多工具/询问/可能再开代理),恢复相位的 result 只出现在相位末尾;若只看"result+代理空闲"就释放,第一段恢复相位期间 stdin 被关、其内所有 ask 全灭(2026-08-26 实测)。边沿时间戳在 handleResult/flushSubagents/handleBackgroundTasksChanged 打点,`maybeSettle` 三处复查,`setSettleGate` 释放 + 1.5s 宽限(`SETTLE_GRACE_MS`,覆盖会话落盘滞后)。兜底:`PROMPT_SETTLE_FALLBACK_MS`(5 分钟)超时强制释放(退化为旧行为而非死锁)+ done 的 finally 防御性释放;迭代器内部与 abort signal 竞速,用户停止不会卡住 streamInput。每轮打 `claude turn start: uiMode/sdkMode/settleGate` 与 `claude turn settled` 日志锚点,排障先看这两行
- **回合末 context-usage 快照不占 turn.done 关键路径**:`handleResult` 对 `emitTurnEndSnapshot` 是 fire-and-forget(turn.done 由 flushFinal 立即发,快照事后补发),内部把 kickoff 的 `getContextUsage()` promise 与 3s 超时竞速(`CONTEXT_USAGE_PATH_B_TIMEOUT_MS`),超时/不可信(总量 < 累计 input 的 10%)即回退 path C——第三方网关的控制通道曾观测 17-36s 才应答且返回垃圾值,直接 await 会拖住整轮结束。`RuntimeManager` 的每轮用量历史随之改为 `pendingTurnEnd` 延迟落盘:turn.done 只记 endedAt/durationMs,等 turn-end 快照到达再写 `usageHistory`(下一轮 `sendTurn` 兜底 flush)
- **网关空响应截断检测(`turn.incomplete`)**:`flushFinal()` 在发 turn.done 前跑 `maybeEmitTurnIncomplete()`——第三方网关(OpenAI 协议桥)会用**空补全**应答最后一次 tool_result,CLI 把它当正常收尾发 `result{subtype:"success"}`,用户侧表现为"任务跑一半停了、还弹'回合完成'"(2026-08-20 实测:回合死在 Read tool_use 之后,无 tool_result、无最终文本)。检测条件刻意收窄:last result 必须是 success(error 子类型已有 error 卡片)、非用户中断、以下三种形态之一:① 有 tool_use 没等到 tool_result(`dangling-tools`,排除 Task 工具——后台子代理合法地活过父流);② 有工具调用但整轮无文本(`empty-response`,排除 EnterPlanMode/ExitPlanMode/AskUserQuestion 交互回合——它们本来就常无叙述文本);③ 最终 assistant 消息为纯文本、以续写标点(中英文冒号/逗号/顿号/分号,`UNFINISHED_TRAILING_RE`)或未闭合 ``` 围栏(奇数个 ``` 计数)结尾、且本轮跑过工具(`unfinished-text`,2026-09-02 实测 deepseek 桥:模型发了"先读当前完整 `onSplitConfirm`:"就结束,宣告的工具调用没到达——桥把所有 finish_reason 抹成 end_turn,CLI 无从得知模型还想继续;同会话一天命中 3 次,对该会话 17 个历史回合回测 0 误报。收窄护栏:要求本轮有工具活动——纯聊天回合用户提问可以合法地以冒号收尾;要求末条消息纯文本——以 tool_use 收尾时更早文本的标点不能说明任何事,后台 Task 派发正是这个形态。判定读的 `lastAssistantText`/`lastAssistantHadToolUse` 在 `handleAssistant` 的 subagent 转发 early-return **之后**更新,转发块不污染)。事件在 turn.done **前**发,renderer 的 `turnIncompleteBySession` 旗标让 turn.done 跳过误导性的"回合完成" toast,改为琥珀色警告卡片 + toast 提示"发送「继续」可恢复"(三种 kind 各有 `chatStream.turnIncomplete.*Desc` 词条);adapter 同时打 `turn incomplete (gateway likely returned an empty final response)` WARN 进 main.log 便于事后取证(`unfinished-text` 附 finalTextTail 尾部摘录)。Pi 侧未接入本检测(`PiMessageAdapter` 已把终态 `agent_end` 的 `stopReason:"error"` 呈现为 error 块;若实测发现 Pi 也有静默空回合,再按同一事件补)
- **OpenAI 协议桥(`providers/bridge/`,bridgeServer + responseTranslator)**:进程内 HTTP 服务,把 Anthropic `/v1/messages` 翻译成 OpenAI `/v1/chat/completions` 转发上游、SSE 流式回译(`OpenAiToAnthropicSse` 状态机:text/tool_use/thinking 块重排,tool_call 按 OpenAI index→Anthropic block index 映射,`<think>` 标签与 DeepSeek `reasoning` 字段归 thinking 块)。**2026-09-02 修两个缺陷**:① `finish_reason` 从未被转发——`feed()` 只读 delta、`bridgeServer` 调 `finish(undefined)`,注释声称"已喂入"但无代码实现,导致**所有消息一律 `stop_reason:end_turn`**(含带 tool_use 的,transcript 337 条全 end_turn 的根源;工具照常执行说明 CLI 按 tool_use 块判定续跑,但协议保真度破了)。修复:`feed()` 捕获 `choice.finish_reason`(真值才记——中间 chunk 是 null,末尾 usage-only chunk 是 `choices:[]`,所以必须在 feed 时捕),`finish()` 经既有 `mapStopReason` 映射(`tool_calls`→`tool_use`、`length`→`max_tokens`),裸流无 finish_reason 退化 end_turn。② 流尾帧丢弃——读循环 `done` 后 break,`sseBuffer` 残留帧直接丢;而工具调用片段+finish_reason 恰在流最末帧,上游不发尾部空行就关 socket 时正好制造"文本正常、末尾工具调用消失"的截断(与 turn.incomplete `unfinished-text` 同症状);修复:`decoder.decode()` 终 flush 后把 `trim()` 的残留当最后一帧过 `processFrame`(回收成功打 INFO "recovered tail SSE frame")。**可观测性**:畸形 JSON 帧从静默 skip 改为 WARN(带 200 字符摘录);流结束后 `finishReason==="tool_calls"` 且 `toolBlockCount===0` 打 WARN"上游丢了工具调用"——这是桥/上游责任分界的定罪证据(桥侧"recovered tail"日志 ↔ 上游丢帧警告,下次截断直接定责)。帧处理抽为 `processFrame` 局部函数供循环内/流尾共用。单元验证:temp 目录编译 responseTranslator 后 6 用例全过(健康工具流 stop=tool_use+参数重组、丢片段+finish_reason=tool_calls 的诊断条件、stop/裸流/length 映射、reset 清捕获)
- **自定义端点请求头 + 网关会话头(`providers/upstreamHeaders.ts`,2026-09-10)**:端点可配 `customHeaders`(设置页「模型配置 → Claude 端点 → 高级选项」的键值列表;契约 `CustomHeaders`),两条投递路径共用 `resolveUpstreamHeaders()` 产出同一套头——`anthropic` 协议走 `ANTHROPIC_CUSTOM_HEADERS` 环境变量(CLI 原生支持,二进制里 27 处引用;与继承的 OS 级同名变量合并,配置项在冲突时优先),`openai` 协议由桥合并进上游请求(用户头最后合并,故可覆盖派生的 `Content-Type`/`Authorization`——自定义鉴权方案正是该字段的用途)。`buildCustomEnv` **只在 anthropic 协议下碰这个 env**:openai 协议的 baseUrl 已被 `RuntimeManager` 改写成 localhost,那些头会发给本地桥而非网关,上游头归桥管。**自动会话头**:主机命中 `opencode.ai`(含子域;按 URL.hostname 判定,`opencode.ai.evil.com` 这类前后缀仿冒不命中)且用户未自带 `x-opencode-session` 时自动注入——OpenCode Zen 的 Go 套餐缺此头一律回 `400 MissingSessionID`,用户侧此前表现为 `Claude Code returned an error result: API error 400`(错误体外面套着 `bridge_error` 外壳,那正是桥包装上游错误的位置,可据此定责)。**会话 id 两条路径语义不同且刻意如此**:anthropic 路径按 Mcode 会话生成(`mcode-<sessionId>`,provider 经 `buildCustomEnv(cfg,{sessionId:req.sessionId})` 传入;titleGen 传自己的会话;连接探测与提交信息生成退回**进程级**稳定 id,避免每次请求换新 id);桥路径因为只有它还记得真实网关主机,用**每桥一个稳定 id**(桥按配置 id 共享、跨 turn 存活)→ 网关始终看到同一个会话而非每请求一个,满足其路由/prompt 缓存契约。`bridgeRegistry.fingerprint()` 已纳入 `customHeaders`(头烘进桥的每次上游请求,不重建就会一直发旧集合)。头合法性校验(RFC 7230 token + 值禁 CR/LF,防换行伪造头)放契约层供 renderer 表单复用,main 侧 `sanitizeCustomHeaders` 在**写和读两侧**兜底丢弃非法行,手改 JSON 只损失那一行而非整个配置。`customModel.test` 探测也带 `customHeaders`,否则"要求该头的端点"永远测不过。冒烟:`scripts/upstream-headers-smoke/run.sh`(esbuild bundle;`@main/lib/logger.js` 打桩绕开 electron 包的 CJS 动态 require;桥用例起真实 HTTP 桥 + stub `globalThis.fetch` 抓上游请求头,44 断言含仿冒域名、大小写去重、继承 env 合并、每桥 id 互异)
- **子代理模型固定(`lib/subagentModel.ts`,per 端点配置)**:设置页模型配置的 `subagentModel`(存进 ApiConfig/CustomModelMeta,save 时校验 ∈ `models[].id`)经 **`CLAUDE_CODE_SUBAGENT_MODEL` 环境变量**(claude 二进制原生覆盖 Task 子代理模型,2.1.238 验证;SDK 无声明式字段)注入——provider 每 turn 构造 options 时把它叠在 `buildCustomEnv` **之后**,覆盖自定义端点路径把主模型镜像到同一变量的默认行为。注入前对配置模型列表**复检**:陈旧 pin 静默降级为「跟随主模型」——网关不认识的 id 不只废一次请求,而是 Task 工具整体 503(model-not-found),正是 buildCustomEnv 分层镜像要防的故障。官方端点(无 ApiConfig)无处挂 pin,恒跟随主模型;仅 Claude 侧有此通道(Pi/Codex 无)。
- **子代理名单只收 agent 类任务**:`task_started` 带 `task_type`(实测 CLI 2.1.x 二进制:`local_agent`=Task 工具子代理、`local_bash`=被 CLI 当任务跟踪的 bash 命令、`local_workflow`=脚本工作流、`remote_agent`),adapter 的 `NON_AGENT_TASK_TYPES` 把 `local_bash`/`local_workflow` 挡在名单外(否则 `sleep` 等待命令会以"运行中"假子代理堆在胶囊里——CLI 不会为它们发收尾 task_updated,状态卡到 turn 结束),忽略的 task_id 记进 `ignoredTaskIds`(`task_progress`/`task_updated` 不带 task_type,防止 progress 的合成分支把已忽略任务加回来);未知/缺省 task_type 放行(老版本 CLI 兼容)。renderer 的 `hydrateCapsule` 另有 `sanitizeSubagentRoster`:水合持久化名单时丢弃 `running` 且非后台的条目(干净数据里静止名单不可能有这种条目,是修复前脏数据的特征),防止旧会话复活假子代理
- **运行命令面板 + 单任务停止(2026-09-21,`bash-tasks.update` 事件族)**:`local_bash` 任务虽然不进子代理胶囊,但进了**自己的名单**——adapter 的 `state.bashTasks`(`BashTaskSnapshot`:taskId/toolUseId/description=命令行/status/isBackgrounded/startedAt/endedAt),四个生命周期信号合成:① `task_started`(`local_bash` 且非 ambient/skip_transcript;ambient=CLI 内务任务,进去就是永久噪音);② `task_updated` 补丁(含 is_backgrounded 翻转);③ **Bash tool_result**(`settleBashTaskByToolUseId`,前台命令的可靠完成信号——CLI 不保证发前台命令的收尾 task_updated;跳过 isBackgrounded 条目,后台命令的 tool_result 是占位符而进程还在跑);④ `background_tasks_changed` 水平信号(`reconcileBashTasksFromLevel`:payload 里的 local_bash 条目 upsert 为 running+isBackgrounded,已标 isBackgrounded 却不在集合里的 running 条目 = 已结算→completed;前台任务从不出现在水平集,缺席退休只作用于后台条目);⑤ `task_notification`(status stopped/completed/failed——**宿主 stop_task 之后 CLI 发的就是它**,不处理的话用户点停止后列表永远"运行中")。`flushFinal` 安全网:interrupt 杀一切 running(未声明 `perTaskStopAffordance`,CLI 的 fail-closed 规则连后台一起杀,与子代理语义对齐);正常结束把仍 running 的前台标 completed、后台标 killed(flushFinal 跑到时 generator 已收——settle 释放条件下后台必空,还 running 的只可能是 5min fallback 释放场景=CLI 正在退出=进程必死)。**跨轮携带**:`RuntimeManager.lastBashTasks`(emit 里记,sendTurn 开头 replay,镜像 lastSubagents;**不持久化**——CLI 与它启动的命令都活不过应用退出)。**UI**:活动区第 5 个节点「运行命令」(`ActivityNodeKey "commands"`,IconTerminal2),`ActivityConsole` 的 `CommandsBody` 渲染(状态点/mono 命令行/时长 ticker/后台徽标/停止按钮);聚簇条在命令运行时显「N 个命令运行中」,**有 running 命令时 `primaryKind` 直升 commands**(可停止的活进程比什么都紧急)。**停止链**:renderer `stopBashTask(sessionId, taskId)` → IPC `claude:stopTask`(桌面 + mobile rpc 同名通道)→ `RuntimeManager.stopTask`(gate 在 `handle.isRunning()`)→ `TurnHandle.stopTask` → SDK `Query.stopTask(taskId)` 控制请求(仅 streaming input 模式可用,Mcode 恒为该模式);provider 用 `liveQuery` 引用跟随 transport-retry 换 query,`finished` 后拒绝(进程已亡);回合继续不中断(模型收到停止的 tool_result 后自行接续)。**退出回收**:claude CLI 是 main 的直接子进程,模型经 Bash 启动的 python/npm 等是孙进程,应用退出时它们孤儿化(macOS 重挂 launchd / Windows 无 job object)——`before-quit` 第一遍的 3s 异步窗里跑 `lib/procTree.ts` 的 `killDescendants(process.pid)`(POSIX:一次 `ps -eo pid=,ppid=` 快照建树,后代全量 SIGTERM→400ms 宽限→幸存者 SIGKILL;Windows:PowerShell CIM 枚举直接子进程后逐个 `taskkill /T /F`),与 cookie 落盘 `Promise.all` 后一起 race 3s 超时。**刻意不做 per-session 树杀**(对 main 后代全集开刀只在退出时才是正确粒度;删除会话的中断路径由 CLI 自行拆前台工具,残余由退出兜底)
- **服务面板 + 端口级检测 + 停止(2026-09-22,`services.update` 事件族,`lib/serviceScanner.ts`)**:「运行命令」名单是 **CLI 台账**,模型启动的服务在多种场景下从台账消失或错标(`nohup … &` 命令秒退即 completed 而进程活着;回合异常收尾 flushFinal 把 running 全标 killed;跨轮 REPLACE 收缩;前台转后台竞态错标)——台账修不完且不知道**端口**。改为**事实检测**:主进程 `serviceScanner` 单例 5s tick(自门控:仅当有活的 CLI 进程、有运行中回合、或有已跟踪服务时才跑平台快照,空闲零成本),每 tick 两次平台调用——① 进程枚举(win: PowerShell CIM `Win32_Process` 含 CommandLine 的 JSON;posix: `ps -eo pid=,ppid=,args=`)建 pid→ppid 树;② 监听端口枚举(win: `netstat -ano -p tcp` LISTENING 行;mac: `lsof -Fpn`;linux: `ss -tlnp` 缺失退 lsof)。**会话归属有两条互补路径**:① **谱系**(主路径)——SDK 用 `node` spawn CLI,命令行固定带 `--output-format stream-json … --input-format stream-json` 签名(sdk.mjs 实证)→ 据此识别 CLI 进程;第 2+ 轮命令行带 `--resume=<cliSid>` → 经 `RuntimeManager.findSessionIdByProviderId`(公开方法,扫内存 `providerSessionId` 映射,由 onProviderSessionId 保持最新)反查 GUI 会话;第 1 轮无 resume → 恰好一个未绑定 CLI + 恰好一个有运行回合但缺绑定的会话时临时绑定(歧义留给下一轮 resume 自愈)。服务 = 监听 socket 的 pid 属于某已绑定 CLI 的子树(BFS 闭包)。② **Bash 窗口差分(claim,补 detach 盲区)**——谱系对 `powershell Start-Process`/`nohup` 类**主动脱离启动**天然失效(2026-09-22 实测:Start-Process 的 powershell 在命令自身生命周期内就退出,Windows 的 ParentProcessId 指向死 pid,死进程不在快照里,BFS 链条永久断裂;就算在命令窗口内扫,链也断在 powershell 处)。修法:adapter 在 **Bash tool_use 边**调 `noteBashToolStart`(快照监听端口集为基线,`state.openBashToolUseIds` 记 id;async 但 CLI 自身权限往返+shell spawn 给 netstat 留足时间),**Bash tool_result 边**调 `noteBashToolEnd`(再快照,新出现的监听 key 基线没有、且时间戳 ≤ 窗口开始的扫描史也没有 → claim 给该会话;`flushFinal` 关闭未决窗口防 stale 基线泄漏到下轮;深度计数处理并发 Bash,最后一次 close 才 diff)——**命令运行期间绑定端口的 socket 就是该会话的服务,不管谱系**。基线竞速兜底:`scanHistory` 环(最近 13 次扫描的带时戳 key 集)区分「窗口前已监听」与「窗口内新绑定」。claim 由下一次 scan 采纳进 `tracked`(消费掉),socket 消亡即剪枝。**粘性跟踪**:按 `pid:port` 记住,此后只验「该 pid 仍监听该端口」——CLI 退场后的孤儿(nohup 场景)继续显示直到消失。emit 复用 RuntimeManager 同款信封(`sendToRenderer(IPC.CLAUDE_EVENT,…)` + `mobileEventBus.broadcast`),非空名单每 tick 重发(renderer reducer 按 key/pid 签名去重防重渲染,顺带自愈重载的 bucket),空名单只在翻转时发一次。**每次发现/claim 打 INFO 日志**(`service scanner: discovered/claimed …`,排障锚点;`started` 亦有)。**停止链**:renderer `stopService(sessionId, service)` → IPC `claude:stopService`(桌面 + mobile rpc 同名通道,`StopServiceSchema{sessionId,pid,port}`)→ `serviceScanner.stopService`(gate 在粘性名单 session+pid+port 全匹配,防止陈旧 UI 杀任意 pid)→ procTree 新增导出的 `killProcessTree(pid)`(含根:win 一次 `taskkill /PID n /T /F`;posix 后代+根 SIGTERM→400ms→SIGKILL)→ 杀后立即补一次扫描刷新 UI。不经 CLI `stopTask`(拿不到端口→taskId 映射;直接杀树后 CLI 自发 task_notification 收账,bashTasks 列表同步翻面)。**UI**:活动区新节点「服务」(`ActivityNodeKey "services"`,IconServer,success 色;顺序 commands 后 plans 前),`ServicesBody` 渲染(脉动绿点/mono `:port` 徽标/进程名+命令行/时长 ticker/「打开」→ `openUrlInBrowser(http://localhost:port)` 应用内浏览器/红色「停止」pill);聚簇条显「N 个服务运行中」,**有服务时 `primaryKind` 直升 services**;移动端 ActivitySheet 镜像(无「打开」——手机壳无应用内浏览器)。**范围**:仅 Claude provider(Pi 的 agent loop 在主进程内,bash 子进程与用户终端无法靠进程谱系区分;Pi 会话亦无 adapter 钩子可挂 claim 路径);应用重启无残留服务(退出 killDescendants 兜底已存在);i18n 键前缀 `chatStream.service.*` + `chatStream.activity.{cluster.services,node.services}`。**冒烟**:`scripts/service-scanner-smoke/run.sh`(esbuild alias 桩掉 window/mobileBus/logger/RuntimeManager 四个 electron 邻接模块,scanner 本体跑真的:38 断言 = netstat/lsof/ss/ps/CIM 五个解析器 + CLI 签名/resume 解析 + 谱系路径真进程端到端(spawn 假 CLI(脚本文件+参数形态,`node -e` 后跟 `--` 参数会被 node 当自身选项拒收)+ 它的监听子进程,断言发现/归属/粘性/停止守卫/杀树/清空 emit)+ **claim 路径三景**(独立监听进程无任何 CLI 祖先:窗口前已监听不认领/窗口内绑定认领且可 stopService/深度语义首次 close 不 diff 最后一次 close 才认领))。procTree 的 logger 导入同日从 `./logger.js` 改为 `@main/lib/logger.js`(全工程一致,也让 esbuild alias 能命中)
- **canUseTool 审批回调由 `ClaudeAgentSdkProvider` 在 `query()` options 里注册**,不在 adapter 里处理
- **文件写入守卫(严格项目内)**:所有 provider 统一拦截 `Write`/`Edit`/`MultiEdit`/`NotebookEdit` 的写入路径:① 把 WSL 式 `/mnt/<drive>/...` 路径修正为 Windows 原生路径(否则 Windows 上会解析成 `D:\mnt\...` 垃圾目录);② 把 `~`/`~/...` 展开为 `homedir()`(`bashWriteGuard.ts` 的 `expandTilde`,所有路径检查共用;`node:path.resolve` 不认 `~`,不展开会被误判为项目内的字面 `~` 目录);③ 目标路径解析后**超出项目工作目录一律拒绝**(提示模型改用相对路径),仅 `bypassPermissions`/`dontAsk` 例外。Claude:在 `ClaudeAgentSdkProvider` 的 `canUseTool` 里实现(工具集 `FILE_MUTATING_TOOLS` 定义在 `fileSnapshot.ts`),归一化路径经 `updatedInput` 回传 SDK;`SdkMessageAdapter` 的"撤销本轮"快照(`recordPre`)用同一助手,保证卡片与实际写入位置一致。Pi:用**内联 Extension**(`mcodeExtension.ts` 的 `createMcodeExtension`,经 `DefaultResourceLoader({ extensionFactories })` 注入)的 `tool_call` 事件 handler 实现——SDK 的 `agent-loop.js` 在 `beforeToolCall` 里 `await emitToolCall(event)`,handler 返回 `{ block: true, reason }` 时执行体把它转成 `createErrorToolResult(reason)` + `isError: true`(模型可见,等同 Claude 的 `behavior: "deny"`)。路径归一化靠原地修改 `event.input`(`event.input` 与最终执行参数 `validatedArgs`/`prepared.args` 是同一引用,等同 Claude 的 `updatedInput`)。同一个 `tool_call` handler 还负责权限审批(读 `ctx.getPermissionMode()` + `ctx.isToolAlwaysAllowed()` + `ctx.requestApproval()` IPC 桥)。bash 守卫读 `params.command`,经 `bashWriteGuard.ts` 的 `guardBashCommand` 提取写重定向目标 `>`/`>>`/`>&`/`tee`/`dd of=`/`sed -i` 后逐一过 `expandTilde` + 路径检查;含 `$`/反引号的目标无法静态展开直接放行,`cp`/`mv`/heredoc/管道目标不覆盖——**非沙箱**,目的是堵住"模型无意识在项目外建脚本文件"的常见模式。win32 下 Claude/Pi 的 systemPrompt 按**实际 bash 环境**附加路径提示(`lib/bashEnv.ts` 的 `detectBashEnv` + `bashPathHintFor`:镜像各 SDK 的 bash 解析逻辑——Pi 按 `settings shellPath` → `Program Files\Git\bin\bash.exe` → `where bash.exe` 首项,Claude 优先 Git Bash(git 安装根推导)再退 WSL;探测出 WSL bash 时提示"bash 跑在 WSL,命令内用 /mnt/... 路径",否则提示"勿用 /mnt 路径")。Bash 重定向写文件不在 canUseTool 守卫范围内,靠该提示缓解;Pi 的 `before_agent_start` 事件 handler 注入 AskUserQuestion 使用说明 + 同一提示文本
- **Pi Extension 架构**(`mcodeExtension.ts`):Pi SDK 无 `canUseTool` 回调、无 system prompt 扩展点、无原生 AskUserQuestion/计划工具——这些全部由一个内联 Extension 补齐,经 `buildPiSkillLoader` 的 `extensionFactories` 参数注入(`DefaultResourceLoader` 在 `getExtensions()` 阶段执行 factory,先于 `_refreshToolRegistry`,所以 `pi.registerTool`/`pi.on` 在首个 turn 前就绑定;reload 时 `loadExtensionFactories` 也会重跑)。六块逻辑:① `tool_call` handler = 权限审批 + 路径/bash 守卫 + plan mode 只读门禁(覆盖**所有**工具);② `registerTool("AskUserQuestion")` = 原生工具,`execute` 桥接 `ctx.requestUserInput`;③ `registerTool("EnterPlanMode")`/`registerTool("ExitPlanMode")` = 计划模式工具,发 `mode.change` + `plan.update` 事件,ExitPlanMode 的 `execute` 里 `await ctx.requestPlanApproval()` 阻塞 agent loop 等用户审批(agent-loop.js `await tool.execute` 确认可阻塞);④ `before_agent_start` handler = 追加 system prompt(Mcode 身份提示 `PI_IDENTITY_PROMPT` + AskUserQuestion + 计划工具使用说明)。**Pi 的提示词必须平台独立:不得出现任何其他平台 SDK/产品字眼**(如 Claude Code CLI、网页版 Claude),身份/驱动描述只讲 Mcode + Pi Coding Agent 自身——Pi 底层模型由用户配置,不保证是 Claude。身份变体 `PI_IDENTITY_PROMPT` 与 Claude 的 `CLAUDE_IDENTITY_PROMPT` 同放 `lib/systemPrompt.ts`,各自独立调词,互不引用;⑤ plan mode 状态用进程内 `planMode.active` 布尔跟踪(**不**用 `ctx.getPermissionMode()`——后者经 IPC 往返有延迟,不能保证下一个 tool_call 前到达);⑥ `tool_call` 守卫的 write/edit 分支在路径守卫通过后 `await getFileSnapshot(sessionId).recordPre(cwd, 规范化绝对路径)`(本轮修改文件快照,与 Claude 共用 `FileSnapshot`;turn 结束由 `PiMessageAdapter.flushFinal()` `freeze()` 后 `ctx.emit({type:"turn.files"})`,provider 的 `done()` 成功/中断路径调用,错误路径跳过——对齐 Claude)。`capabilities.supportsApproval`/`supportsAskUserQuestion` 现为 `true`,`permissionModes` 暴露 Claude 的 4 档。win32 的 read `/mnt` 归一化仍用 `createMntNormalizingReadTool` customTools(只读、无安全风险,不进 `tool_call` 守卫)。plan 模式的 `tools` 白名单显式包含 AskUserQuestion + EnterPlanMode + ExitPlanMode。共享的 `parseQuestions` / `formatAnswersForModel` / `ASK_SYSTEM_PROMPT` 在 `lib/askQuestion.ts`,Claude 和 Pi provider 共用
- **Pi 计划模式(Plan Mode)**:完全复用 Claude 的前端计划卡片体系(`PlanStreamBlock`/`PlanViewer`/`PlanApprovalPrompt` + sessionStore 的 `plan.update`/`plan.approval_request` reducer + `respondPlanApproval` IPC),零前端改动。Pi 的计划能力由 Extension 注册的两个工具驱动:模型调 `EnterPlanMode()` → `execute` 设 `planMode.active=true`,发 `mode.change{plan}` + `plan.update{drafting}`(空文本,卡片不显示,只更新 composer chip);模型只读调研后调 `ExitPlanMode({plan})` → `execute` 发 `plan.update{ready}`(卡片出现)→ `await ctx.requestPlanApproval()`(阻塞 agent loop)→ 用户批准则发 `mode.change{default}` + `planMode.active=false`(工具门禁解除),拒绝则发 `plan.update{drafting}`(留在计划模式)。中断时 ExitPlanMode execute 的 catch 发 `plan.update{cleared}` 清理。plan mode 是 per-turn 状态(Extension 每 turn 重建)。**与 Claude 的差异:plan mode 下允许写文件/执行命令做验证**,但每个修改操作都弹审批框(`shouldAutoApproveForPi` 在 plan 模式返回 false → 走 `ctx.requestApproval`)——模型可以在计划阶段实验验证,用户逐个审批把关。**工具集不用 `tools` 白名单限制**:Pi 没有 SDK 内建 plan mode 状态机(Claude 的 ExitPlanMode 批准后 SDK 自己恢复工具集),`tools` 白名单在 `createAgentSession` 时固化——如果在 plan 模式用白名单排除 write/edit/bash,审批通过后它们仍然不可用(模型报"没有编辑工具")。所以所有工具始终全部可用,plan mode 的权限控制完全靠 `tool_call` handler + `shouldAutoApproveForPi` 动态审批
### 「撤销本轮」文件回滚(rewind)
- **不使用 SDK 内建 `enableFileCheckpointing`**:该机制要求 `permissionMode: "acceptEdits"`,会绕过上面的 `canUseTool` 守卫与工具审批 UI,直接废掉核心安全资产。改为自研「记录/恢复解耦」方案,保留路径守卫。
- **记录**:`FileSnapshot`(`apps/desktop/src/main/lib/fileSnapshot.ts`)只负责捕获——`recordPre(cwd, path)` 在 `SdkMessageAdapter` 每个 `FILE_MUTATING_TOOLS` 的 `tool_use` 上读盘存 `before`(首调生效);`freeze()` 在回合结束读盘算 `adds/dels/before`,产出 `TurnFileEntry[]`,并**过滤净零条目**(`adds===0 && dels===0`:回合内建了又删的文件、从未落盘的写入、逐字节相同的重写——对审查和恢复都无意义;2026-08-28 起,此前"创建 12 · 修改 1"卡片里 12 行「无变化」噪音即源于此)。净零条目同时从内存 Map 剔除,保证卡片路径集与 `hasPaths`/`restore` 一致。renderer 侧兜底:`upsertLiveTurnFilesBlock` 与水合 `fromRecords` 都剪除 0/0 条目(历史会话已落库的脏卡片重开后同样干净;全噪声块整块移除,消息因此变空则整条丢弃——**不能只在卡片组件层滤**,`turn.rewound` 按 block 路径集全等匹配,渲染层过滤会让 targetFiles 对不上)。记录逻辑与 `canUseTool` 守卫**共用 `normalizeToolFilePath`**,路径口径一致。**Pi 侧接入**:工具名小写(`write`/`edit`,字段 `input.path`),`recordPre` 挂在 `mcodeExtension.ts` 的 `tool_call` 守卫 write/edit 分支(路径守卫通过后 `await`,保证 before 先于工具执行);回合收尾 `PiMessageAdapter.flushFinal()`(provider `done()` 的成功/中断路径,错误路径跳过——对齐 Claude)里 `freeze()` + `ctx.emit({type:"turn.files"})`(同一 freeze 过滤,Pi 自动受益)。`turn.files` 晚于 `turn.done` 是常态(agent_end 已先发 turn.done),renderer 的 `turn.files` reducer 专为该顺序而写。**其余链路(rewind IPC、RuntimeManager 持久化、前端卡片)完全复用 Claude 的实现,零改动**——`RuntimeManager` 的 `sendTurn` 开头换快照实例、`rewindTurn`、`turn.rewound` 持久化全部 provider 中立。
- **按轮次换实例(2026-09-04 修中断竞态)**:`sendTurn` 开头对快照做 `dropFileSnapshot`(registry 换新实例)而非原地 `clear()`——被打断的上一回合 adapter 仍持有旧实例,且要等 SDK 生成器真正 unwind 完才跑 `flushFinal()/freeze()`(interrupt IPC 远早于此返回);原地 clear 会让该 freeze 拿到空记录(**旧回合卡片丢失**),更糟的是 `freeze()` 入口无条件置 `frozen=true`,从此留在共享实例上——新回合所有 `recordPre` 被静默丢弃(**第二轮卡片同样消失**,即"运行中重发一条消息后两轮卡片全灭"的根因)。换实例后旧 freeze 在旧实例上照常产出迟到事件(渲染端 `upsertLiveTurnFilesBlock` 按 `filesId` upsert 收敛),新回合从全新快照开始;`clear()` 仅剩 rewind 成功后一个调用点。renderer 的 `turn.files` 持久化同步改为**按引用差异只写被改动的消息行**(此前固定写数组末条——interrupt+send 场景末条是新用户气泡,卡片会丢库)。
- **恢复(统一入口)**:模块级 `restoreFiles(cwd, entries)` 是恢复的唯一实现——遍历 entries,`modified` 写回 `before`、`created` unlink(ENOENT 容忍),每条过 `safeResolveOk` 拒绝逃逸 cwd 的路径。**实例方法 `FileSnapshot.restore()` 已删除(2026-09-14)**:freeze 产出 entries 后即释放内存里的文件正文,留着 restore 只会在内容释放后写回空文件(数据丢失陷阱);rewind 走 `restoreFiles` + DB 持久化条目,freeze 后快照只剩 `hasPaths`/`clear` 消费 keys。
- **`RuntimeManager.rewindTurn(sessionId, files, targetFiles, latest?)`** 接收显式 `TurnFileEntry[]`,与内存快照脱钩——所以**会话重开后、以及任意历史轮次**都能撤回(数据来自 DB 持久化的 `before` 字段,而非易失的内存 Map)。cwd 解析优先 `rt.lastCwd`,缺失时(会话重开未发新轮次)回退 `SessionRepo` → `ProjectRepo` 取项目路径。内存快照仅在 `latest=true` 且路径集合 === 内存快照 keys(`hasPaths`)时才 `clear()`(路径集只是交叉校验,同文件多轮的历史撤回靠 `latest` 区分);`latest=true` 同时清 DB 最新 `turn_files` 列(无 rt 也可)。
- **撤回痕迹(统一形态)**:最新/历史轮次撤回走**同一** `turn.rewound` 事件,`targetFiles`(请求路径集)**必填**。renderer 标记消息流中的 `turn-files` block 为 `rewound: true`——卡片**永不删除**,降透明度 + 「已撤销」徽章,在数据流中留下"曾经撤回过"的痕迹(对齐 SDK「文件回滚不回滚对话」语义)。**标记按被点卡片精确定位(2026-09-20 修同文件多轮误标)**:`rewindTurn` 动作在 IPC 前用卡片自己的 `files` 数组反查所在消息(`findRewindTargetBlock`:引用相等为主、含 `before` 内容的深相等兜底,跳过已 rewound 块),挂模块级 `pendingRewind{sessionId,messageId}` marker(必须先于 IPC 挂——main 在 invoke 应答前就推事件);`turn.rewound` 处理器消费同会话 marker 只标记该消息里的卡,无 marker(他端发起)或 marker 失效(该消息已无匹配块)时回退旧的**路径集合**扫描——路径集在"第 1 轮建文件 + 第 2/3 轮都改它"时三轮全等,旧扫描会把没撤的轮次一起标灰并错误清掉 live bucket,且误标经 upsertMessages 落库、重启仍在。仅当被标记卡片是 live 卡(`isLatestTurn`)时才清 `turnFilesBySession`(文件树点标记/diff 来源不再视其为本轮改动)。main 侧同理由 `latest` 门控(渲染端从被点卡的 `isLatestTurn` 传入,`RewindTurnSchema` 可选字段,缺省 false=历史;mobile 三参调用零改动):仅最新轮撤回才清内存快照(且仍要求 `hasPaths` 交叉校验)与 DB 最新 `turn_files` 列——历史撤回即便路径集与最新轮相同也不动它们(旧快照 clear 门控只看路径集,同文件多轮时会误清)。此前 emit 闭包里按路径集清列的 `turn.rewound` 分支是**死代码**(rewindTurn 直接 sendToRenderer,事件从不流经 emit),已删,清列逻辑移入 `rewindTurn` 本体(重开会话无 rt 也能清列)。
- **UI**:`TurnFilesCard` 每个**未撤销**的卡片都显示「撤销本轮」(历史卡片点击前 `confirm` 警告可能影响后续轮次);`rewound` block 的 `rewound` 字段在 `turn-files` case 上(block 联合类型新增,向后兼容)。`store.rewindTurn(files, targetFiles)` 由调用方显式传 files + targetFiles(必填),不乐观清状态(等 `turn.rewound` 事件)。
- **契约**:`RewindTurnSchema` 含 `files`(内联 zod)+ 必填 `targetFiles` + 可选 `latest`(是否最新轮,main 清 live 态的门控);`TurnRewoundEvent` 带必填 `targetFiles`。
### 自动化(定时任务,2026-09-19,v2 重构「任务即会话」,设计原型 `prototypes/automation-redesign-v2.html`,v1 为 `automation-redesign.html`)
- **v2 定型(以本节为准)**:任务在**任意会话的 composer** 里发起——「自动编排」右侧的「定时任务」按钮(OrchComposerChips 内 ScheduleChip)弹 ScheduleEditor 配规则;配置存 `taskScheduleBySession[sessionId]`(store),**handleSend 拦截**:有配置的发送 → `createScheduledTask`(automation.save + parentSessionId)→ main 创建任务会话(kind="automation",sessionStart 广播)+ registry 行,发起会话落一条 system 回执消息(本地 append + upsertMessages),不进普通回合。**任务 = 会话**:一任务一 kind="automation" 会话(左栏可见,时钟图标),每次触发 = fireTask 向**同一会话**追加一个回合(prompt 带日期 run header + @path 文件行 + skills allowlist);「历史」= 回合列表,保留策略 = `MessageRepo.trimTurns(sessionId, keepRuns)`(按 user 消息锚点裁剪)。**运行在右栏「定时任务」tab**(`RightPanelTab` 新增全局档 `"sched"`,SchedPanel):任务切换器(schedFilterParent 过滤发起者)+ 实时输出(直接渲染该会话的 ChatPane)+ 回合历史 + 暂停/立即运行/删除。**左栏图标体系**(2026-09-20 起**任务会话行不再进左栏**——LeftBar(项目列表/置顶/归档架)与 StreamSidebar(live/pinned)在**渲染层**过滤 `kind==="automation"`,store/SQL 仍含任务会话(mobile 不受影响);发起任务的主会话 = 描边时钟徽标 + 计数,点徽标 `openSchedPanel(parentSessionId)` 过滤直达——这是左栏唯一的任务入口)。**v1 全屏自动化页已退役**(AutomationPage/TaskComposerCard 已删,App overlay/Titlebar automation 模式/快捷区入口已摘)。任务会话对列表**可见**(listByProject/listPinned/listAll/countAll/searchByTitle/searchBookmarks 翻转为 `kind IN ('chat','automation')`),但 **findFreshByProject 与 listStale 保持 chat-only**(普通新建会话绝不复用任务会话空行;AutoArchiver 不碰任务会话,清理权在 trimTurns)。`automations` 表新增 `task_session_id`/`parent_session_id`;`automation.delete` = 先 archive+broadcast+硬删任务会话再删注册行;调度器 `registerTaskSession` 让 IPC 新建的任务即时进入 observer 的 watched 表。
- **/schedule 命令 + 意图识别审批(2026-09-19 增补)**:`BuiltInCommandKind` 新增 `"schedule"`(slash 菜单里与 compact/init 并列,描述走 `lib.slash.schedule`),选中 → 清触发 token + `setTaskScheduleEditorOpen(sessionId,true)` 拉起定时配置弹层(开合状态在 store:`taskScheduleEditorOpenBySession`,ScheduleChip 的弹层即读它)。**意图识别审批(模型判定,2026-09-19 二次增补)**:发送时若 chip 无配置且窄词表 `looksLikeScheduledTaskIntent(text)` 命中(词表只决定**何时去问模型**,宁窄勿误报,不给每条消息付模型成本)→ 拦下发送,弹审批对话框并**立即发起一次性模型调用** `automation.parseIntent` → `main/automation/intentParser.ts` 的 `parseScheduleIntent`(照 titleGen 的 one-shot query 模式:45s 超时/maxTurns 1/`tools: []`/用户原文 JSON 围栏防注入,模型复用 titleGen 设置,未配置则走默认凭据),系统提示要求只输出 `{isTask,reason,schedule}` JSON(schedule 六型,相对时间以附带的当前时间推算)。对话框三态:分析中(创建按钮禁用,「普通发送」随时可点不等待)/ 模型判定 isTask=true(ScheduleEditor **预填模型解析出的规则**,reason 展示,可改)/ isTask=false(明示模型结论,仍可手动配置或普通发送)。解析失败/超时 → 回退手动配置对话框。**是否创建始终由用户批准**。两条路都复用 handleSend 已有的 chip 拦截语义(文件 tag→@path、技能→allowlist)。
- **右栏面板二次演进 + 运行台账(2026-09-19,多任务×多回合易用性)**:① **运行台账 `Automation.runLog`**(`automations.run_log` JSON 列,`AutomationRunEntry[]` 旧→新):调度器**唯一**在 `fireTask` 追加(`{firedAt, manual: !advanceSchedule, status:"running"}`,调度触发与「立即运行」都走它),`settleRun` 在 runtime 事件翻面(waiting-approval/success/failed,终态附 `durationMs`;**已终态条目冻结**,迟到事件不改写);`pruneExpiredTurns` 同步 slice 到 keepRuns(与 trimTurns 对齐);`reconcileOnBoot` 对孤儿运行标 failed 并**回填**——runLog 为空的行扫任务会话 user 消息按 `RUN_HEADER_PREFIX`(契约常量 `"[定时运行"`;scheduler 拼 prompt、MessageRepo 扫描、renderer 锚点匹配三方共用)生成无 status 条目(一次写回,「为空才扫」即幂等守卫)。台账是 scheduler 专属:IPC save 的显式字段清单不含 runLog,用户保存不会覆写。② **台账只记 firedAt 不记锚点消息 id**——消息由 **renderer** 持久化(upsertMessages),main 在 fire 时不知道消息 id;跳转 = renderer 的 `findRunAnchor(messages, firedAt)`(role=user 且 text 以运行头开头,取 **createdAt 距 firedAt 最近**者,±5min 窗口)在**已加载消息**里解析出锚点后 `setPendingBookmarkJump`(ChatPane 既有「等消息→跳转→flash」通道)——**必须在锚点已入 store 后才 set**,否则 ChatPane 的消费 effect 会在「historyLoaded 且未找到」时把它当 stale 清掉;锚点不在首页时 SchedPanel 自己 `loadOlderMessages`(≤2 页;`loadingOlder` 在途时**等待**而非放弃/连发)。③ **SchedPanel 重构**:任务列表 `sortAutomations` 智能排序(running/waiting > failed > enabled 按 nextRunAt > disabled),行尾显「N 次」;列表头行加「+ 新建」(无项目时禁用 + 提示);**运行记录区**(可折叠,失败任务自动展开,切任务重置)= 台账索引(#序号/时间/状态点/耗时/手动徽标,最新在顶),点行跳转该回合;选中切换时 `prefetchSessionMessages`(ChatPane 不自行预取,旧实现未打开过的任务会白屏);底部加「编辑」。④ **AutomationEditor 弹窗(新建/编辑共用)**:标题(空则取 prompt 首行)+ prompt + 技能 chips(`SlashCommandPicker showBuiltIns=false`,只存 allowlist、不往 prompt 注 `/name` 字面量)+ 文件 chips(`FileMentionPicker` attach 模式)+ 受控四槽执行配置(provider 切换重置 model、effort/permission 按 capabilities 收敛、过滤 plan 档;`ProviderInfo` 来自 `@contracts/ipc`、字段是 `displayName`)+ 内嵌 `ScheduleEditor`(含下次运行预览)+ keepRuns;归属只读派生(创建 = 激活 chat 会话的项目+会话,回退首个项目;parent 为 null 不发回执),走 `automation.save` update 分支(main 重算 nextRunAt),编辑不改 enabled(开关是面板独立动作),保存后 `onSaved` 让面板选中新任务。⑤ 顺手修:`selected` 从**过滤后**列表解析(旧实现全量 find,选中项会脱离过滤列表造成列表/详情不一致);`taskSessionId` 缺失的空态与「无任务」文案分离。
- **面板只读化 + 运行历史分页(2026-09-20 增补)**:① **ChatPane 新增 `hideComposer` prop**——右栏任务面板传它隐藏 composer 卡与顶部 chips 行(同样用 `hidden` class 保持挂载,`display:none` 下 focus 天然失效不会抢焦点),**审批/提问/计划审批的 in-flow 卡保留**——那些是决策不是自由输入,无人值守任务可能正等在那里;prop 进 memo 比较器。② **「运行历史」**(`automation.runHistory` 词条,原「运行记录」更名):收起=一行摘要(总次数+最新状态点),展开=`runLog` 倒序分页 **10 条/页**(`RUN_HISTORY_PAGE`,「加载更多」= `layout.loadMore`/`loadMoreRemaining`,切任务重置页数),点行跳转录池对应回合(机制同上条②)。③ 任务会话在**渲染层**退出左栏两视图(见「左栏图标体系」)——store/SQL 查询不动,手机壳不受影响;LeftBar 的 SessionRow 任务分支与 StreamSidebar 的时钟前缀代码成为不可达防御代码,留在原处。
- **面板 scope=当前会话 + 任务⊃历史⊃输出排版 + 「Not logged in」修复(2026-09-20 二次增补)**:① **执行配置落会话行(修 v2 缺陷)**——回合凭据来自**会话行**的 `customModelId/model/providerId`(`RuntimeManager.sendTurn` L512 起解析 apiConfig),但 `ipc/automation.ts` 创建任务会话时只传了 `effort/permissionMode`,任务行上的执行配置**从未生效**→ 所有任务回合都走默认凭据发现,自定义端点用户必报 `Not logged in · Please run /login`。双修:创建分支把 `providerId/model/customModelId` 一并传入 `createOrReuseSession`(StartSessionInput 本就支持);`fireTask` 每次 fire 前 `SessionRepo.updateSettings` 把任务行配置**重新烙到会话行**(编辑任务即对下次运行生效 + 自愈修复创建于缺陷期的旧会话行)。② **AutomationEditor 新建的执行配置默认继承当前会话的 composer 全局槽**(providerId/model/customModelId/effort/permissionMode 经 `useSessionStore.getState()` 取),不再硬编码 claude-sdk/default。③ **SchedPanel scope=当前会话**:`scope = schedFilterParent ?? baseScope`,`baseScope` 由激活会话派生(automation 会话→其 parentSessionId);左栏徽标点击(设 filterParent)导航到别会话时出 banner「来自会话: X ×」;scope 为空(无激活会话)禁用「新建」(`automation.needSession`)。**parentSessionId 为 null 的任务在面板里不可见**(仍会被调度,属测试遗留)。④ **排版 = 包含关系**:任务卡(名称/状态/规则/操作四键——暂停·立即运行·编辑·删除从底部操作条移入卡片)在顶,其下 `ml-3 border-l` 左轨嵌套「运行历史」与「运行输出」,视觉表达 任务⊃历史⊃输出;历史行点击 = `setViewedRunAt(firedAt)` + 跳转,输出头显示正查看的运行(`#N · 时间`)或「实时」(`automation.outputLive`),查看旧运行时出「回到最新」(回跳最新回合锚点);被查看行高亮(`viewedRunAt ?? latestFiredAt`),切任务重置。
- **是什么(v1 沿革,部分已被上条取代)**:任务 = prompt + 执行配置(providerId/model/customModelId/effort/permissionMode,与 Composer 五槽同形状)+ 定时规则(一次性/间隔/每天/每周/每月/Cron 六种);到点由**主进程**自动创建会话并发 prompt 无人值守执行。入口在两栏共用的 SidebarQuickActions(「连接手机」下方,同款行样式);页面是全屏覆盖层(与设置页同款形态,`automationOpen` 旁 `settingsOpen` 挂,workspace 子树保持挂载,左栏 aside 与 Divider 条件各加 `|| automationOpen`)。**页面自身不渲染顶栏/返回键**——Titlebar 的 `Mode` 加 `"automation"` 并与 settings 分支共用(`isSettings = mode === "settings" || mode === "automation"`),标题按 mode 动态取词;`onBack` 按 `automationOpen` 分流关闭;Titlebar 的 rightOpen/bottomTerminalOpen 压制条件同样加 `automationOpen`。Esc 关闭页面(药丸菜单打开时只收菜单——PillMenu wrapper 挂 `data-pill-menu='open'`,页面 Esc 查询跳过;`[role='dialog']` 存在时归 ConfirmDialog)。
- **任务输入框 = Composer 同款能力(TaskComposerCard v2)**:附件 chips(skills + files)在输入框上方,下接 textarea + 药丸段(SDK/模型/思考/权限)+ 无人值守提示。① `/` 命令:textarea 光标前扫出斜杠 token(行首或空白后)→ 复用聊天的 `SlashCommandPicker`(新 prop `showBuiltIns=false` 隐藏 compact/init/browser/sidechat 内置命令 tab——它们是聊天专属行为),选中插入 `/name` 字样并记录进 `skillNames`。**技能真正生效靠 SDK skills allowlist**(stream-json 不解析 prompt 里的 `/name` 字面量),调度器 fireTask 时 `sendTurn({skills})` 透传。② 添加文件:回形针按钮复用聊天的 `FileMentionPicker`(attach 模式,scope 到**任务的项目**而非活跃会话)→ 文件 chips。**文件 = 纯路径引用**(`@path` 行,与聊天 `composePromptWithTags` 的 file 分支同表示),调度器在 fire 时把 `filePaths` 逐行拼到 prompt 尾部——模型运行时用工具读**当下**的文件内容,保存时绝不快照。③ 拖拽:整卡接受文件树的 `FILE_DRAG_MIME`(`application/x-file-path`,FileTree 行本来就在 set)拖入 → 文件 chip;外部文本/图片拖放因无此 MIME 被忽略(与聊天同规则)。两个 picker 都自绑定窗口 capture 键盘(↑↓/Enter/Esc + stopPropagation),页面级 Esc 不与之打架;slash picker 的关闭靠 textarea onBlur(面板根 preventDefault mousedown,点选项不丢焦)。任务表加 `skill_names`/`file_paths` JSON 列(migrate 追加),`AutomationSaveSchema` 对应字段 default []。**覆盖层让位**:overlay 不是恒 `inset-0`,而是 `right: rightOpen ? rightWidth : 0`(宽屏模式换算 `${widePanelPct}%`)+ `bottom: bottomTerminalOpen ? bottomTerminalHeight : 0`——工具栏的右侧栏/终端开关在自动化页**保持可用**(Titlebar 压制条件只保留 settingsOpen),右栏文件树和终端在页打开时可见可交互。
- **展示隔离 = 新会话 kind `automation`**(side/orch-worker 第三次复用同一模式):所有列表/搜索/置顶/流视图/手机端/AutoArchiver(`listStale`)查询都硬编码 `kind='chat'`(orch/side 各自专用查询),automation 行天然不进任何常规会话列表——高频任务(每 5 分钟)再密也不污染会话流。**三处必改缺一不可**:`session.ts` kind 联合 + `StartSessionSchema.kind` enum;**`rowToSession` 归一化加分支**(不加则写进去读出来变 chat,漏进所有列表);`sessionStart.ts` 加 automation 分支——永远新建行、显式 title(任务名 · MM-DD HH:mm)、**绝不 `broadcastSessionChanged`**(side/orch-worker 同款纪律;`session.changed` reducer 只特判 side,任何被广播的非 chat kind 都会 upsert 进桌面+手机列表缓存,这是唯一泄漏路径)。运行历史 = 按 `sessions.automation_id` 列查询(`SessionRepo.listByAutomation`,复合索引 `idx_sessions_automation(automation_id, created_at)`),每次运行就是一行普通会话,「查看」→ `openAutomationRun`(取行塞进 `orchWorkersById` ——「无列表会话」的 findSession 最终兜底,与 openOrchWorker 同一 map 同一纪律——再 openTab),ChatPane 流式/审批/停止/追问全功能复用。
- **调度器**(`main/automation/AutomationScheduler.ts`,挂 `index.ts` 的 `initAutoArchiver()` 旁,`before-quit` 清理链 `disposeAutomationScheduler()`):**轮询不挂 per-task 定时器**——30s tick 扫 `AutomationRepo.listDue(now)`(`enabled=1 AND next_run_at <= now`,驱动 `idx_automations_next_run`),AutoArchiver/updater 同款形态;触发五连照抄 orchestrator dispatcher:`createOrReuseSession → updateStatus(running) → resolveSessionCwd → bindSession → sendTurn`。**写先于跑**:tick 内先落 `lastRunAt/nextRunAt/lastStatus` 再 dispatch,崩溃最坏丢一次运行,绝不热循环重放。**错过跳过**(reconcileOnBoot):启动时对每个 enabled 任务重算 `nextRunAt=computeNextRun(schedule, now)`,应用未运行期间的触发点直接不作数;一次性任务过期 → 自动停用(行与历史保留);上代进程留下的 running/approving 运行会话标 `interrupted`、任务 lastStatus 标 failed(防幽灵运行)。**重叠保护**:触发前查 `lastSessionId` 会话 status ∈ {running, approving} 则跳过本轮(nextRunAt 照常推进,不排队);手动「立即运行」走同一守卫(`runNow` 返回 null)。**保留策略**:每次运行终态后 `listExpiredAutomationRuns`(LIMIT -1 OFFSET keepRuns)逐个 `runtimeManager.dispose + SessionRepo.delete`(消息级联),running/approving 的永不清理。
- **状态跟踪**:调度器 `runtimeManager.addObserver`(绝不能 `setObserver`——会把 NotificationManager 顶掉)按 `watched` Map(sessionId→automationId,dispatch 时登记)过滤事件:approval.request/question.ask/plan.approval_request → `waiting-approval`;turn.done reason=tool_use 是中间边界忽略,`end_turn|max_tokens → success`、`interrupted|error → failed`;每次翻转发 `IPC.AUTOMATION_EVENT` 推送(`{channel, automationId}`),renderer `ingestAutomationEvent` 整页重拉任务表(表小,不做 diff 协议)+ 已加载的运行历史页刷新。审批等待/回合完成的 OS 通知由 NotificationManager 现有 observer 自动覆盖(窗口失焦时),自动化链路零通知层改动。
- **契约与算法**:`contracts/automation.ts` 持有 `AutomationScheduleSchema`(zod discriminatedUnion)+ `computeNextRun`/`parseCron`——**renderer 预览与 main 调度器 import 同一纯函数**,编辑器显示的「下次运行」与实际触发时间不可能漂移。cron 为手写 5 字段解析(支持 `* , - /`),无第三方依赖;**day 语义**:dom 与 dow 都受限 → **并集**(cron 官方怪癖:"13 号或周五",不是"13 号逢周五");只有一个 `*` → 受限的那个说了算;dow 7 ≡ 0(周日,逐值 %7 折叠,范围 "5-7" → 5,6,0);cron 匹配逐分钟步进但上限 366 天。monthly 小月无该日跳过(4 月无 31 号就不触发,cron 语义)。冒烟:`scripts/automation-schedule-smoke/run.sh`(31 断言;曾抓到 dom/dow 语义写反 + dow 7 未归一化两个真 bug)。
- **DB**:`automations` 表(migrate 追加 `CREATE TABLE IF NOT EXISTS`,无版本号机制)+ `sessions.automation_id` 列(`addColumnIfMissing`)+ 两个索引;`AutomationRepo`(ProjectRepo CRUD 骨架 + update 用固定白名单动态 SET,`updated_at` 恒 bump)。`automation.delete` IPC **先删运行会话**(dispose runtime + SessionRepo.delete 消息级联)再删任务行——崩溃窗口留下孤儿运行,不留活任务指向已删会话。
- **UI**(`components/automation/`):AutomationPage(左任务列表 262px + 右详情 配置/运行历史 两 tab)+ ScheduleEditor + TaskComposerCard。**表单是本地 draft,从不写全局 Composer 五槽**——`setModel/setEffort/...` 在有活跃会话时会 `session.updateSettings` 直接改写当前会话行的持久化配置(已知污染路径),所以执行配置做成受控小组件(读同一 store 数据源 providers/caps.builtinModels/customModels/pi-codex 动态模型表,不经过 ModelDropdown 的守卫机器);切换 provider 时 model 重置 default、effort/permission 按新 provider capabilities 收敛(缺省落 acceptEdits)。draft 仅在**选中任务变化**时重初始化——调度器推送会整体替换 `automations` 里的对象,编辑中的输入必须活过后台刷新。权限段语义色(acceptEdits 绿 tint / bypassPermissions 琥珀 tint)+ 卡底常驻「无人值守建议宽松权限」提示。运行历史行状态映射:`Session.status → 运行状态`(running→运行中,approving→等待审批,done/idle→成功,其余→失败),耗时读 `usageHistory` 末条 durationMs。
- **i18n**:`zh/automation.ts` + `en/automation.ts`(键前缀 `automation.*`,zh 为源 en 镜像过 typecheck)+ core.ts 注册;左栏入口词条 `layout.automation`(zh+en 两份 layout.ts);`common.listSeparator`("、"/", ")为 describeSchedule 的星期串新增。移动端 web 桩:`webApi.ts` 的 `on` 精确类型要求补 `automationEvent: () => () => {}` no-op(自动化页是桌面-only)。
- **定时任务查看页(2026-09-20 增补;同日多次调整:行操作全量开放,仅「新建」留在面板 tab)**:左栏快捷区「定时任务」入口(SidebarQuickActions,连接手机下方,`layout.schedTasks` 词条)→ `schedPageOpen`(store,复用 v1 遗留死槽 `automationOpen` 改名)挂**设置页同款全屏覆盖层**(App.tsx 主区内 `absolute inset-0 z-30`,左 aside CSS 隐藏、工作区子树保活,Titlebar `Mode` 增 `"sched"` 档=返回键+页标题,右栏/终端开关同 settings 压制;与 settingsOpen **双向互斥**,后开者关先开者——两个 overlay 同 z 同位,叠着就再也摸不到底下那个)。内容 = **`SchedPanel` 复用**(零复制;2026-09-20 收敛:原 `readOnly/allowOps/allScope` 三布尔 props 合并为单一 **`variant: "panel" | "page"`**——panel=右栏会话 tab(会话 scope + 新建),page=左栏全屏查看器(全量任务、无新建,行操作工具箱与 panel 完全一致),两个宿主共享**同一份实现**,行操作/编辑弹窗/确认框/实例详情零分叉):`openSessionInWorkspace` 覆盖实例详情的「在会话中打开」——全屏页传「开 tab + 关页」的 drill-out,否则 tab 开在 overlay 背后用户看不到任何反馈。Esc 关页(SchedPage 自挂 window keydown,镜像 SettingsPage)。**审批/提问 in-flow 卡在该页仍可操作**(ChatPane hideComposer 的既有语义:那是决策不是编辑)。
- **右栏面板编辑入口(2026-09-20 二次增补;同日调整:行点击不再弹编辑,行编辑按钮两种宿主都有)**:SchedPanel 编辑入口为**显式按钮**——任务行操作条「立即运行」与删除键之间的铅笔「编辑」小按钮(page variant 查看页同样渲染);**点击任务行 = 仅选中**。列表头「+ 新建」(`automation.newTask` 词条):panel variant 打开 create 编辑弹窗(保存成功 `onSaved` 选中新行);**page variant 传 `onNewTaskSession` 回调——点击新建一个空白 chat 会话(startSession,无项目则 toast `layout.needProject` 不跳)并关闭全屏页回到工作区**,任务再从该会话的 composer 编排(ScheduleChip / /schedule,tooltip 走 `automation.newTaskSessionTitle`)。编辑弹窗承载执行配置四槽 + 标题/prompt/技能/文件/规则/保留数全套,经 `automation.save` update 分支(id 在即原地更新,main 重算 nextRunAt,**编辑不改 enabled**);`fireTask` 每次 fire 前 `SessionRepo.updateSettings` 重烙会话行,所以对运行中任务的配置修改**下次运行生效**。弹窗持有的 `task` 是**点击时刻的快照**(SchedPanel 本地 state)——调度器推送会整体替换 `automations` 数组,活绑定会在编辑中途重初始化 draft;save 通道只需要 id。
- **左栏入口在途徽标(2026-09-20 三次增补)**:SidebarQuickActions 的「定时任务」行尾挂 `×N` 徽标(`automation.inFlightBadge` tooltip;sky 色对与 statusMeta running 一致),N = `automations.filter(isInFlightAutomation)`(新增共享判定:`!deletedAt && lastStatus ∈ {running, waiting-approval}`,即调度器重叠保护的两态)。automations 表本是懒加载,徽标在 SidebarQuickActions **挂载时**补一次 `loadAutomations`(幂等,`automationsLoaded` 门控);此后调度器每次状态翻面的 `AUTOMATION_EVENT` 推送 → `ingestAutomationEvent` → `loadAutomations` 让计数实时跟手,开面板/页面无需先于徽标。
- **任务归属项目显示(2026-09-20 四次增补)**:SchedPanel 订阅 `projects` 建 id→name Map,任务行元信息区新增**归属项目行**(folder 图标 + 项目名,truncate + title 悬停;运行计数 `N 次` 并到该行右端,原「规则行」只剩 schedule/删除时间)——查看页 `allScope` 混着多项目的任务,行上不带归属就分不清;概览卡 meta 行首同样补 `📁 项目名`(projectName 由父组件解析传入,行消失时渲染 null 不占位)。右栏 tab 同样受益(会话 scope 通常单项目,多一行成本可忽略)。
- **编辑弹窗字段锁(2026-09-20 五次增补;同日六次增补:composer 化整合)**:AutomationEditor **编辑态只开放三类可改**:定时规则(ScheduleEditor)、执行四槽(SDK/模型/思考/权限)、任务内容(标题 + prompt);**技能、文件、保留条数锁定**——chips 渲染为只读(无「+」无「×」),保留数显纯文本,锁定段标签带「创建后不可修改」提示(`automation.editLocked`)。save 载荷对锁定字段**回传原始行值**(`task.skillNames/filePaths/keepRuns`)而非 draft——UI 只读之外再加一道防陈旧 draft 覆写的闸。新建模式全字段照旧可编辑;归属(项目/发起会话)本就只读派生。**六次增补——直接复用聊天输入框本体(不是仿制)**:① 卡片上方 chips 行 = 「定时规则 chip」(虚线时钟 chip,label=`describeSchedule` 截断,点按折叠规则面板,ScheduleEditor 含下次运行预览;新建默认展开、编辑默认收起,`schedOpen` 随 open 重置)+ 技能/文件 chips(`px-1 pb-1` 同聊天 chips 条);② **输入卡是聊天 composer 的忠实复刻**——`composer-card`(同 class 钩子含 focus-within 柔光)+ **真 `ComposerEditor`(Tiptap,聊天同款组件,受控 onChange/onEnter,Enter=保存)**+ `composer-action-row` 内 `composer-minipill`(data-compact="0")药丸段:SDK 段(getProviderIcon 品牌图标)、模型段 IconCpu、思考段 IconBolt、权限段 IconShieldLock(bypass 段 text-danger),分隔用 `composer-minipill-mid`,标签一律 `composer-lblwrap` 包 truncate;右侧簇只留 **composer-send 发送键 = 保存**(data-ready 随 promptOk,页脚只剩「取消」)。**关键:`.composer-card/.composer-minipill/.composer-action-row` 样式在 styles.css 里是 `[data-chat-root]` 作用域——弹窗滚动内容容器必须带 `data-chat-root` 属性**(`.composer-host .ProseMirror` 与 placeholder 规则是全局的,不受影响)。去掉的聊天多余件:队列/语音麦克风/provider 锁 chip/上下文环/自动编排/拖拽。prompt 种子经 `editorRef.setText` 在 open effect 灌入(ComposerEditor 是命令式 Tiptap 宿主、起始为空;50ms 重试一次防编辑器实例未就绪)。**同日七次增补——下拉也换聊天组件本体**:药丸段不再是手搓 base-ui Select(默认列表样式与聊天割裂),四个段全部复用聊天控件本体 + 新增 `controller` 受控覆盖(值绑本地 draft,不碰全局会话槽):`ProviderDropdown`(新 `segment` 形态=composer-minipill-seg 触发器 + 永不锁定)+ `ModelDropdown`(controller{providerId,model,customModelId,onPick};受控态不响应 modelGuardPulse 防聊天发送守卫误震弹窗 chip、隐藏「管理模型」入口)+ `EffortChip` / `PermissionChip`(controller{providerId,value,onChange},Permission 另有 `modes` 覆盖——编辑器过滤掉 plan 档,无人值守不得落计划模式);divider 按 draft provider 的 capabilities 条件渲染(对齐 ComposerToolbar 的 hasEffort/hasPerm 纪律),编辑器里自建的 modelOptionsFor/effortLabel 等复制品全部删除。
- **提案卡状态持久化(2026-09-20 修;同日二次加固:指纹台账)**:`ScheduledTaskApprovalCard`(消息里 ```scheduled_task_proposal 代码块经 Markdown 渲染)的「已创建」必须**跨重启、且独立于任务行存在**(删任务不回退)。实现 = **确认决定落 settings 表**:`AUTOMATION_CONFIRMED_PROPOSALS_SETTING_KEY`("automation.confirmedProposals",JSON 数组)存每张已确认卡的**指纹**(FNV-1a hex of 卡片原始 JSON——卡片由消息内容渲染,同一条消息永远得到同一指纹)。store 侧 `confirmedProposalFps` + `loadConfirmedProposals`(loaded 标记防重复拉;损坏台账只记 loaded 不重试) + `confirmProposal`(乐观 append + setting.set fire-and-forget),卡片挂载时触发一次加载。卡片的 confirmed = 本地 justCreated ‖ 台账含指纹 ‖ 存在 title+prompt 精确匹配的未删除任务行(后者兜底台账缺失的旧数据)。**删任务不影响**:台账条目不随任务行删除;确认后但任务已删的卡显示已创建绿文案、无跳转按钮(createdTaskId 为 null 时按钮区为空)。已知取舍:同一消息内容永远已确认(重复建需模型出新提案)。「前往任务会话」= 打开右侧定时任务面板并选中该任务(setSchedSelected + `openSchedPanel(parentSessionId)`——scope 锚到任务归属会话,别的会话点旧卡片也能落对;仅在仍能解析到任务 id 时渲染)。
- **右栏面板去重(2026-09-20 八次增补)**:① sched 会话级 tab 名 `layout.automation`(「自动化」)→ `layout.schedTasks`(「定时任务」,与左栏入口同词;`layout.automation` 仍被左栏时钟徽标 aria-label 引用,词条保留);② **概览卡不再放操作按钮**——右栏右侧的任务概览卡纯展示(标题/状态徽标/归属项目/规则/模型/次数/prompt 预览),暂停/恢复、立即运行、编辑、删除(含已删除态的恢复/彻底删除)只留在**任务行操作条**一处,`TaskOverviewAndInstanceList` 的 opsEnabled/allowEdit/onEdit/onDelete/onRestore/onPermDelete props 随按钮簇一并摘除。③ **左右栏高度平衡(同日)**:右栏内容根节点原是整体 `overflow-y-auto`(内容少时下方留白、与左栏满高列表不对称),改为与左栏同构的「固定顶块 + 填满剩余高度的内部滚动」——概览卡 `shrink-0` 置顶,运行实例区块 `mt-4 min-h-0 flex-1` 且列表自带滚动;④ **左右栏上下对齐(同日)**:右栏增加与左栏头部**同构等高**的头部(`p-2` + pb-1.5 标题行「任务概览」`automation.overviewTitle` + 状态徽标 / 同款分段条内嵌任务标题),概览卡瘦身为纯详情(归属/规则/模型/次数/prompt,标题与徽标已上移头部),两栏首卡顶边(头部下 6px)与末卡底边(面板底 6px)逐像素对齐。
- **修:每分钟任务只执行一次(2026-09-20)**:根因是**会话行的 status 永远没人写回终态**——全工程对 `sessions.status` 只有三种写入:发送时 `running`、中断时 `interrupted`、任务 fire 时 `running`;回合正常结束从不落 `done`/`errored`(聊天路径无暴露,因为没人重复触发聊天会话,UI 徽标走的又是 renderer 内存态)。于是定时任务首轮 fire 后会话行停在 `running`,调度器的**重叠保护**(`fireTask` 读 `sess.status ∈ {running, approving}` 即跳过)从第二个 tick 起全部命中「previous run still in flight — trigger skipped」。修法(scheduler 观察者补写会话生命周期,与 automation 行同步翻面):审批/提问/计划审批 → `approving`;`error` 事件 → `errored`;终态 `turn.done`(reason≠tool_use)→ `done`/`errored`;`fireTask` 的 catch(dispatch 半途抛错,会话可能已翻 running)→ `errored` 兜底。**已卡死的存量任务自愈**:重启后 `reconcileOnBoot` 把残留 running 会话标 interrupted、任务标 failed,而 nextRunAt 一直在推进,首个 tick 即补跑。排查主进程日志锚点:`automation fired:` 与 `previous run still in flight — trigger skipped`。
### 左栏双模式:项目树 ↔ 会话流(P5.9,2026-09-02,设计原型 `prototypes/leftbar-redesign-v3.html`)
- **模式偏好**:`ui.leftBarMode` settings key(`"tree"` 默认 / `"stream"`,contracts 的 `LEFTBAR_MODE_SETTING_KEY` + `LeftBarModeSchema`),first-paint getMany 水合——完整沿用 `ui.displayMode` 模式。`LeftBarModeSwitch`(StreamSidebar.tsx 导出)挂在**两个侧栏顶部同一位置**(mac 红绿灯右侧 strip / win brand 行),图标 `IconArrowsExchange`。App.tsx 按 `leftBarMode` 分支挂 `LeftBar` / `StreamSidebar`;两视图是同一 store 的纯渲染者,切换不影响运行中 turn。
- **StreamSidebar**(components/layout/StreamSidebar.tsx,借形 T3 Code 当前侧栏,t3code 仓库 apps/web Sidebar.tsx):现状快捷入口(SidebarQuickActions)原样 →(scope 指向托管工作树时,「新建会话」经 `newSessionOverride` prop 改道 `startSession(projectId,{worktreePath})` 在该 checkout 开线程,图标切 fork;仅 `referencedBy>0` 的托管目录可覆盖——main 侧 bind 校验拒绝外部路径,故孤儿/外来工作树回落默认行为;**2026-09-03 起 scope 指向普通项目时同样改道** `startSession(scopedProjectId)`,tooltip 经 `newSessionOverrideTitle` 走 `layout.newSessionHere`——用户筛到哪个项目,新会话就落哪个项目)→「全部项目 ▾」scope 过滤(**持久化,2026-09-03**:settings key `ui.streamScope`(`UI_STREAM_SCOPE_SETTING_KEY`),store 的 `streamScope` + `setStreamScope`,first-paint getMany 水合,选中项目的 scope 重进应用/重挂侧栏均保留;scope 编码 `""`=全部 / `g:<分组名>` / projectId / `wt:<normWorktreeKey>`(setStreamScope 把 null 编码为 `""`——setting.set 只收 string);组件侧 `scope` 是对 `streamScope` 的**校验 memo**:项目 id 需存在且非归档、分组需仍有成员,失效降级为全部视图——放渲染期而非水合期,因项目列表晚于侧栏首帧落地;`wt:` 直通,matcher 本身安全且探针可能未落地;分组条目下缩进列出分组内项目,项目条目下缩进列出该仓库的**工作树**——来自 `worktreeInfoByRepo` 清单,排除 `main`/`missing`,显示名走 `worktreeDisplayName`,`wt:` scope 按 `normWorktreeKey(s.worktreePath)` 匹配)+ 添加项目入口 → 置顶卡片 ─ hairline ─ 活跃卡片平铺 → 归档 shelf(收起)。**三行富卡片(无元信息时退化为两行,2026-09-03)**:① 项目色块头像(名称哈希调色板,见 `lib/projectAvatar.ts`)+ 项目名 + 状态标签 ② 标题 ③ 元信息行:worktree 会话显 fork+分支 mono+未合并琥珀点,**本地会话同位置显项目根当前分支**(`localBranchOf`:`worktreeInfoByRepo[proj.path]` 里 `main===true` 条目的 `branch`,detached 退回短 SHA)/ 行尾 provider 品牌图标(`getProviderIcon` 的 `Icon`+`color`,hover 有 `label` 提示);**本地会话且项目非 git(非工作树)时 L3 恒为空**——`hasMetaLine`(`worktreePath || localBranch`)为假则整行不渲染,卡片退化为两行,provider 图标移到 L2 标题行**右端固定**(`flex-1` 标题 + `shrink-0` 图标,git 探针未落地的短暂窗口同此形态,落地后恢复三行)。**状态=行内彩色标签且 hover 让位给操作按钮**(T3 语义):运行中 sky+走字时长(`runningTurnStartedAt`,1s ticker 仅当有运行行)、等待输入 amber(`pendingQuestionBySession`)、失败红(`turnErrorBySession`)、完成未读 accent ✓(`unreadBySession`)、否则相对时间;**运行中非激活行整体淡出**(收件箱模型:"working threads aren't your problem yet");hover 操作簇 = **新建会话**(local 行 `IconPlus` → `startSession(projectId)`,worktree 行 `IconGitFork` → `startSession(projectId,{worktreePath})` 同 checkout 开兄弟线程,词条分别 `layout.newSessionHere` / `layout.newSessionInWorktree`,对齐树视图同款按钮)/ 置顶 / 归档 / 删除。
- **worktree = 行属性而非容器**(T3 形态):同树多会话各自成行;目录级操作(在此新建/合并回/重命名/移除)在共享 SessionContextMenu 的 **worktree 组**(回调可选,树视图同样接线——树里泳道头右键依旧)。`worktreeInfoByRepo` 缓存按 `gitChangeVersionByRepo` 版本失效,`ensureWorktreeInfo` 幂等。
- **聚合数据**:`session.listAll` RPC(contracts schema + `SessionRepo.listAll/countAll`,跨项目 `archived=0 AND pinned_at IS NULL AND kind='chat' ORDER BY updated_at DESC`,offset 分页默认 10)→ store 的 `streamSessions/streamHasMore/streamTotal` + `streamDirty` 脏标记(发送/远端 session.changed/pin/归档/删除/重命名处置位)——流视图挂载且脏时刷首页(10 行本地 SELECT,不复制 per-project 补丁逻辑)。**scope 服务端过滤(2026-09-03)**:`SessionListAllSchema` 增可选 `projectIds`(项目/分组 scope,分组在 renderer 解析为成员 ids)+ `worktreeKey`(checkout scope,main 侧 `normPathKey` JS 比较——存储路径与归一化键的表面形式不同,故 wt 分支先物化全量匹配再 JS 切片,SQL LIMIT/OFFSET 会分错集);store 的 `streamScopeQuery` 把 scope 解析成参数(同一套失效降级校验),`loadStreamSessions/loadMoreStreamSessions` 都带上;`setStreamScope` 置脏触发重拉,init 落地 set 也置脏(早挂载的侧栏可能已按空项目列表拉过非过滤首页);模块级 `streamFetchSeq` 序号防在途回应乱序覆盖。**动机**:scope 过滤原本只在 renderer 端做,`streamHasMore/streamTotal` 永远是全聚合口径——切项目后底部「显示更多(还有 n 条)」计数不变、点击加载的页可能全是别项目的行(用户报告的 bug);服务端过滤后计数/分页天然跟随 scope。`turnErrorBySession` 在 error 事件置位、新 turn 清除、`dropSessionBuckets` 清理。**⚠️ 第二页会话只存在于 `streamSessions`**(per-project 缓存只装各自首页)——所有按 id 找会话的查找都必须以 `streamSessions` 兜底:store 的 `findSession(...)` 第 5 参与组件版(SessionTabs.tsx 导出,UnifiedTabsBar 共用)第 4 参,漏掉的症状是第二页会话开 tab 后标题栏显示 "(unknown)" 且 `syncConfigFromSession` 静默 no-op(chip 停留上一会话的配置)。
- **聚合缓存的变更一致性(2026-09-14,修「删除会话后的幽灵行」)**:删除/归档/置顶/重命名现在**同步修补 `streamSessions` 聚合缓存**,并引入 `streamMutateSeq` 代际守卫——变更前发起的在途列表快照直接丢弃,防止快速连续删除时被旧响应复活已删行;侧栏行内删除改为与树一致的二步确认(√ 即确认,不再弹额外对话框)。回归:`scripts/stream-aggregate-smoke/run.sh`(真实 store 动作的无头冒烟)。
- **共享件** `SidebarShared.tsx`:RenameDialog / SessionContextMenu(含 worktree 组) / HoverIconButton / ArchivedRow——两视图行行为必须一致,故只此一份。providerIcon.tsx 增 `dot`(色点 hex)/`label`(品牌短名)字段。
- **职责分工**:项目管理(重命名/删除/置顶项目/分组增删改/拖拽排序)只在树视图;流视图 scope 下拉仅过滤 + 添加项目入口。归档 shelf 内容 = 树视图归档桶(归档项目 + 按会话)同源。
- **mobile 不受影响**(MobileSessionDrawer 独立实现;2026-09-14 起手机壳有自己的视图切换器与显示模式语义,见「移动端壳」节)。
### 中间面板 Tab 模式(P3.5)
- **显示模式偏好**持久化在 `settings` 表的 `ui.displayMode` key(`DISPLAY_MODE_SETTING_KEY`),`init()` 启动时 `setting.get` 拉取,`setDisplayMode()` 写回。
- `openTabs: string[]` 是已开 tab 的 sessionId 有序列表;**不论 single / tabs 模式都写**,切模式不丢已开线程。
- `closeTab()` **不取消运行中的 turn**,只从 tab 列表移除;事件流继续按 sessionId 入桶,重新打开 tab 可看到最新状态。
- 单 slot 字段(原 `pendingQuestion` / `turnFiles`)已改为 per-session 桶(`pendingQuestionBySession` / `turnFilesBySession`),多 tab 并发不会互相覆盖。
- `ChatPane` 接受 `sessionId: string | null` prop,所有 per-session 选择器都按 prop 读;`null` 走空态(`EmptyCenterPane`)。
- `CenterPane`(在 `App.tsx`)按 `displayMode` 决定:**`tabs` 模式挂 `UnifiedTabbedPane`** —— 顶部一条 `UnifiedTabsBar`(`components/layout/UnifiedTabsBar.tsx`,复用 SessionTabs 的 `SortableSessionTab` + OpenTabsBar 的 `SortableFileTab`/`FileTabContextMenu`/`useDirtyFiles`,同一 DndContext 内两个 SortableContext,跨类型拖放忽略),**会话 tab 与文件 tab(+计划伪 tab)混排一条栏、不分组**;内容由 store 的 `centerTabFocus: "chat"|"editor"` 决定:会话 tab 激活 → 全宽 ChatPane(所有 openTabs 的 pane 常挂、非前台 `hidden` 保活草稿/滚动),文件/计划 tab 激活 → 全宽 `EditorColumn`(传 `hideTabsBar`,避免与统一栏重复)。**没有 chat|editor 分栏**,激活视图独占整个中间宽度。**`single` 模式挂 `SplitCenterPane`**,保持旧的"聊天列 | 编辑列"分栏(编辑列内自带 `OpenTabsBar`)。**wide 模式(3:7)不是独立组件树(2026-09-09 重构,废除旧 `WidePanelSplit`)**:`CenterPane` 接收 `wide` prop 作纯渲染变体——tabs 模式的条栏换成 SessionTabs(文件 tab 在宽屏下是死控件)、编辑器宿主/split 编辑列改 `hidden` **CSS 保活**(Monaco 活过宽屏切换,退出秒回),chat 恒为可见面;右栏**复用 ThreePaneLayout 的右 aside**(`rightWidthPct` 百分比宽度覆盖 `rightWidth` px,divider 的 onResize/onReset 按模式切 `adjustWidePanelPct`/`resetWidePanelPct`,px→pct 用根容器宽换算——宽屏强制左栏关,窗口宽即行宽)。动机:旧实现 `center={widePanelOpen ? <WidePanelSplit/> : <CenterPane/>}` 是**不同组件占同一树位**,React 只能整树卸载重建——每次进出宽屏全部 ChatPane(Tiptap/时间线)+ RightPanel(文件树/git 扫描/浏览器 view 归属)+ 左栏重挂,一个同步 commit 内完成,即用户报告的进出卡顿。左栏 aside 同日改为 `(!leftOpen || settingsOpen) && "hidden"` CSS 保活(退出宽屏不再冷重建 StreamSidebar + `loadStreamSessions` 重拉)。**行为取舍**:宽屏下底部终端从"横跨 chat+右栏全宽"变为"仅 chat/main 宽"(终端生在 main 内,与普通模式语义一致);浏览器 tab 激活时进出宽屏不再 hide→show 闪烁。**ChatColumn 两种模式都是 keep-alive 结构(2026-09-03)**:挂载桶内非前台 pane `hidden` 保活,切换=可见性交换而非重挂载(此前 single 模式 `key={activeSessionId}` 强制重挂载,左栏切会话要重建 Tiptap + 全时间线,点击有可感延迟;用户报告后改齐 tabs 模式)。single 模式无 tab strip 供用户手动清理——**2026-09-14 起真·单槽:只挂载激活 pane**(keyed 重挂载=模式承诺的「销毁」;旧方案是 8-pane keep-alive LRU,`openTabs` 只进不出,隐藏 pane + 完整 store 桶——含 base64 图片的历史、turn-files 卡、子代理转录、usage 历史——随浏览过的会话数无限累积)。**两代宽限窗(当前 + 上一个)**:A↔B 来回切不重拉数据,更早的会话在切走时调 store 的 `pruneSessionHistory` 修剪重量级历史桶,下次激活经 `historyLoadedBySession` 门控自动从 DB 重灌首页;活状态(running/未读/待答/todos/胶囊/草稿/书签)刻意保留——会话行还在,左栏与活动区徽章读它们;**running 或待答中的会话绝不修剪**(事件流与 turn.done 持久化依赖桶),进重试队列下次切换再试。tabs 模式不动:pane 生命周期归用户的 tab strip 管。
- **`centerTabFocus` 焦点流转**(UI-only,不持久化;渲染端对 "editor" 做兜底——无 activeFile 且无激活计划 tab 时视同 "chat"):置 "chat" = `selectSession`/`openTab`/`startSession`/`enqueueChatFile`/`clearIdeActiveFile`(隐藏编辑器语义);置 "editor" = `openFileInIde`/`setIdeActiveFile`/`setPlanTabActive(true)`/`openPlanDrawer`(均 **gate 在 tabs 模式**,single 模式不写,保证切回 tabs 时落在聊天);`closeTab` 仅在被关的是**激活 tab** 时移动焦点(关后台会话 tab 不拉走编辑器),关最后一个会话 tab 时若有 activeFile 则落到编辑器;`closeFileInIde`/`closeFilesUnderDir`/`closeAllFilesInIde` 在"无剩余文件且无激活计划 tab"时回落 "chat";`closePlanDrawer` 有可回退文件则保持焦点,否则回 "chat"。Titlebar 的 `EditorColumnToggle` 在 tabs 模式按 `centerTabFocus` 判显隐。
- 4 个全局 config 槽(model / effort / permissionMode / customModelId)保持不变——它们表达"前台 tab 的配置",`syncConfigFromSession` 在 `selectSession` / `openTab` / `closeTab` 切活动时自动同步,Composer 立即反映。
### 右栏会话级 tab:轮次流程/子会话/浏览器跟会话走(2026-09-18)
- **模型**:右栏 rail(`RightPanel.tsx` 顶部横向图标条)的 tab 分两层。**全局层**(files/git/orch):激活值是单值偏好 `rightPanelTab`(persist 到 `ui.rightPanelTab`,hydrate 只恢复 files/git;类型收窄为 contracts 的 `RightPanelGlobalTab` = `Exclude<RightPanelTab,"browser"|"turns"|"sidechat">`,编译期杜绝再有人把会话层三档写进全局)。**会话层**(turns=轮次流程 / sidechat=子会话 / browser=侧边栏浏览器):**不再固定在 rail**,由 rail 右端(ml-auto 簇,宽屏切换键左侧)的 **「+」菜单**(base-ui Menu)按需打开——点击行 = 打开并激活;已打开的行有对勾(当前显示)与悬浮 **×**(关闭)。打开后在该会话的 rail 上以普通图标出现(仅该会话可见);**可直接关闭**:点击**正在显示**的那个 tab = 关闭(toggle 语义,与旧浏览器 rail 键完全一致),悬停时图标切换为 × 明示可关;未显示的靠 + 菜单的 ×。面板实际显示 = `sessionTabs[activeSessionId].active ?? rightPanelTab`(会话级激活遮蔽全局值,关闭/切全局自动回落)。
- **状态**:`sessionRightTabsBySession: Record<sessionId, { open: SessionRightPanelTabId[]; active: SessionRightPanelTabId | null }>`(store 导出 `SessionRightPanelTabId = "turns"|"sidechat"|"browser"`),**不持久化**(应用内跟会话切换走,重启清零);`active` 必属 `open`。动作 `openSessionRightTab(tab, sessionId?)/closeSessionRightTab(tab, sessionId?)`(省略 sessionId 取激活会话;无激活会话 no-op);`setRightPanelTab` 切全局档时**清掉当前会话的 active 但保留 open 集**(rail 图标还在,回来还能切)。删除会话经 `dropSessionBuckets` 清理(注意:`applySessionDeletedState` 对不在任何列表里的未知 id **提前返回**、不走桶清扫——冒烟里删桶断言必须先 seed 会话行)。
- **浏览器特殊性**:进入会话层的只是**面板可见性**——tab 列表(`browserTabs`)/WebContentsView/overlay 全屏容器仍是全局共享,切走会话只是右栏不再显示它,浏览状态不丢。browserTabCount 徽标跟在会话级图标上(`RailButton` 加 `badgeCount` prop)。入口全部改道 `openSessionRightTab("browser")`:`openUrlInBrowser`、App.tsx 的 `agentOpened`(adopt 后拉起侧栏)、`BrowserPanel.handleReturnToSidebar`(overlay 回侧栏);**改道 `closeSessionRightTab("browser")`**:`handleCloseTab` 关掉最后一个标签(侧栏模式,面板回落全局档)、`handleSwitchMode` 侧栏→overlay(overlay 接管浏览器);命令 `layout.toggle-browser` 改为按会话级 active 判 toggle(开时顺带 `setRightOpen(true)`)。+ 菜单弹层压在浏览器 WebContentsView 上,照例走 `useSuppressBrowserView(addOpen, popupRef)`。
- **入口改道(轮次/子会话)**:`openSubagentTranscript`(子代理转录,传**归属会话** id,非激活会话也能开)/`openSideChatPanel`(子会话面板 + 快捷键 `sidechat.open`)/命令 `view.right-panel.turns` 都改为 `openSessionRightTab`。
- **验证**:`apps/desktop/scripts/session-store-smoke/run.sh` 第 [9] 组(真实 store 无头冒烟 44 断言):开/关/重激活、全局切换清激活保 open、跨会话切换回落、显式 sessionId 开在不活跃会话、browser 同机制、无激活会话 no-op、删除清桶。UI 端(菜单交互、rail 图标显隐、徽标)靠 typecheck + electron-vite build,行为留人工确认。词条:新增 `layout.tabBrowser`/`layout.rightPanelAddTab`/`rightPanelCloseTab`/`rightPanelSessionHint`;`layout.openBrowser` 已无引用、随本轮删除。
### 语言服务器 LSP(P4.5)
- **可安装、可启停**:设置页"语言服务器"面板,每种语言(TS/JS、Python、Go、Java)一张卡片。安装走包管理器(`npm`/`pip`/`go`/`brew`),Java win/linux 走直接下载 tar.gz 解压到 `userData/lsp/java`。
- **配置持久化**:`settings` 表 `lsp.servers` key(JSON 数组 `LspServerConfig[]`),每语言一个条目(`enabled` + 可选 `serverPath`/`args`)。
- **主进程 `LspManager`**(单例,`apps/desktop/src/main/lsp/LspManager.ts`):按 `(workspacePath, language)` 懒启动 stdio JSON-RPC 子进程;手写 Content-Length 分帧 + JSON-RPC 收发;`initialize` 握手后才放行 `request`;`textDocument/publishDiagnostics` / `window/logMessage` 推送到 renderer(`lsp:event`);`before-quit` 调 `disposeAll()` 杀全部子进程。
- **语言规格**:`apps/desktop/src/main/lsp/languageSpecs.ts` 是扩展点--新增语言加一个 `LanguageServerSpec` 即可,`LspManager` 自动获得安装/探测/启动/同步行为。
- **二进制探测**:`which()` 抽到 `apps/desktop/src/main/lib/binaryResolve.ts`(terminal 的 shellResolve 也共用)。Windows 额外探 `%APPDATA%/npm`(npm 全局 bin)。 `terminal.shell` 解析不到时的语义(2026-09-18,修「设置里配了 Shell 路径不生效」):`resolveDefaultShell` 会静默回退平台默认(建终端必须成功),于是设置页改用 `terminal.resolveShell` IPC(→ `shellResolve.resolveEffectiveShell`)拿「设置是否真被采纳」并显示新终端实际会用的 Shell,解析不到标红提示;已打开的终端不变(PTY keep-alive),终端标签显示实际 Shell 并在配置变更后提示点「重开」。冒烟:`scripts/terminal-shell-resolve-smoke/run.sh`。
- **Monaco 桥接**(renderer):`apps/desktop/src/renderer/lib/lspProviders.ts` 手写 `registerDefinitionProvider` / `registerReferenceProvider` / `registerHoverProvider`,每个 provider 通过 `api.lsp.request` RPC 转发到 main。**不引入 `monaco-languageclient`**(最小依赖)。跨文件跳转在 definition provider 内直接调 `openFileInIde(path, line, col)` 并返回 null,同文件则返回 Location 让 Monaco 原生导航。
- **文档同步**:`EditPane` 的 `onMount` 发 `didOpen`,`onChange` debounce 300ms 发 `didChange`,`handleSave` 成功后发 `didSave`,卸载时发 `didClose`。`openDocument` 在 didOpen 后**后台预热**:fire-and-forget 发一次 `textDocument/documentSymbol`(typescript-language-server 等在首个工作区查询才懒加载项目,不预热的话用户第一次 F12/Ctrl+F12 要吃整个项目加载延迟;错误吞掉,60s 请求超时兜底)。
- **跳转交互反馈**(`lspProviders.ts` 的 `lspGotoTracker` + `FileEditor` 的 `GotoActivityPill`):definition/implementation/references 三个 provider 把每次查询登记进模块级 pub/sub(同 `ideDirtyTracker` 模式),EditPane 底部中央显示浮动 pill——pending >250ms 显示"正在查找{定义/实现/引用}…"(≥3s 附耗时秒数),结束短暂显示"未找到{kind}"或失败原因(如"语言服务器未启用",hover 看详情)后 ~1.8s 自动消失;hover 不追踪(太频繁)。`lspRequest` 失败从返回 null 改为 throw,由调用方决定是否呈现。
- **诊断 markers**:`useLspDiagnostics` hook 订阅 `lsp:event`,按 `uri` 过滤后 `monaco.editor.setModelMarkers`。
- **跳转定位**:`openFileInIde` 扩展了 `opts.line`/`opts.column`,写入 `idePendingReveal` + bump `ideRevealNonce`;`EditPane` 的 `useEffect([nonce])` 消费后 `revealLineInCenter` + `setPosition` + `clearIdePendingReveal`。
- **TS worker 去重**:TS LSP 启用时,`monacoSetup.ts` 的 `setTsWorkerDiagnosticsEnabled(false)` 关掉内置 tsWorker 诊断,避免双份波浪线。由 `reloadLspLanguages` 在水合后驱动。
- **安全**:所有 `workspacePath` 过 `isKnownProjectPath`,`filePath` 过 `findContainingProject`,只允许已知项目内的文件进 LSP。
- **崩溃恢复**:`proc.on("exit")` 非主动关闭时从 Map 移除 + 推 `stateChanged{running:false}`;下次 `request` 自动 `ensureServer` 重启。
- **jdtls 工作区损坏自愈**:Equinox 退出码 13 = `-data` 工作区打不开(典型:`SaveManager.restore` 抛 `ObjectNotFoundException`,Maven `target/` 生成文件在服务器保存后被 `mvn clean` 等外部删除所致;损坏的是落盘元数据,每次启动必崩)。`ensureServer` 的 initialize 失败路径检测到 java + exitCode 13 时,把 `workspaces/<hash>` 轮换为 `.corrupt-<时间戳>`(rename 失败退回 rmSync)并**自动重试一次**(递归传 `allowWorkspaceRecovery=false` 防循环,重试前清该 key 崩溃计数);新工作区会重新导入项目。健康检查同理:jdtls 无 `--version` 模式(参数会被转发给 Equinox 起完整服务器且永不退出),`healthCheck` 对 java 走静态检查(launcher jar 存在性),通用 `--version` 探测加 10s 超时 + win32 `taskkill /T` 杀树兜底。
- **Java 首次导入:预热 + 进度可视化**(jdtls 无"分批导入" API,导入是整体后台任务,只能把时机提前 + 让等待可解释):① **预热**——渲染层在项目激活时(`selectProject`/启动水合/新建项目,以及设置页启用 Java 后的 `reloadLspLanguages`)调 `lsp.prewarm` IPC,main 侧 `LspManager.prewarm` 校验已知项目 + java 已启用 + 根目录有 pom/gradle 标记文件后 fire-and-forget `ensureServer`(幂等,活句柄复用不重启),把一次性导入藏进用户浏览项目的时间窗,而不是卡在第一次打开 .java 文件;只预热 java(其它语言秒级启动,无需隐藏),不预热无构建文件的目录(避免白烧 1GB JVM)。② **importing 相位**——探针实证 jdtls 1.37 通过 `language/status` 持续下发 `{type:"Starting", message:"24% Starting Java Language Server - Importing project xxx"}`,`LspStateChangedPayload.phase` 增加 `"importing"` + `detail` 字段,onMessage 把 Starting+百分比消息映射为 `stateChanged{importing, detail:"24% · Importing project xxx"}`,ServiceReady/Started 映射回 running;编辑器 pill(`FileEditor`)显示"{name} 项目导入中… 24% · Importing project xxx",取代神秘超时。③ java 功能请求超时 180s(`JAVA_REQUEST_TIMEOUT_MS`),`sendRequest` 超时打 WARN 进 main.log + java 超时消息附导入提示。工作区 data 目录按项目持久化,导入一次后续启动走恢复路径,秒级就绪。
- **diff 对比视图的 LSP 跳转(2026-09-04,修复"对比视图里 F12/hover 全灭")**:`DiffPane` 的两栏是**匿名 Monaco 模型**(`@monaco-editor/react` 的 DiffEditor 不传 model path → `inmemory://model/N`),此前 diff 视图四层全断——provider 可能从未注册(diff 可以是用户打开的第一个编辑面,如回合卡片「审查」)、请求拿不到真实文件 URI、服务端没有 didOpen、且 Monaco standalone 的 `findModel` 对 Location URI 与当前模型 URI 做**严格字符串相等**比较(不等即返回 null,跳转静默丢弃)。修复四件套:① `lspProviders.ts` 新增模型→路径绑定表(`bindModelToPath`/`unbindModel`,`modelToLspUri` 先查绑定);② `DiffPane` onMount 调 `ensureLspProviders`(幂等)+ 把两侧模型存入 state 触发绑定 effect(依赖 `[diffModels, filePath, language]`——文件切换时库只对老模型 setValue 不重建,组件不 remount,必须按 filePath 重绑)+ `openLspDocument`(workspace 用 `selectActiveEnvPath`,与 provider 的请求路由同源,防止 didOpen 与查询落到不同 server)/卸载时 unbind + `closeLspDocument`(diff→edit 切换时 destroy 阶段 cleanup 先于 EditPane create 阶段 effect,IPC FIFO 保证 close 先到);③ definition/implementation/references 的**同文件 Location 改写为模型自身 URI**(`rewriteToModelUri`,edit 视图下模型 URI 本就等于文件 URI,改写是 no-op——edit 链路零行为变化),跨文件仍走 `gotoLocation`→`openFileInIde`(store 对带 line 的打开强制 `viewMode="edit"`,reveal 不会被残留的 diff 模式吞掉);④ DiffPane 也挂 `GotoActivityPill`(冷服务器 F12 需要可见反馈)。历史 diff(`after` 为 blob)查询对磁盘文件,大改区域的行号是近似的——与一切 diff 审查面同一取舍,未变更代码精确。
### 编辑器常驻 + 模型缓存(方案1.5,2026-09-03)
- **演进**:第一步「模型缓存」(`keepCurrentModel` + 路径→`{model, baseline}` 缓存)消掉了每次切换的重新 tokenization + 读盘 spinner,但用户仍感切换小卡——根因是 `key={filePath}` 令每次切换在同一 commit 内**完整 dispose+create Monaco 控件**(约 30–80ms,与文件大小基本无关)。第二步去掉 key:**EditPane 常驻,单实例 Monaco 靠 `path` prop 变化做模型交换**(库源码行为:`saveViewState(旧) → setModel(新) → restoreViewState(新)`,控件不销毁),切换成本降到一次 setModel + 可视区重绘(亚帧级)。
- **结构**(`FileEditor.tsx`):`EditorColumn` 渲染 `<FileEditor>`(**无 key**,App.tsx)→ `EditPane`(**无 key**)持有唯一 `<Editor>`。`readyPath` state = 当前展示文件;`filePath` prop 跟随时:缓存命中 → 立即 `flipTo`;未命中(**hold-last-ready**)→ 继续显示旧文件 + 加载遮罩,readFile 后**自建模型**(`adoptModel`:`monaco.editor.createModel(content, language, uri)` + registerModel;孤儿库侧模型则收养)再 flip。**`value` prop 永远不传**——库的内容同步 effect 彻底不参与(clobber 风险消失),模型创建/内容写入全部归 EditPane。loader 的 monaco namespace 经 `useMonaco()` 取得并镜像进 latest-ref(`monacoInstanceRef`)供异步回调使用;尚未就绪时 pendingCreate 停车、flush effect 补做(只在本文件仍是目标时翻转,否则仅入缓存)。
- **displayedPath 所有权**(`editorModelCache.ts`):常驻编辑器下「挂载中的模型」≠ store 的 activeFile(新激活文件加载窗口内两者可不同),且 dispose 已附加的模型会打崩活编辑器——store 七个处置动作(closeFileInIde / closeFilesUnderDir / closeOtherFilesInIde / closeAllFilesInIde / openFileInIde replace 分支 / renamePathInIde 两分支 / deleteProject)一律**跳过 `getDisplayedPath()`**,直接 dispose 其余(背景 tab 无卸载事件,漏一条 = 泄漏到重启);被跳过的 displayed 文件由 EditPane 的 `flipTo` 离开簿记 / 最终卸载清理处置:不在其项目 open 列表 → dispose + 清脏标记。
- **per-file 簿记全走 `readyCtxRef`(path+projectPath+pid)**:Ctrl+S(挂载时注册一次,经 handleSaveRef)、onChange 脏判定/LSP didChange(debounce)、didOpen/didClose(readyPath post-swap effect;声明序在 follow effect 之后 → 执行于库的子 effect 之后,即交换落地后)、viewState 存取(onDidScrollChange/onDidChangeCursorSelection 即时暂存,事件时从 ref 取路径)、freshness 校验(读盘与 baseline 比对:一致无事;不一致且干净 → `setValue` 静默同步;不一致且脏 → 「文件已被外部修改」横幅,`ide.editor.externalChanged`/`reloadFromDisk`;新建模型的首次展示经 `skipVerifyPathRef` 跳过——内容刚读过)。激活时读盘比对天然覆盖 agent 并发写盘,无需 watcher。
- **护栏**:readSeq/flipSeq 双序号防快速切换下的过期异步(首读/校验);`disposedRef` 挡卸载后的异步回调改状态/翻转 displayedPath;spinner 只出现在「本次 EditPane 挂载后第一个文件且内容未就绪」。
- **顺带修复**:脏文件(未保存编辑)跨切换/跨重挂载存活(模型常驻;此前只存 viewState,内容直接丢),undo 历史同样存活。**内存**:模型集合 = open tabs 集合,关 tab 即 dispose,天然有界。**diff/preview 模式**仍按需卸载 EditPane(DiffPane 自建匿名模型,既有 `keepCurrentOriginalModel/keepCurrentModifiedModel` 行为不变);返回 edit 时缓存命中秒开。方案 2(编辑器控件 keep-alive 池,连 setModel 都省、诊断标记跨切换常驻)如需再做,基础设施已全部就位。
### 查看面滚动位置记忆(2026-09-14,修「打开过的文件再次打开回到顶部」)
- **缺口**:滚动位置此前只有 Monaco 的**编辑面板**(EditPane 的 `viewStateCache`)和**差异面板**(`diffViewStateCache`)记得住。**预览类查看面一个都没记**——桌面 Markdown 预览(`MarkdownPreviewPane`,`.md` 首次打开默认进这个模式)和手机端文件查看器(`components/mobile/FileViewer.tsx`,`FileViewerOverlay` 与 `MobileViewerOverlay` 共用同一份 `FileViewerContent`)都是普通 `overflow-auto` 容器:文件切换或点「返回」时整块被 React 卸载,再次打开必然回顶部。
- **共享实现 `lib/scrollMemory.ts` 的 `useScrollMemory(key)`**:模块级 `Map<string, number>`(会话级、不落盘,与 `viewStateCache` 同性质);key 由调用方加前缀区分查看面(`ide-preview:<path>` / `mobile-md:<path>` / `mobile-source:<path>`——Markdown 的「渲染预览」与「高亮源码」是同文件两种排布、滚动量不同,各记一份,对齐桌面 preview/edit 分离的语义)。返回**回调 ref**,在 layout effect(首帧前)写回 `scrollTop`,再在 120/400ms 重试两次:内容可能还在长(图片异步解码、Shiki 替换 raw 兜底),容器比保存位置矮时写入会被**钳制**,不重试就停在文件中段。落地(与保存值差 <1px,允许小数偏移)或**任何 wheel/pointerdown/touchstart** 立即停止重试——迟到的恢复绝不能把用户从自己滚到的位置拽回去。ref 回调里用「state 包一层对象」而不是直接存节点:文件切换后重新挂上的容器若元素身份相同,直接存节点会让 effect 不重跑、恢复静默失效。
- **EditPane 挂载路径补同级护栏 `armMountReassert`**:库(`@monaco-editor/react`)在容器还 `display:none` 时就 `create()` 控件(它的 wrapper 组件 ready 前隐藏容器),Monaco 首次 layout 因此跑在 0 高度盒子上——挂载时的 `restoreViewState` 可能被钳制,控件自身的 init/scroll 事件还会把「文件顶部」写进缓存,**这正是差异面板早已修过的那类损坏**(`DiffPane` 的 render-time 快照,commit b831b59;EditPane 因为是常驻控件、切文件走 `path` 交换,一直没有拿到等价补偿)。补偿:挂载时恢复后,在**首次 layout change** 再应用一次(400ms 超时兜底),应用前先 `viewStateCache.set(path, saved)` **重播种**(控件仍不肯挪动时也把缓存修回来,否则下一次切换照样落顶部);wheel/pointerdown/keydown、`applyReveal`(显式跳转优先)、**已切换文件**(`readyCtxRef.current.path !== path`)、卸载任一发生即取消。重复应用是幂等的(用户没滚动前缓存就等于屏上内容),所以正常恢复不受影响。
- **刻意没做**:图片预览(`ImagePreviewPane`)不记滚动——它的滚动只在 1:1 缩放(`natural`)下存在,而缩放状态本身不记,恢复位置无意义(适应窗口时不溢出,恢复被钳制为 0,无害);宽面板模式下编辑器宿主是 CSS 隐藏的,此时挂载的查看面容器高度为 0、恢复仍落顶部(点文件名时本来也看不见它,退出宽屏才可见)——与修复前行为一致,未额外处理。
- **验证**:`tsc --noEmit` + `electron-vite build` 通过。**未做真机端到端**:当时用户的 dev 实例窗口处于最小化状态,驱动它会抢占屏幕并改动其会话数据,故这一轮的验证停在类型/构建层,行为回读仍靠人工使用确认。
### 用户消息气泡 5 行折叠(2026-09-14)
- **行为**:用户发送的消息超过 5 行时默认折叠——钳在 `calc(5 × --chat-md-leading × --chat-font-size)`(纯 CSS,跟随对话紧凑度设置),底部 mask 渐隐代替硬切(气泡是 `user-bubble-fill` 半透明着色,表面色渐变会与色调解离);点击气泡任意处展开,再点收起。展开态在气泡底部右侧一枚「收起」chip,折叠态在底缘中央一枚「展开」圆角 pill——两枚形态镜像 Markdown 代码块的既有折叠 pill(`chatStream.code.*`),两处折叠面读感一致。i18n 键 `chatStream.userMsg.expand/collapse`。
- **实现**(全在 `ChatPane.tsx` 的 `MessageRow`,用户消息唯一渲染路径;3043 行附近的 `isUser` 只喂 `canEdit`):溢出判定 = `el.scrollHeight > 行高×5+1`,**折叠/展开两态通用**——`scrollHeight` 是完整内容高度、与钳制无关,展开态重挂载(下条)也能得出判定,否则「收起」chip 永不出现;行高读 `.chat-md` 的 computed line-height(密度驱动),无 markdown 内容时回退 `fontSize×1.5`。`ResizeObserver` 在字体/图片/KaTeX 改变高度后重测。展开集合 `expandedUserMessages`(模块级 `Set<msg.id>`,不落盘)熬过 LegendList 单元格重挂载——滚远再滚回,手动展开过的仍展开,其余回落折叠。
- **点击守卫**(`onBubbleClick`):`closest("a,button,input,textarea,select,[role='button']")` 命中即放行——消息内的文件链接、附件卡、图片(缩略图整个包在 `<button>` 里开灯箱)保持自身行为;`window.getSelection()` 非折叠时放行——框选文本的拖拽收尾不算点击。溢出时气泡才挂 `cursor-pointer` 与 onClick,短消息零行为变化。编辑模式整行替换、与本机制无交集。
- **媒体块豁免(2026-09-21 增补,方案 A)**:此前测量与钳制都挂在**整个内容容器**上——块序为 [attachment chip → image 缩略图 → text] 的用户消息里,图片行(56px/行)或几张文件/粘贴 chip 就能吃光 5 行预算(≈120px),纯图片消息也会被折叠,被 mask 裁掉的图片毫无阅读价值。现 `MessageRow` 把用户消息块二分为**媒体组**(`kind === "attachment" || kind === "image"`,即粘贴卡/文件卡/引用卡 + 内联图片缩略图)与**正文组**(其余,text),媒体组在气泡内**永远完整渲染**、置于正文上方(store 块序本就如此,视觉不变),测量与 clamp/mask 只挂正文容器——只有**文本本身**超 5 行才折叠,折叠时图片仍全部可见,「展开」pill 因正文恒为末块仍正落切口处。纯媒体消息(无正文块)不渲染正文容器、ref 为空、判定恒 false。assistant 行保持单容器不动。分区走 memo + 模块级 `EMPTY_BLOCK_LIST` 空数组常量(传给 memo 化 `MessageBlocks` 的 props 引用稳定);拆分对 `segmentKeys`(kind+重复计数)与 `beforeMap`(只服务 tool_use,用户消息无关)均无影响。
- **连续附件卡合并为一条 chip 行(2026-09-21 二次增补)**:`groupBlocks` 原把每个 `attachment` 块当 `single` 段落逐个塞进 `space-y` 纵向栈,而 AttachmentCard 根节点是块级 div——一次引用多个文件/粘贴卡时一卡一整行,气泡长成难看的竖排塔。现镜像 ImageGallery 的先例:`Segment` 联合新增 `{ kind: "attachments"; blocks }`,`groupBlocks` 把**连续** attachment 块收集成 run(附件卡断 tool batch 与 image run,任何其它块断附件 run),渲染为 `AttachmentRow`(`flex flex-wrap gap-1.5`,与 composer chips 条同款);两处分段渲染点(MessageBlocks 主路径 + live-turn 路径)各加显式分支——新增段成员后 TS 会强制兜底分支前的显式收窄,漏加即编译失败。`segmentKeys` 的 head 泛型键控无需改动;单卡视觉不变,每张卡的 TagPopover / 文件卡直开 IDE 交互原样保留。
- **外部文件卡禁止查看 + 编辑消息可删卡(2026-09-21 三次增补)**:① 附件卡点击此前对带路径文件卡直接 `openFileInIde`——工作区外路径(外部拖入)会被 main 读守卫拒掉,开出一个空白编辑页。新增 **`file.isViewable` RPC**(纯内存包含性检查,无磁盘访问:`findContainingWorkspaceRoot(path) !== null`,即项目根 ∪ 已物化会话工作树——工作树根来自全部会话数据,renderer store 没有可靠镜像,故必须问 main):AttachmentCard 两分支(非图片卡开 IDE / 图片卡开 TagPopover 预览)点击时先查,不可查看弹 info toast(`chatStream.attachment.external*` 词条),粘贴卡/引用卡(无路径,内容内联)不受影响。② `UserMessageEditor` 的附件 chips 从只读改为**可删除**——chips 变本地 state(带稳定 id),chip 尾部小 × 移除(`chat.removeAttachmentName` 词条);`onSubmit` 签名加第三参 `attachments`,`handleEditSubmit` 改用**幸存**卡片列表重建 tags(原实现恒从 `msg.blocks` 重取,删卡无从生效),全删光则重发为纯文本。图片缩略图的 × 删除为既有行为,未动。
### 回合结束重新锚定到本轮提问(2026-09-18)
- **行为**:模型输出完毕(本轮回合结束)后,若长回答把用户自己的提问顶出了视口上缘,消息流不再滑落回底部,而是**定位回本轮用户消息处**(顶部对齐 + 8px 余量,回答在下方可续读)——对齐左缘时间轴(MessageTimeline)上该条 dash 的所指;提问仍在屏内的短回合保持原有贴底滑落,零行为变化。
- **挂载点**:完全寄生在既有的「回合完成折叠」链路——`TurnPanel` 的 `justCompleted` one-shot fold 调 `pauseBottomAnchor({suspendDataChange:true})`("full" 模式),其 settle 回调(原 `scrollToEnd` 处,`TURN_FOLD_MS+60`)即是决策点。`wasNearBottom||recomputeNearBottom()` 门槛保留(用户滚离阅读时绝不打扰);定位执行时经 `holdAnchorSuspension("full", TURN_FOLD_MS+700)` 延长锚挂起,防 turn-files 卡片落地触发 bottom snap 与定位滚动打架。
- **四个抑制条件**(任一成立则维持贴底):① `sessionBusy`(队列连发的新回合已接管,不与跟随打架);② 非干净结束——`interruptedBySession`(中断停在模型停止处)/`turnErrorBySession`/`turnIncompleteBySession`(错误与截断的诊断卡片要在底部可见);③ 目标 id 已不在 `msgToRenderIndex`(消息被 compact 截断);④ 提问行仍可见(挂载行 `top >= scroller.top - 4`)。
- **实现纪律**:新增的 `scrollToMessageTop`(顶部对齐版 jumpToMessage,同款「positionAtIndex 粗瞄→DOM 精调→drift 校正重试」,无 flash)与 `holdAnchorSuspension` **引用恒稳定**(`renderListItem` 的 memo 依赖 `pauseBottomAnchor`),一切易变值(`lastUserMessageId`/`msgToRenderIndex`/`sessionBusy`)经 `*Ref` 镜像读取,不进 useCallback deps。**没有新增组件状态、i18n 词条与持久化**。
- **验证**:`tsc --noEmit` + `electron-vite build` 通过(build 需 `NODE_OPTIONS=--max-old-space-size=8192`,默认堆在本机 OOM——基线同样崩,与改动无关)。滚动行为留人工确认。
### 集成终端环境刷新(win32,2026-08-31)
- **问题**:PTY 环境此前是主进程 `process.env` 的冻结快照,启动链路丢失、或 app 启动后才安装的工具(nvm/java/sdkman)在集成终端里不可见——典型症状 `nvm list` 报 `ERROR open \settings.txt`(nvm-windows 靠 `NVM_HOME` 定位,进程里缺这个变量)。系统 PowerShell 正常是因为它由 Explorer 用"当前注册表合并值"启动。POSIX 无此问题:`shellResolve` 用 `-l` login shell,每次建终端都重 source profile。
- **修复**(`main/terminal/envRefresh.ts` 的 `buildTerminalEnv`,`TerminalManager.create` 已 async 化):win32 下每次创建终端时用一次 powershell.exe 现读 HKLM `Session Manager\Environment` + HKCU\Environment(**不用 `reg.exe`**——管道输出是 OEM 代码页,中文 PATH 条目会乱码;PS 里显式 UTF-8),值取**原始未展开**形态(`DoNotExpandEnvironmentNames`,否则会用过期 env 预展开),再按 Windows 构建新进程 env 的语义合并:用户值覆盖系统值、PATH = 系统+用户拼接后展开 `%VAR%`(查找顺序注册表优先、process.env 兜底——USERPROFILE 这类 volatile 变量不在注册表键里)、注册表值大小写不敏感覆盖快照同名项、快照里注册表没有的 PATH 条目去重后**追加回尾部**(保住 `pnpm dev` 等启动链注入的路径)。结果 10s TTL + in-flight 缓存(终端成对创建只读一次注册表),~300ms 冷启动开销。
- **失败策略**:任何失败(PS 缺失/超时 8s/解析不出)降级为纯继承 env 并打 WARN,终端创建永不因刷新而失败。非 win32 平台是 no-op 直通。
### Composer 输入框·迷你药丸(方案 B·单药丸,2026-09-10,设计原型 `prototypes/composer-redesign.html`)
- **药丸 = 唯一内联形态,任何宽度常驻**(`ComposerToolbar layout="pill"`,替代旧的"宽屏 chip 行"):一颗带描边的分段容器,**+ 附件 → 模型 → 思考级别 → 权限 → 上下文环** 五段并列,每段仍是独立触发器(打开各自原有的菜单/弹层,store 读写、快捷键、模型守卫 nudge 全部原实现)——窄屏下"当前什么模型/级别/权限"仍可读可点。**+ 与环随卡片变窄永不收起**(用户明确的硬要求);环的百分比数字、各段文字标签则按档位经 `composer-lblwrap`(**1fr↔0fr grid 壳**,可动画宽度过渡)平滑收起,紧凑态段落 = 图标 + `LevelBars` 柱状级别(≤5 根,codex 8 级会爆段)+ 权限语义色 inline。
- **档位(useComposerRowFit 返回 `tier` 0|1)**:0 = 展开(标签 + 环百分比可见)→ 1 = 紧凑(标签收成图标)。判定按**卡片宽 <560px** + **24px 回滞**;tier 0 时保留**实测溢出提升**(冻结 `.composer-minipill` 为 `max-content` 量 `row.scrollWidth`,locale/长模型名在阈值上方提前收紧),`collapseAt` 记卡片宽、只封锁"回 tier 0"。`chipsMode="collapsed"`(SideChatPanel/手机壳)**跳过药丸直接渲染 `ComposerToolbarToggle`**(gear + `layout="row"` 纵向设置弹层)——该宿主每个宽度都窄,纵向列表才是可用形态。
- **药丸段落化**:AttachMenuButton 增 `segment` 形态(w-7 方形段、IconPlus 15);三 picker 的 `layout` 只剩 `"pill" | "row"`(旧 chip 形态连同 `.composer-chips-root` 一起删除,`composer-chip` 类仅剩 ProviderDropdown 用);`data-popup-open`(base-ui 自动加)高亮打开中的段。**ProviderDropdown 不进药丸**(发送键左侧,会话锁定后只读),但窄档 `compact` 把名称经同一 lblwrap 壳收成品牌图标。分段显隐随 provider capabilities:无 thinkingLevels/permissionModes 时对应段与分隔线(`composer-minipill-mid`)自动消失。
- **质感层(styles.css,`[data-chat-root]` 作用域)**:`composer-card` 的 `data-busy="1"` 顶部扫光(`composer-sweep`)+ `focus-within` accent 发丝线(`::after` 渐变);发送键 `data-ready` 常驻柔光、发送/入队成功后 `composer-send-launch` 起飞动画(图标飞出→对面回位 + 光环脉冲,`onAnimationEnd` 清 class);停止键 `::before` conic 旋转进度环(radial mask 成 2.5px 环);附件标签/图片/队列项入场动画(`composer-tag-in` spring / `composer-q-in` 滑入,**fill 必须 backwards 不能 both**——both 会持续覆盖元素样式,劫持队列项后续的拖拽 `opacity-40`);ContextRing 弧线 `stroke-dasharray` 500ms 过渡(占用变化扫弧不跳变)。曲线族 `--composer-ease-out/.22,1,.36,1` + `--composer-spring/.34,1.56,.64,1`;`prefers-reduced-motion` 全部降级。
### Composer 外部文件拖入(2026-09-21)
- composer 卡的 `onDrop` 原来只认内部文件树的 `FILE_DRAG_MIME`(注释明写"外部拖拽忽略");现 `onDragOver`/`onDrop` 同时放行 `types` 含 `"Files"` 的外部 OS 拖拽(Explorer/Finder/桌面/其他应用),拖拽高亮复用同一 `dragOver` 态。纯文本网页拖拽不拦(Tiptap 原行为不变)。
- **路径解析必须走 preload**:Electron 32 起 `File.path` 已移除,唯一途径 `webUtils.getPathForFile(file)`;沙箱 renderer 不能 `require("electron")`,故 preload 的 `file` 组新增 `getPathForFile(file)`(contextBridge 透传 File 对象是 Electron 官方文档背书的模式;内部 try/catch 兜底返回 `""`)。web 桩 `webApi.ts` 的 file 组补 `getPathForFile: () => ""`(浏览器永远拿不到真实路径)。
- **分流(`ChatPane.handleExternalDrop`)**:**图片 → 粘贴同款内联管线**(`stageImageFile`,base64 内联,chip 变缩略图卡)——不能走 `@path` 引用:TagPopover 预览走 `api.file.readBinary`,其主进程守卫(`readBinaryGuarded`)只放行已知项目内路径 + 粘贴临时目录白名单,外部拖入图片的原位置(桌面/下载目录)必被拒,点击查看报「图片加载失败」(2026-09-21 修复);且字节内联让模型不依赖 Read 工具读项目外路径、Mcode 写守卫本就禁止 agent 写项目外路径,路径引用对图片价值极低。**非图片且有真实路径**(Explorer 拖入)→ `appendUniqueFileTags` 直接建 `@path` 引用 chip——与文件树拖入/提及选择器完全同形态,零字节拷贝,项目外路径同样放行(agent 用 Read 工具读原位,与写入守卫互不相干)。**无路径的虚拟 File**(网页拖图等)→ 整体复用 `handlePasteFiles` 管线(图片 → base64 内联、普通文件 → `clipboardFile.save` 落临时目录、50MB 上限与去重逻辑全部继承)。
- **作用域**:只有主聊天 composer(ChatPane)。SideChatPanel、AutomationEditor 等其它 ComposerEditor 宿主未接(它们本来也没有内部拖放);需要时复制同两处 handler 即可。
### 聊天活动区:收放聚簇 + 活动台(方案 B,2026-09-11,设计文档 `prototypes/chat-activity-capsule-directions.html`)
- **来龙去脉**:旧的右上角「状态胶囊」(`StatusCapsule` + `ActivityPopover`)是绝对定位浮在消息流上的一颗玻璃药丸,四段「图标 + 数字」并列(计划/子代理/任务/书签),压在首屏正文上且读不出含义。第一版重构做成了**右缘轨**(竖列 + 面板向左浮出,零遮挡),随后按设计文档的四方向对比改选**方案 B「收放」**:让控件体积跟着紧急度走。旧的两个文件已删除,文件换成 `ActivityCluster.tsx` / `ActivityConsole.tsx` / `activityShared.ts`。
- **形态(三态)**:空闲(或无运行中的代理且任务已完)时只有一颗 **30px 圆钮** —— conic 进度环(mask 成 2px)+ 中间三个静默小点,表达「没事需要你」;有代理在跑时一条**文字横条**从圆钮长出来(`activity-cluster-bar` 的 `max-width: 0 → 280px`),圆钮就是横条的头;需要人介入时整簇转琥珀色并**直接写出原因**("N 个子代理失败"/"等待你的回答"),不必悬停。横条内容:运行数 → 任务 `done/total` + 34px 迷你进度 → 「N 计划」按钮(可点)。窄栏下横条保持收缩(`max-w-[280px]` 与内容都不换行),不会把整簇挤扁。
- **落点与遮挡(明确的取舍)**:聚簇是**流区右上角的浮层**(`absolute right-5 top-2`,与旧的药丸同性质),消息列表不为它留宽度。因此横条展开时会盖住首行的尾巴 —— 这正是「空闲时几乎不可见」换来代价,文档里**保持零遮挡的是方案 D(缘轨)**。若日后要求零遮挡,把流区顶部内边距(`MESSAGE_LIST_TOP_PADDING`)加高到能容纳 30px 即可(一次纵向回流),或回退到缘轨形态。
- **注意力信号**:`attn = 有 pending AskUserQuestion(=等待你的回答) || (无运行中 && 有 failed 子代理)`。注意**有代理在跑时失败不改变文案**(文档的规则,运行中优先),失败详情在活动台里看。`waiting` 由 ChatPane 传 `pendingQuestion`(会话级待答问题)进来。
- **点开=活动台**:`primaryKind` 决定圆钮开哪一类(子代理 → 任务 → 计划 → 书签,按易逝程度排),「N 计划」按钮直接开计划;活动台**带节点页签**(`nodeTabs`),所以四类始终可达(圆钮只开主类)。面板挂在聚簇**内部**(`right-0 top-[38px]`),定位祖先就是这个角落盒子;缺口开在面板**顶缘**(`right-[34px]`),因为聚簇右缘钉在角落,缺口永远落在它下方 —— 不需要任何测量。`Esc` 与面板外按下关闭(捕获阶段监听;聚簇自身不算「外部」,所以换类不会先关后开)。
- **共享层 `activityShared.ts`**:`SUBAGENT_STATUS_META` / `fmtUsage`(SideChatPanel、TurnFlowPanel 仍在用)、`extractPlanTitle`(PlanStreamBlock 仍在用)、`extractPlanExcerpt`、`formatClock` / `formatDuration`(与运行台账同一措辞)、`NODE_META`(四类节点的图标/名称键/强调色)、`RAIL_NODE_ORDER`(节点页签顺序)、`primaryKind`、`hasNodeData`、`useActivityTabs`(每类筛选页签,由聚簇持有以熬过面板关闭)。**改这些的导入路径时记得三个外部引用方**。(缘轨的 `RAIL_NODE_SIZE` / `RAIL_NODE_PITCH` 已随缘轨退场;若回到方案 D 那套纯算术定位需要重新引入。)
- **活动台内容(四类共用一套壳)**:头部(节点身份 + 实时摘要 + 「在右栏打开」/关闭)→ 汇总条 → 筛选 chip(计数为 0 的自动不显示)→ **分区正文(唯一滚动区)** → 页脚。正文按数据类型换形态:**子代理 = 会话时间轴**(每条代理按真实起止铺 4px 条形,运行中的顶到右端「现在」刻度;按 运行中/已结束 分区)、**任务 = 看板**(conic 进度环 + 按状态着色的分段进度 + 按状态分组,保留优先级色条)、**计划 = 索引**(序号 + 标题 + 两行摘要 + 「最新」标记)、**书签 = 时间轴**(按 今天/更早/已失效 分组挂在一条竖线上,失效项灰掉但保留)。
- **时间轴是真实数据,不改契约**:`SubagentSnapshot` 没有 `startedAt`,也不需要——已结束的代理以 `endedAt` 收尾、运行中的以 `now` 收尾,两者都带累计 `durationMs`,所以每条条形的起点 = `end - durationMs`(`subagentEndpoints`)。面板的空隙、行上的时钟与条形长度由同一函数推出,三者不可能互相矛盾。
- **移动端/Web 壳**:`isElectron` 为假时不开 380px 面板(手机放不下),点圆钮/横条改为打开现有底部抽屉 `ActivitySheet`(同样复用 console 与节点页签)。console 只负责内容,外框由宿主提供(桌面=聚簇下方的面板框+顶缘缺口,手机=抽屉框+页签),两个壳因此不会漂移。
- **动效(styles.css「Activity cluster + console」块)**:`activity-cluster-bar`(横条 max-width/opacity 过渡)+ `activity-cluster-miniprog`(34px 迷你进度)、`capsule-pop-in`(活动台入场)、`capsule-shimmer-track`(运行中子代理行的扫光)。圆钮的进度环是 conic-gradient + mask,三态切换只改边框/背景(过渡在组件里)。`prefers-reduced-motion` 同步降级(面板入场拍平、横条瞬间切换、扫光静态着色)。
- **词条**:`chatStream.activity.cluster.*`(aria / running / failed / waiting / tasks / plans / noPlans / openPlans)+ 活动台的 `node.*`、`group*`、`tabAll`、`unit*`、各面板副标题与页脚;旧的 `activity.capsuleTitle`/`plansTitle`/`tasksTitle`/`subagentsTitle`/`runningCount`、`bookmark.capsuleTitle`/`sectionTitle` 与缘轨时代的 `nodeHint.*` 均已删除。zh 是源、en 镜像,缺键过不了 typecheck。
- **已知取舍**:①旧的「段落可折叠」被筛选页签取代(内容可达性不变,折叠状态不再保留);②横条展开时盖住首行尾部(见上,方案 D 是零遮挡的那一支);③圆钮与横条是同一颗控件的两种形态,状态切换的动效必须准,否则显毛躁。
### 图片灯箱(2026-09-11 重设计,设计原型 `prototypes/image-lightbox-redesign.html`)
- **问题**:旧版把复制/下载顶左、关闭顶右、左右箭头贴两侧、计数贴顶中——图标 20/24px,半透明但仍压在图上,四角全是悬浮控件;且顶右的关闭键与 Windows/Linux 原生窗口按钮覆盖层(画在 webview 之上)在同一条带上,视觉打架。
- **形态**:控件全部收进图**下方一条 docked 玻璃栏**——`复制 · 下载 · 在资源管理器中显示 │ 上一张 · 2 / 5 · 下一张 │ 关闭`,单图时中段整段消失(只剩 复制/下载/显示 │ 关闭);图标降到 17px、按钮 36px。**图像区与栏是 flex 兄弟**(`Dialog.Popup` = 全屏 flex 列,舞台 `flex-1 min-h-0`,栏 `shrink-0`),几何上不可能重叠;栏宽 ≈280px,400px 窗口也不换行。
- **「在资源管理器中显示」= 传字节,不传路径**(2026-09-13):图片 block 契约里**只有 base64,没有路径**(浏览器截图从 provider 回来就是裸 base64,粘贴图从来就没有文件),所以 `shell:showImageInFolder` 收的是**用户正在看的那张图的字节**,由 main 侧的**内容寻址注册表**(`main/lib/imageArtifacts.ts`,sha1 → 路径)反查出它自己写过的文件——`saveScreenshotToDisk` 与 codex `resolveGeneratedImage` 各自在落盘后 `registerImageArtifact`,`browser.image`/tool_result 两条链路的 base64 与落盘字节同源,哈希天然相等(**Claude 侧 MCP handler 拿不到 SDK 的 tool_use id,所以按键是内容而非 toolCallId**);查不到(进程重启后的历史会话、粘贴图、MCP 工具图)就把同一批字节物化到 `<userData>/images/<sha1>.<ext>`(内容寻址,重复点击/重复图共用一个文件、不重写)。**安全性由此不在路径校验上**——renderer 无法指定任何路径,main 只可能 reveal 自己写的东西(这也是唯一一条不走 `pathWithin` 项目根校验的文件管理器通道)。反馈同复制:按钮原地变对勾/变红(`layout.image.reveal|revealed|revealFailed`);手机壳 `isElectron === false` 直接不渲染该段(webApi 的 `shell.showImageInFolder` 是 no-op 桩)。
- **闲置淡出**:指针静止 2.6s(`CHROME_IDLE_MS`)后整条栏 `opacity: 0` + 下移 6px,光标一并隐藏;任何 `pointermove`(120ms 节流)或按键唤回。底部 88px(`CHROME_ZONE_PX`)算「在栏上」——悬停时永不淡出,否则栏会在静止的指针下消失、把下一次点击变成「点背景关闭」。判定走几何而非 hover 事件:栏隐藏时 `pointer-events: none`,收不到 enter/leave。
- **点击穿透(三个坑,改这里前先读)**:① `Dialog.Popup` 自己带 `pointer-events-none`——它现在是全屏的,不给 none 就会吞掉「点空白处关闭」(base-ui 的 dismiss 要求 press 的 target **恰好是 backdrop 元素**,见 `useDialogRoot.js` 的 `outsidePress`),图像与栏各自 `pointer-events-auto`;② `pointer-events: none` 只影响继承,子元素上的 `pointer-events: auto` 会**重新启用**命中——所以隐藏态必须在 `.lightbox-chrome` 和它的所有后代上重复声明 none,否则留下一颗看不见却挡点击的栏;③ 光标隐藏规则必须落在**后代**(`.lightbox-figure` / `.lightbox-scrim`)上,落在 `.lightbox-root` 上永远不生效——穿透的容器自己不会成为命中目标。
- **取消的浮层**:曾做过「复制成功在栏上方浮一条回执」,原型渲染时发现它会盖住高图的下缘(或让图重排),改成**按钮原地变绿对勾 + `lightbox-check` 弹一下**(260ms spring),任何回执都不离开栏。**别再往图面上加悬浮元素**。
- **键盘**:`←`/`→` 在画廊内翻页(同步 `onNavigate`,并唤回控制栏),`Esc` / 点背景 / 栏内关闭键三条关闭路径都在。样式在 styles.css「Image lightbox」块(`lightbox-scrim` 渐晕、`lightbox-figure` 入场、`lightbox-bar` 玻璃、`lightbox-chrome` 淡出),`animation-fill-mode` 一律 `backwards`(用 `both`/`forwards` 会把闲置淡出的 opacity 顶掉),`prefers-reduced-motion` 全部拍平。
### 手绘风主题 · 纸面手绘一期(ui.themeStyle,2026-09-11,设计原型 `prototypes/theme-sketch-redesign.html`)
- **立场:手绘是「风格」维度,不是「明暗」维度**——与 `ui.theme`(theme.get/set/nativeTheme)正交,落地为 settings key `ui.themeStyle`(`"classic"` 默认 | `"sketch"`),经通用 `setting.get/set` IPC(与 displayMode/leftBarMode 同管道,**main 侧零改动、nativeTheme 不感知**)。渲染端在 `<html>` 上同时维护 `.dark` 与 `.sketch` 两个 class。设计稿里「main:theme.ts 持久化 + theme.changed 加字段」一行按实际代码模式简化掉了。
- **管道**:contracts(`THEME_STYLE_SETTING_KEY` + `ThemeStyleSchema` in ipc.ts,`ThemeStyle` 类型 in theme.ts)→ sessionStore(`themeStyle` 字段进 **first-paint getMany 批**水合 + `setThemeStyle` action)→ `lib/theme.ts` 的 `applyThemeStyle`(toggle `.sketch` + 镜像写 localStorage `mcode-theme-style`)→ `lib/appearance.ts` 的 `useThemeStyle`(挂 App 根)订阅应用。FOUC:`initFoucGuard` 里**同步读 localStorage**(SQLite/IPC 未就绪时的首帧猜测,DB 值到达后校正)——手绘没有 OS 媒体查询可猜,localStorage 镜像是唯一同步来源。
- **styles.css「Sketch theme」区块(集中管理,禁止散落)**:①纸面配色 `html.sketch:not(.dark)`(暖纸 252 250 243 + 墨色 + 铅笔灰描边 + 微降饱和墨绿,token 与 :root/.dark 同名同集);②形状配方 `--sk-r-lg/md/sm`(手抖圆角,相邻角 225px/18px 悬殊让浏览器按比例收缩出有机边缘)+ `--sk-sh*`(硬阴影实色偏移)+ `--sk-font` 手写栈;③工具类映射(`.rounded-md→sk-r-sm`、`.rounded-lg→sk-r-md`、`.rounded-xl/2xl→sk-r-lg`、`.shadow-*→硬阴影`、`.border→1.5px 墨线`、`.chat-md pre→虚线`)——**不进 @layer 且带 html.sketch 前缀,层叠序与特异性双保险压过 Tailwind 工具类**(本构建的 utilities 未进原生 layer);④荧光笔选区 + 虚线焦点环(`:focus-visible`,排除 Monaco 的 `.inputarea` 与 xterm 的 `.xterm-helper-textarea`——隐藏输入宿主不该有框);⑤composer 双笔描边用 **outline** 实现(`::before/::after` 已被 busy 扫光/聚焦发丝线占用);⑥纸纹 = `body::after` fixed 一层 feTurbulence data-URI 噪声(pointer-events none,弹层之上也有纹理)。
- **图标抖动滤镜**:`index.html` 静态挂 `#sk-wobble`(feTurbulence baseFrequency .019/.032 + feDisplacementMap scale 2.2),CSS 侧 `html.sketch svg.tabler-icon { filter:url(#sk-wobble) }`。**必须首帧前静态存在、绝不能 React 渲染**——Chrome 对引用不存在滤镜的元素**直接不渲染**(图标全灭);只挂 tabler-icon(Tabler 稳定根 class),react-icons 品牌/平台图标保持清晰(身份标识不手绘)。**性能红线**:滤镜绝不挂根节点/滚动容器/动画元素(整树重栅格化)。「换一支笔」(换 seed)二期接设置项。
- **刻意不正清单**:`rounded-full` 不进半径映射(上下文环/头像/状态点保持正圆——仪表与身份标识不手绘);代码/终端/diff/Monaco/xterm 保持等宽(font-mono 元素级栈 + 自管字体,手写栈 cascade 不进去);`border-t` 单向发丝线不加重。
- **字号上调一档(14→15px)**:手写字体小字号发虚。`appearance.ts` 的 `applyChatFontSize/applyRightPanelFontSize` 改为**等于默认 14 时 removeProperty**(对齐该模块「未定制即移除 inline」的既有哲学),`html.sketch:not(.dark)` 才能用 stylesheet 默认顶到 15px;用户显式改过字号时 inline 仍优先(原型规格明示的取舍——设置页步进器显示「用户设定值」而非主题生效值)。
- **手写字体的作用点(踩过的坑)**:基础规则 `html, body, #root { font-family }` 是**分组选择器**,`#root` 自己钉着系统 sans——整个 React 树渲染在 #root 里、从它继承字体,只覆盖 body 传不进去(模型回复/全部 chrome 都会漏)。sketch 的覆盖必须 `html.sketch body, html.sketch #root` 两条一起,外加 `.font-sans` 工具类映射(Tailwind 默认 sans 栈,个别输入框显式钉它)。`pre`/`code` 不需要豁免规则:浏览器 UA 样式表对它们有**直选**的 monospace,直选永远赢过继承,「代码不手绘」红线天然成立。
- **牛皮纸(二期,2026-09-11 落地)= sketch × dark**:正交模型下 `html.sketch.dark` 一个 token 块即成(暖深底 #3b3126 + 粉笔白 #f0e7d5 + 粉笔线 #695a46 + 粉笔绿 #40be8e,值取自原型 kraft 表),形状层(圆角/硬阴影/字体/滤镜/虚线)与纸面共用零改动;字号 15px 上调从纸面配色块**上移到 html.sketch 形状基座**,深色形态同样拿到。三处深色专属处理:①纸纹 `html.sketch.dark body::after` 减淡到 55%(深底上全强度噪声读成污渍);②**经典暗色的两处冷灰文本提亮改为 `.dark:not(.sketch)` 作用域**(`.settings-root` 的 muted/subtle 直写值与 `.chat-md` 列表的 color-mix——冷灰落在暖棕底上冷暖打架;kraft 自身对比 ≈7.2:1 不需要提亮),这是 sketch 区块外仅有的两处 sketch 相关选择器;③`--git-lane-*` 刻意不重定义(身份色与风格无关)、Monaco/shiki 编辑器配色继续用户按明暗自选(`ui.editorTheme` 明暗各存一份,预设库 `lib/editorThemes.ts`(Mcode/One Dark/Monokai/Solarized 等),FileEditor 的 `useMonacoTheme` 跟随)。main 侧 `window.ts` 的 overlay/bgColor 同日补 kraft 分支(dark+sketch:窗口底 #3b3126、overlay #332a20、符号 #aca089)。设置项文案改双形态:「手绘」= 浅色纸面 / 深色牛皮纸。
- **霞鹜文楷已捆绑**(2026-09-11 补):`lxgw-wenkai-webfont@^1.7.0`,main.tsx 只导入 `lxgwwenkai-regular.css` + `lxgwwenkai-bold.css`(400 正文 + 700 真·粗体,免合成;**刻意不导 style.css**——它把 wenkai+wenkaimono 两族 × 3 字重 30MB 全引进来,mono 族无用且 light 用不上,只取 8.8MB)。97 个 unicode-range 切片/字重,运行时只加载可见文本命中的 woff2 分片;`font-display:swap` 落盘前回退系统楷体。**安装时若 `pnpm add` 报 registry fetch failed,先查 `http_proxy` 是否指向已停掉的本地代理**(本机 7897 端口的代理客户端没跑、env 仍指向它,一切直连都会被路由进死端口;`env -u http_proxy -u https_proxy pnpm add …` 绕开即可)。
- **titlebar overlay 同步(2026-09-11 补)**:main 侧 native chrome 感知 sketch——`lib/theme.ts` 新增 `getThemeStylePreference()`(读 SettingRepo;DB 未就绪(建窗早于 initTheme 的 awaitDb)try/catch 回退 classic),`window.ts` 的 `bgColor/overlayColors` 按「明暗 × 风格」取值(light+sketch:窗口底 #fcfaf3、overlay 色 #f6f2e7/符号 #8d8371,**须与 styles.css sketch token 手工同步**;dark+sketch 维持经典暗色)。刷新时机两条:启动时 initTheme 的 `updateTitleBarOverlay()`,以及 `SETTING_SET` handler 拦截 `THEME_STYLE_SETTING_KEY` 直接触发(renderer 的 `.sketch` class 管不到原生覆盖层)。mac 无 overlay(updateTitleBarOverlay 直接 no-op),视觉验证需 win/linux。
- **虚线内衬框覆盖(2026-09-11 补)**:聊天代码块(`.chat-md pre` 的 border 转 dashed)+ 终端(`[data-terminal-session]` 宿主,TerminalView 的稳定标记、TerminalPanel 唯一挂载点,底部/右侧终端共用;sketch 下宿主改绝对定位 + inset 6/8px 内缩 + 虚线 + `surface-muted/45` 浅底,压过组件的 h-full/w-full)。思考块在 2026-09-11 无框台账重构后已是无边框行,**不再描框**(强行加框与无框设计打架)。
- **cn-font-split 子集化:评估后不做(2026-09-11)**——官方 webfont 本身就按 unicode-range 切片(97/字重),运行时只加载可见文本命中的分片,设计稿的「首屏只下常用几片」性能目标已天然达成;子集化只剩安装包磁盘收益(8.8MB → 2–5MB),却要引入构建管线 + 构建期网络/源 TTF(≈19MB)。相对刚按需下载瘦身 ~600MB/平台的安装包,8.8MB 不构成约束;发版前若在意再启。
### 界面字体自定义(ui.uiFontFamily,2026-09-12,classic 限定)
- **settings key `ui.uiFontFamily`(空串=默认)**,与 themeStyle 完全同管道:contracts key → sessionStore 字段(first-paint getMany 水合)→ `setUiFontFamily` 乐观更新 → appearance.ts hook → `lib/theme.ts` 的 `applyUiFontFamily` 往 `<html>` 写 `--app-font` 变量(值=`"所选字体", <系统栈>`——fallback 内嵌在写入值里,字体被卸载时优雅回落而非裸浏览器默认)。sketch 不感知:CSS 特异性天然隔离(`html.sketch body/#root`→`--sk-font` (0,1,1) 压过基础规则 body/#root (0,0,1);classic 门控的 `.font-sans` 覆盖带 `:not(.sketch)`)。等宽面(代码/终端/diff/Monaco)元素级直选栈不受影响。
- **枚举走 main 进程,别再试 `navigator.queryLocalFonts`**:Chromium 的 Local Font Access API 在 Electron 33 实测不存在(navigator 成员缺席,`enable-blink-features=LocalFonts,LocalFontAccess` 与 `enable-experimental-web-platform-features` 都救不了),这是 Electron 至今未接线的 Chromium 能力。`main/lib/fontEnum.ts` 按平台 shell 出去:mac = `osascript -l JavaScript` 调 `NSFontManager.availableFontFamilies`(~250 族/100ms,显示族名可直接进 CSS;**别用 `system_profiler SPFontsDataType`,秒级慢;别用 CF 级桥 `CTFontManagerCopyAvailableFontFamilyNames`+`CFBridgingRelease`,JXA 下段错误**);win = PowerShell 读字体注册表 HKLM+HKCU(每用户字体在 HKCU;envRefresh.ts 同款显式 UTF-8 纪律,reg.exe 管道是 OEM 代码页),条目名 `"A & B (TrueType)"` 剥后缀拆 `&`;linux = `fc-list --format`。失败回落各平台 curated 真实族名清单,选择器永远可用。30s TTL 缓存,`fonts.listSystemFamilies({refresh:true})` 硬刷(装完字体立刻重开选择器用)。sanitization(剥引号/反斜杠+64 长度帽)在 main 与 renderer `sanitizeFontFamily`(theme.ts)两处把守——值要内插进 CSS 字体串。
- **FOUC**:`localStorage["mcode-ui-font"]` 镜像(initFoucGuard 同步应用,DB 水合后校正),同 themeStyle 的 `mcode-theme-style` 模式。**`.font-sans` 覆盖用 `data-user-font` 属性门控**(`html[data-user-font]:not(.sketch) .font-sans`)——默认态零行为变化,不碰 Tailwind 工具类原语义;`UI_FONT_FALLBACK_STACK`(theme.ts)与 styles.css 基础规则的 var fallback 手工同步。设置页有 `document.fonts.check` 探测:所选字体已卸载时显示「以回退字体显示」警示。导 入字体文件(FontFace + userData/fonts)是规划好的二期,未实施。
- **验证(2026-09-12)**:fontEnum 冒烟 8 断言(246 族/缓存/排序/去重/消毒/CJK);真机端到端过枚举→搜索→样字预览→选字全 UI 即时切换;重启持久化与手绘边界留给用户日常确认(前者与 themeStyle 同管道,后者是 CSS 层叠数学)。
### 移动端配对记忆(2026-09-13,修「出门在外按返回回到验证码页」)
- **问题**:设备令牌本来就已经持久化在 localStorage(`mcode-web-token`,webApi 的 `isPaired()`),但两个入口都不看它——① 手机扫码/浏览器返回命中 `?nonce=…` 时,main 侧 `serveMobileAsset` 按 URL 强制发独立配对页 `pair.html`;② `AppMobile` 的启动闸门是 `isPaired() && !hasNonce`,带 nonce 进来一律落回配对表单(原意是防止过期 token 跳过闸门后连环 401)。两者叠加的后果:**已配对的手机只要被重新导航到配对 URL,就必须重新输入显示在电脑屏幕上的 6 位码**;用户离开电脑(仍在同一局域网,但看不到屏幕)后彻底卡死,只能回电脑前刷新二维码。
- **两端一致的新规则**:有 token 就问电脑一句「这个 token 还认吗」(`GET /api/auth/check`),**只有明确 401 才回配对表单**。探测失败(超时 / 网络错误 / 5xx)一律按"仍配对"处理直接进应用——连不上电脑不是撤销配对的证据,把用户丢回他读不到验证码的表单才是更坏的失败(这条边界两边都必须保持:判定只认 401)。契约 `MobileAuthCheckResult`;main 侧复用既有 `authorize()`(Bearer 头,与 SSE/RPC 同一套),返回 `{ok, deviceId, name, endpoint}`(**不下发 token**),顺带走 `validateToken` 的 `lastSeenAt` 节流刷新。
- **pair.ts**(独立、零依赖的配对页):boot 先读 token,有则先把卡片切成"已配对,正在进入…"(隐藏表单与页脚,复用 `.done`/`.spinner`),**而不是先画表单再跳**;`ok`/`unreachable` → `location.replace("/")`;`401` → 清 token、表单与页脚复位、显示失效说明(URL 里的 nonce 原样保留,可直接重新配对,不必重新扫码)。探测超时 5s。
- **AppMobile** 启动闸门改为三态 `checking | paired | unpaired`(删掉 `hasNonce` 强制分支):token 存在 → 探测期间显示 spinner 闪屏(`layout.pairRestoring`),`ok`/`unreachable` → 进壳,`401` → `clearAuth()` + 配对屏。探测通过时同时 `stripNonceFromUrl()`——nonce 一次性且 5 分钟过期,留在地址栏只会让后续刷新/返回再走一遍配对路由(配对屏期间**不**剥离,否则 token 失效就没法不扫码重配)。
- **验证**:① 已提交冒烟 `scripts/mobile-pair-auth-smoke/run.sh`(35 断言:按项目选项转译 pair.ts → 单独 .mjs 以便按 `?case=N` 重跑 boot → 在 DOM 桩里逐场景驱动。元素注册表由真实 `pair.html` 的 id 构建,故 id 契约也在此守卫;场景含:无 token 直接显示表单、有效 token 不显示表单直接跳转、401 回表单+清 token+文案复位、网络错误/5xx 保留 token 进应用;并断言 pair.html 的**表单初始 hidden** 与 **`[hidden]{display:none}` 规则**——`.form`/`.done` 自带 `display:flex`,作者样式压过 UA 的 `[hidden]`,漏掉这条规则则 `hidden` 是静默空操作、表单与状态块会同时显示)。**转译产物必须叫 `.mjs`**:Node 对 `.js` 会按 CommonJS 按路径缓存,`?case=N` 不再重新求值,第 2 个场景起会静默拿上一场景的 DOM 断言(踩过)。② main 侧路由另用一次性 esbuild 桩壳(桩掉 electron/DB/logger,真实 `MobileHttpServer` 起在 7399)走真实 HTTP 验证(17 断言:无头 401、错 token 401、有效 token 200 且带 deviceId/endpoint、响应体不含 token、`lastSeenAt` 被刷新、未知 `/api` 仍 404、`POST /api/auth/check` 404、静态路由与 `?nonce=` → pair.html 未受影响;该桩壳未入库)。③ 真 Chromium 端到端(隐藏 Electron 窗口 + 桩服务器 + 新构建产物):未配对扫码见表单、**已配对重入 nonce 链接全程无验证码并落到应用壳**、401 时表单回来且 nonce 保留、普通刷新带有效 token 直进、token 被撤时回落配对屏。
### 移动端 IDE 面跟随会话环境(2026-09-13,修「切到工作树会话后文件树/Git 面板还看主检出」)
- **问题**:桌面端的文件树与 Git 面板一直走 `selectActiveEnvPath`(会话工作树优先,否则项目根),所以 PC 上切到工作树会话整棵树/Git 会跟着换;手机端两个屏却各自从 `activeProjectId → projects.find().path` 取根——`MobileFilesScreen` 把项目根当面包屑第 0 段并作为 `file:listDir` 的 `projectPath`,`MobileGitScreen` 用项目根 `discoverRepos`。后果:工作树会话下手机浏览/搜索到的是**主检出**的文件,Git 页显示的是主检出的分支与改动,且**从这里提交/切分支会落到主检出上**(主检出在那时通常还有别的改动,很容易误提交)。
- **主进程侧** `file:listDir`(→ `listDirGuarded`)用的是 `isKnownWorkspaceRoot`(项目根 ∪ 已materialize 的会话工作树根),所以传工作树路径本来就被放行、**不需要改 main**;`git:discoverRepos` 手机版却写成 `ProjectRepo.listPaths().some(resolve 相等)`(只认项目根、且是精确相等),已改为与桌面同一句 `isKnownWorkspaceRoot(input.projectPath)`。其余 git 仓库级 handler(status/stage/commit/checkout…)在手机与桌面**共用 `@main/ipc/git.js` 导出的 `findContainingProject` 包装**,而那个包装内部就是 `findContainingWorkspaceRoot`(含工作树)——**别被名字骗了**:它是 git.ts 自己的局部别名,不是 `pathGuard` 的同名函数,工作树路径一直是被接受的(本次一度误判为 desktop 也坏)。
- **渲染层**:`MobileFilesScreen` 与 `MobileGitScreen` 都改用 `selectActiveEnvPath`(稳定 string|null selector)。文件树两处细节:① 重定根 effect 依赖 **envPath 而不是 project**——同项目内换会话时根会在项目根↔该会话的工作树之间移动;② 面包屑根名按 render 时算(`envPath === project.path ? 项目名 : worktreeDisplayName(envPath, worktreeNames)`),**不要冻结进 stack**——`worktreeNames` 是异步水合的,冻结会导致水合完成时重定根、把用户的面包屑位置重置掉(实现上 `shownStack` 只在名字变化时替换第 0 段的 `name`)。空态判据也换成 envPath。
- **顶栏工作树徽标**:`MobileShell` 顶栏在项目 chip 右侧再加一个工作树 chip,身份与抽屉的 `WorktreeGroupHeader` 一致(`IconGitFork` + `text-accent/80` 图标 + `worktreeDisplayName` 名字,`title` 是原始工作树路径)。**判据取自同一个 `selectActiveEnvPath`**(`envPath !== activeProject.path` 即视为工作树),而不是另去查 `session.worktreePath`——否则徽标可能与文件树实际展示的根不一致(两个真相源)。与项目 chip 一样只在 `view === "chat"` 显示:files/git 视图各自已有上下文(面包屑根名就是同一个工作树名、Git 页有分支),不重复挤占窄屏标题栏。
- **验证**:已提交冒烟 `scripts/worktree-env-smoke/run.sh`(18 断言)。它用 **git CLI 造真实 repo + 真实 linked worktree**(放在项目根之外,镜像 `<userData>/worktrees/<repo>/<branch>-n`),把真实 `pathGuard` + `ipc/files` 的 `listDirGuarded` 用 esbuild 打包(electron / repositories / logger 三个桩)后跑:工作树根被 `isKnownWorkspaceRoot` 接受、无关目录仍被拒、**列工作树列出的是工作树的文件而非主检出的**、`dirPath` 仍无法 `../` 逃逸、工作树内文件解析到工作树根、工作树是独立分支且其 `.git` 是**文件**(发现逻辑按名字匹配故仍能找到它)。渲染层接线(envPath → 两个 screen)由 typecheck 覆盖,未做真机浏览器端到端。
### 移动端壳:去底部标签栏 + 抽屉头部视图切换器(2026-09-14)
- **底部标签栏已删除**(小屏寸土寸金,把可视范围还给会话面板):视图切换收进 MobileSessionDrawer **头部分段控件(会话/文件/Git)**(`MobileView` 类型,`onPickView`),顶栏标题镜像当前视图(files/git 显视图名,chat 显项目 chip + 工作树 chip,见 09-13 节)保证「我在哪」可见。设置 = 顶栏右上入口 → `MobileSettingsSheet`(极简设置壳,非 SettingsPage)。
- **`ui.displayMode` 与桌面共享同一 settings key**:默认 `"single"` 隐藏 tab strip(抽屉就是会话切换器);`"tabs"` 在 keyed 激活 pane 上方显示共享 `SessionTabs` 条。**与桌面不同:手机背景 pane 恒不挂载**(省内存;桌面 tabs 模式是 hidden 保活)——手机上切会话本来就是重挂载语义,与同日桌面 single 模式的「真·单槽」(见「中间面板 Tab 模式」节)方向一致。
- **配对门在先**:未配对先渲染 PairingScreen,**配对通过后才订阅事件流与水合 store**(SSE 需要有效凭据;闸门三态见「移动端配对记忆」节)。
### SSH 中继远程访问(relay,移动端出网直达桌面)
- **定位**:手机伴侣 app 在局域网外直达桌面端,不经第三方隧道服务(Cloudflare/ngrok 在国内常被墙)。契约 `packages/contracts/src/relay.ts`,main 侧 `main/relay/RelayManager.ts`——模块级单例,生命周期镜像 LspManager(懒连接 + `disposeAll()`),状态经 `relay:event` 推送到 renderer。
- **数据路径**:`phone → VPS:publicPort → forwarder(socat/python3) → VPS:localhost:<tunnelPort> → SSH 反向隧道(ssh2 的 forwardIn) → desktop:7331(MobileHttpServer)`。VPS 上的 forwarder 是极简 TCP 端口转发(nohup+disown 常驻,SSH 断了也活着,重连快),**无需 sshd GatewayPorts 配置**;SSH 连接带 keepalive + 自动重连。
- **配置**:settings key `relay.vpsConfig`(VPS 连接 JSON)+ `relay.autoStart`(启动自动连接);**转发器程序可选**(`RelayForwarderChoice`):`"auto"` = socat 优先回落 python3(默认)/ `"socat"` / `"python3"` = 强制指定——VPS 缺该二进制时**显式报错,不静默回落**。
### shell 三通道守卫对齐工作树(2026-09-14,修「工作树文件树右键『在资源管理器中显示』无反应」)
- **问题**:`ipc/shell.ts` 写于工作树机制之前,三个通道的守卫只认**项目根**(`ProjectRepo.list()` + 局部 `pathWithin`,且不带大小写归一):`shell:showItemInFolder`(文件树右键「在资源管理器中显示」,文件夹/文件两个菜单同走)、`shell:openFile`(编辑器「不支持的文件」面板的「用系统应用打开」)、`shell:openPath`(精确匹配)。工作树检出在设计上位于所有项目根之外,于是工作树会话里右键 reveal 被守卫拒绝后**静默返回**(只有 main.log 一条 WARN),菜单看起来"点了没反应"。同轮排查确认菜单其余条目无恙:新建/重命名/删除/复制/粘贴/读取全走 `files.ts` 的工作树感知守卫,复制路径/加入聊天是渲染端本地操作,「在浏览器打开」走内置浏览器面板不经 shell。
- **修复**:`shell.ts` 删掉局部 `pathWithin` 与 `ProjectRepo` 依赖,三通道全部改走 `pathGuard` 的现成函数——`openPath` 用 `isKnownWorkspaceRoot`(精确匹配,`samePath` 大小写归一),`showItemInFolder`/`openFile` 用 `findContainingWorkspaceRoot` !== null,与 `files.ts` 同一口径。**口径变化要知情**:旧代码过滤 `!p.archived`,新守卫与 `files.ts` 一致地**放行归档项目**下的路径(文件树本就能浏览归档项目,reveal 拒绝反而不一致);`showImageInFolder` 不动(收字节不收路径,内容寻址注册表,天然无路径攻击面)。
- **验证**:已提交冒烟 `scripts/shell-reveal-smoke/run.sh`(11 断言):真实 `registerShellHandlers` esbuild 打包(electron/repositories/logger/imageArtifacts 四桩,electron 桩是**记录型 spy**),工作树内文件/工作树根本体/项目内文件三条 reveal 放行、无关目录与前缀孪生目录(`proj-twin` vs `proj`)拒绝、win/mac 大小写不匹配仍命中、`openPath`/`openFile` 同口径、image 字节透传。真机行为(资源管理器真的弹出)留待日常使用确认。
### 持久层迁移 sql.js → better-sqlite3(2026-09-14)
- **动机**:sql.js 整库常驻内存 + 每次写操作 `db.export()` 整库导出,主进程 RSS 随库体积线性增长(210MB 库 → ~1.6GB);better-sqlite3 按页访问文件,内存只与页缓存相关。
- **db.ts**:WAL + `synchronous=NORMAL` + `foreign_keys=ON`;`persist()` 保留为 **no-op**(仓库层 ~45 处调用点零改动);首次启动前自动备份旧库一份(`claude-gui.db.sqljs-era.bak`,确认无误后可删)。sql.js 写的是标准 SQLite 文件,直接打开零迁移;`migrate()` 顺带清扫 358 条孤儿消息(foreign_keys 之前实际未生效的历史残留)。
- **顺带修复 `listBySession` 分页存量 bug**:旧 `rows.slice(1)` 丢的是窗口**最新**一行——每个翻页边界静默丢 1 条、首页丢整个会话最新一条;改 `slice(0, limit)`。
- **ABI 红线**:better-sqlite3 必须匹配 Electron ABI(33 → v130)——postinstall 钩子 `scripts/ensure-better-sqlite3-electron-abi.mjs` 在每次 `pnpm install` 后经 prebuild-install(npmmirror 镜像)换入 Electron 预编译;打包链路既有 `rebuild:native`(install-app-deps)不变。**native 模块 dlopen 失败先查这里**。
- **冒烟**:`scripts/sqlite-migration-smoke/run.sh`(真库副本 48 断言 + 合成库 31 断言,node-ABI 下运行;`electron-abi-check.cjs` 在真实 Electron 运行时验证 dlopen)。
### 会话运行时 dispose 接入删除/归档链路(2026-09-14,主进程内存治理)
- **此前 `RuntimeManager.dispose()` 是死代码**:每个创建过的会话在主进程 sessions Map 永驻(usageHistory 无限追加、subagentTranscripts 跨回合累积、最后一回合被改文件全文常驻);且删除会话时运行中的 turn 会继续向已删会话写事件、重新插回孤儿消息行。
- **接线**:SESSION_DELETE / PROJECT_DELETE(桌面 IPC + 移动 RPC)在删行前先 `dispose` / `disposeProject`(项目级靠新增 `SessionRepo.idsByProject` 在 SQL 级联前拿会话清单)——正在跑的 turn 会被中断。SESSION_ARCHIVE ×2 + AutoArchiver:**归档即释放**(`bindSession` 在每次 send 前幂等重绑,恢复 → 重开 → 发送自动重建,归档不是数据死刑)。
- **`bindSession` 回灌 subagents/subagentTranscripts**(来自会话行),重绑后 sendTurn 的跨回合重放不再以空状态开场(顺带修复重启后首回合重放缺失)。
- **`FileSnapshot.freeze()` 产出 entries 后即释放内存中的文件正文**(rewind 走 `restoreFiles` + 持久化条目,freeze 后只剩 `hasPaths`/`clear` 消费 keys);实例方法 `restore()` 已删除——内容释放后它会写回空文件,属于数据丢失陷阱(rewind 节的恢复唯一入口即 `restoreFiles`)。
### 前端组件与图标
#### 组件库
- **`@base-ui/react` ^1.5.0** — Radix UI 原班人马开发的新一代无头 UI 组件库。项目中的可复用 UI 组件基于 base-ui 封装,位于 `src/renderer/components/ui/` 目录。
- **辅助**:`class-variance-authority` ^0.7.1 — 用 `cva()` 管理组件 variant/size;`tailwind-merge` ^3.6.0 — 用 `twMerge` + `clsx` 暴露 `cn()` 工具函数。
#### 图标库
- **主图标库**:**`@tabler/icons-react` ^3.44.0**。图标统一以 `<IconX size={16} />` 形式使用。
- **辅助图标库**:**`react-icons` ^5.6.0** — Tabler 未覆盖的特殊图标集(Phosphor `Pi*`、Remix `Ri*`、Simple Icons `Si*`、VS Code `Vsc*`)。
- **适配层**:`src/renderer/lib/icons.tsx` 集中 re-export 所有可用图标,附带常用图标的简写别名(如 `SettingsIcon = IconSettings`)。
#### 使用规范(新代码必须遵守)
1. **class 合并**:**必须**使用 `cn()`(从 `@renderer/lib/cn.js` 导入)替代 template literal 拼接。旧代码可保持原样,新代码一律用 `cn()`。
2. **基础 UI 组件**:优先从 `@renderer/components/ui/index.js` 导入 `<Button>` / `<Input>` / `<Dialog>` / `<Select>` 等封装组件,不直接写原始 `<button>` + inline className。
3. **variant 管理**:用 `cva()` 定义组件的 variant/size 变体,不手写条件 className。
4. **图标**:使用 `@tabler/icons-react` 的 `<IconX>` 组件替代 Unicode 字符(✦▶✕⚙等)。需从 `@renderer/lib/icons.js` 导入。
5. **语义 Token**:所有 Tailwind class 使用现有的语义颜色 token(`bg-surface` / `text-content` / `border-edge` / `text-accent` / `text-content-muted` / `text-content-subtle` 等),不使用原始 Tailwind 颜色值。
### 鼠标手势(P5.10,2026-09-03)
- **架构定位**:手势 = 命令注册表(`lib/commands.ts`)的第三个消费者(命令面板 / 键盘快捷键 / 鼠标手势共享同一份 `CommandDef.perform`),整层镜像 shortcuts 体系:`lib/gestures.ts`(默认表 `DEFAULT_GESTURES` + 识别纯函数)↔ `lib/shortcuts.ts`;`useMouseGestures`(App 根挂载)↔ `useGlobalShortcuts`;settings key `ui.gestures`(overrides-only,`GestureSettingsSchema:{enabled,trigger,overrides}`,deferred 批水合)↔ `ui.shortcuts`;`GesturesPanel`/`GestureRecorder` ↔ `ShortcutsPanel`/`ShortcutRecorder`(store 哨兵 `gestureRecording` 让全局监听 Stand down)。默认绑定(全可逆,刻意不含删除/归档/rewind):↓→ 关闭当前会话(`session.close`,专门为手势加的静态命令——始终指向激活会话、不限 tabs 模式,与 `tab.close` 的"关中间面板 tab"语义区分)、↓← 新建会话、↓ 终端、← 左栏、→ 右栏(↓ 是 ↓→/↓← 的前缀,首段后提前松手会触发单向前缀绑定,实时徽章会在松手前显示当前解析结果)。**↓→ 上下文感知(2026-09-04)**:tabs 模式且编辑器持有中间面板(非宽屏,`editorCenterTarget` 判定——镜像 store 的 `isSessionChatOnScreen` 规则)时,首划先关聚焦的计划页签或文件(镜像 `tab.close` 的编辑器分支;`closeFileInIde` 关最后文件自动回落 chat,下一次 ↓→ 即关会话),其余形态一律关会话;`available` 放宽为「有激活会话 或 编辑器聚焦有文件/计划」——无会话也能用手势关文件;命令面板与手势徽章按实时状态显示实际目标名称(`lib.commands.closeFocusedFile`/`closeFocusedPlan`/`closeSession` 三词条,`commandDisplayName` 增可选 state 参数经 `commandLabel` 解析),手势标签缓存改为 pointerdown 清空(标签依赖状态后,按订阅生命周期缓存会跨手势取到旧上下文的名称)。识别(**2026-09-04 重做容差**——此前 45° 硬扇区 + 每 24px 把量化原点重置到当前点,抖动被逐窗放大、稍微画弯就把一笔切碎成多段:主方向宽容锥 ±30°(`CARDINAL_TOLERANCE_DEG`,斜向只占中间 30° 窄带,默认绑定全是主方向故容差刻意偏向主方向);**锚定段**提交阈值 40px(`FIRST_SEGMENT_PX`,净向量越短抖动占比越大,也直接抬高单段 ←/→ 的误触门槛,续段/转角腿仍 24px);同向延续按锚点**净漂移**判定(横向抖动在净向量里相互抵消,不再逐窗重判);拐弯双触发——①局部硬转角:距最近「路径内」样本(`perp ≤ PATH_EPS_PX=12`)≥24px 且偏角 >`TURN_ANGLE_DEG=45°` 即提交新段、锚点移到该拐角(解决 ↓→ 第二段比第一段短时从旧锚点看仍落在 ↓ 锥内、永远不拐的问题),②从锚点向量离开方向锥时回退到沿旧轴最远的样本(拐角)重新起步(解决斜着画的第一段 + 明确转角);浅偏离(小鼓包、弯了又回来的漂移)一律宽容为同段;总位移 <8px 不进手势态(纯点按=原生行为);失配静默 no-op。**顺带修匹配键碰撞**:`sequenceToString` 原来无分隔符 join,`["D","R"]` 与 `["DR"]` 都序列化成 `"DR"`——单画一条 ↘ 会误触发「↓→ 关闭会话」(同理 ↙ 误触 ↓←),改逗号 join 修复(`ui.gestures` 持久化的是原始数组而非该字符串,格式可安全变更)。
- **contextmenu 平台差异(关键坑)**:Win/Linux 的 contextmenu 在 mouseup 后触发——手势结束时开 250ms 一次性压制窗(`suppressMenuUntil`)吞掉尾随菜单,纯点按不受影响;**mac 在 mousedown 时就触发**(早于任何移动)——arm 时即拦,若至 pointerup 未成手势则向原 target 合成重发冒泡 `contextmenu`(React 合成事件与 base-ui 菜单都消费真实 DOM 事件,合成事件可走通)。中键触发时对 pointerdown `preventDefault` 压掉 Chromium 自动滚动。
- **豁免与盲区**:终端 host 标 `data-gesture-exclude`(保住右键复制/粘贴习惯,全局监听在 window capture 先于其 host capture,不豁免会抢走);`[role="dialog"]` 内不手势(不操作弹层背后的视图);`-webkit-app-region: drag` 区与内嵌浏览器 WebContentsView 是天然盲区(renderer 收不到事件,设置页 footer 有注明)。轨迹绘制**不经 React**(`lib/gestureTrail.ts` 直改 SVG polyline,pointermove 频率下 per-frame setState 会重渲染整树),静止时零成本(未启用时连监听都不挂)。手势期间 Escape 取消,拖出窗口/失焦自动取消。**实时徽章**(`showGestureBadge`,同模块的 HTML div):手势进行中跟随指针显示「箭头序列 · 匹配命令名」(部分序列一旦精确匹配绑定即显名,标签经 `commands.ts` 的 `commandDisplayName` 解析——**不做 `available` 过滤**,被过滤的命令照常显示名称但不派发,与键盘语义一致;标签按 stroke 缓存);失配收尾显示 `lib.gestures.unrecognized`;徽章淡出(~650ms)比轨迹(~180ms)慢以便读取;设置页录制中徽章只显箭头不显命令名(匹配提示会误导录入)。
- **验证**:`gestures.ts` 纯函数冒烟(esbuild 转译后 node 跑 35 断言:8 方向量化/主方向容差边界/抖动竖线不拆段/短第二段 L 形/斜第一段 L 形/起点漂移+抖动+转角的完整 ↓→/短行程/直线不堆叠/往返/小鼓包宽容/Z 形/匹配注入性([DR] 不误撞 [D,R])/冲突/解析)。
### Codex provider(第三个 AgentProvider,2026-09-05)
- **路线:自研 `codex app-server` JSON-RPC 客户端,不用官方 `@openai/codex-sdk`**(它只是 `codex exec --experimental-json` 的包装:无审批回调、无 interrupt API、无 diff/usage 事件,满足不了审批/计划/本轮修改需求)。app-server 协议 = stdio 上的 JSONL(JSON-RPC 2.0 去掉 `jsonrpc` 头),VS Code 扩展同款,协议 schema 可用 `codex app-server generate-json-schema` 生成、实现前先跑它校准。依赖只加 `@openai/codex`(钉精确版本 0.153.4,平台二进制经 optionalDependencies 别名 `@openai/codex@<ver>-<platform>` 分发;electron-builder `asarUnpack` 覆盖 `@openai/codex*/**`)。
- **协议硬事实(0.153.4 实测,换版本必须重新探测)**:① `initialize` 必须带 `capabilities:{experimentalApi:true}`,否则 `thread/start.dynamicTools` 报 -32600;② `thread/start` 响应是 `{thread:{id}}`(**不是** `{threadId}`);③ **`turn/start` 立即返回** `{turn:{id,status:"inProgress"}}`——turn 完成全靠通知驱动,`turn/completed` 携带最终 `turn.status`(completed|interrupted|failed),**没有 `turn/failed` 通知**,失败走 `error` 通知 `{error,willRetry}`(willRetry=true 是重试中间态,**不能**据此发 turn.done);④ approvalPolicy 是 kebab-case(`on-request`/`never`,untrusted 在退役),thread/start 的 `sandbox` 是 kebab SandboxMode(`read-only`/`workspace-write`/`danger-full-access`),turn/start 侧则叫 `sandboxPolicy` 且用 camelCase tagged object(`{type:"workspaceWrite"}`)——两套拼写并存;⑤ effort 字段在 turn/start 叫 `effort`(自由字符串,模型广播支持值);⑥ item 的 type 判别值是 camelCase(`agentMessage`/`commandExecution`/`mcpToolCall`/`fileChange`/`webSearch`/`plan`/`dynamicToolCall`/`userMessage`…),item 字段也是 camelCase(`aggregatedOutput`/`exitCode`);⑦ reasoning 流经 `item/reasoning/textDelta` + `item/reasoning/summaryTextDelta`;⑧ token usage 在 `thread/tokenUsage/updated`,`{tokenUsage:{last,total,modelContextWindow}}`,breakdown 字段 camelCase;⑨ 审批 server 请求:`item/commandExecution/requestApproval`/`item/fileChange/requestApproval`(fileChange 参数只有 `grantRoot`,**没有逐文件路径**),决策 `accept|acceptForSession|decline|cancel`,响应 schema 只有 `decision` 字段(**decline 无法携带理由给模型**);⑩ dynamic tool 调用的 server 请求方法是 `item/tool/call`(`{callId,threadId,turnId,tool,arguments}`),响应 `{success,contentItems:[{type:"inputText",text}|{type:"inputImage",imageUrl}]}`;⑪ codex 原生 `item/tool/requestUserInput`(questions=[{title,options?}],answers 按 question title 键控)也桥到同一个提问卡;⑫ **图片 item(2026-09-06 接入)**:`imageGeneration`(schema 形状 `{result: b64, status, revisedPrompt?, savedPath?, failure?}`,OpenAI `image_generation_call` 的落盘+回传形态)渲染为合成工具卡 `image_generation` + 内联图片——adapter 先补发 `tool.use`(`item/started` 对该 item 可能缺席,`imageItemsSeen` Set 去重防双卡),图片优先读 `savedPath`(扩展名定 mime),退化用 `result` 串(≥1KB 且纯 base64 字符集才当图,否则当状态文本),经共享 `browser.image` 事件挂到卡后(契约 `BrowserImageEvent.mimeType` 已从钉死 png 放宽到 png/jpeg/webp/gif);读盘用 `readFileSync` 保住通知泵内的发射顺序;`failure.usageLimitExceeded` 走 isError result。`imageView`(view_image 工具,模型查看本地图片)只出 path 卡不出内联图(已看过 ≠ 新生成,防重复噪音)。展示链路(渲染端 `Block kind:"image"` + Gallery + lightbox、持久化)全部复用浏览器截图的现成组件,零渲染端改动;⑬ **turn/start 静默丢弃未知字段(2026-09-06 实测定罪)**:其 params schema **没有 `modelProvider`**,serde 默认忽略未知字段不报错——传了也无效(实测:双假 provider 端口探针,turn/start 覆盖 modelProvider=pB 请求仍打到线程原 provider pA 的端口)。**跨回合切换 provider/model 必须经 `thread/resume`**(其 params 正式支持 `model`/`modelProvider`/`cwd`/`sandbox`/`approvalPolicy` 线程级覆盖,实测覆盖 pB 后请求打到 pB 端口;model 切换时 server 会发 "resuming with m-b" warning,属预期);⑭ `dynamicTools` 随 rollout 持久化(二进制 rollout 元数据含 `dynamic_tools` 字段),thread/resume 新进程无需也无法重注册;⑮ `thread/start` 后未跑过 turn 的线程没有 rollout,对它 thread/resume 报 "no rollout found"(Mcode 的 resume 只发生在跑过 turn 的会话,不受影响);⑯ **子代理 roster(2026-09-06 接入)**:codex 没有 Claude 的 task_started/task_updated 边沿,子代理状态走两类 item——`collabAgentToolCall`(主 agent 的 collab 工具调用 spawnAgent/sendInput/wait/closeAgent…,`receiverThreadIds` 是目标子代理线程 id,`agentsStates` 为 per-agent `{status,message}`)与 `subAgentActivity`(独立生命周期边沿 started/interacted/interrupted/completed,也覆盖 review/compact 等系统内部子代理)。adapter 维护按线程 id 键的 roster,每次变更发整表 `subagent.update`(REPLACE 语义,镜像 Claude);状态只允许 running→终态单向升级(后续 item 携带的陈旧 agentsStates 不得复活已完成代理);collab 调用同时出聊天卡(toolName=collab 工具名,Task 工具对应物,否则 spawn 在对话里不可见);回合末 `finishTurn` 先把仍 running 的清扫为 completed(中断则 killed,对齐 Claude 的流末清扫)再发 turn.done——per-turn app-server 随回合销毁,残留 running 会锁死 composer 的 busy 门;**子代理模型继承断裂(2026-09-06 修)**:collab spawn 出的子代理线程是 server 内部新建的,**不继承**主线程经 thread/start/thread/resume 传入的 model/modelProvider 覆盖(那是主线程私有;multi-agent v2 的 spawn_agent schema 还把 model 参数对模型隐藏,见 openai/codex#32031)——子代理回落**进程默认模型**,而 config.toml 只物化 [model_providers.*] 无顶层 model 键,进程默认即 codex 内置 gpt-5.6-sol;provider 却继承到线程端点,于是第三方网关收到 "gpt-5.6-sol" 直接 invalid_request_error(DeepSeek 实测)。修复:spawn app-server 时 `-c model=<modelId> -c model_provider=<providerId>` 进程级钉死(config/read 实测生效;基线两键为 null)——显式 thread/turn 参数对主线程优先级更高不受影响,per-turn 进程使其天然会话隔离,与 1M context window 的 -c 同款模式);**子代理 transcript(2026-09-06 接入,修右侧面板空白)**:SideChatPanel 的 SubagentView 按 `subagentTranscriptsBySession[sid][agent.toolUseId]` 取数据,codex adapter 此前从不发 `subagent.transcript` → 点击子代理行必空白。两条数据源:① **实时转发**——`item/started|completed` 通知带 `threadId`(schema 必填字段),provider 在 thread/start|resume 落定后 `adapter.setMainThreadId()`,threadId ≠ 主线程且 roster 有该线程(toolUseId 存在)的 item 经 `appendSubagentBlock` 转块(agentMessage/reasoning→text/thinking,commandExecution/mcpToolCall→tool_use 状态机),每次变更发整表 `subagent.transcript`(REPLACE,键=roster.toolUseId);activity 边沿早于 collab 调用建的无 toolUseId 条目由后续 collab 调用**回填** toolUseId,否则行永远无数据源;② **thread/read 兜底**——live 路径对该线程零产出时,activity 边沿触发 `thread/read{includeTurns:true}` 折叠 turns[].items 重建(fire-and-forget;live 一旦产出即 stand down,防互踩;turnEnded 后 stand down);rollout 折叠的 completed tool call 无 started 边沿,`appendSubagentBlock` 需直接落 done/error 块(按 toolCallId 找不到既有块就 append);⑰ **delta 通知也带必填 `threadId` + `<think>` 内联拆分(2026-09-06 修「过程数据当最终回复」)**:schema v2 实测 AgentMessageDelta/ReasoningTextDelta/ReasoningSummaryTextDelta 的 params 都**必填 threadId**——此前三个 delta handler 只读 delta/itemId 无视 threadId,collab 子代理的流式输出会以主对话正文形态发出,且落在主线程最后一个工具之后、被渲染端的「过程/回复」切分(ChatPane 以最后 tool_use 为锚,thinking/tool_use 强制回收面板)归类为**最终回复**。修复:delta handler 读 `p.threadId`,≠ 主线程时走 `appendSubagentDelta`(transcript 末尾同类块追加,~120ms 节流整表 REPLACE 发射,`finishTurn` 同步排干;无 roster toolUseId 的线程直接丢弃,绝不漏进主聊天)。同时**主线程 agentMessage delta 过共享 `ThinkTagSplitter`**(从 bridge 提升到 `main/lib/thinkTagSplitter.ts`,bridge 与 codex 共用):第三方 Responses 网关把推理以内联 `<think>…</think>` 混在 message 文本里时,拆成 thinking 通道(thinking 有专属 messageId,经 `agentThinkIds`)而非带字面标签的正文;`item/completed` 时 flush 拆分器残留,且**无任何 delta 直达时用 `item.text` 兜底补发**(经新拆分器,防跳过 delta 的传输把回复弄丢)。mcpToolCall 的对象结果改紧凑 JSON(去掉 2 空格缩进,预览截断预算不再浪费在缩进上)。⑱ **imagegen 工具对第三方 provider 默认不注册(2026-09-07 解锁)**:模型说「没有可用的内置图像生成工具」的根因——codex 把独立 imagegen 工具(`image_gen.imagegen`,extension 注册)的门控放在两处:`ext/image-generation/src/extension.rs` 的 install 门(`is_openai() || requires_openai_auth || uses_openai_actor_authorization()`)与 `core/tools/spec_plan.rs` 的 `image_generation_available` 请求组装门(`Feature::ImageGeneration` 默认开、auth 非 Free、provider capabilities 默认开、fallback 模型元数据 `input_modalities` 含 Image,最终要求 `uses_openai_actor_authorization() || (requires_openai_auth && 认证走 codex backend)`)。Mcode 的自定义 provider 三者皆无 → 请求体 tools 里没有 imagegen。**解锁 = provider 配置勾选「支持生图」(`CodexProviderConfig.imageGeneration`,设置页 Codex 表单开关,默认关)后,物化 config.toml 时给该 provider 加 `http_headers = { x-openai-actor-authorization = "mcode" }`**(非空即满足 `uses_openai_actor_authorization`,对 OpenAI 兼容网关是未知 no-op 头);**`requires_openai_auth = true` 无效**(auth 走 env_key bearer 时 `current_auth_uses_codex_backend` 为 false,实测确认),且会改认证语义,不用。副作用面:standalone web search 虽共享 actor 门但还需 `model_info.supports_search_tool`(fallback 元数据为 false)→ 不被连带解锁(已实测工具名单无 web.run);imagegen 执行走 `{base_url}/images/generations` + env_key bearer(`resolve_provider_auth` 中 env_key 优先于 auth.json)——**网关必须支持 OpenAI images API**,否则调用报错(模型可见),不再是「没有工具」。冒烟手法:本地 mock Responses API + 局域网 IP(⚠️ 系统代理开启时 reqwest 会吞发往 127.0.0.1 的请求,冒烟必须绕开 loopback),对比请求体 tools 数组,三组对照(基线无 imagegen / requires_openai_auth 无 imagegen / actor 头出现 imagegen)。端到端 mock 冒烟(imagegen function_call → images API → imageGeneration item completed + savedPath 落盘)已通;⚠️ imagegen 的 images 请求 `model` 固定 `gpt-image-2`——第三方网关须支持该模型的 images 端点,否则工具调用失败(模型可见错误)。冒烟 mock 的接收层须绑 localhost(精确名,reqwest 认 ProxyOverride;裸 IP/127.0.0.1 的入站被防火墙或代理吞)。
- **文件改动追踪(与 Claude/Pi 的本质差异)**:codex 没有写入前钩子,文件改动只在落地后以 per-turn 聚合 unified diff(`turn/diff/updated`)出现。`CodexFileSnapshot`(继承 `FileSnapshot`,`originals`/`frozen` 为 protected)在 freeze 时对每个 diff 路径**反向应用聚合 diff 到当前磁盘内容**重建 before(`codexTurnDiff.ts` 的 parseUnifiedDiff + reverseApplySection,保守匹配失败即跳过该路径不猜);`registry` 加了 `getOrSetFileSnapshot(sessionId,factory)` 供 provider 注册特化实例。**没有 recordPre 路径**:审批参数只带 `grantRoot` 无逐文件路径,审批 handler 无法在 accept 前记录 before——diff 反向重建是本 provider 唯一的捕获路径(类注释按此表述,勿再声称 recordPre)。
- **权限模式(对齐 Codex 官方 Permission Profiles,不套 Claude)**:值/名/语义逐字取自 codex 二进制的 profile 定义——`read-only`("Read Only",`:read-only`,read-only 沙箱+on-request,编辑/联网需审批)/`default`("Default",`:workspace`,workspace-write+on-request,联网或改工作区外需审批;即 Agent 模式,复用中立 "default" 槽位,新会话开箱即官方默认)/`full-access`("Full Access",`:danger-full-access`,danger-full-access+never,无需审批)。旧实现的自创四档(codex-readonly 等)经 `LEGACY_MODE_MAP` 归一到最近官方档。OS 沙箱(Seatbelt/Landlock)是主防线;审批只在动作要逃逸沙箱时由 server 发起,经 `ctx.requestApproval` 走既有审批卡,Full Access 下(never)防御性自动 accept。**审批决策的 scope 映射(2026-09-06)**:契约 `ProviderApprovalDecision` 新增 `persist` 标志(ApprovalBridge 在用户点「总是允许」时置位并同时记录宿主侧 always-allow 集合)——`persist=true`(或命中 always-allow)映射 `acceptForSession`(服务端会话级缓存,同线程内相同动作不再询问),**一次性批准映射 `accept`(仅本次执行)**——旧实现把一切 allow 都升级成 acceptForSession,一次性批准会被静默放大为会话级放行。**没有「改命令后放行」**。思考级别(Codex 原生,值=TUI 显示名=二进制 effort 枚举):default(不传 effort→模型自身默认,GPT-5.6 默认 medium)/minimal/low/medium/high/xhigh/max/ultra(最大推理+自动任务委派);persistent 虽在枚举里但是 Responses API 的推理持久化机制而非选择器档位,刻意不下发。EffortDropdown 的 hint 字典同权限模式一样按 provider 限定(codex 走 chat.effort.hintCodex* 官方 catalog 文案)。**计划模式是提示性的**:codex 原生工具(Bash/apply_patch)不经过宿主、沙箱档位不能 mid-turn 下调,所以 enter_plan_mode 后沙箱内写文件不弹审批(靠 AGENTS.md 提示约束),仅沙箱逃逸动作照常弹审批;planMode.active 驱动计划卡/composer chip 生命周期,回合在计划模式中结束(中断/出错/模型未调 exit_plan_mode)时由 provider 的 finally 块补发 mode.change default + plan.update cleared,防止 chip 卡死。
- **模型配置(第三方 Responses-API 端点驱动 harness)**:settings key `codexProviders`(元数据)+ `codexProviderKeys`(safeStorage);物化为 `<CODEX_HOME>/config.toml` 的 `[model_providers.<id>]`(base_url、env_key=`MCODE_CODEX_KEY_<ID>`、`wire_api="responses"` 钉死——Codex 已弃用 chat-completions),**明文 key 只进 spawn env 不落盘**;与 Claude 侧 CustomModelStore(chat-completions 桥)完全并存不可复用。session.model = `"<providerId>/<modelId>"`(同 Pi 形态)。**config.toml 写入纪律(2026-09-06)**:materializeConfigToml 先比对现有内容、无变化跳过;有变化走**原子替换**(同目录 tmp + rename)——每 turn start 都会物化,正在启动的 app-server 随时可能读它,直写会让并发读者看到截断文件。**上下文窗口(第三方 1M 模型,2026-09-18 修正)**:CodexModelOption.contextWindow(设置面板模型行的「1M」开关,1000000)只传 spawn 参数 `-c model_context_window=<n>` 是不够的——codex 对它取 `min(模型元数据, 覆盖)`,**只能收窄不能抬高**;第三方模型没有元数据时回落 272k 兜底,覆盖抬不上去(0.153.4 实测会话窗口:未知模型+覆盖 1000000 → 258400=272k×95%;未知模型+覆盖 100000 → 95000;内置 gpt-6-astra 元数据 872k+覆盖 1000000 → 828400)。修正做法:`CodexModelsStore.ensureModelCatalog()` 先用 `codex debug models` 拉 codex 自家 catalog 当模板(**克隆整条条目**——schema 齐全,且保留 codex 自己的 instructions 模板;`base_instructions` 是必填项,自己写文案等于把 agent 系统提示词换掉,写空串则提示词为空),把声明了窗口的模型条目(覆写 slug/display_name/description/context_window/max_context_window,priority=1000+ 排在 codex 自家模型之后)合并写进 `<CODEX_HOME>/mcode-model-catalog.json`(内容只取决于 settings,原子替换),再按 turn 用 `-c model_catalog_json=<绝对路径>` 注入。仍是进程级 + 每 turn 独立进程,**并发会话零共享竞态**;模板按二进制路径缓存,`debug models` 不每 turn 跑。最终窗口 = context_window × effective_context_window_percent(95%);该值同时作为 adapter 的 token 快照兜底窗口(server 不报 modelContextWindow 时,第三方 128k 模型若按 272k 默认算占用率会低估一半)。设置面板 `CustomModelsPanel` 第三个 tab(Codex),ModelDropdown/sessionStore 镜像 pi 分支(`codexAvailableModels`)。**无 builtinModels**:codex 自家 catalog(gpt-5.x 走 ChatGPT 登录态)在隔离 CODEX_HOME(从不写 auth.json)里不可达,下拉只出用户配置的端点模型。
- **隔离**:`CODEX_HOME=~/.mcode/codex`(对齐 CLAUDE_CONFIG_DIR 先例),config.toml 由 Mcode **整体生成**(hand-edit 会被覆盖,这是设计),AGENTS.md(身份提示 CODEX_IDENTITY_PROMPT + ASK/PLAN/browserToolsUsage + win32 路径提示)写入同目录。MCP 同步:物化时把 user-scope 启用的 `.claude.json` mcpServers + 项目 `.mcp.json` allowlist 服务转成 `[mcp_servers.*]`(stdio:command/args/env;http:url+http_headers);browser 走 dynamicTools(受 `mcp.management.browserDisabled` 门控——注册在 thread/start、调用侧在 invokeDynamicTool 双门禁,因 resume 线程的 dynamicTools 随 rollout 持久化无法撤回,见「MCP 服务器管理」节)。skills 走 `skills/extraRoots/set` RPC(~/.mcode/skills + <cwd>/.claude/skills,**不是** config.toml [skills]——实测该键不生效)。
- **dynamicTools 注册的宿主工具**:`ask_user_question`(复用 askQuestion.ts 的 parseQuestions/formatAnswersForModel)、`enter_plan_mode`/`exit_plan_mode`(完全复用 Pi 的 plan 事件模式,planMode.active 闭包布尔 + mode.change/plan.update/plan.approval_request,前端计划卡零改动)、browser_* 七件(复用 agentBrowserTools)。spec 形状 `{type:"function",name,description,inputSchema}`。**dynamicToolCall item 渲染为普通工具卡(2026-09-06)**:adapter 对 `item/started`(tool.use,input=arguments)与 `item/completed`(tool.result,文本 contentItems 拼接;`inputImage` data URL 解析后经 `browser.image` 按 item.id 内联——此前 dynamicToolCall 被整类跳过,模型调 browser_* 在聊天里完全不可见、browser_screenshot 的图片因找不到 tool_use 卡被 renderer 丢弃)。`item/tool/call` 请求的 `callId` 与 ThreadItem 的 `id` 不保证相等,所以 browser_screenshot 仍保留按 callId 的 onImage 事件作双保险——callId==id 时 store 按 toolCallId+去重收敛,不等时 item.id 路径兜底(onImage 事件找不到卡被静默丢弃)。
- **进程模型**:每 turn 一个 app-server(claude 同款,无 session 泄漏),threadId 存 `claudeSessionId` 通用槽位,下 turn `thread/resume {excludeTurns:true, cwd, sandbox, approvalPolicy, model, modelProvider}`(覆盖参数见协议硬事实⑬);`turn/interrupt` 需要 turnId(从 turn/start 响应取)。中断 = 本地 abort + turn/interrupt + dispose 兜底。**崩溃自愈(2026-09-06)**:client 注册 `onExit`——mid-turn 进程死亡时没有在途 client request 可以 reject waitTurnDone 竞速,不 finalize 会话会永久挂起;onExit 发 error 事件(`CODEX_APP_SERVER_EXITED`)+ finalizeError,`crashEmitted` 旗标防止 done() 的 catch 路径重复发错误卡。回合输入图片写 tmp 文件(localImage 传路径不传 base64),finally 块 best-effort 清理。
- **协议栈验证**:`CodexAppServerClient`/`CodexMessageAdapter`/`CodexFileSnapshot` 是纯 node 模块(不碰 electron),可 esbuild bundle 后对真实二进制跑无头冒烟(死端点全链路:handshake→thread/start(dynamicTools)→skills/extraRoots→turn/start→5 次重试→error+turn.done(error) 恰好一次)。真机端到端需用户配一个支持 Responses API 的端点。
---
### Agent 运行时按需下载(2026-09-07,安装包瘦身 ~600MB/平台)
- **动机与形态**:三个 agent 运行时原先随安装包分发——claude 平台包 209MB(单 claude.exe)+ codex 平台包 378MB(vendor 树)+ pi JS 依赖闭包 44MB/28 包,合计约 600MB/平台,是安装包体积的绝对大头。现全部改为**按需下载**,安装到 `<userData>/runtimes/<agent>/<version>/`(main/index.ts 启动早期 `setManagedRuntimeRoot` 注册根目录)。JS 客户端保留捆绑的只有 `@anthropic-ai/claude-agent-sdk` 本体(4.9MB,进程内协议驱动,不可拆)。
- **依赖迁移**:`@openai/codex` 与 `@earendil-works/pi-coding-agent` 移入 devDependencies(pi 改精确版本 `0.83.0`,类型检查/开发用,electron-builder 不打 devDeps)→ 平台包是其 optionalDependencies,自动从安装包消失;electron-builder.yml 对应的两条 `asarUnpack`(claude-agent-sdk-* / @openai/codex*)已删除。新 dependencies 只加 `tar@^7`(纯 JS 解包,7.x 自带类型,无需 @types)。
- **分发物与布局**:`claude` = `@anthropic-ai/claude-agent-sdk-<platform>-<arch>@<期望版本>`(无依赖 tarball,claude[.exe] 在包根);`codex` = `@openai/codex@<期望版本>-<platform>-<arch>`(**平台构建是同名包的版本别名**,如 0.153.4-win32-x64;vendor/<triple>/bin/codex[.exe]);`pi` = `@mcode/runtime-pi@<期望版本>`(Mcode 自发布的**预组装 meta 包**,见下)。
- **pi meta 包**:`build/pack-pi-runtime.cjs`(`pnpm pack:pi-runtime`)用**真实 npm install**(ignore-scripts + omit-dev,拒绝走 pnpm store 拷贝——保证任意机器可复现 + 冲突嵌套交给 npm hoisting 算法)把 pi 闭包装进 `build/pi-runtime-dist/package/node_modules/`,打成 npm 形态 tarball,打印 `npm publish --access public` 指令(@mcode scope,MIT 重分发)。产物 22MB/140 包,**完美嵌套布局**(依赖在 `pi-coding-agent/node_modules/` 下而非提升——Node 解析逐级向上走,语义等价,已实测 import 成功)。发新版 = 版本号跟随 package.json 里钉死的 pi 版本。
- **下载管线**(`main/runtimes/runtimeInstaller.ts`):registry 元数据 → tarball 流式下载(**npmmirror 优先、官方源回退**,逐个 15s/8s 超时)→ **sha512 对 registry `dist.integrity` 校验,缺失/不匹配即拒装** → `tar extract strip:1` 到 staging → 载荷存在性断言(claude 包根 claude[.exe] / codex vendor 探测复用 `findCodexBinaryInPackage` / pi 的 package.json)→ rename 原子落位 → 写 install.json → **prune 其它版本目录**(500MB 级运行时不留双份,回滚=重装)。进度经 `runtimes:event` 推送(downloading 带分数/extracting/done/error)。**pi 的 registry miss 本地组装 fallback(2026-09-07)**:`@mcode/runtime-pi` 未发布/镜像滞后时(两 registry 对该版本都 404),pi 不再直接报错——`assemblePiClosureWithNpm` 在 staging 目录跑与 pack 脚本**同一配方**的 `npm install @earendil-works/pi-coding-agent@<版本>`(ignore-scripts + omit-dev + no-save,先写 `{name:"@mcode/runtime-pi",version,private:true}` 的 package.json;win32 spawn npm 必须 `shell:true`——npm.cmd,Node≥18.20 拒绝无 shell spawn .cmd;10 分钟 kill 兜底防 npm 挂死把面板 installing 状态卡死),产出布局天然满足 `payloadEntryPath("pi",...)` 断言,之后走同一 `finalizeInstall`;npm 缺失/失败时报错带上 npm 尾部输出并回落 pack+install-from-file 指引。pi 装完后 meta 包一旦发布,下次更新走 registry 路径(prune 会换掉本地组装副本,二者布局等价)。
- **解析顺序(三个 resolver 各加分支 0)**:`sdkBinaryPath.ts` / `codexBinaryResolve.ts` / `piSdkLoader.ts` 都先扫管理目录(newest-first;installer 已 prune,通常仅一个),再走既有 node_modules/asar.unpacked 逻辑。claude/codex 的扫描对 dev 与打包环境**都生效**(dev 装了管理运行时即优先,可实测全链路;dev 无管理运行时时行为不变)。`managedRuntimeRoots.ts` 是**纯 node 模块**(注册 setter + 版本扫描 + 版本比较),被 codex 冒烟链路引用,禁止 import electron。
- **版本模型**:期望版本运行时从 app 自身 package.json(dependencies+devDependencies)读取,读不到退回硬编码 fallback——**改依赖版本号即改期望版本,无第二处清单**。`updateAvailable` = 已装 ≠ 期望(app 更新后必现);`latestVersion` 仅展示上游 dist-tag(claude 查平台包 latest、codex 查 wrapper latest=裸 semver、pi 查 meta 包),**不提供一键升到 latest**——adapter 按协议硬事实写,跨版本适配必须随 Mcode 发版。
- **IPC/UI**:`runtimes.*` namespace(list/install/installFromFile/remove,contracts schema + preload 白名单 + `ipc/runtimes.ts` handler)。**installLocal = registry 失败的逃生通道**(@mcode/runtime-pi 未发布/镜像失效/离线):用户经 `api.pickFolder` 选本地路径,`installRuntimeFromLocalPath` 按形态识别——**目录**(claude:根含 claude[.exe] 的平台包目录;codex:含 vendor/<triple> 或 legacy codex/ 的目录;pi:含 node_modules/@earendil-works/pi-coding-agent 的 meta 包目录 = pnpm pack:pi-runtime 产物,或 pi 裸包目录本身)、**单个二进制文件**(claude/codex,pi 拒绝并提示是 JS 库)、**npm 形态 .tgz**(兼容旧路径)——复制/解包到 staging → 从 package.json 读版本(codex 剥掉 `-<platform>-<arch>` 后缀归一)→ 与 registry 路径共用 `finalizeInstall`(载荷断言/权限/原子落位/prune/install.json 记 `source:"local-path"`);面板每卡「从本地安装」ghost 图标按钮,remove 在**任意 running turn 时拒绝**(保守全局闸,镜像 worktree 删除闸);设置页新增「Agent」面板(`RuntimesPanel`,导航在 AI 组第一位;面板名中英文均叫 Agent,2026-09-07 起不再叫「Agent 运行时」);LSP 面板(`LspLanguagesPanel`)同日改为同款单行布局(nav/title 中英文均叫 **LSP**,不再叫「语言服务器」——每语言一行:展开箭头 + 名称 + 状态徽标 + server 提示 + 启用开关 + 安装/重装/健康检查/卸载,展开区收纳 resolved 路径(复制/定位)、下载页与从文件安装、自定义 path/args/javaHome 覆盖、Java JDK 说明、安装日志),每卡:期望/已装/上游版本 + 磁盘占用 + 安装路径 + 安装/更新/重装/卸载 + 进度条;store 的 `runtimes` + `reloadRuntimes`/`applyRuntimeProgress`。
- **缺运行时的错误引导**:Claude 在 startTurn 构造 options 时(`is.prod` 且解析不到二进制)抛双语错误卡;Codex 的 `CODEX_BINARY_MISSING` 文案同步改;Pi 的 `loadPiSdk` 对 `ERR_MODULE_NOT_FOUND` 翻译成引导文案——三处都指向「设置 → Agent」。
- **pi 加载注意**:`importManagedPiSdk` 按文件 URL 加载管理目录里的 `dist/index.js`(exports["."] 优先,退化 dist/index.js);worker_threads polyfill 仍在 bare import 之前应用(importManaged 失败回落 bare 时也已 polyfill)。**验证手法**:解压 meta tarball 后带 polyfill import,Node 20 可加载全部导出(createAgentSession 在列);polyfill 必须经 CJS require 拿 worker_threads——ESM namespace 对象冻结,不能加属性。
- **状态语义 = 有效来源,不是"是否装过"**:dev checkout 里三个 agent 都能经 node_modules 回退正常工作(codex/pi 是 devDeps、claude 平台包是 SDK 的 optionalDependency),面板若只查管理目录会显示"未安装"而发消息一切正常——所以契约 `RuntimeAgentState.source`("managed"/"dev"/"bundled"/null)表示 provider **实际**从哪加载:`runtimeAvailability.ts` 的 `probeRuntimeAvailability`(managed 扫描 → 回退探测)是 list 的唯一事实来源,`updateAvailable` 按**生效副本**的版本对期望版本判定;面板徽标 dev/bundled 显示「开发依赖可用/内置副本可用」+ 说明行。**探测的两个坑(实测)**:① `@earendil-works/pi-coding-agent` 与 `@anthropic-ai/claude-agent-sdk` 的 exports map 都不暴露 `./package.json`(require.resolve 报 ERR_PACKAGE_PATH_NOT_EXPORTED)——claude 从 SDK 主入口(`sdk.mjs`)解析再用它的 require 取平台包 exe;② pi 是 **ESM-only**(exports 只有 import 条件),必须用 `import.meta.resolve`(与 piSdkLoader 的动态 import 同一解析条件),require.resolve 永远失败。
- **已验证**:registry 元数据字段(npmmirror 与官方同构)/ claude 全管线真下载冒烟(95MB→sha512→strip:1→208MB claude.exe)/ pi meta 包生成 + round-trip import / 可用性探测 headless 冒烟(out/ 上下文下 claude/codex/pi 全部 dev v 对应版本)/ `pnpm build` 全绿。macOS:下载文件无 quarantine、npm darwin 二进制自带 ad-hoc 签名(原样落盘不破坏),无 Gatekeeper 障碍。
---
### 插件系统 v1(2026-09-09,规划文档 `docs/plugin-feasibility.md`)
- **架构**:插件装进 Mcode 自己的缓存 `~/.mcode/plugins/<name>/<version>/`(**单版本模型**:重装同/异版本都会 prune 旧目录),marketplace 克隆在 `~/.mcode/plugins/marketplaces/<name>/`,启用集合持久化 settings 表(`plugins.enabled`,JSON string[];`plugins.marketplaces`;`plugins.mcpDisabled` 为插件 MCP 的 per-server 禁用 denylist)。契约在 `packages/contracts/src/plugin.ts`(ipc.ts re-export),main 侧 `main/plugins/`:pluginManifest(清单三格式探测 `.claude-plugin`/`.zcode-plugin`/`.codex-plugin` + 组件摘要)+ pluginManager(生命周期 + provider 投递查询);IPC `plugins.*` 十通道(list/installLocal/installGit/installMarketplace/setEnabled/remove + marketplace 四个),handler 在 `ipc/plugins.ts`。
- **安全模型(安装时审查前移)**:安装**默认落地未启用**——renderer 拿到返回的 `PluginState`(含组件摘要)弹审查对话框,「启用」是显式点击;含 hooks 的插件启用前再弹一层 ConfirmDialog 明示「hooks 当前不执行」。**hooks v1 只解析展示、永不执行**:Claude 侧有插件启用的回合设 `options.settings.disableAllHooks: true` 拦住 CLI 引擎原生执行(v1.5 换逐条审查后移除该行),Codex/Pi 本无 hook 通道。面板/审查弹窗/行展开共用 `ComponentDetails` 呈现 skills/commands/agents(name+description)、MCP(kind+command/url 明文)、hooks(event+matcher+command 琥珀警示)——「明示而非静默」是硬原则。
- **投递(per-turn,下一回合生效)**:每次 turn start 由 provider 调 `pluginManager.getEnabledPlugins*` 现查现扫,无失效协议。**Claude**:SDK 原生 `options.plugins: [{type:"local", path, skipMcpDiscovery: true}]`(skills/commands/agents 由 CLI 引擎装配,commands 因 stream-json 不解析 `/name` 实际只能模型侧触达——宿主端展开是 v2);**Codex**:`skills/extraRoots/set` 追加插件 skills 目录 + `materializeConfigToml` 把插件 MCP 物化进 `[mcp_servers.*]`;**Pi**:`buildPiSkillLoader` 的 `extraSkillPaths` 追加(composer `/name` pill 改写对插件 skill 天然生效)。**composer `/` 菜单合并插件 skills(2026-09-10)**:`listSkillsForProject` 在全局/项目扫描后追加 `getEnabledPluginSkillRoots()` 的 SKILL.md frontmatter,`SkillSource` 新增 `"plugin"`(**最低优先级**:同名时 项目 > 全局 > 插件,只补空位不遮蔽);pill 语义不变(skill chip 发送时序列化为文本 `/name`,SDK `skills:"all"` 下模型认识插件 skill 名)。边界:插件 skills **只读**——SkillsPanel 列表过滤 `source==="plugin"` 行,`skills.read/save/delete` 的 `resolveSkillRootForRequest` 对 plugin 返回 null;`PluginsPanel` 启停/卸载成功后调 `useSessionStore.reloadSkills()` 刷 composer 缓存。
- **插件 MCP 自管(不写 `.claude.json`)**:命名空间 `<plugin>__<server>`,Claude 经 `options.mcpServers` 会话级注入(与 in-process browser server 同机制)、Codex 经 config.toml 同名物化——两个 provider 工具名一致,且完全避开 CLI 频繁重写 `.claude.json` 的竞态。McpPanel 新增「插件」分组(`McpScope` 加 `"plugin"`):只列**启用中**插件的 server(插件自身开关是主闸,两个控制位不矛盾),开关翻 `plugins.mcpDisabled` denylist,插件卸载时清 `name__` 前缀条目。
- **设置页「插件」重构为两层页签(2026-09-10,原型 `prototypes/plugins-settings-redesign.html` 方案二)**:此前「已安装 + 插件市场」是两个叠在同一条滚动流里的 section,装得越多市场被推得越远(用户报的根因)。**整面板是一条居中列、整页滚动**(根节点 `mx-auto w-full max-w-3xl`,照 McpPanel 的形状——**不用** SkillsPanel 那种 h-full 双滚动容器):页头 `sticky` 吸顶;早先的「页头全宽 + 内容居中列」和「页头在列内 + 列表自带内滚动」两种写法都被用户否掉了——前者页头"漂"在左上角,后者同一屏出现两种宽度(页头/工具行按列宽,列表在滚动容器里少一个滚动条宽度,且滚动条随内容量出现/消失),所以**一个页面只留一个滚动容器**,标题栏、工具行、市场页签、列表块全部同宽,`max-w-3xl` 保证内容再多也不会变宽。**第一层页签在页头右侧 action 槽**(与用量统计面板的区间预设同款:小按钮一组,选中 `primary`、未选 `secondary`,计数 `tabular-nums` 内嵌)——**已安装 n / 插件市场 n**;第二层在市场 pane 内、**每个市场一个页签**,用「模型配置」家族页签那套 pill 条(`rounded-lg border border-edge bg-surface/40 p-0.5`,选中 `bg-surface-hover text-content`;名称截断 220px + 条目数,右侧一个 ⊕ 按钮展开「添加市场」表单;零市场时退化为带文案的虚线按钮)。两层都常挂 `hidden` 保活——搜索词、过滤器、展开中的行、当前市场跨切换存活。**已安装行 3 行压成 2 行**:组件计数徽章内嵌名称行,启用态改由 monogram 底色(accent/灰)+ 开关表达,去掉「已启用」长徽标;描述为空且无组件时第二行显示 `noComponents`。**安装入口收敛为「安装 ▾」菜单**(从 Git 安装 / 本地目录 / zip 包),git 的 url+ref 内联表单只在该项被选中时展开(收起时同样 `hidden` 保活,半途输入不丢)。**搜索按 名称/描述/组件名 匹配**(`pluginHaystack` 摊平 skills/commands/agents/mcp/hooks 的 name+description——搜 `postgres` 能命中 MCP server 名,不必记得插件名)。市场页签下的目录卡 = 传输徽标(git / 本地目录)+ 源地址(mono 截断)+ 条目数 + 刷新/移除,条目两行 + 「安装」按钮或「已安装」标签;**搜索只作用于当前页签的条目**,切页签清空查询(查询是冲着上一个目录写的);页头「刷新全部」逐个 `marketplaceRefresh`,刻意不并发以防争 git 锁。原型里「含 hooks 的市场条目挂琥珀标签」**未落地**:`PluginMarketEntry` 无该字段,且 292 条里 240 条是远程 URL(不下载无从得知 hooks),只在本地相对路径条目上显示会自相矛盾——hooks 预警仍在**安装后**的审查弹窗与行展开区明示。
- **安装管线**:来源四路——本地目录(`fs.cp`)/ zip(平台 `tar -xf`,bsdtar 读 zip;Linux 回退 `unzip`)/ git(`git clone --depth 1 [--branch ref]`,装完剥 `.git`,120s 超时)/ marketplace 条目(source 支持 `./相对路径`(resolve 后必须在市场树内,逃逸即拒)/ `{source:"github",repo}` / `{source:"git",url,ref?}`)。**git 死代理自愈(2026-09-10 实测用户机器)**:git config/环境变量指向当前未运行的本地代理(如 Clash 关闭后的 `127.0.0.1:7897`)时,首跳即 `Failed to connect to 127.0.0.1 port …`——`gitClone` 对该特征(仅 loopback 连接拒绝;运行中代理因 auth/DNS 失败不绕,防止把请求泄直连)重试一次 `-c http.proxy= -c https.proxy=` + 剥离 `https?_proxy/all_proxy` 环境变量后直连;两次都败则报错提示「若 GitHub 需要代理请开启代理软件」。**URL 源的真实语义 = git 仓库(2026-09-10,修用户报的「tar: Unrecognized archive format」)**:官方市场 292 条里 152 条是 `{source:"url", url:"https://github.com/<owner>/<repo>.git"}`——**全部 152 条都是 git 仓库,没有一条是压缩包**(实测该市场清单)。原先一律按 zip 下载 → 抓回的是 GitHub 的 HTML 仓库页(HTTP 200)、交给 tar 就报 `Unrecognized archive format`,半个目录根本装不上。现在 `resolveMarketplaceEntrySource` 按 URL 形状分流:路径以 `.zip/.tgz/.tar.gz/.tar` 结尾(`urlLooksLikeArchive`)才走下载,其余解析成 `git` 源走 clone(复用既有 git 分支与代理自愈)。**URL 源的下载传输换 curl(同日,修用户报的「安装失败:fetch failed」)**:`{source:"url"}` 条目(官方目录 292 条里 152 条)原先用裸 `fetch` 下载——undici **不读代理环境变量**、无重试,且把所有网络故障都压成 `TypeError: fetch failed`(真正原因如 `connect ECONNREFUSED 127.0.0.1:7897` 挂在 `cause` 上),于是同一台机器上「git 源能装、zip 源一律 fetch failed」且无从诊断。现在 `downloadFile()` 走 curl(`-fL --silent --show-error --retry 2 --connect-timeout 15`):首次继承环境(与 git 同一套代理解析),**仅当失败是 loopback 代理拒连**时用 `noProxyEnv` 直连重试一次(判定与 gitClone 逐字一致,运行中的代理不绕),curl 不存在才回落 fetch;错误消息把 `cause` 链一起带出来(如「下载失败:fetch failed ← connect ECONNREFUSED …」),HTTP 错误也带状态码。流程统一:staging(`.staging-*` 隐藏目录)→ 清单探测(`findPluginManifestDeep`:zip/git 检出常带一层包裹目录,单子目录自动下钻)→ zod 校验(名字走 `PLUGIN_NAME_RE`,version 宽松 sanitize 缺省 0.0.0)→ swap 目录复制 + rename 落位 → 写 `.mcode-install.json`(source 记录)→ prune → **staging 无论成败都清**。清单声明的组件路径(skills/hooks/mcpServers 字段)resolve 后必须在插件根内,逃逸组件静默忽略(插件仍可安装,面板如实显示更少组件)。**组件字段接受 `string | string[]`(2026-09-10 修)**:官方市场真实清单两种形态都有,此前 schema 只收 string,数组形态直接「清单校验失败:skills Expected string, received array」装不上;resolvers(`pluginSkillsDirs`/`pluginMcpFiles` 等,复数名,返回 `string[]`)归一化 undefined→默认值、string→单元素,逐条过 in-root 守卫后合并。**win32 zip 解压防 GNU tar(2026-09-10 实测)**:PATH 里 Git Bash 的 GNU tar 排在 System32 bsdtar 前时,`tar -xf C:\...` 报 `Cannot connect to C: resolve failed`(把盘符冒号当远程主机)——`extractZip` 优先显式用 `%SystemRoot%\System32\tar.exe`,仅当 PATH tar 报该特征时重试一次 `--force-local`(GNU tar 语义,健康 bsdtar 不需要)。卸载在**任意 turn 运行中拒绝**(运行中的回合可能持有插件路径)。
- **marketplace**:git URL / 本地目录添加(staging 在 PLUGINS_ROOT 内保证 rename 同卷),清单 `.claude-plugin/marketplace.json`(根 marketplace.json 兜底),条目 `installed` 对照已装集合按 name 匹配;刷新 = 删了重新物化;移除市场不动已装插件。**source 四形态 + 条目级容错(2026-09-09 对官方市场实测后补)**:官方 [claude-plugins-official](https://github.com/anthropics/claude-plugins-official) 292 条目 = 152 `url`(zip 下载,fetch+extractZip)+ 88 `git-subdir`(`{url,path,ref?,sha?}`——clone 后只取子目录,path resolve 后必须在克隆树内)+ 52 相对路径,另有 github/git 形态;**清单解析严格 schema 失败时逐条 safeParse 重建**,未知 source 形态只跳过该条目、不清空白名单(schema union 新形态缺补是常见演进)。sha 记录不强校验(安装审查对话框是完整性闸门)。**单插件探测的仓库形态容忍**(`findPluginManifestDeep`):根级清单 → 一级子目录逐个探测(README/LICENSE 等平铺文件不干扰,恰一命中装/多命中报错列名引导走市场)→ 唯一子目录链下钻(深度 4,跳过隐藏目录与 node_modules);**marketplace 仓库误当插件安装**(`findMarketplaceManifestFile` 先探测 `.claude-plugin/marketplace.json`/根 marketplace.json,含一层包裹)→ 报错引导「在插件市场区添加」而非误导性的「清单未找到」。
- **内置插件市场(2026-09-10)**:契约里 `BUILTIN_MARKETPLACES` 固定两个仓库——`zai-org/zcode-plugins`(ZCode 生态)与 `anthropics/claude-plugins-official`(Claude 生态,292 条)。它们**始终在列**:`listMarketplaces()` 每次调用先跑 `ensureBuiltinMarketplaceRecords()`(幂等、无变化不写库),缺席的补一条记录;**匹配按归一化 git URL 而非名字**(大小写/尾斜杠/`.git` 后缀都归一,`https://github.com/x/y` 与 `.../y.git` 是同一仓库),所以用户已手动添加的同仓库副本会被**采纳**为内置(只加 `builtin` 标记、不重复出现);只有名字撞上不相关用户市场时才跳过该条,不抢目录。**不可移除**——`removeMarketplace` 对内置(按 URL 判定,老记录同样生效)返回错误,前端也不渲染删除按钮;**手工重复添加同一仓库被拒**(git 添加前按归一化 URL 查重,顺带堵住"同一仓库加两遍")。`PluginMarketplaceState` 增 `builtin` + `cloned` 两个字段:内置是**先列后拉**的(记录先落地,首次使用才 clone),`cloned=false` 时面板显示「正在拉取 / 尚未拉取」而不是误导性的「清单为空或无法解析」。拉取时机 = **市场页签第一次被看到**时由 renderer 触发一次 `marketplaceRefresh`(每次挂载每个市场只试一次,失败只留错误条与刷新按钮、不循环;`busyKey` 保证两个内置不会并发 clone),刷新时记录、目录、面板三处同步。
- **冒烟**:`apps/desktop/scripts/plugins-smoke/run.sh`——esbuild bundle(esbuild 用 vite 的传递依赖二进制)+ `@main/store/repositories.js` alias 到内存 stub + `HOME`/`USERPROFILE` 双重定向到 scratch(隔离真实 `~/.mcode/plugins`;⚠️ win32 上 Node 的 `os.homedir()` 读 `USERPROFILE` 不认 `HOME`,只重定向后者会让冒烟**静默写真实插件目录**并在里面留夹具——2026-09-10 实测踩过),105 断言覆盖:三格式探测/组件摘要/hooks 解析/数组形态清单(string[] skills 双根合并、逃逸条目丢弃)/四路安装(含 zip 包裹下钻、file:// git 克隆)/启用投递/skill roots/MCP 命名空间+denylist 开关/路径逃逸/非法名拒绝/卸载清理/marketplace 全生命周期/**内置市场(种子顺序、未拉取状态、不可移除、重复添加拒绝)/URL 源两种形态(url 指向 git 仓库走 clone、指向压缩包走 curl 下载并解压,404 报状态码)**/monorepo 与多层包裹下钻/多插件仓库报错列名/marketplace 误装引导/**git-subdir 子目录安装(只装子目录、不带 .git)/官方样式清单条目容错**;`run-e2e-official.sh <checkout>` 对真实官方市场仓库全量解析(292/292 通过);`run-e2e-url-source.sh [count] [checkout]` 用生产 manager 真装官方市场的 `{source:"url"}` 条目(需 git 能访问 GitHub;市场本身可用本地 checkout 跳过 clone)。
---
## 当前进度
| 阶段 | 状态 | 说明 |
|------|------|------|
| P0 脚手架 | ✅ | 三进程、三栏布局、IPC 契约 |
| P1 端到端 | ✅ | claude stream-json + 流式渲染 + 输入框 |
| P2 会话持久化 | ✅ | better-sqlite3(SQLite;2026-09-14 从 sql.js 迁移)、`--resume` 续传、会话列表 |
| P2.5 SDK 迁移 | ✅ | @anthropic-ai/claude-agent-sdk + AgentProvider 抽象层 + ProviderRegistry |
| P3 工具审批 | ✅ 基础 | canUseTool 桥 → approval.request/approve IPC(后端已通,前端审批 UI 待 P5) |
| P3.5 中间面板 Tab 模式 | ✅ | 中间面板显示模式偏好(单/tab);tabs 模式 = `UnifiedTabsBar` 统一 tab 栏(会话+文件混排,激活视图全宽),single 模式 = 旧分栏布局;关闭 tab 后台 turn 继续运行 |
| P4 IDE 右栏 | ✅ | 文件树、git、终端(xterm+node-pty)、Monaco 编辑器 + diff |
| P4.5 LSP 语言服务器 | ✅ | 设置页可安装/启停 TS/Python/Go/Java 语言服务器;`LspManager`(main)管理 stdio JSON-RPC 子进程;Monaco 手写 Provider(definition/references/hover)+ 诊断 markers + 跳转定位 |
| P5 体验打磨 | 🟡 | ✅ 浏览器预览(agent 驱动应用内浏览器);⬜ checkpoint 时间线、Cmd+K、审批 UI |
| P6 发布 | ✅ 基础 | electron-builder(mac/win 安装包)、electron-updater(GitHub Releases 渠道)、CI(typecheck + tag 自动发布)。mac 包已接 ad-hoc 签名(无 Apple 付费证书,dmg 直下首次启动需 `xattr -dr com.apple.quarantine` 或系统设置"仍要打开";brew cask 安装无此问题);真实 Developer ID 签名+公证未做,未含 Vitest |
详见 `docs/tech-stack.md` 第八节。
### Agent 浏览器工具(P5)
- **架构**:复用应用内嵌入式浏览器(`BrowserManager` 的 `WebContentsView`,与右侧浏览器面板同一套 view)。不引入 Playwright/Puppeteer/CDP 等外部浏览器自动化依赖——零新二进制,打包/签名不受影响。
- **底层能力**(`apps/desktop/src/main/browser/BrowserManager.ts`):在已有 `loadUrl`/`show`/`hide`/`setPickMode` 等面板方法基础上,agent 专用方法:`list()`(发现 browserId)、`snapshot()`(`executeJavaScript` 注入只读快照脚本,返回结构化页面数据 + 带索引的可交互元素)、`click()`(**真实鼠标事件优先**——`ELEMENT_CENTER_SCRIPT` 先 scrollIntoView 到中心、算出视口坐标并检查 `elementFromPoint` 遮挡,然后主进程 `sendInputEvent` mouseDown/mouseUp 走真实输入管线,hover 敏感组件可用;遮挡层会以 `obscured` 回报实际点到什么;无布局盒/CSP 注入失败回退 `el.click()` 脚本;selector 双重 JSON 编码注入,不拼进 script 源码)、`clickAt()`(裸坐标点击,canvas 类元素)、`type(selector,text,clear)`、`sendKeys()`(解析 "Control+a" 类组合键 → `sendInputEvent` keyDown/keyUp,Enter 能提交表单;`normalizeKeyName`/`parseKeyCombo` 在模块层,CmdOrCtrl 按 darwin→meta 映射)、`scroll()`、`waitFor()`(主进程 300ms 轮询注入脚本,导航中断轮询只重试不失败)、`historyAction(back|forward|reload)`(复用面板的 goBack/goForward/reload + waitForLoad)、`selectOption()`(原生 `<select>`,不匹配时回传全部选项)、`find()`(CSS 元素查询带属性提取 / 页面文本字面与正则搜索)、`screenshot(id,{fullPage})`(fullPage 走 `webContents.debugger` 的 `Page.captureScreenshot{captureBeyondViewport}`,**不动可见性、离屏可截**;失败退化为视口截图)、`printToPdf()`(⚠️ **必须用 Electron 原生 `webContents.printToPDF()`**——CDP `Page.printToPDF` 只在 headless Chromium 注册,headed Electron 走 `debugger.sendCommand` 对所有页面一律 -32601 "wasn't found",页面无关;原生 API 走静默 Ctrl+P 同款打印管线,离屏可出,不占 debugger 槽;纸张映射表 `PAGE_SIZE_NAMES`(letter/legal/tabloid/a3/a4/a5 → Electron 具名尺寸),headerFooter 必须带显式 font-size 的模板否则 Chromium 渲染成隐形,返回 base64 由调用方落盘)、`setFileInputFiles()`(**文件上传**:先 `CHECK_FILE_INPUT_SCRIPT` 预检给出友好报错,再 CDP `DOM.getDocument`→`DOM.querySelector`→`DOM.setFileInputFiles`——与 Playwright setInputFiles 同机制,Chromium 原生触发 input/change,React/Vue 可感知;只传路径不传内容,大文件不进 executeJavaScript;主进程侧 statSync 校验存在性)。CDP 调用统一走 `withDebugger()` 助手:Electron debugger 每 webContents 单槽,attach 失败即复用既有会话(UA/配色),**只有本次 attach 的才 detach**,DevTools 占槽时报 `{ok:false}` 由调用方退化。注入脚本全部是固定常量(`snapshotScript.ts`),参数经 `%XXX_JSON%` 槽位以 `JSON.stringify` 双重编码注入,只用于 `querySelector`/`JSON.parse`,无注入风险。
- **下载跟踪(2026-09-09)**:`installDownloadListener()` 挂在共享 browser session 的 `will-download`(随首个 view 懒安装,同 cookie vault 定时器模式)——**自动下载无保存对话框**(对话框会阻塞在 agent 答不了的 UI 上),落盘 `<系统下载>/mcode-browser/`,`uniqueDownloadPath` 按已存在文件去重(`a.pdf`→`a-1.pdf`);条目存主进程 `downloads`(最新 50 条),起始+终态(completed/cancelled/interrupted)各推一次 `browser:event / "download"`(contracts 新增事件类型 + `BrowserDownloadProgress` payload;进度 tick 不推,agent 用 `browser_downloads` 工具查询)。**面板下载条(渲染端)**:BrowserPanel 的事件订阅在 browserId 查找**之前**接住 download 事件(下载的 view 可能还没被收编成 tab),`DownloadBar`(components/browser/,两种容器模式都渲染,Chrome 下载条风格)每条下载一个 chip——下载中转圈、终态着色(完成 accent/失败红),completed chip 点击经新 RPC `browser.downloadAction({downloadId, action:"open"|"reveal"})` 打开文件/在文件夹中显示(**渲染端只传 downloadId,路径由 main 从下载注册表解析,不信任任何 renderer 提供的路径;open 仅 completed 可用**),终态 chip ~8s 自动消失(计时器 ref 簿记,卸载/手动移除时清理);条高度收缩 stage,边界经 stage ResizeObserver 自动重同步。agent 拿到 completed 路径后用普通文件工具读取。
- **共享工具实现**(`apps/desktop/src/main/browser/agentBrowserTools.ts`):三个 provider(Pi/Claude/Codex)共用的核心,18 个函数(`browserList/browserNavigate/browserSnapshot/browserClick/browserType/browserKeys/browserScroll/browserWait/browserHistory/browserSelect/browserFind/browserSwitchTab/browserCloseTab/browserUploadFile/browserSavePdf/browserDownloads/browserEvaluate/browserScreenshot`),返回 MCP 兼容的 `{content: [TextBlock|ImageBlock]}`。**元素索引制(2026-09-09,借鉴 browser-use)**:`snapshot` 给每个可交互元素分配 1-based `index`(含表单状态 value/checked/disabled/href、`inView` 视口标记;采集上限 200、展示上限 `SNAPSHOT_DISPLAY_CAP`=80,展示顺序视口内优先),index→selector 映射存模块级 `snapshotIndexMaps`(按 browserId,`browser_list`/`resolveBrowserId` 时剪枝死视图),click/type/select/upload_file 优先收 `index`,裸 `selector` 仍是合法句柄;索引失效报错引导重新 snapshot。**browserId 寻址**:所有工具的 `browserId` 可选——省略时用模块级 `activeBrowserId`(navigate/switch_tab 设置),再退第一个已开 view;无 view 时 `navigate` 自动 create(让用户看到 agent 在浏览),其他工具返回"请先 navigate";`navigate` 支持 `newTab:true` 强制新标签。`savePdfToDisk` 与截图同目录纪律(`browser.screenshotDir` 设置或系统图片目录;`fileName` 剥掉路径分隔符**永不出受管目录**,无会话上下文落 `pdf/` 子目录)。
- **Pi 侧**(`mcodeExtension.ts` 的 `registerBrowserTools`):用 `pi.registerTool`(typebox schema)注册全部 18 个工具。**审批分级**:`MCODE_BROWSER_READONLY = {browser_list, browser_snapshot, browser_screenshot, browser_find, browser_scroll, browser_wait, browser_switch_tab, browser_save_pdf, browser_downloads}` 在 `tool_call` 守卫的 ③ 步硬编码白名单放行(永不审批);navigate/click/type/keys/history/select/upload_file/close_tab/evaluate 有副作用,走正常审批(支持 always-allow)。screenshot 的 execute 里 `ctx.emit({type:"browser.image"})` 发结构化事件给 renderer 内联渲染(Pi path)。
- **Claude 侧**(`ClaudeAgentSdkProvider.ts` 的 `buildBrowserMcpServer`):用 SDK 的 `createSdkMcpServer({name:"mcode-browser", tools:[...]})` 挂**进程内 MCP server**(无子进程),放进 `options.mcpServers`。inputSchema 用 zod。Claude 不能 `registerTool`(SDK 不支持),in-process MCP server 是唯一路径。工具名呈现为 `mcp__mcode-browser__<name>`,`shouldAutoApprove` 里 `isReadOnlyBrowserTool()` 放行只读后缀。screenshot 的 image 通过 tool_result content 透传(SDK 原生支持 image block),store 从 `ToolResultEvent.content` 解析(Claude path)。
- **图片渲染**(双路径汇聚到同一 store reducer):`RuntimeEvent` 新增 `browser.image`(Pi emit);`Block` union 新增 `kind:"image"`(base64 + mimeType)。`sessionStore` 的 `tool.result` reducer 检测 content 含 image block 时追加 image block(Claude path),`browser.image` reducer 按 toolCallId 追加(Pi path),两者按 toolCallId 去重。`MessageBlocks.tsx` 的 `BlockView` 新增 `case "image"` 渲染 `<img>`;`GenericToolCard` 的 result 预览(`resultPreview`)剥离 image block 避免把 base64 当文本 dump。图片随消息持久化(toRecords 透传 blocks 数组,不区分 kind)。
- **浏览器体验对齐普通浏览器(2026-09-08,三项)**:① **新窗口请求站内化为标签页**——`setWindowOpenHandler` 不再把 `target=_blank`/`window.open` 一律丢系统浏览器:http/https/file 协议走 `spawnView`(从 `create()` 抽出的核心建 view 流程,panel 与 window-open 共用)新建 view + `loadUrl`(等 cookie vault 恢复,OAuth 弹窗落登录态)+ 推 `browser:event / "tabOpened"`(contracts 新事件类型 + `BrowserTabOpened{url,title?,background?}`,`background` = disposition 为 background-tab 即中键/Ctrl 点开在后台);BrowserPanel 的事件订阅在 browserId 查找**之前**接住 tabOpened(此时 tab 还不存在),`adoptWindowOpenTab` 镜像 createTab 的关 pick/隐藏旧 view/激活/showActiveView 编排(无 create/loadUrl 往返,view 已在 main),按 browserId 去重;其它协议(mailto 等)、解析失败、spawn 失败回退 `openExternal`。渲染端订阅只挂在激活容器上,而点击链接要求 view 可见 = 面板必在浏览器页签,故无孤儿窗口(极端时序下 view 留在 main 未被采纳,`list()` 仍可见,agent 工具可复用)。② **桌面 UA 剥离 Electron 标识**——`chromeLikeUserAgent` 截取 UA 到 Safari 标记为止(去掉尾部 `App/x.y.z Electron/x.y.z`),`spawnView` 时 `setUserAgent` 立即生效,且**存为 `defaultUserAgent`**(移动仿真关闭时的回退 UA 同样干净);Google 登录/部分 Cloudflare 防护会拒 webview UA,Chrome 版本号本身是真的,只删框架尾。③ **cookie vault 迁 safeStorage**——新 settings key `browser.cookieVault.enc`(base64 密文),`saveCookieVault` 在 `safeStorage.isEncryptionAvailable()` 时加密写入并**把旧明文 key 置空**(迁移;无 delete API 用空串),加密不可用(Linux 无 keyring)回退明文旧 key;`restoreCookieVault` 优先解密 enc key(解密失败如跨 OS 用户拷库,WARN 后回退旧 key,不丢全部登录态)。
- **弹层遮挡判定 vs portal 挂载时序(2026-09-08,修"打开任意 select 浏览器白闪")**:右栏浏览器是 OS 级 WebContentsView 永远浮在 renderer DOM 之上,`useSuppressBrowserView(open, popupRef)` 按弹层实测几何决定是否隐藏 view(`browserOcclusion.ts` 的 overlap 判定,null rect = 保守全屏抑制)。**坑**:base-ui 的 FloatingPortal 容器节点是 layout effect 里 setState 创建的——`open` 翻 true 的那一轮 passive effect 里 popup ref **必为 null**,旧逻辑注册 null → reconcile 保守判重叠 → hide → 下一帧 rAF remeasure 拿到真实几何 → show,每次打开任何 select 都白闪 1-2 帧("注册时几何已就绪"的注释前提对 portal 弹层不成立)。修复:有 ref 的调用方在首帧注册 `OCCLUDER_UNMEASURED` 哨兵(零面积 rect,overlap 数学上永不相交 = 乐观不遮挡——此刻弹层还在 opacity-0 渐入不可见,remeasure 一帧内换真实几何,真重叠也会在弹层可见前藏好 view);无 ref 调用方(全屏对话框/灯箱/WidePlanDialog)保持 null 保守抑制不变。
- **工具栏菜单冻结帧占位(2026-09-08,形态二——"悬浮面板替代白屏")**:历史/设备下拉原先**无条件** `browser.hide`(`handleHistoryMenuOpenChange`/`handleDeviceMenuOpenChange`,连几何判定都不走),打开必白屏。改为 `freezeViewForMenu`:先 `browser.captureFrame`(新 RPC,`BrowserManager.captureFrame` 纯内存截当前帧,**不动可见性**,无 screenshot 的 temp-show/仿真 rect 舞步;空帧重试一次,失败退化回普通 hide)→ 渲染端把 base64 PNG 按视图的 stage 相对 rect(`lastBoundsRef` - stage rect;桌面=满 stage,仿真=居中设备列)铺成 `pointer-events-none` 占位 `<img>`(外点穿透→菜单照常关闭)→ **双 rAF 等占位帧真正绘制后**再 hide view(消除 hide→paint 白帧);关闭 `unfreezeViewForMenu`:先 show view(原生面一上来就盖住占位图)+ 150ms 宽限后才撤占位(遮住 addChildView 重托管掉帧)。`freezeSeqRef` 序号守卫:快速 关→开 防旧 grace 定时器误清新帧,freeze 的 await 后校验防菜单已关还把 view 藏进快照;capture 后回读 activeTab 防 tab 切换;activeTabId 变化即清占位。已知取舍:菜单打开瞬间菜单本体有 ~60-90ms 不可见(还被活 view 盖着,截帧+双 rAF 的窗口),感知为轻微延迟;全屏对话框(确认销毁/authRequest)仍走普通隐藏。通用化方向:同一机制可推广到任何压进 stage 的业务弹层(几何判定的重叠分支)。
- **工具栏「更多」菜单 + 书签(2026-09-08,替代「关闭浏览器」)**:工具栏最右的关闭浏览器按钮(IconX → `onRequestDestroy` → ConfirmDialog 全量销毁)整体移除——退出靠逐个关 tab/侧栏开关,`browser.closeBrowser*`/`confirmClose` 词条已删。原位换成「更多」(IconDots,`onMoreMenuOpenChange` 走同一冻结帧契约),下拉面板两个分区:**收藏**——分区头右侧「收藏此页/取消收藏此页」按当前 URL 匹配切换(约=URL 时星标填充),条目点击经 `onOpenUrl`(2026-09-09 起为 `handleOpenUrlSmart`:**当前 tab 空白(url 空串/about:blank)就在当前 tab 打开,否则新开 tab**——收藏/历史点击不覆盖用户正在看的页面;`createTab(url)` 直载目标 URL;地址栏输入与历史下拉的 `onNavigate` 仍固定当前 tab,Chrome 惯例)打开、hover 出垃圾桶删除;**历史记录**——取前 15 条,点击打开、条目删除、分区头「清空历史记录…」。书签存储完全镜像 AddressHistory 单写者模式:新 settings key `browser.bookmarks`(`BrowserBookmarkEntry{url,title,addedAt}`,去重置顶、上限 100、只收 web URL),main 侧新模块 `main/browser/bookmarks.ts` + `browser.bookmarkAdd`/`bookmarkRemove` RPC,渲染端 `setting.get` 读 + 变更后刷新;More 菜单开时顺带 `refreshBookmarks`。外点/Escape 关闭用 document capture 监听(历史下拉靠输入框 blur,More 按钮无焦点耦合,机制不同)。侧栏模式的前置「展开为 PC 全屏」按钮同步移入 More 菜单顶部(仅 sidebar 分支渲染,overlay 的 返回工作台/切换到侧边栏 前置按钮保留不动),点击关菜单再 `onSwitchMode`。
- **「更多」菜单树形重构(2026-09-09,方案 A,原型 `prototypes/browser-more-menu-tree.html`)**:三段平铺(收藏/历史各 max-h-44 互相挤压、「收藏此页/清空历史」藏分区头小字)改为**顶层动作 + 多开折叠树**。顶层:`MenuActionRow` 的「收藏此页/取消收藏此页」(星标态,切换不关菜单)+「展开为 PC 全屏」(仅 sidebar)。四个 `MenuTreeNode`(chevron 旋转、`openNodes` Set **多开**、展开态由 toolbar 持有跨开关记忆,默认开 收藏+历史):**收藏**(CountPill 计数;条目 = host 哈希色字母方块 `EntryFavicon`(无网络 favicon 请求)+ 标题/host,hover 出「在新标签页打开」(新 prop `onOpenUrlNewTab` → `createTab`,不走空白 tab 复用的 smart open)+ 删除)、**历史**(按本地日历日 今天/昨天/更早 分组,`historyGroups` 空 bucket 丢弃,副标题 host·时间,节点尾「清空历史记录…」,不再截 15 条)、**下载**(复用 DownloadBar 的会话级 `downloads` 列表,**仅非空渲染**;进行中显进度条 + 琥珀「{n} 项下载中」徽标 + 不确定总长时 animate-pulse,完成行点击=打开文件、hover 出「在文件夹中显示」;`formatBytes` 改为 DownloadBar 导出共用)、**隐私与缓存**(清除浏览缓存 = 既有 `browser.clearCache`;**新增 `browser.clearCookies` RPC**——`BrowserManager.clearBrowserCookies` 清共享分区 session cookies 后**必须同时把 `browser.cookieVault.enc`/`browser.cookieVault` 两个 settings key 置空**,否则 `restoreCookieVault` 在下次 spawn/重启时把快照注回、清除被静默撤销;UI 侧 `window.confirm` 确认后才关菜单发 IPC)。数据源与 freeze 契约(`onMoreMenuOpenChange`)零改动;改动收敛在 BrowserToolbar(菜单本体)+ BrowserPanel 接线 + contracts/preload 三层。
- **Agent 工具集对齐 browser-use(2026-09-09,7 → 15 → 18 个工具)**:对比 browser-use 开源项目后的能力补齐,思想是「补操作闭环 + 索引制观察」而不引入其 CDP 直连真实 Chrome 的架构。新增:`browser_keys`(真实按键/组合键)、`browser_scroll`(页/元素内滚动,回传 scrollY/剩余距离)、`browser_wait`(等 selector/text/秒数,防过早 snapshot 空页)、`browser_history`(后退/前进/刷新)、`browser_select`(原生下拉,错配回传选项列表)、`browser_find`(selector+属性提取 / 文本字面+正则搜索,替代 dump 整页 HTML)、`browser_switch_tab`/`browser_close_tab`(agent 侧标签管理,`activeBrowserId` 模块态);P2 补齐:`browser_upload_file`(CDP `DOM.setFileInputFiles`,相对路径按项目根解析,预检脚本给友好报错)、`browser_save_pdf`(Electron 原生 `webContents.printToPDF`——CDP `Page.printToPDF` headed 下不存在,曾因此全页面失败,受管目录落盘)、`browser_downloads`(查 `will-download` 自动下载记录,completed 后用文件工具读);扩展:`navigate` 加 `newTab`、`screenshot` 加 `fullPage`(CDP `captureBeyondViewport`——captureScreenshot 是 headed 也注册的正规 DevTools 域,与 printToPDF 不同,走 debugger 没问题)、`type` 加 `clear`(false=追加,空文本=清空)、`click` 加 `index`/`coordinateX+Y` 寻址 + 真实鼠标事件(见「底层能力」条)。**审批不变式**:只读白名单仅收"不能改页面/不能导航/不能提交"的工具(list/snapshot/screenshot/find/scroll/wait/switch_tab/save_pdf/downloads);keys/scroll 边界——scroll 只动视口归只读,keys 能提交表单归审批;upload_file 把用户文件交给网站,必须审批。新增工具的 spec 描述统一写在 `BROWSER_TOOL_SPECS`(agentBrowserTools.ts),三个 provider 的 schema(zod/typebox/JSON-schema)只是参数形状镜像,**描述文本绝不在 provider 侧复制**。未采纳的 browser-use 能力:CDP 连接用户真实 Chrome(有 cookie vault + 站内 OAuth,架构级改造)、云浏览器、反检测、`extract` 式 LLM 二次抽取(agent 用 find/scroll/evaluate 已覆盖)。
### Worktree 隔离会话(P5.7,2026-09-01,简化单向生命周期)
- **场景**:用户在 develop 上,两个需求并行——各开一个会话、在输入框把「工作环境」切到隔离,首条消息创建 detached worktree,各改各的互不干扰;完成后「合并回」本地分支,删工作树。**刻意裁剪**了完整 playbook(所有权标记/操作台账/状态机/环境切换/初始化脚本/保留策略)——本流程是单向的(local→worktree→merge back→end),安全性靠"合并或删除前必处理未提交改动 + 运行中会话闸 + 补丁导出"三道便宜得多的闸。
- **数据模型**:sessions 表加 `env_mode`(默认 local,老行零迁移)+ `worktree_path`(物化后回填)+ `wt_style`(2026-09-02 加,TEXT,NULL/'detached'=detached 检出、'branch'=生成名分支——**只在 envMode=worktree 且未物化时被读**,物化后形态由 checkout 自明,是纯意图字段;老行 NULL 零迁移)。**目录可多会话共享**:`StartSessionSchema.worktreePath` 让新会话 BIND 到一个已被其他会话引用的托管目录(main 校验其 ∈ `SessionRepo.listWorktreeRoots()`,任意路径拒绝)——LeftBar 的 worktree 会话行 hover「在此工作树中新建会话」(fork 图标,`startSession(projectId,{worktreePath})`)复用同一 checkout 及其依赖开新线程;绑定即视为物化(目录已存在,首条消息直接使用,resolveSessionCwd 零改动,bind 会话的 wtStyle 无意义)。**⚠️ 新行路径 bind 后必须回读(2026-09-01 踩坑)**:`applyWorktreeBind` 直接写 DB,`createOrReuseSession` 新建行若继续广播/返回构造时的内存对象(其 `worktreePath` 还是 null),渲染端就把会话归进平铺区而非工作树分组(bind 日志成功、DB 有值、UI 却不挂组)——两条路径都要 `SessionRepo.get(id)` 回读后再 broadcast/return(fresh 复用路径原本就回读,新行路径曾漏)。**意图先行**:composer chip 只写 envMode+wtStyle(`StartSessionSchema` / `UpdateSessionSettingsSchema` 均两字段,wtStyle 在 updateSettings 侧 nullable——local 翻转传 null 清残留意图);**物化在 sendTurn**(`ipc/claude.ts` 的 `resolveSessionCwd`):`envMode=worktree` 且无路径 → 按 wtStyle 分叉 `createBranchedWorktree`(branch 形态,`worktree add -b mcode/<目录名>`)或 `createDetachedWorktree`(base=用户视角 checkout 的 HEAD,先 `rev-parse` 验证)→ **先落库再发 turn**(崩溃后 resume 仍指向 worktree)→ cwd=worktreePath。**cwd 派生是全案枢纽**:写入守卫/bash 守卫/MCP 注入全部吃 `req.cwd` 参数,隔离边界零改动成立(两种形态共用,零差异)。sentinel 追问路径 `cwd: session.worktreePath ?? project.path` 同步。
- **托管目录**:默认 `userData/worktrees/<repo名>/<sessionId尾12>`,**可在设置页改根目录**(settings key `worktree.root`,`worktreeOps.managedWorktreeRoot` 每次创建时现读——只影响未来的 worktree,已物化会话的落库路径不变)。**在所有项目根之外**是铁律。
- **IDE 面板跟环境走(P5.8,2026-09-01)**:`pathGuard` 新增 workspace 根二级合法域——`isKnownWorkspaceRoot` / `findContainingWorkspaceRoot`(项目根优先,**`SessionRepo.listWorktreeRoots()`(sessions.worktree_path 去重)兜底**)。files(listDir/readFile/readBinary/search/grep/writeFile/mkdir/delete/rename/copy)、git(findContainingProject 委托 workspace 版 + discoverRepos 的 known 检查)、terminal、LSP(`assertWorkspace`)四类守卫全部切换。renderer 侧 `sessionStore.selectActiveEnvPath(s)` selector(激活会话的 worktreePath ?? 项目根)驱动 **FilesPanel(文件树,随 envPath remount)/ GitPanel(worktree 会话看到自己仓库的 status/commit/合并冲突)/ TerminalPanel(终端开进隔离 checkout,独立分组桶)/ lspProviders.workspacePath(编辑 worktree 文件时按其根起 LSP——jdtls 是全新导入,故 java prewarm 仍锚定项目根不跟 env)**。「本轮修改」卡片的 worktree 路径点击查看随 readFile 守卫放行而恢复可用。
- **双形态:detached(实验)与生成名分支(开发),2026-09-02**:detached 形态(原默认)适配实验性验证——git 禁止同分支双 checkout,分支名是用户决定,merge 时才落;branch 形态适配真实功能开发——`createBranchedWorktree` 用 `git worktree add -b mcode/<目录名>`(目录与分支同名,`MCODE_BRANCH_PREFIX` 前缀是清理时的所有权标记),worktree 里的 commit 从第一刻起就有命名引用、可见于 `git log --all`、强删后仍可经保留分支找回。**分支名碰撞**:目录 stat 探测 ≠ 分支存在探测(强删未合并树会保留分支、目录却释放),`nextWorktreeDir` 的 `branchStyle` 参数让同一循环同时探测 `refs/heads/mcode/<candidate>`——否则 `worktree add -b` 撞"branch already exists"且重试永远同号死循环;refname 合法性在 `sanitizeBranchName` 补(尾部 `.`、连续 `..`;目录名的 charset 过滤已挡 `@{`)。合并回(`worktreeOps.mergeBackWorktree`,两形态共用):worktree 脏 → `add -A` + auto-commit(仓库无 user.name/email 时 `-c` 内联身份 fallback)→ 本地 `merge --no-edit <worktree HEAD SHA>`(按 SHA merge,分支存在与否对它透明);up-to-date 防御;冲突返回 conflictedFiles 走现有 AI 解冲突 UI。**⚠️ simple-git 3.36 的 `raw()` 吞非零退出码(2026-09-01 实测两处)**:`merge-base --is-ancestor` 的"否"(exit 1、stderr 空)与 `git merge` 的冲突(exit 1)**都不抛异常、promise 正常 resolve**——旧代码靠 try/catch 判定,导致 ① `isAncestor` 永远 true →「已合并防御」每次早退:返回 ok 却从不合并、无日志、对话框报成功(用户侧"合并了但文件没回分支",日志里零 merge-back 记录是佐证);② merge 冲突被当成功上报。**修复原则:判定一律不依赖 throw**——`isAncestor` 改用 `merge-base <commit> <ref>` 输出与 commit 全 SHA 比较(祖先 ⇔ 最佳公共祖先就是它本身);merge 后用 `status().conflicted` 判冲突、用修好的 `isAncestor` 探针验证合并真的落地(HEAD 未动且未包含 → 报"合并未生效"错误而不是假成功)。`listWorktrees` 的 `merged` 徽标同因修好(祖先探针对 branch 形态同样恒真——branch worktree 也从 base 创建、agent 不提交时 HEAD==base,`!dirty` 收紧已覆盖)。写任何 `raw(["merge-base","--is-ancestor",...])` 式探针前先想这坑。
- **删除与防堆积**(`removeWorktree`):三道闸——①引用该路径的会话有 running turn 拒删(`runtimeManager.runningSessionIds()` ∩ `SessionRepo.listByWorktreePath`);②非 force 时脏拒删;③`exportPatch` 可先把 `git diff --binary --full-index HEAD` 落 `userData/worktree-snapshots/`(遗照,非备份)。目录被手删 → `worktree prune` 自愈。**生成分支清理(2026-09-02,branch 形态)**:目录删除成功后,若该 worktree 的 porcelain branch 以 `mcode/` 开头**且 ref tip == worktree HEAD**(用户 `git switch` 走了就不碰)→ `git branch -d`(只删已合并;**未合并时 git 拒绝、分支保留**——正是强删场景的安全网,被丢弃的提交可找回),拒绝时返回 `retainedBranch`,两个删除对话框(ManagerPanel 行内 + `WorktreeRemoveDialog`)都不静默关闭,显示"分支 {branch} 已保留,git checkout 可找回"。⚠️ 实测 `branch -d` 对**仍被 worktree checkout 的分支一律拒绝**(已合并也一样)——清理必须排在 `worktree remove` + `prune` 之后(当前实现即是)。删除后引用会话 `clearWorktreePath` 退化回 local(历史保留)+ broadcast。**常态防堆积**:合并成功默认引导删工作树;**兜底**:Git 面板新增「工作树」子页签(`WorktreeManagerPanel`)——按仓库分组列出 linked worktree,徽标 已合并(祖先探针通过**且工作树干净**;2026-09-01 收紧——worktree 从主 HEAD detach、agent 改文件不提交,HEAD 相等令纯祖先判定恒真,"已合并"整程常亮还盖住"脏"徽标发绿色安全信号)/无会话引用(孤儿)/脏/目录缺失,删除带 force/导出补丁选项;branch 形态的行显示分支名(IconGitBranch + mono 文本,detached 行维持只显 SHA)。**退化回声留下左栏幽灵分组(2026-09-10 修)**:`session.changed` reducer 的**末段 else**(既不在 local 段也不在 worktree 段)是「远端新建行」与「worktree 目录被删后降级回 local」的共用入口——原实现只 `next = [materializeSessionEntry(entry), ...local, ...worktree]`,把降级行补进 local 段却**没把旧行从 worktree 段摘掉**,同一个 id 在一份缓存里出现两次(旧行仍带 `worktreePath`)。LeftBar 的工作树分组是按 `session.worktreePath` 现算的(`LeftBar.tsx` 的 `worktreeGroups`),于是被删的工作树**分组连同会话行继续显示**(用户报告:"删除工作树后没有刷新列表,工作树还显示");顺带连重型载荷也丢(`materializeSessionEntry` 产出的是 null 载荷行)。修复:先 `activeList.find(id)` 取回缓存行并 `{ ...prevRow, ...entry }` 合并(保住 `contextSnapshot`/`turnFiles`,与 `wasLocal` 分支同款语义),再用 `filter(x => x.id !== entry.id)` 从 **local 与 worktree 两段**同时剔除旧行。`inWorktreeWindow`(materialize 方向)与 `!inLocalWindow`(归档/置顶)两个分支本来就做了双段剔除,只有这一段漏了——**动这个 reducer 时三个分支的剔除集合必须一致**。回归:`apps/desktop/scripts/session-store-smoke/run.sh`(esbuild 打包 store + 桩 `window`/`document`/`localStorage`/`navigator`,把唯一的动态 import `@renderer/lib/monacoSetup.js` 置 external 绕开 monaco 资源图;27 断言覆盖:降级后单行无残留 worktree 行/分组消失/载荷保留/总量+1、最后一个 worktree 行降级触发视图回退、兄弟 worktree 不受影响、materialize 反向、纯本地更新、远端新建行、归档剔除、未加载项目不动;回退修复即 4 条失败,缓存实况 `["wt1","loc1","wt1"]`)。
- **UI(2026-09-01 调整;2026-09-02 chip 三选项)**:三处入口分工——⓪ **目录切换 chip**(`SessionDirectoryChip`,composer 顶部与 WorktreeModeChip 同一行、排其左):**fresh + local + 未物化**会话才渲染(envMode 已带 worktree 意图、或候选项目 <2 时隐藏),把新会话改挂到其他项目——store 的 `moveSession` 走 `session.updateSettings` 新增的 `projectId` 字段,**main 侧守卫三连**:无消息(`MessageRepo.hasAny`)+ 未物化(无 worktreePath)+ 目标项目存在且非归档,任一不满足**整个调用 reject**(renderer 不动缓存);成功后 `session.changed` 回声先落新项目桶(与 startSession 同序),`moveSession` 只补回声不做的事——从旧项目桶逐出+总量-1,激活会话跟到新项目并显式拉首页(回声会跳过未加载桶;**2026-09-03 起激活会话被移动时同步持久化 `ui.lastProjectId`/`ui.lastSessionId`**——这对手本来只在 selectSession/openTab 的 `syncConfigFromSession` 里写,move 不触发,漏写的症状是:chip 面板选了项目 B 并发消息后重启,应用落回旧项目 A,新会话面板又默认 A;`selectProject` 同日补齐同一持久化,空项目此前同样不落盘)。**项目管理菜单(2026-09-03)**:chip 菜单项目行**行末 hover 显示「⋯」图标**(点击 `stopPropagation` 阻止行级切换激活,base-ui 对 Menu.Item 内右键事件不可靠,故不用右键),打开光标锚定的管理菜单(独立 Menu.Root 避免嵌套,打开时先关 switcher 弹层保持单菜单)——重命名项目(复用 `renameProject`)/ 移出分组 / 加入已知分组 / 新建分组(`RenameTarget` 扩了 `kind:"group"`,id=projectId)/ **项目颜色**(settings key `project.colors`,JSON projectId→hex,store 的 `projectColors` + `setProjectColor`,first-paint getMany 水合;`lib/projectAvatar.ts` 的 `projectDisplayColor(p,colors)` 是所有头像取色统一入口——SWATCHES 色板刻意与哈希默认 6 色分离,扩哈希表会重排存量项目配色)。`projectAvatarColor/projectInitial` 提到 `lib/projectAvatar.ts` 供流侧栏与 chip 共用)。**管理菜单共享化 + 左侧栏接入 + 自定义色盘(2026-09-03)**:管理菜单抽为共享组件 `components/layout/ProjectManageMenu.tsx` 的 `ProjectManageMenuPopup`(chip 与流侧栏共用,`ManageMenuState` 为共享状态类型);流侧栏「全部项目 ▾」范围下拉的**项目行(顶层 + 分组内成员)行末同款 ⋯**(常显淡色 50%、行 hover 加深,`layout.projectManageIcon` 词条;scope 下拉改受控 `scopeOpen`,点击 ⋯ 先关 scope 弹层再开管理菜单,重命名/新建分组经 StreamSidebar 既有 `RenameDialog` 分发——`renaming.kind` 联合补 `"project"|"group"` 两分支)。颜色区固定色板外新增 **`<input type="color">` 原生色盘**(Chromium 取色器,与树视图分组自定义颜色同款交互):`onChange` **实时生效且不关菜单**(关菜单会卸载 input、原生取色器随之消失),Escape/外点照常关闭;自定义色不在色板时彩虹圆点(label 包裹隐藏 input,conic-gradient 背景)显示选中环,picker value 一律 lowercase #rrggbb。① `WorktreeModeChip`(**composer 卡片左上角**的简约文本下拉,11px 触发器)只承担**新建阶段的环境选择**,三选项:**本地 / 工作树·实验(detached,IconGitFork)/ 工作树·开发(mcode/* 分支,IconGitBranch)**;无仓库项目整体不渲染(`discoverRepos` 带 **`rootOnly: true` 只查项目根本层 `.git`**——worktree 只能在项目根物化,子目录有仓库不算数;项目切换时重探)、非 fresh 会话不渲染(`session.title === "New session"` 占位判定,与后端 fresh-row 复用同规则;**2026-09-03 起 freshness 要求会话行存在**——`session != null && title === "New session"`,两个 chip 的行查找只扫主列表桶,子会话(side chat)必 miss,旧 `!session ||` 把 miss 读成 fresh 导致右侧子会话面板渲染出项目切换器(行点击全是 no-op)和工作树环境选择器(选择会误写全局新建默认);SessionDirectoryChip 同日同规则);已物化会话不渲染(隔离已由左栏 fork 徽标/分组 + Titlebar 按钮表达)。**语义**:chip 显示"当前会话(无会话则新建默认)的生效环境"(store 的 `EnvChoice = "local"|"wt-detached"|"wt-branch"`,由 session.envMode+wtStyle 推导);选择经 `setEnvChoice` 路由——任何未物化会话在前景时直接编辑该会话的 envMode+wtStyle(双向,`UpdateSessionSettingsSchema` + patchSessionInCache 本地回写——首版曾把 local→worktree 误路由到全局默认,导致"点了没反应",这是修复要点);空态编辑持久化的新建默认(settings key `session.worktreeDefault`,**值即 EnvChoice 字符串**;旧布尔值 "true" 水合为 "wt-detached","false" 回退 local,first-paint 水合)。② **合并回迁到 Titlebar 工具栏**(`WorktreeMergeToolbarButton`,components/chat/WorktreeMergeBack.tsx,排在分支 pill 右侧):仅当激活会话已物化**且**工作树有未合并内容(dirty 或 `!merged`,worktreeList 判定)才渲染,12s 慢轮询 + 会话切换/对话框关闭即刷新。③ **左栏工作树分组**(ProjectNode 按 `normWorktreeKey(worktreePath)` 分桶,`WorktreeGroupNode` 可折叠目录节点 + hover「+」在同一 checkout 新建会话;显示名可重命名,存 settings key `worktree.names`,sessionStore 的 `worktreeNames`/`expandedWorktrees`;**分组默认折叠(2026-09-11:`expandedWorktrees` 语义反转为「显式 true 才展开」,absent key = 折叠,`toggleWorktreeExpanded` 按裸真值取反、两处 reveal 断言随之从 `!== false` 改为 `!value`;会话激活/新建 worktree 会话仍自动展开**目标**分组,保证活动线程与刚建的线程不被折叠藏住**):分组右键菜单 = 新建会话 / **合并回本地分支(全量——同组所有会话共享一个 checkout,一次 merge 覆盖全部)** / 重命名。`WorktreeMergeBackDialog` 也是同文件共享组件(preview 用 `git.mergePreview` 传 SHA,**按归一化路径匹配 worktreeList 结果**——porcelain 正斜杠 vs 库存反斜杠,严格相等永远 miss;**"有无可合并" = 新提交 或 工作树脏**——`rev-list` 看不见未提交改动,纯 upToDate 不能禁用合并按钮;冲突留 worktree 重试)。会话列表 SessionRow 标题旁 fork 徽标(session.worktreePath)。**⚠️ 非 Git 项目的两层防线(2026-09-02 修复)**:chip 隐藏只挡了"当场选",挡不住**全局默认的跨项目泄漏**——`session.worktreeDefault` 是项目无关的全局设置,在某 Git 项目把默认环境设成工作树后,非 Git 项目新建的会话会被 `startSession` 打上 `envMode=worktree`,而 chip 在无仓库项目不渲染、用户无从改回,首条消息在 `resolveSessionCwd` 抛"当前项目根目录不是 Git 仓库"永久卡死。修复:① 创建侧矫正——`sessionStart.ts` 的 `coerceEnvMode`(新建行 + fresh 复用两路径共用):`envMode=worktree` 且无显式 `worktreePath`(bind 的 checkout 已存在,项目根 repo 与否无关,不受此限)时 stat 项目根 `.git`,无仓库即强制 local + WARN;② sendTurn 侧自愈——`resolveSessionCwd` 对同一状态不再抛错,而是 WARN + `updateSettings(envMode:local, wtStyle:null)` 回写 + broadcast,退回项目根执行(兜住修复前的存量脏行、选完意图后 `.git` 被删等 chip 同样够不着的场景;对齐 envRefresh 的"降级不硬失败"原则)。
- **依赖安装不自动**:新 worktree 无 node_modules,agent 首轮自理(pnpm 硬链接秒级);Java 是新 jdtls 工作区(分钟级导入)。
### MCP 服务器管理(设置页)
- **三类来源**(`contracts/ipc.ts` "MCP management" 段 + `main/lib/mcpConfig.ts`):① 用户级 = `~/.mcode/.claude.json` 的 `mcpServers`(CLI 原生 user 级位置,`CLAUDE_CONFIG_DIR` 已重定向,settingSources 默认含 user → **binary 自动加载,provider 零注入**;文件即开关);② 项目级 = 项目根 `.mcp.json`(**只读**,永不写项目文件);③ 内置 = 进程内 `mcode-browser` server。
- **开关状态**存 settings 表单 key `mcp.management`(`MCP_MANAGEMENT_SETTING_KEY`,JSON):`userDisabled`(关闭的用户级 server 全量配置暂存,关闭=配置移出文件、开启=移回——SDK options 没有"禁用 user 级 server"的声明式入口,移出文件是唯一可靠手段)、`projectEnabled`(项目 .mcp.json server 的**允许名单**——项目级默认关闭,面板开关替代 CLI 首次审批弹窗,因 `onUserDialog` 对未知 kind 返回 cancelled)、`browserDisabled`。**不向 .claude.json 写自定义 key**(CLI 频繁整体回写该文件,自定义 key 会被冲掉);写文件一律 read-modify-write 保留其它 key + 原子写,不认识的配置条目原样保留(只是不可管理)。
- **provider 注入**(`ClaudeAgentSdkProvider.startTurn` MCP 段):读 `getMcpManagement()`,`browserDisabled` 时不构建 browserServer;cwd 有 `.mcp.json` 时按允许名单算出 `enabledMcpjsonServers`/`disabledMcpjsonServers` **合并进 `options.settings`**(这两个字段在 SDK 的 `Settings` 接口上,不在顶层 `Options`!)。改动下一轮生效(每轮重建 options)。
- **远程 server 的 OAuth 授权(2026-09-10,Canva/Figma 实测)**:官方市场的 http/sse 插件 server(如 `https://mcp.figma.com/mcp`)要求 OAuth 登录——SDK 无头模式没有 TUI 的 `/mcp` 入口,CLI 连接时发现授权服务器后把 `{"<namespaced名>":{ts}}` 写进 `<CLAUDE_CONFIG_DIR>/mcp-needs-auth-cache.json`,令牌存凭据库的 `mcpOAuth`(空 accessToken = 未授权),会话里模型只看到 needs-auth 状态、工具永不出现。**面板双状态**(`MCP_AUTHORIZE` + `MCP_UNAUTHORIZE`):list 对 http/sse 行合并出 `authorized`(凭据库有非空令牌)与 `needsAuth`(缓存命中)——「已授权」绿徽 + 取消授权、「待授权」琥珀徽 + 去授权;二者互斥,`needsAuth` **优先于** `authorized`(理由见下③)。**去授权**(`claude mcp logout`,实测无 TTY 门槛但**同样要求配置里有条目**):与 login 同一套临时注册 → PTY 执行 → 恢复配置,成功后 `markNeedsAuth` 把名字**回写**进 needs-auth 缓存(镜像 CLI 的 401 行为),面板立即翻回「待授权」。**PTY 通道**:`claude mcp login` 对 `stdin isn't a terminal` 直接拒绝认证(普通 spawn 与 `--no-browser` 全灭),PTY 下 CLI 自己拉系统浏览器、localhost 回调收尾;ConPTY 不解析 .cmd 垫片,必须传 `resolveSdkBinaryPath()` 的原生 exe;CLI 对部分失败打印错误**仍退出 0**,故以凭据库里令牌的出现/消失复核(凭据库不可读时视为未知、信任 CLI);超时 login 5 分钟 / logout 1 分钟;启动 2s 后补发 `\r` 防可能的回车确认提示(无提示时 no-op)。
- **凭据身份是 `name + {type,url,headers}`,不是 name+url(2026-09-10 修 Figma「授权成功却始终未授权」)**:症状是浏览器里授权明明成功、回到面板仍显示待授权、Figma 工具永不出现。三处缺陷:① **临时注册剥掉了 headers**——CLI 的凭据键是 `sha256(stringify({type,url,headers}))[:16]`(函数从二进制提取,`headers` 参与哈希;完整键 = `<serverName>|<hash>`),而 Mcode 登录时只往 `.claude.json` 写 `{type,url}`;插件 server 的真实配置带自定义头(`X-Figma-Plugin-Bundle`),于是令牌存到「空 headers」键、运行时按「带 headers」键查,查出个没有 refreshToken 的空桩 → 每轮 401 → CLI 把 server 写回 needs-auth 缓存 → 面板永远待授权(钥匙串实证:带 headers 键 accessToken 长度 0,空 headers 键长度 45)。修复:`resolveRemoteServerConfig()` 按 scope 从**真实来源**(user 文件 / `userDisabled` 暂存 / 插件 / 项目 `.mcp.json`)取回完整配置原样注册,login/logout 共用;契约 `McpAuthorizeSchema` 增可选 `scope`/`projectPath`(**同名可跨源存在,登录必须取被点那一行的源**,url/kind 退化为兜底身份),插件侧加 `getPluginMcpServerConfig()`(忽略 per-server 禁用名单,面板对已关的 server 仍显示 OAuth 行)。② **darwin 也能读凭据**:`readStoredCredentials()` 在 mac 下走 `security find-generic-password -a $USER -w -s "Claude Code-credentials-<sha256(configDir)[:8]>"`(服务名/账号规则提取自二进制,本机 `fba7368e` 实测一致),退出码 44 + `could not be found` = 确实没有凭据(可读),超时/ACL 拒绝/JSON 坏 = **不可读(= 未知,绝非「无令牌」)**——未知时必须信任 CLI,否则会把成功登录报成失败;原来两处 `process.platform !== "darwin"` 复核门随之删除。③ **precedence 反转**:needsAuth 是 CLI 在**真实 401** 时写的活信号,必须压过「有令牌」——令牌可能落在运行时查不到的键下(缺陷①)、也可能已过期/被吊销,显示「已授权」会掩盖一个根本连不上的 server。登出后本地仍留有旧键令牌只打 WARN 不报错(报错会让面板卡在「已授权」,看着像登出没生效)。
- **去授权入口前置:主动探测(2026-09-10,用户要求「需要登录的直接显示入口,不用等模型说」)**:此前 `needsAuth` 只来自 CLI 的 needs-auth 缓存,而那是**真实 401** 的产物——刚加进来的 OAuth server 在模型第一次用它之前,面板上完全看不出要登录。现在 MCP_LIST 对「缓存无记录 + 凭据库无令牌 + **enabled**」的 http/sse 行主动探一次:`POST {url}`,体为最小 `initialize`,头带**该 server 自己配置的 headers**(带静态 `Authorization` 的 server 因此正常响应、不会被误判),**401/403 且 `WWW-Authenticate` 以 `Bearer` 开头**才判 requires-auth(MCP 授权规范信号;实测 figma 返回 `401` + `www-authenticate: Bearer resource_metadata="https://mcp.figma.com/.well-known/oauth-protected-resource",scope="mcp:connect"`,约 350ms);其它响应与网络失败一律**无判决**,绝不猜(猜错的代价是用户被拉去走一趟无意义的浏览器授权)。探测量级控制:TTL 5 分钟内存缓存(键 `name|url`,URL 变即失效)+ 单个 `AbortSignal.timeout(5s)` + 整批 `Promise.race` 预算 2.5s——超预算的探测**继续跑并把结果留给下次 load**,面板绝不被不响应的 server 拖住;授权成功后删该缓存项。**只探 enabled 行**:关掉的 server 不进任何回合,没有可前置的状态,也不该替用户发一次他没要求的请求(用户重新打开开关时面板会 `load()`,届时才探)。行的配置在列表构建时按 `scope:name` 收集(同名可跨源存在,各自判各自的 URL)。**已知边界**:① 探测走 undici,**不读 `*_proxy` 环境变量**(与 zip 下载踩过的坑同源)——需要代理才能出网的环境下探测失败即无判决,退化为"等 CLI 的 401",正确性不受影响(本机 dead proxy 实测仍能直连成功);② 2.5s 内没答完的 server 本轮无徽标,下次 load 由缓存补上(面板不主动刷新);③ 探测只回答"这个端点要不要 OAuth",而登录/凭据仍走 Claude CLI 的凭据库——原生 codex/pi 会话的 MCP 授权是另一条链路(既有范围限制,非本次引入)。
- **IPC**:`mcp.*` namespace(list/toggle/save/remove/scanImport/import),handler 在 `main/ipc/mcp.ts`(skills.ts 模板:zod parse + findKnownProject 校验 + `{ok,error}` 返回)。`scanImport` 只读扫 `~/.claude.json`(全局 + 各 project 条目,带 origin 标签)——Mcode 因配置重定向**看不到**用户真实 CLI 的 MCP,导入是唯一复用方式。
- **UI**:`McpPanel.tsx`(SkillsPanel/BrowserPanel 模板):用户级(开关+删除+新增表单 dialog:stdio/http/sse 三类型)+ 项目级(managedProjectId Select 切换、只开关)+ 内置(单开关)。`VscMcp` 图标经 `icons.tsx` 的 `McpIcon` 适配层包装(react-icons 的 `stroke` 类型与 TablerIconProps 不兼容,不能直接进 `ComponentType<TablerIconProps>` 槽位)。用户/项目两级 server 仅 Claude 会话生效(Pi 走 extension、Codex 走 config.toml 物化,面板 desc 有注明);**内置 browser 开关三引擎统一生效**(2026-09-10 修齐,Claude 之外两家的接法不同,见下)。
- **内置 browser 开关的三引擎接法(2026-09-10 修齐)**:① Claude = 注册时门禁(`browserDisabled` 时不构建 browserServer,`options.mcpServers` 不注入,每轮现读);② Pi = **注册时门禁**——`createMcodeExtension` 增 `browserToolsEnabled` 选项(provider 每轮读 `getMcpManagement()` 传入),false 时跳过 `registerBrowserTools` 且 system prompt 注入器不同步发 `browserToolsUsagePrompt()`(工具不存在就不能在提示词里做广告);③ Codex = **注册 + 调用双门禁**——`buildDynamicTools(browserToolsEnabled)` 只在 `thread/start` 生效,**`thread/resume` 不传 dynamicTools 且 dynamicTools 随 rollout 持久化(无法重注册),续接的旧线程永远带着 browser_* 工具定义**,故 `invokeDynamicTool` 对 `browser_*` 前缀 + `!browserToolsEnabled` 直接回 `success:false` 拒绝结果(工具对模型仍可见但调用必败,等同停用);`browserToolsEnabled` 经 `RequestDeps` 传入请求路由。
### 语音输入(voice dictation,2026-09-10 修「转换后的文字重重复复出现」)
- **链路**:renderer `useVoiceInput`(getUserMedia → 设备原生采样率 AudioContext → ScriptProcessorNode → JS 线性重采样 16k → `voice.feed` 每 250ms 4000 样本一批)→ main `voice/speechRecognizer.ts`(sherpa-onnx online zipformer 流式解码,partial 边听边出)→ `voice:result` push → `MicButton.applyLiveText`(partial 单调递增,只往 composer 追加 delta;尾部对不上改写尾部;用户改过则静默丢弃)。模型来自 `VOICE_MODEL_CATALOG`(两个流式 zipformer,HF 下载)。
- **重复出现的根因(已定位 + 修复)**:流式 transducer **必须**在停顿处 reset——不 reset 时解码器一直握着上一次的 result,**之后每个 endpoint 都把同一句话再 commit 一遍**;`sherpa 1.13.6 + zh-14M 实测:静音 3 秒内同一句被重复提交 11 次,整段文本里同一句出现 46 次`(连续静音时 endpoint 每 250ms 触发一次)。原实现把 reset 完全押在引擎的 endpoint 规则上(`if (rec.isEndpoint()) { commit; rec.reset() }`),该规则一旦在某台机器/某个引擎构建上不生效,就直接退化成无限重复。修复三件套:**① 自己的静音闸门**——按 PCM 峰值(自适应包络 `SILENCE_PEAK_FLOOR`/`SILENCE_PEAK_RATIO`)测出 1.2s 连续静音即强制 commit+reset,不再依赖引擎 endpoint(实测:把引擎 endpoint 关掉,新实现输出与正常引擎**逐字一致**,旧实现则分段与句号全无);**② 残留段不追加**——segment 与上次 commit 完全相同且期间没有语音(`spokeSinceCommit`)判为解码器残留丢弃(真人连说两遍同一句不受影响);**③ reset 后校验**——`reset()` 后若 `getResult().text` 仍非空(某构建没清干净)就**换一个新 stream**(否则残留上下文会被无限重放)。②③ 各打一条 WARN(`decoder re-emitted…` / `decoder kept N chars after reset…`),用户机器上取证先搜这两行。
- **引擎行为事实(1.13.6 源码 + 双模型实测,改这段前先读)**:`OnlineRecognizer::Reset()` 把解码器 result 截断为最后 `context_size` 个 token 当下一段上下文(**编码器状态保留**),`GreedySearchDecoder::StripLeadingBlanks` 又把这 `context_size` 个 token 从**公开结果**里剥掉——所以 reset 后 `getResult()` 必为空(两模型共 12 次 reset 实测 0 次非空),**重复不是上下文泄漏造成的**,真正风险是 reset 没被调用/没生效。node addon 用 camelCase 配置键(`enableEndpoint` / `rule1MinTrailingSilence` / `rule2MinTrailingSilence: 1.2` / `rule3MinUtteranceLength: 20`,与 Python API 同义,平台包二进制内含这些字符串),endpoint 在 -15dBFS 噪声下仍会触发。**离线复现法**:`pip install sherpa-onnx==1.13.6` + 从 ModelScope 取同款模型(`HZZSCIENCE/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23`、`pkufool/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20`)与 `test_wavs`,按 4000 样本/批喂 `wav + 2.5s 静音 + wav`,对比「正常 reset / 去掉 reset / endpoint 关闭」三种输出即可复现。
### Agent 编排(orchestration,2026-09-15,设计文档 docs/orchestration-plan.md,P0-P3 全量落地)
- **语义模型(Orca 学语义、不学传输)**:编排层是"哑"基础设施(任务表 DAG+状态机 / 决策门 / 派发器 / check-wait 等待),智能在入口处——三个入口:① **会话内拆解(2026-09-17 重构,默认路径)**:composer「自动编排」开关 → 普通回合 + per-turn `orchestration` 旗标[SendTurnSchema/RuntimeManager sendTurn/StartTurnRequest 三层透传] → ClaudeAgentSdkProvider 注入规划者系统提示(`buildOrchPlanNudge`:角色/工具用法/可用模型面/硬约束)+ `orch_submit_plan` 进程内 MCP 工具(`orchestrator/planTool.ts`);模型调工具 → main 侧 `clampNodeExecConfig` 钳制 → 创建 paused run → `plan.proposed` 事件推渲染端挂画布。**拆解就是会话回合**:历史/审批/停止/台账全部复用普通回合管线,多轮调整("简单一些")天然携带上下文——曾因无头 query() 旁路(`orch.proposePlan`,单轮无状态)丢上下文而整体废弃;② @agents 目标模式(确定性骨架,不经模型);③ 画布控制面 IPC(run/task/gate/merge)。**Agent 角色域与编排模板已整体退役(2026-09-17)**:task.profileId 字段留在契约里仅为历史 run 兼容(恒 null),worker 简报用通用 system 段 + 节点/协调者配置继承;原 @agents 目标簇(@@ 选择器/骨架画布/`startOrchestrationFlow`)、编排模板(`templates.ts`)、角色模板(`profiles.ts`,含 `orch.routing.v1` 学习)与 `orch.agent*/orch.template*` IPC 全部删除,设置页 AgentsPanel 只剩触发档位 + 默认预算。**监督派发 vs 完全移交协议层分开**:移交(`orch.handoff`)= 普通新会话+简报、不建任务行、不追踪;监督 = 建 run/任务、等 worker_done。**完成权威是事件推导不是 prose**:worker 对编排协议零感知,`worker_done` 由基础设施从 turn.done/turn.files/token-usage 推导;派发上下文是 sessions 表 `orch_meta` 列里的结构体({runId,taskId,dispatchId,coordinatorSessionId}),幽灵上报在协议层消失。
- **会话内拆解的产物落点(plan.proposed reducer)**:工具 handler 在 main 侧**一次**创建 run(多客户端不会重复建卡),事件自带 run(免除与 run.updated 的顺序依赖);渲染端把画布块挂到**当前回合最后一条 assistant 消息**(通常就是承载工具调用的那条;模型总结文本是后到的独立消息,自然落在画布之后),幂等(同 run 的画布块已存在则跳过;无 assistant 消息可挂时兜底自建 `orch_canvas_*` 消息)。工具入参无任务 id(渲染层 `OrchPlanToolCard` 按序显示 t1..tN,main 钳制时才编号);工具卡折叠态 = 「提交编排任务图 · 目标摘要 · N 个任务」,任务图的结构化视图在画布上,不再有 JSON 平铺。规划工具是纯数据提交:`shouldAutoApprove` 对 `isOrchPlanTool` **在 `if (!mode)` 之前**放行(default 档的 mode 是 undefined)。
- **契约与通道**:`packages/contracts/src/orchestration.ts`(AgentProfile/OrchestrationRun/TaskNode/Gate/OrchestrationTemplate/OrchSettings 全 zod;Session.kind 扩 `'orch-worker'`;OrchestratorEvent 含 run.updated/gate.created/gate.resolved/worker_done/plan.proposed——planner.delta 已随无头 planner 删除);IPC 走 `orch:*` RPC 命名空间 + `orchestrator:event` push;preload `api.orch.*` + `api.on.orchEvent`(webApi 桩 no-op,移动端不渲染编排入口)。原 `orch.proposePlan`/`orch.abortPlan` 已删。
- **main/orchestrator/**:`OrchestratorService`(模块级单例,run 生命周期;settings key `orch.runs.v1` 持久化、上限 50 条、load 是合并不是替换;启动 `start()` 幂等[IPC 变更 handler 一律 `await ready()`]→`reconcileOnBoot` 断点收敛[重启时在途任务按 worker 会话 DB 状态判定:running=中断失败走重试梯,done=从持久化数据补 worker_done]→`runtimeManager.addObserver` 挂 worker 生命周期映射[addObserver 是新的多观察者 API,与 NotificationManager 的单槽 setObserver 并存];before-quit `disposeAll`)/`taskStore`(DAG 校验[环/深度≤4/未知依赖]、ready 计算、`BREAKER_LIMIT=3`)/`dispatcher`(agent runner=建 kind:'orch-worker' 会话[显式标题防 fresh-row 复用,worktree 走 `lib/sessionCwd.ts`——从 ipc/claude.ts 抽出的共享物化逻辑]+简报注入[通用 system 段+目标+上游产物 blackboard+工作纪律,review 任务要求末行 `VERDICT: pass|changes_requested`];terminal runner=子进程逃生舱,输出落 `userData/orch/<runId>/<taskId>.log`)/`waiter`(事件驱动 check-wait,超时=检查点返回快照,run 删除以 null 释放)/`worktreePlanner`(策略决策表:auto=本地检出[续作节点沿用既有 worktree]、always_new/active_only 显式档;review 任务恒 active 检出不越权)。`profiles.ts`/`templates.ts` 已删。
- **运行纪律(服务内编码)**:波次派发(**不限并发**,2026-09-17 起移除并发槽上限——`run.concurrency` 字段仅存于数据形态,ready 任务每波全发,依赖边即波次边界;画布统计行与设置页的并发字样一并移除);熔断(连败 3 次→blocked+escalation gate);重试梯(节点级自动重试→人工 gate);审查打回(review verdict≠pass→目标任务重置 pending、spec 追加 findings、同 worktree 续作,`reviewLoopLimit=3` 轮上限超限升人工);预算(token-usage 累计 spentUsd,超限 pause+budget gate,resolve「继续运行」恢复);竞争组(variantGroup 全 completed→review_pick gate 择优,落选 superseded);merge-back(`orch.mergeTask`→worktreeOps.mergeBackWorktree)。
- **worker 会话与 UI 接线**:kind='orch-worker' 对所有列表/listAll 不可见(查询过滤 kind='chat' 天然排除);`SessionRepo.listOrchWorkersByParent/ByRun` 供 reconcile;打开 worker=store `openOrchWorker`(fetch 行→`orchWorkersById` 缓存→openTab;**store 与 SessionTabs 两处 `findSession` 已扩可选末参 workersById 兜底**,否则 tab 标题"(unknown)")。右栏新增两 tab:`orch`(OrchPanel:run 卡片+状态色标+节点控制[暂停/终止/重试/换模型重跑/标记完成]+gate 解决+worker 报告+merge-back)与 `inbox`(InboxPanel:聚合 open gates + worker 的 question.ask[复用 QuestionPrompt+新增 session 定向 `dismissQuestionFor`]+工具审批[worker 会话 id 集合从 runs 的 dispatches 派生,零新契约])。
- **触发与分类(sendPrompt 内渲染端拦截)**:「自动编排」开关开启(或 `looksOrchestratable` 启发 + triggerMode=auto)→ **本条消息照常进入会话回合**,仅置 `orchestration` 标记 + 用户气泡 `orchTag:"auto"` 徽标;triggerMode=ask 仅 toast 建议后照常发送;消息右键菜单「移交给新会话」(`orchHandoff`,继承来源会话的执行配置,profileId 参数已随角色域删除)。设置页 AgentsPanel 只剩编排设置(触发档位 + 默认预算——`planTool.ts` 建 run 时读入,是无人值守 worker 的预算闸)。i18n 全量 `orch.*` 词条(zh/en `orch.ts`,core.ts 注册)。
- **节点配置「必须要有值」(clampNodeExecConfig,现居 planTool.ts)**:planner 输出的每个节点 providerId/model/effort/permissionMode 必填、claude 节点加 customModelId,值只能逐字取自已配置清单——钳制后新增**缺省补值链**:planner 编造/不在线的 providerId 视为未给(不再整节点作废),空字段落沿 节点 → profile → 协调者会话 补成已配置的具体值(model 候选必须与生效厂商同源,均无时取该厂商已配置模型清单首个非 default;claude 节点的 customModelId 与 model 配对归属,归属不到配置的 builtin 别名保持与协调者同源——协调者走官方则同走官方)。补出的值与派发时「跟随默认」的继承链(dispatcher 的 `task ?? profile ?? coordinator`)同源,派发语义不变。规划者提示(`buildOrchPlanNudge`)同样声明这些硬约束 + 「修订后完整任务图」语义(调整轮不输出增量)。
- **已知边界**:orch_submit_plan 工具仅 Claude provider(Pi/Codex 无 createSdkMcpServer 等价物,这两家的会话暂无自动拆解入口;worker 任意 provider);每回合**条件挂载**工具(普通回合不注入提示不挂工具,零上下文开销);worker 会话的事件照常进 renderer 各 per-session 桶(question.ask/approval 全走既有链路);AgentsPanel 色点的 tailwind 动态类(`bg-${c}-500/70`)在 JIT 扫描下不生成——色点如不显示需改为安全映射表。历史会话里旧旁路流的 `orch_host_*` 消息(大段 JSON 文本)由 ChatPane 完成态分组的 `orchSplitIdx` 边界收进过程台账(检出 orch_host_ 前缀消息时,过程/回复边界改锚在首个画布/错误块)。冒烟:`scripts/orch-planner-clamp-smoke`(工具 handler 全链路:nudge 渲染 + 补值链 + plan.proposed)与 `scripts/orch-plan-canvas-smoke`(reducer 挂画布/幂等/兜底)。
---
## 关键提醒
1. **改 ClaudeRuntime 前先读 stream-json 文档**。schema 来自真实 dump,字段名不要猜。
2. **不要打包 claude.exe**。License 合规:只调用用户已装的,不内嵌二进制。
3. **新增 IPC 必走 zod 校验**。这是 renderer→Node 的唯一安全边界。
4. **本机的 superpowers 插件 hook 是坏的**(SessionStart 报 ParserError),与本项目无关——claude 会跳过它,日志里看到不要当成我们的 bug。
5. **空白屏调试**:main 进程已把 renderer 的 `console-message` 转发到 stderr,不用开 DevTools 就能从启动日志看渲染层报错。
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

