# 开发经验库 > **这份文件随插件分发。** 装了这个开发工具箱,就自动获得下面所有经验 —— > 它们是从实际开发中踩出来的坑,目的是让你不用重新踩一遍。 > > **怎么更新**:去项目 GitHub 拿这个文件的最新版,**直接替换掉插件文件夹里的这一份**, > 重启客户端即可。插件会在设置页显示当前版本与条数,方便你对照。 > > **怎么贡献**:踩到新坑后,在下面**追加一条**(格式照抄现有的), > 顺手更新文件顶部注释里的 `entries`、`updatedAt`、`fingerprint`(或只改前两项, > 插件会提示头部与实际不符),然后提 PR。 > > **格式约定**(固定格式是为了能被脚本解析、注入到 AI 的提示词里): > > ``` > ## E<编号> · 一句话标题 > - **症状**:会看到什么现象 > - **解法**:该怎么做 > - **细节**:可选补充 > ``` > > ⚠️ 条目要写**具体症状**,不要写"要注意 XX"这种原则。原则 AI 知道, > 具体症状才能让它对上号。 --- ## E1 · 复制到剪贴板静默失败 - **症状**:点"复制"按钮没反应、或提示成功但粘贴出来是空的。自己写的 `navigator.clipboard.writeText` 加 `execCommand` 兜底,两级都失败。 - **解法**:**照抄 DSH 自己的实现**。它在 `@deepseek-ai/dsh-client-ui-primitives` 里叫 `writeClipboard()`:`execCommand` 只在 `navigator.clipboard` **完全不存在**时才用 —— API 存在但被拒时直接返回 false。另外准备一条不依赖权限的出路:失败时把文本全选, 让用户按 Ctrl+C。 - **细节**:查法 —— `node tools/diag/diag-find-impl.mjs writeClipboard`。 有人凭"现代 API 优先"的通用经验猜了两轮才去读源码,白费两轮。 --- ## E2 · 悬浮框/提示层疯狂闪烁 - **症状**:浮框每帧都在重建,看不清内容。 - **解法**:`useEffect` 的依赖数组里**不能放它自己 setState 的那个 state**。 否则形成"setState → 依赖变化 → 清理并重跑 effect → setState"的自触发循环。 要读"上一次的值"就用 `useRef`(ref 变化不触发重渲染),依赖数组留空。 - **细节**:判断规则 —— **effect 内部调用了某个 state 的 setter, 那个 state 就一定不能出现在它自己的依赖数组里。** --- ## E3 · 关闭了的工具还在后台跑 - **症状**:界面上的开关关了,但监听器/定时器还在响应,或者 DOM 还在被读。 - **解法**:让 `enable()` **返回一个真正的清理函数**(卸监听器、停定时器、注销注册), `disable()` 调用它。不要用"React 组件返回 null"代表关闭 —— 那样只是看不见, 东西还在跑。 - **细节**:`ctx.slots.inject(...)` 返回的就是一个幂等注销器,正好可以当清理函数用。 --- ## E4 · 加了兜底,但兜底永远轮不到 - **症状**:写了 `a ?? b ?? fallback` 或"配置 → 服务 → 兜底"的取值链, 但兜底从不生效,程序用了上游给的**坏值**继续跑。 - **解法**:区分"**缺失**"和"**错误**"。`undefined` 会走兜底, 但"给了一个错的值"不会。后者必须**显式校验**(白名单 / 格式检查), 不能依赖 `??` 或任何"取第一个非空"的辅助函数。 - **细节**:实测案例 —— `agentDefaultModel.currentSelection()` 返回了本机没有凭据的 provider,取值链就停在那一层,兜底代码根本没执行。 --- ## E5 · 用 shell / 系统层面的手段绕开框架,越绕越深 - **症状**:框架没提供某个操作,于是想用外部手段实现(杀进程、改文件、hook)。 每次都能"找到确切原因 → 修 → 又冒出下一个判定点",循环好几轮。 - **解法**:**先搜文档**。搜到 0 命中 = 框架不支持 = **停止尝试**, 如实告诉用户并给框架内的替代方案。 **"绕开框架"也算"改 DSH"**,同样适用"先查文档"这条规矩。 - **细节**:实测案例 —— 做"重启客户端"按钮时没查文档就连试四轮。 最后查出来:`relaunch` 在 1112 份文档里命中 **0 次**,框架没这个能力; 而桌面外壳里至少有四个独立守卫在阻止外部终止。 --- ## E6 · 改完插件代码没生效 - **症状**:改了 `index.js` / `client.js`,重启插件(禁用再启用)后行为没变。 - **解法**:分两种情况。**先分清是哪一种,别一上来就下"必须重启"的结论** —— 我下过一次,那是错的(2026-10-12 更正): 1. **默认配置下确实要重启**。Node 的 ESM 缓存以文件 URL 为键,同进程内同路径 永远返回同一模块实例;Cordis 的 runtime 又按 callback 身份缓存。 两者叠加 → "禁用再启用"用的永远是旧模块。 2. **但 DSH 自带热重载**(`@deepseek-ai/dsh-hmr`,桌面应用默认启用), 只是默认 `root: []` —— **只监听配置,不监听源码目录**。 所以"改源码要重启"是**默认值造成的结果,不是框架做不到**。 - **细节**:要打开源码监听,在 profile patch 里配 `hmr` 条目(`disabled: false`, `config.root` 填要监听的目录;`debounce` 默认 100ms;`ignored` 默认排除 `**/node_modules`、`**/.*`、`cache`、`data`)。两个坑: · **`link:` 安装的插件要用 `realpathSync()` 之后的那份路径**(真实源码目录, 不是 `node_modules` 里那个软链),否则监听根覆盖不到它; · 监听根别开太大(整个盘会拖慢),只填正在开发的几个目录。 ⚠ 这几类变化**本来就不需要重启**,别记混:Plugin Manager 的安装/启用/禁用 (文档原话"无需等待文件事件即可应用")、profile 配置与 patch、 以及插件在运行期读的那些文件(经验库 md、人设文件、数据 json)。 排查用 `node tools/dev/check-restart-needed.mjs` (比对代码改动时间与客户端启动时间)。 --- ## E7 · 宿主插件的日志是黑洞,出问题什么都看不到 - **症状**:插件"不崩溃但什么都不做",无法判断它到底跑了没有。 - **解法**:Host 插件的 `console.log` 写到宿主进程 stdout,**桌面应用不落盘**。 所以**关键节点必须写进文件**,并且写成**结构化的行**(每行一个 JSON), 方便后续摘要成界面状态。 - **细节**:可以像本项目那样——宿主写状态文件,客户端设置页读一份摘要接口, 用户不用碰文件就能看到"连上没有 / 最近收到什么 / 最近一次错误"。 --- ## E8 · 自己建的 agent 收到消息但永远不执行 - **症状**:消息进了收件箱、日志有 `agent/inbox/spliced`,但永远没有 `turn/start`。 - **解法**:两个可能原因,都要查: 1. **没有唤醒**:`agent.inbox.append()` 是底层写法,**不唤醒 agent**。 公开 API 是 `agent.followup(msg)`(= `send(msg,'next-turn',true)`,入队并唤醒)。 2. **没有驱动器**:`ctx.agents.create()` 造出来的 agent 不一定有 driver。 用 `parentAgent` 把它的生命周期挂到一个活动 root agent 上。 - **细节**:官方 practices 原文:*"A timer that starts work calls `agent.followup()`, which wakes the agent; `agent.inject()` does not wake it."* --- ## E9 · 外部消息投进了"用户正在用的会话" - **范围**:项目特定 —— 这是"把 IM 消息接进 DSH"这类功能的架构取舍, 不一定适用于别的插件;保留在此作为**写法范例**。 - **症状**:外部消息(QQ / IM)触发的回答内容**完全不对** —— 收到的是用户和模型当前对话的模型输出。 - **解法**:给外部消息一个**专用会话**,不要复用用户正在用的那个。 否则模型的回复会被当成"对外部消息的回答"发出去。 - **细节**:还要注意串行化 —— DSH 会把**同一 turn 内的多条收件箱消息合并处理**, 两条消息只产生一个回答;所以要有队列,一条一条投。 --- ## E10 · 找不到某个界面位置该往哪插 - **症状**:想在某处加东西,但不知道该用哪个 slot,或者试了几个都不对。 - **解法**:`cordis_inspect_query` 查 `Slots.listSubTree`,传 `root` 看**实时树** (含 `occupants` 和 `catalog.description`)。槽位说明里往往直接写了用途, 比如 `shell.overlay` 写着 *"a badge, a toast stack or a status pill all belong here"*。 - **细节**:`single` 类型的槽位**一旦被占用就插不进去** (比如用户名那一行是 `settings.trigger`,被 ui-settings 独占)。 这时要么换位置,要么承认做不到。 --- ## E11 · 插件详情页有两个看起来一样的开关 - **症状**:插件详情页有两个 switch,不知道区别。 - **解法**: - **包级开关**(DOM 里 `INbgUW_detailActions`):切换整个组合包的层选择, 关掉 = 包里所有行一起停。 - **行级开关**(`INbgUW_rowLine`,在"包含的组件"列表里):只停那一行加载器条目。 - 一个包只插一行时两者效果相同;**一个包可以插多行** (内置 agent preset 就是同一个模块插三行)。 - **细节**:`cordis.patch.yml` 的 `insert` 列表就是"包含的组件"的来源。 --- ## E12 · 超时自动消失的浮层打断操作 - **症状**:浮层/提示设了几秒后自动消失,但用户正要把鼠标移过去操作,它自己跑掉了。 - **解法**:**不要用超时**。改成"**点击浮层外部才消失**", 或者"**松手/按键时固定下来**" —— 把展示时长交给用户控制。 - **细节**:如果是跟随鼠标的浮层,还要让它在用户靠近交互按钮时**冻结位置**, 否则按钮永远点不到。 --- ## E13 · 自测通过 ≠ 行为正确 - **症状**:自己写的校验器全绿,但实际用起来是坏的。 - **解法**:**校验器只能覆盖它能覆盖的**。界面类改动必须**人眼确认**; 时序类问题必须**核对进程启动时间**;effect 类问题校验器测不出来 (桩里 effect 不会因依赖变化重跑)。 - **细节**:实测踩过三次 —— 组件返回 `null` 被误判失败(校验器自身有误)、 effect 自触发闪烁(校验器测不出)、 shell 吃掉引号导致改动没生效但脚本静默通过。 --- ## E14 · 校验器自己的断言写错,导致误报 - **症状**:校验器报"组件能渲染 ✗",但组件其实是好的。 - **解法**:React 组件**允许返回 `null`**(表示"当前什么都不渲染"), 这是很常见的写法。断言只应该把 `undefined` 当异常 (那通常意味着"忘了 return")。 - **细节**:**误报比不报更糟** —— 它会让人去改本来正确的代码。 写校验器时对"合法的空值"要显式放行,并在输出里注明原因。 --- ## E15 · 模块级可变中继是陷阱:忘了接线不报错,只给错答案 - **症状**:某个"读"接口永远返回同一个值(通常是它的默认值),而"写"接口看起来 完全正常 —— 日志里能看到写入成功的记录,但读出来就是不对。 更糟的是:如果读接口的结果会被另一方用来"纠正"状态,就会形成**翻转循环** —— 用户刚打开的功能,几秒后被自动关掉。 - **解法**:不要把"可变的模块级默认值 + 等别人来 bind"当作接线方式。 改成把回调**从函数参数一路传下去**(`startX({ getState, setState })`)—— 这样漏了就是显式的 `undefined`,而不是安静地返回一个错误答案。 写完之后必须验证**往返**:写进去的值,能通过读接口读回来。 - **细节**:实测案例 —— `app-control.js` 里写了 let reader = () => false; export function bindDevMode(r) { reader = r; } 打算让 `index.js` 调用它接上真实读取器,**但只在 index.js 里传了 setter、 忘了调 `bindDevMode`**。于是 POST 写成功、GET 永远返回 false; 客户端轮询 GET 看到 false,以为用户在别处关了,就把开关翻回去 → 用户每次打开几秒后又被关掉。 表现是"某个工具怎么都不注册",而日志里全是"开"、没有一条"关" —— 因为坏的只有读那条路。 --- ## E16 · 读写各自测过 ≠ 它们接在同一条线上 - **症状**:单元测试全绿:A 组件的写逻辑正确、B 组件的读逻辑正确 —— 但合起来用是坏的,因为**中间的线没接**。校验器抓不到,人眼也容易忽略。 - **解法**:集成测试必须显式覆盖**往返**: 「通过写接口写入 → 用读接口读回 → 断言读到的等于写入的」。 只测"A 能写"和"B 能读"是不够的。 - **细节**:实测案例 —— 修完开关状态后,校验器测了 `dev-rule.js` 的 `setDevMode` / `isDevModeOn` 往返(通过),但没测 `app-control.js` 的 HTTP 读接口是否接到了同一个状态上。 结果是一个"两端都正常、中间断线"的 bug 活了下来,补上 8 项接线检查才抓住。 --- ## E17 · 重排整份文件比重写那一段危险得多 - **症状**:想"顺手把格式整理一下",于是写脚本从**解析结果重建**整个文件 —— 结果把没坏的东西弄坏了:单词被从中间切断、代码块语法被破坏、 分隔符被吞进正文。 - **解法**:**别重建,只做外科手术。** 要改哪一段就精确替换那一段 (用 `edit` 这种"匹配原文 → 替换"的方式),让工具**拒绝**在匹配不到时静默通过。 如果非要整篇重排,先备份、重排后**逐条人工核对**,别只看"解析器还能解析"。 - **细节**:实测案例 —— 我为修两条经验的排版,写了个"从解析结果重建"的脚本。 它按固定字数硬折行,于是出现两类破坏: ① 单词被从中间切断 —— `navigator.clipboard` 被断成了 `navigator.clipboar` + 换行 + `d.writeText`,行内代码语法随之破坏 ② 正文结尾的分隔符 `---` 被吞进了上一条的"细节"字段 而且**解析器仍然报告一切正常**(条数对、指纹一致、头部未过期)—— 因为解析器不检查行的完整性。这次靠备份恢复的。 --- ## E18 · DSH 内部简写 ≠ 注册时的真实格式(同一个坑有三种表现) - **症状**:三次栽在同一个模式上 —— "DSH 内部用的简写格式"和"运行时真正接受的格式"不是一回事: ① 工具参数声明直接传简写 → 模型 API 报 `Invalid schema for function 'x': {"type":"string","required":true,...} is not of type "string"`,**整个会话这一轮直接失败**。 ② 想用 schemastery 声明 Config → `Cannot find package`。 ③ 模块 URL 少一个斜杠(`file://D:/...` 而不是 `file:///D:/...`)→ 加载失败。 - **解法**:记住两条铁律: ① **`tools.register` 只接受真正的 JSON Schema**:`{ type:'object', properties:{...}, required:[...] }`。`{ 参数名: { type:'string', required:true } }` 这种简写是 `defineTool()` 的专属输入,它内部会调 `parameterSchemaSpecToJsonSchema()` 转换。**不走 `defineTool` 就得自己转**(外部插件也走不了 —— 见 ②)。 ② **外部插件 import 不到只在 app.asar 内部的包**(`@deepseek-ai/schemastery`、`@deepseek-ai/dsh-tools` 都是)。只能手写:Config 用 Standard Schema(`{'~standard': {version:1, vendor, validate}}`),工具参数自己写转换函数。 **排查口诀**:看到"某个简写形式的对象被当成最终值"的报错,先问自己"这个格式是给哪个函数吃的?我是不是跳过它了?" - **细节**:同一个模式在本项目里出现了三次,每次都以为是新问题: ① 做工具参数声明时,**读了** `defineTool` 的源码、**看到了**它调用 `parameterSchemaSpecToJsonSchema`,甚至自己照抄实现了一个 `toJsonSchema()` —— **但注册时忘了调用它**。用户那一轮直接报 `Invalid schema for function` 白跑。 ② 想用 `z.object({...})`(schemastery)声明插件 Config,直接 `Cannot find package` —— 它只存在于 app.asar 内部。 ③ `'file:///D:/.../experience.js'` 这种 URL:`file:///` + 盘符路径在不同拼接方式下会得到 `file:///D:/...` 或 `file://D:/...`(少一个斜杠),后者在 Windows 上解析失败。 **共同点**:DSH 内部大量使用"简写格式",而简写是给某个特定函数吃的。你不走那个函数,就得自己转 —— 而转换步骤很容易在"照抄一半"时漏掉。 **一条可操作的检查**:写完注册代码后,把参数对象打印出来看一眼 —— 如果看到 `{ 属性名: { type, required } }` 这种形态,它还没被转换,不能直接交给 registry。 --- ## E19 · 写下来的规则会失效;要把它变成结构 - **症状**:同一个坑踩了三次,而**这个坑就写在我自己的文档里**: 配置里的 `experienceFile` 是空字符串(Config schema 给未配置字符串的默认值),代码写 `experienceFile ?? DEFAULT` → 兜底永远轮不到 → 报错是"文件不存在:"(冒号后面空的)。 **危险信号**:你发现自己在说"我知道这个坑,文档里写着呢" —— 但代码里还是踩了。这说明**文档不是有效的防线**。 - **解法**:**把规则变成结构,而不是留在文档里等自己想起来的。** 具体手段(按可靠性排序): ① **做成共享函数 / 工具**:`firstNonEmpty([...])` 放在公共模块里,各处 import 它。看到"要判空"就自然会用它 —— 比"记得 `??` 对空串无效"可靠得多。 ② **让错误立刻可见**:在注册/装配路径上加断言,用错格式立刻抛错,而不是安静地降级成空串。 ③ **让工具拒绝坏输入**:校验脚本、`edit` 这类"匹配不到就报错"的工具,比"尽力而为"的脚本安全。 ④ 最后才是写文档 —— 它有用,但**只是补充**,不能作为唯一防线。 **判断一条规则会不会失效**:问自己"它需要我记得吗?"需要,就迟早会失效。 - **细节**:实测案例:空字符串兜底这件事,**用户全局指令里写着、我自己的经验库 E4 里也写着**,但写代码时还是用了 `config.experienceFile ?? EXPERIENCE_FILE`。 原因是 DSH 的 Config schema 会把**未配置的字符串字段默认成 `''`**,而 `'' ?? x` 得到 `''`(空串不是 nullish)。于是路径变成空串,兜底永远轮不到,工具第一次被调用就报: {"ok":false,"error":"经验库文件不存在:"} —— 冒号后面什么都没有,因为路径就是空的。 **为什么"写下来"没用**:规则是**被动信息**(passive information),要 AI 在正确的时机**主动想起来**才生效。而"主动想起来"正是最不可靠的环节 —— 项目里其他几次失败(查文档、重启按钮)都是同一个根因。 **有效的做法是把它变成结构**: · 做成一个共享函数 `firstNonEmpty([...])`,别在各处写判空 —— 看到要判空就自然会用它 · 或者在注册/装配路径上加一条断言,让用错格式立刻报错,而不是安静地给出空串 **判断标准**:如果一条规则需要"记得"才能生效,它迟早会失效。 --- ## E20 · 槽位能"减法":single 槽位可以用 priority 遮蔽 shipped 的注册 - **症状**:你读到的调研结论说"DSH 的槽位 API **只有加、没有减**,隐藏/替换已有 UI 做不到",于是放弃某个设计方案 —— **但这个结论是错的**。 真实症状是:想替换一个已经被占用的 `single` 槽位(比如侧边栏底部账号那一行), 按"复用 shipped 的 id"去注册,发现**根本不知道它的 id**(实时树里 occupant 只有 `priority`,没有 `id`), 于是以为"做不到"。 - **解法**:**`single` 槽位是可以"减法"的 —— 用更低的 `priority` 遮蔽 shipped 的注册。** 规则在 `dsh-client-ui-slots/lib/index.js` 的 `SlotCore.register` 里,按槽位类型分: | 类型 | 冲突判据 | 替换方法 | |---|---|---| | `single` | **同 priority** 已有人 → 抛错 | 用**不同** priority(低的渲染) | | `list` | 同 `id` **且**同 priority | 同上 | | `keyed` | 同 `key` **且**同 priority | 同上 | **关键**:碰撞判据是 `priority`,不是 id/key。所以想替换一个已占用的 single 槽位, **不需要抢它的 id**(你也不知道),只需要 `priority: -1`(shipped 通常是默认的 0)。 **动手前先查**:`Slots.listSubTree` 传那个槽位 key,看 - `kind`(single / list / keyed / chain) - `occupants[].priority`(shipped 用的值) - `catalog.description`(常常直接写了用途和"缺席时会怎样") - `replaceRisk`(`shadows-shipped-ui` = 有主,占它要付代价) **还要注意连带影响**:有些槽位是**别人注册时声明出来的子槽位**(如 `settings.trigger` 由 `sidebar.settings` 的注册声明)。遮蔽父槽位会让它声明的所有子槽位一起消失 —— 只遮蔽**子**槽位才安全。 - **细节**:这一条来自一次真实调研 —— 我一开始得出的结论是"槽位 API 只有加没有减",**那个结论是错的**,因为没读源码就下了判断。 **源码在哪**:`dsh-client-ui-slots/lib/index.js` 的 `SlotCore.register`: ```js register(options, component) { const priority = options.priority ?? 0; const occupantHint = (o) => `at priority ${priority} — register at a different priority to shadow it (lowest renders)`; switch (spec.kind) { case "single": { const occupant = rec.entries.find((e) => (e.options.priority ?? 0) === priority); if (occupant) throw new Error(`single slot "${options.name}" already has a registration …`); break; // ← 没有 occupant 就放行:不同 priority 可以共存 } case "keyed": { /* 同 key 且同 priority 才冲突 */ } case "list": { /* 同 id 且同 priority 才冲突 */ } } } ``` **三个要点**: ① 碰撞判据是 **priority**,不是 id/key —— id/key 只在**同 priority** 时用于区分 ② 不同 priority 可以共存,**最低的渲染**(shadowing) ③ 报错信息自己写了办法:"register at a different priority to shadow it (lowest renders)" **实测案例**:要把重启/关闭按钮放进侧边栏底部"账号那一行"(`settings.trigger`,shipped 用默认 priority 0)。 用 `priority: -1` 注册,**成功遮蔽**,shipped 的内容不再渲染。 **怎么查这类事实**:`Slots.listSubTree` 会给出 `kind`、`occupants`(含 `priority`)、`catalog.description` 和 `replaceRisk`(`none` / `shadows-shipped-ui`)。`replaceRisk` 就是"你占了会不会顶掉 shipped UI"的官方标记 —— **看到 `shadows-shipped-ui` 就说明这个槽位本来就有主,占它是有代价的**。 **踩过的坑**:我一开始以为要靠"复用 shipped 的 id"才能替换。查了实时树才发现那个 occupant **根本没有 id**(只有 priority),所以"复用 id"这条路是死的 —— 正解是 priority。 --- ## E21 · DSH 没有对外的重启接口:那个"报错框"其实是故障恢复框 - **症状**:你想给 DSH 加一个"重启客户端"的按钮,于是去找框架有没有重启接口 —— **找不到**,然后开始想各种绕法(杀进程、外部拉起、模拟崩溃)。 具体表现: · 搜 `relaunch` 在所有文档里 0 命中 · 拉起的进程要等很久(实测 64 秒)才可用,而 DSH 崩溃框里点「重启」只要 7 秒 · 点了"关闭"之后弹出一个**报错框**,在报错框里点「重启」反而成功重启了 —— 于是困惑:"这到底是关闭还是重启?我是不是该直接用这个框?" - **解法**:**DSH 没有对外的重启/关闭接口。** 具体地: ① 重启原语是 `app.relaunch() + quitWithoutConfirmation()`,写在 `lib/main.js` 的崩溃恢复对象里 —— **只在主进程内、只被 fail() 调用**,没有任何 IPC 暴露。 ② 全部 42 个 IPC 通道里**没有** restart / relaunch / quit / shutdown (通道清单见"细节")。`window.dshDesktop` 只有 browser / keyboard / shortcuts / updates。 ③ 所以插件能做的只有两条: · **让宿主进程结束** → 外壳判定异常 → 弹**故障恢复框** → 用户在那里选「重启」或「退出」 · 或者**从外部杀外壳**再自己拉起(要慢 9 倍,实测 64 秒 vs 7 秒,不推荐) **推荐做法**:与其自己实现重启,不如**只做"结束进程"按钮**,把重启/退出交给 DSH 的恢复框。 那个框自带两个选项、走的是官方 `app.relaunch()`、比自造方案快得多。 **要传达给用户的认知**:那个框**不是报错**,是 DSH 的正常流程 —— 外壳把"宿主进程终止"一律当异常处理,所以任何结束宿主的操作都会经过它。 这一点要写进按钮的提示文案里,否则用户会以为程序坏了。 - **细节**:**查证过程**(都在这一轮实测): ① 重启原语在 `lib/main.js` 的崩溃恢复对象里(用缩进式代码块写,别用围栏): const recovery = new DesktopFatalRecovery({ exit: () => { quitWithoutConfirmation(); }, restart: () => { app.relaunch(); quitWithoutConfirmation(); }, }); function quitWithoutConfirmation() { skipQuitConfirmation = true; app.quit(); } `recovery` 只被 `fail()` 调用(宿主进程终止时)。 ② **42 个 IPC 通道全文**(从 5 个 preload 文件的通道常量表抽出,遍历 asar 得到): preload-app.cjs(24 个) dsh-desktop: shortcuts-input / shortcuts-close-window / shortcuts-get / shortcuts-edit / shortcuts-changed / shortcuts-recording / boot / enter-workspace / onboarding-active / onboarding-api-key / boot-failed / browser-acquire / browser-release / browser-open-requested / directory-pick / locale-bootstrap / locale-changed / updates-status / updates-open / updates-presentation / native-theme-set / window-fullscreen / windows-appearance / windows-menu dsh-platform: bootstrap / locale-changed / open / bounds / close preload-welcome.cjs(7 个): dsh-welcome: save-api-key / skip / start / cancel / copy-link / state / take-notice preload-mandatory.cjs(3 个): dsh-desktop: mandatory-status / mandatory-state / mandatory-action preload-platform-account.cjs(5 个): dsh-platform: bootstrap / locale-changed / open / bounds / close preload-update-dialog.cjs(3 个): dsh-update-dialog: status / changed / respond **没有一条和 restart / relaunch / quit / shutdown 有关。** ③ `window.dshDesktop`(= preload 里的 `createProductApi()`)全文只有四块: `protocolVersion` / `browser` / `keyboard`(closeWindow, subscribe)/ `shortcuts`(get, edit, recording, subscribe)/ `updates`(status, open, subscribe)。 **没有任何生命周期控制。** **一个容易搞混的点**:`fail()` 的两个分支**都会弹框**: child.once("close", (code) => { if (code !== 0 && code !== null) this.fail(`exited with ${code}`); else this.fail(`host stopped`); // code === 0 也 fail }); 所以"让宿主干净退出"也躲不掉弹框。而 `stopping` 只有外壳**自己**发起的关闭才会置位(`stop()` 里),插件无法触发。 **自己造重启的实际代价**(实测日志):`taskkill /PID <外壳> /F` + 外部 spawn 新实例 → 大约 64 秒才恢复可用;而走恢复框的 `app.relaunch()` 只要约 7 秒。**慢 9 倍。** **结论**:不要自己造重启按钮。要么接受"用恢复框"(更快、官方),要么去掉这个功能。 --- ## E22 · "搜 0 命中"要分清:框架没有 vs 我的搜法不对 - **症状**:你按"搜 0 命中 = 框架不支持 = 停止尝试"的规则做了一次搜索,得到 0 命中,于是下了"框架没有这个能力"的结论 —— **但那个结论是错的**,只是你的搜索模式没匹配上。 具体表现: · 用某个具体的字符串去搜源码,0 命中 · 于是告诉用户"框架不支持这件事" · 后来换一种搜法,发现**有几十处命中** · 前后结论矛盾,前面那次已经把方案带偏了(比如放弃了本来可行的路) **危险信号**:你的结论是"没有",而证据**只有一次字符串搜索**。 - **解法**:**"搜 0 命中"要先分类,再决定要不要下结论:** | 情况 | 判据 | 该怎么办 | |---|---|---| | **框架真的没有** | 换了多种搜法、搜了源码和数据定义处都找不到;或文档里**明确说了**不支持 | **停止尝试**,如实报告(这条规则本身没错) | | **我的搜法不对** | 只试了一种字面量匹配;代码是压缩/多行/用变量传参的;结论和别的证据矛盾 | **换搜法**,别急着说"没有" | **换搜法的具体手段**: · 放宽空白与换行:`ipcMain.handle(` → `ipcMain\.(handle|on)\s*\(` · **从数据定义处反查**,而不是从使用处正查 —— 这条对"枚举类"问题最有效 (例:要列出所有 IPC 通道,就读各 preload 里的通道常量表,而不是搜 `ipcMain.handle` 的调用点) · 搜兄弟标识符(搜 `IPC`、搜 `channel`,而不是搜某一个具体调用) · 用独立的第二种工具交叉验证(不同的搜索脚本 / 不同的文件范围) **关键警告**:**"搜不到"和"不存在"是两件事。** 我自己写下的规则被我自己误用了 —— 规则本身(0 命中 = 不支持 = 停止)是对的, 但它**预设了"搜索是可靠的"**。搜索不可靠时,那条规则会把人引向错误结论。 - **细节**:**这次的具体教训**(发生在同一轮里,前后矛盾): 我先搜 `ipcMain.handle(` 后面紧跟字符串的写法 → **0 命中**,于是下了结论: "主进程没有注册任何 IPC 处理器,所以插件触达不到重启能力。" 后来换成**宽松匹配**(`ipcMain.(handle|on|removeHandler)\(`,允许任意空白和换行),结果: ipcMain.handle ×27 ipcMain.on ×5 ipcMain.removeHandler ×6 **32 个注册,一个都没被我第一次搜到。** **为什么**:那段代码里通道名是**变量**(`DESKTOP_IPC.boot` 这种常量), 而且格式化成多行,所以我那个"函数名 + 左括号 + 紧贴引号"的模式一个都匹配不上。 **不是框架没有,是我的模式太紧。** **修正后的正确做法**:要查"框架暴露了哪些 IPC",应该 **从通道名常量表反查**,而不是猜注册点的写法。我最后是从 5 个 preload 文件里 抽出 `DESKTOP_IPC` / `PLATFORM_IPC` 等常量对象,得到完整的 42 个通道名 —— 那才是权威清单。 **一个可操作的判据**:结论如果是"**没有**",而你的证据只是一次字符串搜索, 那就**换至少一种搜法再确认**:放宽空白、用正则替代字面量、搜相关的兄弟标识符 (如搜 `IPC` 而不是搜 `ipcMain.handle`)、或直接从"数据的定义处"反查而不是从"使用处"正查。 --- ## E23 · 开工前定需求:四要素对齐 → 规划 → 再确认才动手 - **症状**:用户说"我想做一个什么什么功能",你**直接就开始规划了** —— 而最基本的信息根本没对齐: · 它叫什么名字?(决定了仓库名、目录名) · 用途一句话说得清吗? · 谁在什么场景下用它? · **代码放哪个工作空间?** 那里有没有 `AGENTS.md`? 后果:规划跑偏(做的不是用户要的)、做完才发现放错目录、 或者下一轮进那个目录时什么都不知道(因为没写约定文件)。 **更隐蔽的一种**:你写了一份规划,用户回「确认」—— **但你不确定他确认的是"需求"还是"你的规划"**,于是直接开工了。 - **解法**:**用户提出"我想做一个 XX"时,先走这一关,再谈规划:** **① 对齐四要素**(名字 / 用途 / 使用场景 / 工作空间) 不要把四要素做成四次顺序问答。做法:能从上下文推断的先填好、标注"(推断)", 只问真正缺的,整理成清单**在对话里发出来**请用户确认。 只记用户说的,想加功能先提建议等他点头。 **② 一次确认只授权一步** 用户确认四要素后**只做规划**,规划再确认才动手。 打「确认」时确认的是四要素,不是规划 —— 别合并成一步。 **③ 工作空间不越权** 用户没提就**不要自动新建**,先问或给建议路径让他选。 要新建时顺手放一份 `AGENTS.md`(目录结构 / 验证命令 / 已知的坑)。 **④ 落进文件,否则下个会话就忘了** 项目专属约定 → 那个工作空间的 `AGENTS.md`;通用方法论 → 经验库。 **关键实现选择**:这条规则放进**注入的系统提示词段落**,不是只写经验库 —— 因为经验库是按需查阅的,而这是每次都要走的关卡(见 E19)。 - **细节**:**为什么必须有这一关**:自检清单管的是"怎么动手"(查文档、查槽位、抄实现), 但管不到"动手前需求定没定"。实测的失败模式是:用户说"我想做一个 XX 功能", 我直接开始规划 —— 而四要素(名字/用途/场景/工作空间)根本没对齐, 结果规划跑偏,或者做完才发现放错了工作空间。 **三个设计要点**(都是踩过才定的): **① 四要素是"对齐",不是"表单"** 不要走四次顺序问答 —— 中途取消会丢掉前面填的,而且不能回改。 正确做法:能从上下文推断的先替用户填好并标注"(推断)",只问真正缺的, 然后**一次整理呈现**。用户回修改意见时,改完重新发一遍,不要自己脑补后续。 **② "确认"必须有明确对象** 用户打「确认」时,确认的是**四要素**,不是你的规划。 如果合并成一步(确认后直接开工),就失去了"规划也过一次目"的机会。 所以:**一次确认只授权一步** —— 需求确认 → 规划 → 再确认 → 开工。 **③ 工作空间不要越权** 用户说"我想做个导图工具"时**自动建目录是越权**,而且可能建错位置。 只有在用户明确指定、或明确说"你看着办"并同意建议路径时才建。 要建时顺手放一份 `AGENTS.md`(目录结构 / 验证命令 / 已知的坑)—— 没有它,下一轮进这个目录就什么都不知道。 **为什么进"注入"而不是只写经验库**: 经验库是**按需查阅的资料** —— 我得先意识到"该查"才会去查。 而这条是**每次都要走的关卡**,必须强制执行(见 E19:规则要变成结构)。 **写法要求(这条也踩过)**:第一版工作流写了 1054 字符,把"为什么"也塞进去了, 结果注入总量超预算(6641 > 6000)。**它是清单,不是文档** —— 只留可执行的动作,理由写进代码注释和经验库。压缩后 644 字符。 **省下来的空间从哪来**:把经验索引的"解法"截断从 100 字降到 60 字 ("症状"仍保留 100 字,因为症状用来"对上号",解法只用来判断值不值得读全文)。 索引 4414 → 3670,用省下的空间换工作流,净省 100 字符。 --- ## E24 · 读文件工具不存在时不要凭印象调用 - **症状**:想列目录却调用了 `glob`,返回 unknown tool;同一会话里实际上根本没有 read / grep / shell 这类文件工具。 - **解法**:先看本次会话实际暴露的工具清单再动手;清单里没有就不要反复换名字试,直接如实告诉用户"我没有文件访问能力",并请他贴出内容或改用别的方式。 --- ## E25 · 用 select 绑 preset:resume 后 agent 丢掉几乎全部工具(能回话但干不了活) - **症状**:机器人(或任何自己创建的 agent)**能回话,但说它没有文件/命令工具**,干不了活。 实测对话: 用户(手机): 请查看我 D 盘有哪些文件 机器人 : 查不了 —— 本会话没有文件/命令工具(只有 load_workspace_dependencies 和 record_experience) 而**同一台机器上 GUI 会话的工具是齐的**(30 个),所以不是权限问题、也不是配置问题。 **数字上的特征**(这是最快的判据): 首次创建 工具 28 个 resume 之后 工具 2 个 ← 只剩全局层注册的那两个 **日志里的伴随现象**(容易被当成无害噪音而忽略): preset-select-failed "This session has already started" 这条错误从第一次 resume 起**每次都会出现**,但功能上似乎"还能回话", 所以很容易被当成良性告警放过去 —— 实际上它**就是丢失工具的原因**。 - **解法**:**用 `agentPresets.mount(agentCtx, id)` 绑 preset,不要用 `select(agent, id)`。** `select` 的契约是"只在会话还没跑过第一轮时有效"("before a session starts its first turn"), 所以**任何 resume 过的会话都会 select 失败**,然后静默地只剩全局工具。 正确写法(照抄 `dsh-webhook`,它就是这么做的): const preset = await ctx.agentPresets.resolve(presetId); // 先解析出本体 // setup 回调里(它在 agent 发布之前运行): await ctx.agentPresets.mount(agentCtx, preset.id); // ← mount 不是 select `mount` 的契约是 "Bind an **unpublished** Agent to the current preset revision" —— setup 回调正好在发布之前,所以 create / resume 两条路都能绑上。 **另外要做的三件事**: 1. **绑完做核对**(能提前发现没绑上): const composed = ctx.agentPresets.composedPreset(agentCtx); // composed 应该是那个 preset id;undefined 就是没绑上 2. **mount 失败要显式记日志并点明后果** —— 不要像 `select` 那样只是记一条 "preset-select-failed" 就完事(它会让人以为是无害噪音) 3. **验证方法**:跑一轮之后数 `request/header.tools.length`, 或者直接问 agent "你有哪些工具"。**数字从 28 掉到 2 就是这个问题。** **排查手法(值得单独记)**:症状是"模型说它没有某个工具"时, **不要读代码猜,直接数会话日志里 `request/header` 事件的 `tools` 数组**: seq=13 reason=initial 工具 28 seq=30 reason=resume 工具 2 ← 从这里开始丢的 (主会话是 reason=series,从不 resume,所以从不丢 —— 这是有用的对照组) - **细节**:**这个 bug 最阴的地方:症状和根因看起来完全无关。** 日志里一直有一条被当成"无害噪音"的错误: preset-select-failed "This session has already started" 而它的真实后果是**agent 丢掉了 26 个工具**。 **怎么定位的(方法本身值得记)**: 不看日志、不看代码,而是**直接数会话请求里带的工具** —— `session` 日志的 `request/header` 事件里带 `tools` 数组: seq=13 reason=initial 工具 28 个(pwsh/read/write/edit/glob/grep 都在) seq=30 reason=resume 工具 2 个 ← 全丢了 seq=42/53/98 resume 工具 2 个 然后**横向对比**另外两个会话(对照组): 主会话 initial 30 → series 30(80 次请求,从不丢) 用户新会话 initial 28 → change 28(不丢) QQ 专用会话 initial 28 → resume 2,2,2,2 ← 只有它丢 **关键推论**:主会话用的是 `reason=series`(同一轮内连续请求), **从来没经历过 resume** —— 所以对照组恰好说明"是 resume 引起的"。 **为什么是 2 个而不是 0 个**: 残留的那两个工具是**全局层注册**的: · `load_workspace_dependencies`(agent 自带) · `record_experience`(dev-tools 插件在全局注册) 而 preset 那 26 个工具是在 **preset 的插件组**里注册的 —— 组没挂上,它们就都不存在。 **契约差异(这是根因)**: select(agent, id): "Select a preset **before a session starts its first turn**" mount(ctx, id): "Bind an **unpublished** Agent to the current preset revision" `setup` 回调是在 agent **发布之前**跑的 → 所以 `mount` 对 create 和 resume **两条路都有效**,而 `select` 只对"还没跑过第一轮"的 blank 会话有效。 **正确写法在 `dsh-webhook` 里**(照抄它,别自己拼): const preset = await ctx.agentPresets.resolve(id); await ctx.agentPresets.acquireScope(preset.id); await ctx.agents.create({ sessionId, meta: { cwd, agentPreset: preset.id }, setup: async (agentCtx) => { await ctx.agentPresets.mount(agentCtx, preset.id); }, }); **排查这类问题的通用手法**:症状是"模型说自己没工具"时, **不要去读代码猜,直接数 `request/header.tools`** —— 那是运行时的事实, 而且能看出是"从头就没有"还是"某次之后开始没有"。 --- ## E26 · workspace-write 沙箱初始化失败时,所有 shell 调用报 grantWrite Win32 5 - **症状**:当前会话文件策略是 workspace-write 时,**任何** pwsh 调用都返回 Error: SetNamedSecurityInfoW failed (Win32 5): grantWrite(<工作区路径>) 连 `'ping'` 这种空命令也一样。错误里带的是**工作区路径**,不是你要访问的路径, 所以看起来像"D 盘不让读",其实和命令内容、目标路径完全无关 —— 沙箱在**建 ACL** 那一步就挂了。 误判点有两个: · 以为是权限拒绝(真拒绝的长这样:`[sandbox: file access denied under mode]`) · 以为换个读文件工具就行 —— glob 也救不了:给绝对路径模式(如 path=`D:\`、pattern=`D:/*`) 并不能把深度锚在根目录,它会从工作区**全盘扫描**,30 秒超时(实测两次都超时)。 - **解法**:先分类,别重试: ① 报的是 `SetNamedSecurityInfoW ... grantWrite()` → **沙箱初始化失败**, 重试同一条命令不会有任何变化(实测 4 次全同错)。不要连续重试。 ② 报的是 `[sandbox: file access denied under ...]` → 那才是策略拒绝。 处理:把本次会话的文件策略改成 danger-full-access(由用户/宿主侧切换), 改完**同一条命令立刻成功**。切换后注意:approval 提示也被禁用了, 不要再传 `sandbox_permissions`(会被自动拒绝:approval prompts are disabled)。 如果必须留在 workspace-write:查工作区目录的 ACL 归属 (Win32 5 = 拒绝访问,常见于目录属主是另一个账户 / 继承被禁用), 让沙箱能改它的 ACL,而不是去修命令。 - **细节**:实测(DSH 桌面端 + Web GUI,工作区 D:\EasyDSH,2026-09-26): · workspace-write 策略下,`Get-ChildItem D:\`、`'ping'` 全部报同一个 grantWrite 错。 · 期间试过 `sandbox_permissions: danger-full-access` 做一次性提权,两次都被中断 —— 该会话的 approval 通道不可用,别指望这条路。 · 宿主把策略切成 danger-full-access 后,**同一条 `Get-ChildItem -LiteralPath 'D:\'` 一次成功**。 教训和 E24 同源:先看这次会话**实际能不能用**某个工具,再决定要不要用它解决问题; 工具报的错要分清是"我被拒绝"还是"工具自己没起来"。 --- ## E27 · 用 1.1.1.1:443 探出网会假失败,别据此报"网络不通" - **症状**:用 `Test-NetConnection -ComputerName 1.1.1.1 -Port 443`(或 8.8.8.8)探"能不能出网", 得到 `FAIL`,于是写下"出网不通 / 被拦"——**结论是错的**。 同一时刻 `Dns.GetHostAddresses('www.baidu.com')` 正常返回 3 个地址, `www.baidu.com:443` / `www.qq.com:443` / `github.com:443` 三个 TCP 探测全 OK, `Invoke-WebRequest https://www.baidu.com` 直接 `Status=200`。 也就是说:**只有 1.1.1.1 这一个目标不通,网络本身是好的。** - **解法**:别拿单一 TCP 探针敢下"网络不通"的结论 —— 先换目标主机交叉验证: ① `Test-NetConnection 1.1.1.1 -Port 443` **不能**当出网判据(国内网络常拦,会给假 FAIL); ② 换成国内可达主机再测:`Test-NetConnection www.baidu.com -Port 443`、`www.qq.com`; ③ 最终以**应用层**为准:`Invoke-WebRequest -Uri 'https://www.baidu.com' -UseBasicParsing -TimeoutSec 15` 拿到 `Status=200` 才算通(有 TCP 也可能被中间设备复位)。 报告时写清"哪一条探针失败、用哪一条对过" —— 不要从一条 FAIL 推出"出网不通"。 - **细节**:实测(本机 Windows,2026):脚本探出 `1.1.1.1:443 FAIL`,我差点就在报告里写"出网不通"。 紧接着换成 `Test-NetConnection www.baidu.com -Port 443` + 实际 HTTP GET,第一次就 status=200。 · 参考判据(同一次实测):`localhost:<端口>` 可连 = 本机服务活着; `Dns.GetHostAddresses` 有返回 = 解析没问题;这两条都过而 TCP 探测失败时,**先换目标主机再下结论**。 · 不要用 ICMP ping 当判据(Windows 防火墙默认丢 ICMP,Linux 也常关)。 --- ## E28 · 长任务被审批框反复打断:审批策略由权限预设决定,且一次授权只生效一次 - **症状**:跑一个多步任务时,**每一步都被审批框拦住**,而且: - **每次只能批准当前这一次**,点了"同意"下一步又来 - 弹框里的理由长得像 `escalate sandbox to danger-full-access: <工具名> <参数摘要>` - 最常见的是 `plugin_manager`(**连 `list_plugins` 这种只读操作都会问**)、 以及任何带 `sandbox_permissions` 的 `pwsh` / `write` / `edit` **用户的真实抱怨不是"那一下点击",而是"任务被切成一段段"** —— 原话:"任务过程中一直在等待审批,每次也只能审批当前一次,这个有解法吗?可以不让他弹审批框吗?" **判据(先查这个,别急着猜)**:看会话事件流最前面三行: seq=0 permission/preset {"preset":"workspace-write"} seq=1 sandbox/mode {"mode":"workspace-write"} seq=2 approval/policy {"policy":"ask"} ← policy=ask 就是原因 内置预设表里 `workspace-write` 的 approval 就是 `ask`,而**它又是内置默认值**, 所以新会话开出来就是"每步都问"。 **⚠ 容易误判的地方**:不同会话的策略**互相独立**。 我曾给"QQ 机器人"那条会话设过 `danger-full-access`,看到它的日志是 `approval: never`, 就以为问题解决了 —— 而**用户正在用的 GUI 会话仍然是 `workspace-write` + `ask`**,照样每步都问。 - **解法**:**审批策略由"权限预设"决定,不是逐次可配置的** —— 所以要换预设,不是找"同意并记住"。 **改当前会话**(唯一能改正在跑的会话的办法,自己在输入框敲): /permission danger-full-access 它来自 `PermissionPresetsService.current()`,空参 `/permission` 会报当前预设和所有可选项。 **改以后的新会话**:界面里 **设置 → 权限**(`settings.permission` 槽位, `ui-permission-presets` 提供),选「完全权限」→ 确认框里点「启用完全权限」。 那一节的说明原文就是"**选择新会话的默认权限模式**"。 **为什么不建议由 agent 代改**:`defaultPreset` 的 schema 标了 `.volatile()`, **不落 `cordis.patch.yml`** —— 它走 DSH 自己的设置存储。 而手改 profile 的 `cordis.patch.yml` 是禁区(会触发"禁用第三方插件"逻辑、连带重置用户设置)。 **想免打扰但保留审查**:装 `@deepseek-ai/dsh-experimental-auto-review`(DSH 自带、默认关着)。 它把审批交给模型判:普通项目内工作自动放行,删除已存在对象/生产操作/外部写入才问, 跨信任边界外发敏感数据永远拒绝。 本机版本已确认兼容(cordis 4.0.4 满足它要的 `~4.0.4`,peer 全是 `0.2.0-rc.2`)。 代价:实验性、可能误判、每次工具调用前多跑一次模型判断(多花 token 且变慢)。 **⚠ 各会话的策略互相独立**:给 QQ 机器人那条线设了 `never`,不等于你正在用的 GUI 会话也设了。 要分别查(看会话日志 seq 0~2 的 `permission/preset` / `sandbox/mode` / `approval/policy`)、分别设。 - **细节**:**完整机制(查证来源:`dsh-permission-presets` 的 README 与 `approval` 服务的 inspect 契约)** 审批策略由**权限预设**决定,预设是 `sandbox × approval` 的组合: 预设名 sandbox approval read-only read-only ask workspace-write workspace-write ask ← 内置默认,所以新会话默认是"每步都问" danger-full-access danger-full-access never ← 不弹 auto(实验性) danger-full-access ask ← 靠"自动审查"免打扰 **"一次审批只授权一次"是设计如此。** `approval` 服务的契约里写得很明确: @returns the closed outcome; 'allowed-once' is the only grant. `ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'` 所以**没有"记住本次会话"这个选项**,也没有"以后都同意"的按钮 —— 每步都要重新问。 **怎么查当前的策略**(只读,很快): # 会话的事件流里,seq 0/1/2 就是初始策略 grep -l "approval/policy" 会话日志 实测当前会话: seq=0 permission/preset {"preset":"workspace-write"} seq=1 sandbox/mode {"mode":"workspace-write"} seq=2 approval/policy {"policy":"ask"} ← 就是它 seq=38 approval/asked toolName=plugin_manager reason="escalate sandbox to danger-full-access: …" **触发审批的典型工具**:`plugin_manager`(任何 action,包括只读的 `list_plugins`)、 以及任何带 `sandbox_permissions` 的 `pwsh` / `write` / `edit`。 **三条解法(按"不打断的彻底程度"排)** ① 当前会话立即不弹 —— 用命令(**这是唯一能改"正在跑的会话"的办法**): /permission danger-full-access # 直接切 /permission # 空参:报当前预设 + 所有可选项 ② 以后新会话默认不弹 —— 在界面里改: 设置 → 权限 → 选「完全权限」→ 确认框点「启用完全权限」 界面上那一节的原文(`ui-permission-presets` 的 zh 字典): title: 权限 description: 选择新会话的默认权限模式 preset.readOnly 仅可查看 preset.workspaceWrite 工作区内修改 preset.fullAccess 完全权限 confirm.description 启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作, 包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。 **⚠ 为什么②必须用户在界面点,不能代办**:`defaultPreset` 的 schema 标了 `.volatile()`, 它不落 `cordis.patch.yml`,走的是 DSH 自己的设置存储(`permission` 命名空间)。 而手改 profile 的 `cordis.patch.yml` 是明令禁区(会触发"禁用第三方插件"那套逻辑,连带重置用户设置)。 ③ 想"免打扰但保留审查" —— 装 DSH 自带的自动授权审查: 包名:@deepseek-ai/dsh-experimental-auto-review 位置:asar 内 dsh/node_modules/@deepseek-ai/dsh-experimental-auto-review(默认关着) 它的行为(README 原文大意): 普通项目内工作、清理本次会话自己建的东西 → 低风险 → 自动放行 删除已存在的对象、生产操作、外部写入 → 中风险 → 才问用户 跨信任边界外发敏感数据 → 高风险 → 永远拒绝 **兼容性实测**(本机 dsh-desktop 那条线): asar 内 cordis 4.0.4 auto-review 要 ~4.0.4 ✓ asar 内 dsh-permission-presets 0.2.0-rc.2 auto-review 要 0.2.0-rc.2 ✓ asar 内 dsh-tools / dsh-llm 0.2.0-rc.2 同上 ✓ 开启方式:Web 侧边栏的 Plugins 页,或 `pnpm dsh plugin --profile web add ./packages/experimental/auto-review`。 装好后在权限选择器里选带 EXP 角标的「自动授权审查」。 代价(README 自己写明):experimental,可能放行不安全操作 / 拒绝有用工作,且**每次工具调用前多跑一次模型判断(多花 token、每步变慢)**。 **⚠ 一个容易搞错的地方**:不同会话的审批策略**互相独立**。 我先前给"QQ 机器人"那条会话设了 `danger-full-access`,于是它的日志里是 `approval: never`; 但**用户自己用的 GUI 会话仍然是 `workspace-write` + `ask`**,两条线互不影响。 所以"我已经给某个会话设过不弹了"**不等于**"用户当前会话不弹" —— 要分别查、分别设。 **这条的通用教训(比审批本身更重要)**: "反复问、每次都被答可以"的审批,**真正的代价不是那一下点击,而是它把长任务切成了一段段**, 每一段都要用户重新进入上下文。所以遇到这种症状,应该**先去查"策略在哪里配"**, 而不是接受"它就是这样的"。而这次的答案在包里 README 里写得非常清楚 (`## Use this package` → `### Switching presets` / `### Session defaults`), 我却先去读了源码 —— 又一次踩了"先读源码、后读文档"的老毛病。 --- ## E29 · 中文输入法下快捷键静默失效,且 preventDefault 拦不住字符 - **症状**:插件里监听某个键(例如 Tab 上方的反引号键),**英文状态连按/单按都正常;一开中文输入法就完全没反应,而且那个字符照样被插进输入框**。如果代码里用 `event.keyCode === 229` 当"输入法正在组字"的判据,问题更彻底:中文输入法开着时**每一次** keydown 的 keyCode 都是 229(`event.key` 是 `'Process'`),于是整个快捷键"只有英文状态才生效"。这条判据看着很合理,所以很难自己想到。 - **解法**:① 不要用 keyCode 229 判组字:只判 `event.isComposing === true`,另外监听 `compositionstart` 清状态。 ② 中文状态下 `preventDefault()` **拦不住**输入法插字符(keyCode 229 的键由系统输入法处理),所以"先吞掉按键再处理"这套思路在中文状态必然失效。两条出路: · 换修饰键组合(Ctrl / Alt + 键),输入法不接管; · 或按**证据**判断字符有没有被插进来再决定怎么办:用 `useInput((s) => s.draft)` 读草稿,若"长度正好 +N 且那 N 个字符内容对得上这次按的键",就认定是输入法插的 → 单按时**跳过补回**(否则变成两个字符)、连按时**连这 N 个残留字符一起替换**(`insertText(text, { start: n - N, end: n, draftRev })`)。 ③ 排查手段:在 keydown 里临时 `console.log(JSON.stringify({ code, key, keyCode, isComposing }))`,用 F12 控制台取真实参数 —— 输入法行为在外部观察不到,只能这样取证。 - **细节**:实测环境:DSH 桌面端 + Web GUI(Windows,中文输入法),2026-10-07,dev-tools 的「需求模板快捷键」(连按两次反引号插模板)。 现象出现顺序(根因不同,别混): · 先报"只有英文状态能用" → 根因是 `keyCode === 229` 判据; · 去掉该判据后英文正常,中文**仍不触发**且字符照插 → 根因是 preventDefault 拦不住输入法。 ⚠ 中文那条路径**最终没有验证通过**。代码里保留了"按证据处理"的逻辑,但设置页说明已改成"请先用英文状态",不要当成已可用。要修就得先取到中文状态下 keydown 的真实参数(上面第③条),这必须由能操作输入法的人来做。 --- ## E30 · 往 DSH 对话输入框写文本:用官方 InputActions,别改 DOM - **症状**:想往对话输入框里插一段文本(模板、引用、命令、按钮填内容),第一反应是找 textarea / contenteditable 改 `value` —— 在受控编辑器上会"看着有字、发出去是空的"(React 不认这次改动)。也可能反过来:以为框架没提供写入口,于是放弃或去绕。 - **解法**:官方就有写入 API:**`inputActions`**,由 conversation 侧的 slot 组件通过 standardProps 拿到(`conversation.composer.dock`、`conversation.composer`、`conversation.input.*` 等都带它)。 · `inputActions.captureInsertion()` —— 捕获**草稿选区与版本**,返回 `{ start, end, draftRev }`(detect 坐标) · `inputActions.insertText(text, span)` —— 插入一次**可撤销**的纯文本编辑;仅在 `span.draftRev` 仍是当前版本、且编辑器允许编辑时生效,否则返回 false(**调用方要保留结果等用户操作,不能当成功**) · 把 span 的 `start`/`end` 拉开就是**替换**那段文本(用它可以把别的残留字符一次替换掉) · 还有 `setDraft(text)` / `addAttachments` / `removeAttachment` / `pruneAttachments` / `submit` · `insertText` 与 `captureInsertion` 必须**同一个 session 的同一份 inputActions**,且要在拿掉它之前判断状态(比如先算好 span 再清 pending) - **细节**:契约原文(asar 内 `@deepseek-ai/dsh-client-ui-conversation/README.zh.md`,用 diag-search-docs.mjs 搜 `captureInsertion` 即可命中): "InputActions.captureInsertion() 捕获草稿选区与版本;insertText(text, span) 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。" action 面本身可以从 app.asar 里直接读出来(`diag-find-impl.mjs captureInsertion`),实现长这样: insertText: (text, span) => { if (phase === 'adjudicating' || phase === 'submitting' || disposed) return false; if (span.draftRev !== this.rev) return false; return this.draftEditor.insertAsyncText(span, text); } 实测:dev-tools 的「需求模板快捷键」用它把模板插进光标处,一次编辑、可撤销,英文状态下工作正常(中文输入法那条路径见 E29)。 --- ## E31 · 查槽位 props 契约:owner props 与 standardProps 是两回事,契约表在 runner 里 - **症状**:想知道某个 slot 的组件到底能拿到哪些 props —— 于是去源码里看 render 调用点,看到 `renderSlot('conversation.composer.dock', {})` 里是空对象,就断定"这个槽位拿不到 `inputActions`/`sessionId`",**于是放弃了一个本来可行的方案**。另一种表现:本会话没有 `cordis_inspect_query` 工具,于是完全不知道该去哪查槽位契约,只能靠试。 - **解法**:① 有 `cordis_inspect_query` 时用它查 `Slots.listSubTree`(见 E10)。 ② **没有这个工具时**:全部 90 个槽位的完整契约被编译进了 `dsh/node_modules/@deepseek-ai/dsh-cordis-client-runner/lib/client.js`,每条含 `key / kind / scope / summary / registerOptions / ownerProps / ownerPropsReferences / standardProps / keyDomain / declaredBy / occupants / replaceRisk / example / source`。用现成脚本导出即可(关键词可中文): node D:\DSH-dev\tools\diag\dump-slot-contract.mjs [关键词] 结果同时写入同目录 slot-contract-dump.txt。该脚本直接 `readFileSync('D:\\DSH\\resources\\app.asar')` 解析 asar 头(node 读 asar **文件**没问题,只是不能当目录用)。 ③ **关键区分**:`renderSlot(slot, {...})` 里那个对象是 **owner props**;**standardProps 是另外合并进来的**(`inputActions`、`sessionId`、`useInput`、`useConversation`、`useSession` 等一大批都在 standardProps 里)。所以 `renderSlot(..., {})` **不代表组件拿不到标准 props**。 ④ 顺手看 `kind`:`list` 用自己的 id 就是**并列新增**(不覆盖已有条目);`chain` 要提供 `select` 选择器,返回 null 表示不接管;`single` 只能有一个占据者(要遮蔽先读 E20,并搞清占据者负责什么)。 - **细节**:实测(2026-10-07):`conversation.composer.dock` → `kind: "list"`、`scope: "session"`、`ownerProps: []`、`standardProps` 含 `inputActions: InputActions` 与 `sessionId: SessionId`、`occupants: ["client-ui-chat StatsPills id 'stats'"]`、`replaceRisk: "none"`,契约里连 `example` 和 `source` 行号都有(`packages/client/ui-conversation/src/client/contract/slots.ts:199`)。 误判过程:先看到实现里 `renderSlot("conversation.composer.dock", {})` 传的是空对象,就据此下了"这个槽位拿不到 inputActions"的结论,差点把方案换掉。实际那个 `{}` 只是 owner props。**结论:判断"拿不到某个 prop"之前,必须查 standardProps 列表,不能只看 render 调用点。** --- ## E32 · git 全局代理指向已关闭的端口:所有 git 操作失败但网其实是通的 - **症状**:**网络明明是通的,但所有走 git 的操作都失败** —— 而且报错信息把原因说成"代理": $ git ls-remote https://github.com//.git fatal: unable to access 'https://github.com/...': Failed to connect to github.com:443 over proxy 127.0.0.1 after 2054 ms: Could not connect to server **同一台机器上换个客户端就通**(这是判断"不是网络问题"的关键): curl https://api.github.com/... → 200 node -e "fetch('https://raw.github...')" → 200 # 甚至:不钉版本的 dsh plugin add https://github.com/... 也能成功 **最容易上当的表现**:`git fetch` 失败后,`git status` / `git rev-list --count` **仍然能跑**,并给出"本地领先 0 / 落后 0" —— 那是**拿上次 fetch 的缓存**比出来的。 我差点据此报"本地与远端一致"。 **另一个特征信号**:pnpm 装 `github:owner/repo#tag`(钉版本的)**失败**, 而装 `github:owner/repo`(不钉版本的)**成功**: [ERROR] git ls-remote "https://github.com/owner/repo.git" v1.1.0 "v1.1.0^{}" fatal: unable to access ...: over proxy 127.0.0.1 原因是"钉版本"需要 `git ls-remote` 解析标签,而"不钉版本"可以走 codeload 那条 HTTPS 路。 - **解法**:**先分清"网络不通"和"git 的代理配置坏了"——它们的表现一模一样。** **判据(一定要做交叉验证,别拿 git 当唯一探针)**: # git 失败 git ls-remote https://github.com/owner/repo.git # → over proxy 127.0.0.1 连接失败 # 但换个客户端就通 —— 这说明网络没问题,是 git 自己的配置 curl -s -o NUL -w "%{http_code}" https://api.github.com # → 200 node -e "fetch('https://raw.githubusercontent.com/...').then(r=>console.log(r.status))" # → 200 **查出根因**: git config --global --get http.proxy # 例如 http://127.0.0.1:7897 # 再看那个端口有没有人在监听:**这一步是关键** Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -eq 7897 } # 空结果 = 代理软件没开,而 git 还在往死地址发 **修法**: ① **单条命令绕过代理**(最安全,不改任何配置): git -c http.proxy= -c https.proxy= push origin main git -c http.proxy= -c https.proxy= push origin v1.1.0 ② **临时清掉全局代理**(当别的工具也要用 git 时,例如 `pnpm` 装 `github:...#tag`): git config --global --unset http.proxy git config --global --unset https.proxy # …跑完 … git config --global http.proxy 'http://127.0.0.1:7897' # 还原 ③ **永久删掉**(若代理软件是间歇性开的,那这个配置本来就是个隐患): git config --global --unset http.proxy git config --global --unset https.proxy **⚠ 两个试过但无效的绕法,别再花时间**: # ✗ 用环境变量传空值 —— PowerShell 会吃掉空字符串 # 报 "error: missing config value GIT_CONFIG_VALUE_0" $env:GIT_CONFIG_COUNT='2'; $env:GIT_CONFIG_VALUE_0='' # ✗ http.noProxy='*' —— **压不住显式配置的 http.proxy**(实测仍走代理) $env:GIT_CONFIG_KEY_0='http.noProxy'; $env:GIT_CONFIG_VALUE_0='*' **最后一条**:`git fetch` 失败之后,**不要用 `git status` / `git rev-list --count` 判断"是否落后"** —— 它们会拿上次 fetch 的缓存给答案,看起来一切正常,实则完全不知道远端状态。 此时要看远端就直接问 API(`https://api.github.com/repos///commits/main`)。 - **细节**:**症状的完整形态**:一个矛盾 —— 网络明明是通的,但所有走 git 的操作都失败。 $ git fetch fatal: unable to access 'https://github.com/...': Failed to connect to github.com:443 over proxy 127.0.0.1 after 2054 ms # 但同一台机器上: curl https://api.github.com/... → 200 node -e "fetch('https://raw...').then(...)" → 200 dsh plugin add https://github.com/... → 成功过(还没加 #tag 时) **关键错误是**:`git fetch` 失败后,`git status`/`git rev-list` 仍然能跑, 于是输出"本地领先 0 / 落后 0"—— 那是**拿上次 fetch 的缓存比出来的**。 我差点据此下"本地与远端一致"的结论。 **根因**:git 的全局代理指向一个**没在监听**的端口。 $ git config --global --get http.proxy http://127.0.0.1:7897 $ git config --global --get https.proxy http://127.0.0.1:7897 # 而 7897 上没有任何进程: Get-NetTCPConnection -State Listen | Where LocalPort -eq 7897 → 空 代理软件(Clash/v2ray 那类)不常驻,而 `git config --global http.proxy` 是**持久**的, 所以关机再开机、或退出代理软件之后,git 就一直在往死地址发。 **怎么快速确认(只读,别急着改配置)**: # ① 看 git 配了什么 git config --global --get http.proxy git config --global --get https.proxy # ② 看那个端口有没有人监听(这一步是关键) Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -eq <端口> } # ③ 交叉验证"网络本身通不通"—— 别用 git 当探针 curl -s -o NUL -w "%{http_code}" https://api.github.com **③ 很重要**:`git` 失败 ≠ 网络不通。git 只是一个客户端, 它的失败可能全是自己的代理配置造成的。 **解法(三种,按侵入性排序)** ① 一次性:给单条命令绕过代理(`-c` 覆盖) git -c http.proxy= -c https.proxy= push origin main git -c http.proxy= -c https.proxy= push origin v1.1.0 ② 让别的工具也用得上:临时清掉全局配置,用完还原 $orig = git config --global --get http.proxy git config --global --unset http.proxy git config --global --unset https.proxy # … 跑需要 git 的命令(pnpm 装 github: 依赖就走这条路)… git config --global http.proxy $orig git config --global https.proxy $orig ③ 永久修(推荐,前提是代理软件经常不开):直接删掉 git config --global --unset http.proxy git config --global --unset https.proxy **⚠ 两个走不通的路,别浪费时间去试** # ✗ GIT_CONFIG_VALUE_0='' —— PowerShell 会把空字符串吃掉, # 然后 git 报 "missing config value GIT_CONFIG_VALUE_0" $env:GIT_CONFIG_COUNT='2'; $env:GIT_CONFIG_KEY_0='http.proxy'; $env:GIT_CONFIG_VALUE_0='' # ✗ http.noProxy='*' —— 压不住**显式配置**的 http.proxy。 # 实测仍然走代理失败。(noProxy 是用来豁免某些主机的, # 前提是代理本身可用;代理已死时它没用。) $env:GIT_CONFIG_COUNT='1'; $env:GIT_CONFIG_KEY_0='http.noProxy'; $env:GIT_CONFIG_VALUE_0='*' **另一个要记住的点**:`pnpm` 安装 `github:owner/repo#tag` 这类依赖时, 会调 `git ls-remote` 去解析标签 —— **所以 git 代理坏了, 连"装某个 release 版本"都会失败**,而"不钉版本的 github: 安装"却可能成功 (因为 pnpm 对没有 ref 的 GitHub 仓库会直接走 codeload/tarball 那条 HTTPS 路)。 报错的样子是: [ERROR] Command failed with exit code 128: git ls-remote "https://github.com/owner/repo.git" v1.1.0 "v1.1.0^{}" fatal: unable to access '...': Failed to connect to github.com:443 over proxy 127.0.0.1 **所以"装最新"能过、"装指定版本"过不去**,这个差异本身就是代理问题的特征信号。 --- ## E33 · PowerShell 5.1 里把环境变量设成空字符串等于删掉它(连带空参数也被吞) - **症状**:想临时让某个子进程"不要用配置里的那个值"(例如让 git 别走那个没在跑的代理),于是设一组环境变量,其中一个是空字符串:`$env:GIT_CONFIG_VALUE_0=''` —— 子进程报 `error: missing config value GIT_CONFIG_VALUE_0`,**看起来像命令用法写错了**,实际是那个环境变量**根本没被创建**(5.1 里设空 = 删)。同类表现:给外部命令传空参数 `""`,参数被吞掉,命令收到的是**后一个**参数 —— 搜索条件静默变成一个不存在的过滤值,返回 0 命中,于是你得出一个反向结论。 - **解法**:① 判据:**Windows PowerShell 5.1**(不是 7)里 `$env:FOO=''` 等于**删除**该变量,子进程根本看不到它;命令往往报一个看起来不相关的错(比如缺参数)。先确认版本:`$PSVersionTable.PSVersion`。 ② 要表达"空值"就别走环境变量,改用**命令自身的开关**: · git 单条命令:`git -c http.proxy= -c https.proxy= <子命令>`(显式空值 = 不用代理) · 脚本内部 spawn git 的场景:用 `NO_PROXY=` 绕过 —— 这个值非空,能设进去(实测可让 clone/push 通过) ③ 要传**空参数**:不要依赖 `""`,改成显式占位符、写临时文件、或把参数设计成可选(不传 = 默认),别让"空"承担语义。 ④ 写完立刻**交叉验证一次**:拿控制台回显(`Test-Path env:FOO` / `$env:FOO.Length`)确认变量真存在,再用它 —— 别等子进程报错才反推。 - **细节**:实测(2026-10-08,dev-tools 发版时): · `git ls-remote` 因全局配置的代理 127.0.0.1:7897 没在跑而失败; · `git -c http.proxy= -c https.proxy= ls-remote` 成功(exit 0); · 想把"不用代理"注入给**脚本自己 spawn 的 git**(没法逐条加 -c), 于是试 `$env:GIT_CONFIG_COUNT='2'` + `$env:GIT_CONFIG_KEY_0='http.proxy'` + `$env:GIT_CONFIG_VALUE_0=''` → git 报 `error: missing config value GIT_CONFIG_VALUE_0`(看起来像用错了 git 的用法,其实是变量没被创建); · 换成 `$env:NO_PROXY='github.com'`(非空)→ 之后的 git clone / push 全部成功。 同类(更早一次):`& node script.mjs "" 1 1` 里那个空参数被 PowerShell 吞掉, 脚本的 `pathFilter` 收到了 `"1"`,于是"全库 0 处 relaunch" —— 差点写出反向的调研结论。 两条同源:**在 PowerShell 里,空值/空参数不是"空",是"没有"。** --- ## E34 · WorkBuddy 的「微信助理」不是开放能力:没有 AppID/AppSecret 可申请 - **症状**:想给自己的程序加"个人微信入口",于是去搜"微信助理 接入 / 开放平台",只找到 WorkBuddy(腾讯)的扫码绑定文档;照着找 appid/appkey 找不到,换了几种搜法仍然 0 命中,开始怀疑是自己搜错了。 - **解法**:先看判据:**官方文档里只要同时出现"扫码绑定"+"无需填写 App ID、App Secret"+"同一账号登录",那就是腾讯自家产品之间的账号级集成,第三方无法复刻** —— 不要再找开放接口。 要"个人微信用户 ↔ 你的程序",官方通道有两条,**优先第 ①**: ① **微信 iLink / ClawBot 插件**(个人微信,首选):域名 `https://ilinkai.weixin.qq.com`,纯 HTTP/JSON,**扫码换 bot_token**,**长轮询收消息**(`ilink/bot/getupdates`,服务端 hold 最多 35 秒)→ **免公网、可零依赖**。协议参考官方 npm 包 `@tencent-weixin/openclaw-weixin`(MIT,author Tencent、维护者全是 @tencent.com 邮箱),**但那个包本身是 OpenClaw 的 channel 插件,不能直接搬到别的框架,要自己实现协议、照抄它的源码**。 ② **企业微信「微信客服」(kf)**:微信用户在微信侧和企业客服会话,程序侧用 `kf/sync_msg` 拉消息再回;**标准做法仍要求公网回调 URL**,回复受 48 小时 / 5 条限制。 - **细节**:实测(2026-10):WorkBuddy 微信助理接入指南原文要求 WorkBuddy >= 4.6.4、微信 >= 8.0.70、两边同一账号登录,**扫码即可,"无需填写 App ID、App Secret 等开发凭证"**,一对一绑定。同一份文档里"微信客服号"是并列的另一条通道,原文说"两者功能相同"。 微信客服的关键细节:企微服务器只向回调 URL 推一个**轻量事件**(带短期 token),真实消息要用 `POST /cgi-bin/kf/sync_msg?access_token=` 主动拉(cursor 增量,看 has_more / next_cursor)。官方文档称那个回调 token "optional but recommended" —— 即**理论上可以不走回调、纯轮询拉取**,代价是"严格限流"。 回复限制:**48 小时窗口 / 最多 5 条**、单条 2048 字节、欢迎语要在事件触发后 20 秒内调一次。标准做法仍要求"回调 URL 可被公网访问"(npm wecom-kf 插件的前置条件第 5 条)。 【iLink/ClawBot 细节 —— 2026-10 从官方包源码核过,非二手文章】:拿二维码是 **POST** `ilink/bot/get_bot_qrcode?bot_type=3`,body `{local_token_list:[]}`(只传本机历史 token,**不含任何 OpenClaw 账号凭据** → 登录不依赖 OpenClaw);轮询 `GET` `ilink/bot/get_qrcode_status?qrcode=xxx`(可带 `&verify_code=`),客户端超时 35 秒,二维码有效期 5 分钟;请求头 `AuthorizationType: ilink_bot_token` + `X-WECHAT-UIN`(随机 uint32→十进制→base64,防重放)+ `Authorization: Bearer `;发消息 `POST ilink/bot/sendmessage`,**必须原样回带入站消息的 `context_token`**,否则关联不到会话;媒体走 CDN `novac2c.cdn.weixin.qq.com`,**AES-128-ECB**。 踩坑提醒:网上流传很广的那篇《微信 Bot API 技术解析》把取二维码写成 GET,**官方源码是 POST**(而且它分析的是 1.0.2,官方包已到 2.4.9)→ 协议类结论**一律以官方包源码为准**,别抄二手逆向。 条款边界:《微信ClawBot功能使用条款》4.7 腾讯保留"决定可连接的第三方 AI 服务类型/规模/频率"并可拦截阻断;4.6 禁止绕过微信技术保护措施 —— 所以只能走官方扫码通道,不能去逆向客户端。 --- ## E35 · link: 装进 profile 的 DSH 插件一旦 import @deepseek-ai/* 就 ERR_MODULE_NOT_FOUND 启动即崩 - **症状**:同一个 profile 里,一个插件用 link:(或裸路径)装着跑得好好的,另一个同样装法的插件启动就崩:`Cannot find package '@deepseek-ai/schemastery'`(或 dsh-session / dsh-agent / dsh-llm)。代码看不出错,禁用再启用也没用。 - **解法**:先看这个插件**是否 import @deepseek-ai/***(`grep -r "@deepseek-ai/" *.js` 一秒就能查)。用了就必须改装法: · **生产**:用 `file:` 装 —— pnpm 会把包放进 profile 内的 .pnpm 虚拟仓库,自包含,任何机器都能起。 · **开发**:让仓库根的 node_modules 已链到 DSH 安装。 只用 `ctx.get(...)` / `ctx.inject` 拿服务的插件不受影响,可以继续用 link:。 - **细节**:原因:`link:` 让插件的**真实路径落在 DSH home 之外**。Node 的 ESM 解析是从源码实路径逐级向上找 node_modules,够不到 `$DSH_HOME/profiles/node_modules` 这层 DSH 模块回退,于是 ERR_MODULE_NOT_FOUND。 来源:`zhengmz/dsh-wecom-plugin` 的 README(它自己踩过,写进了"部署与迭代注意")。**我没有在本机复现**,但判据可立刻自查。 实例对照:im-bridge 用 `link:D:/EasyDSH/im-bridge` 一直正常,正因为它**不 import 任何 @deepseek-ai/***,只用 ctx.get/ctx.inject;而 dsh-wecom-plugin 直接 `import z from '@deepseek-ai/schemastery'`,所以它必须用 file:。 --- ## E36 · 给插件加 IM 平台前,先问一句:这家有没有免公网的长连接 - **症状**:计划支持多个 IM 平台,按"接口好不好调"排期。做到某一家才发现它**只有 HTTP 回调**,于是被迫要公网 IP/域名 + 验签 + 消息加解密 + 5 秒内应答,工作量翻几倍;更糟的是产品形态从"家用电脑就能跑"变成"必须先买服务器"。 - **解法**:排期前先按**"官方有没有免公网的长连接"**分两类,判据是官方文档里能不能搜到"长连接" / "Stream": · 飞书 = 长连接 ✅ · 钉钉 = Stream 模式 ✅ · 企业微信智能机器人 = 长连接 ✅(`@wecom/aibot-node-sdk`) · 微信公众号 = **没有**,只能公网 80/443 回调 遇到回调派别急着买服务器,先看它有没有**"主动拉取"**接口(如企微 kf 的 `sync_msg`、微信云托管的消息推送),能拉就不用公网,只受限流代价。 - **细节**:2026-10 按官方文档核过: 飞书长连接 —— 免公网,但仅企业自建应用;**收到后 3 秒内必须处理完**,超时重推 → 必须用 message_id 去重。 钉钉 Stream —— 免公网,但 **Stream 通道不能回复**,回复要另走 REST OpenAPI + `msgKey` 模板;标准版 10000 次/自然月(组织内合计)。 企微智能机器人 —— 免公网,`wss://openws.work.weixin.qq.com`,JSON cmd 协议(aibot_subscribe / aibot_msg_callback / aibot_respond_msg);**每个 botId 只能有一条有效连接,第二条会把第一条踢下线**;普通消息回复窗口 24 小时。 微信公众号 —— **没有长连接**,必须公网 80/443;官方**没有 Node 加解密库**(只给 C++/PHP/Java/Python/C#)。 微信云托管"免加解密"是真的(原文:"走内网链路,无需加解密和签名"),但消息是推到**云上的容器**,不是你家电脑 —— 等于把"加解密"换成"云中转 + 本机主动拉取",不是免费午餐。 --- ## E37 · 校验器用文本搜索断言代码形态时,注释里引用的反例会被误报 - **症状**:给校验器加了一条"代码里不能出现 X"的断言,代码明明已经改对了,校验器还是红。反过来也成立:你为了让后来人别踩坑,在注释里写了"不要写成 X",结果把这条断言弄红了。 - **解法**:按"你对那个校验器有没有改动权"选一种修法: · **有改动权** → 断言前先**剥注释**(块注释 + 行注释),只对代码文本断言。注意行注释的正则要避开 `http://` 这种(用 `([^:])//` 而不是裸 `//`)。 · **没改动权**(别人的校验器)→ **别在注释里复述反例原文**,改成描述性说法,例如写"不要用基址加动态路径段的拼法",而不是把那个模板字符串原样敲出来。 **通用判据**:凡是要断言"代码里没有 X"的校验器,先问一句"X 会不会出现在注释里?" - **细节**:2026-10-08 一天内踩了 3 次,前两次在我自己写的 diag 脚本里,第三次在别人的校验器上: ① im-bridge/control-server.js 的注释里写了旧的写死路径 → 断言"源码里没有写死开发机路径"失败; ② 同一脚本断言"没有条件调用 hook",而 client.js 注释里正好复述了那个反例 → 失败; ③ 修复①时我在注释里原样写了被禁的动态拼路径 → 官方 verify-two-sided-paths.mjs 失败(它不能改,只能改注释措辞)。 第 ③ 次最值得记:那个校验器的**全部价值**就在于静态比对两端路径字符串,所以一旦代码里出现动态拼接,防线就形同虚设 —— 它是**对的**,我改代码才对(改成调用处字面量)。 --- ## E38 · DSH 客户端插件里 ctx 不是全局:ctx?.remote?.x 会抛 ReferenceError 且常被 catch 吞掉 - **症状**:客户端插件(client.js)里想调宿主的 credentials / remote 能力,于是写了 `ctx?.remote?.credentials` —— 以为可选链能兜住。结果**永远拿不到**:如果外面套了 try/catch(或 catch {}),连一行错误都看不到,只表现为"操作静默失败",或者界面给一句很委婉的提示。 - **解法**:**判据**:客户端 bundle 的工厂签名是 `factory(require)` —— **参数里没有 ctx**,它也不是全局。 验证方法(两秒):grep 工厂签名看有没有 ctx 参数;再全局搜 `globalThis.ctx` / `window.ctx`(本机实测均 0 命中)。 **关键认知**:可选链只防 `null`/`undefined`,**防不住未声明的标识符** —— 那会抛 `ReferenceError`,`ctx?.x` 和 `ctx.x` 一样炸。 **正解**:要用宿主能力就别在浏览器里调,**让宿主做** —— 加一个只监听回环的控制接口端点(本项目是 `POST /im-bridge/credential`),浏览器 POST 请求,宿主用自己的服务写,写完**回读确认**再把结果返回。 - **细节**:证据(im-bridge,2026-10-08):client.js 里 let written = false; try { const remote = ctx?.remote?.credentials; // ← ReferenceError if (remote !== undefined && typeof remote.set === 'function') { ... } } catch { written = false; } // ← 被吞掉 于是设置页点「连接 QQ」永远走 else 分支、提示"没写进 Host 凭据存储"。用户看不出哪里错,我也花了很久才定位。 **附带教训**:空的 `catch {}` 会把"这根线根本没接上"伪装成"操作失败了"。凡是静默兜底的分支,至少记一条状态日志。 --- ## E39 · 给 DSH 插件加"可选重依赖":装到用户数据目录,别装进插件目录;卸载用标记+重启清理 - **症状**:插件要支持一个可选能力(例如某平台的长连接),需要引一个几十 MB 的 SDK,但不想让所有用户都被迫下载。第一反应是"把依赖装进插件目录"或"写进 package.json 的 dependencies"。 - **解法**:五条,按顺序: ① **不要写进 `dependencies`** —— 那样一装插件就全量下载,"可选"直接没了。要用 `optionalDependencies` 之外的办法,或者干脆不声明。 ② **不要 `pnpm add` 进插件目录** —— 会改插件的 package.json(源码仓库变脏);**更要命的是生产环境里插件是 profile 内的缓存副本,升级就被覆盖、依赖全丢**。 ③ **装到 `$DSH_HOME/<插件名>-deps/`**(用户数据目录)。运行时用 `createRequire(join(depsDir,'package.json')).resolve(包名)` 解析入口,再 `import()` —— **不要硬编码 dist 路径**(包升级换了入口也能跟);没装就返回 null,做成**可失败的动态加载**而不是崩。 ④ **卸载 = 先试立即删,失败就写 pending 标记,下次插件启动第一件事再删**(那时还没 import 任何 SDK)。Windows 上文件被进程加载后删不掉,硬删会留下半损坏状态。 ⑤ **pnpm 可执行文件不要写死路径** —— 用 `resolve(dirname(process.execPath), '..', '..', 'pnpm', 'bin', 'pnpm.mjs')` 从当前 node 推,跨机器成立。 - **细节**:2026-10-08 在 im-bridge 上实现并**端到端实测通过**(用临时 DSH_HOME,真装 35KB 的 dingtalk-stream、真用 createRequire 解析出 `DWClient` 类、真删掉、临时目录清干净):依赖目录方案、pnpm 定位、动态解析、删除路径四项全部验证。 参考实现:`D:\EasyDSH\im-bridge\components.js`。 两个补充要点: · **安装端点要立即回 started、不阻塞** —— 30MB 慢网要几分钟,同步回包会把浏览器请求挂死。进度靠轮询拉流式输出(pnpm 的 stdout/stderr 转发到一个带上限的缓冲)。 · **界面上把"预估体积"写在安装按钮旁边** —— 空间管理的决策点在**装之前**,不是装之后能卸。 --- ## E40 · 想实验常量就去复制模块改常量:副本里 import.meta.url 派生路径全断,量出来全是 0 - **症状**:想量"某个模块级常量取不同值时输出怎么变",于是**把模块复制到临时目录、改掉那一行常量、再 import 副本**。 结果量出来的每个档位都是**同一个值、而且常常是 0 或空**: 预算 索引长度 是否截条 4300 0 否 4400 0 否 ...全部为 0 看起来像"这个常量根本不影响结果" —— **完全反了**。 **根因(两层,都会静默)**: ① 副本里 `import.meta.url` 指向**临时目录**,于是所有"相对自己算出来的路径"失效: const HERE = dirname(fileURLToPath(import.meta.url)); // ← 临时目录 export const EXPERIENCE_FILE = join(HERE, 'experience', '经验库.md'); // ← 不存在 // 于是 parseExperienceFile() 找不到文件 → { ok: false, entries: [] } // 而 buildExperienceText() 里 `if (!parsed.ok) return ''` → 返回空串 ② 副本里若还有 `import './sibling.js'`,相对 import 也会断(本项目的模块恰好只用 `node:` 内置,躲过了)。 **伴随的自伤**(更容易把人带偏):探针脚本里正则写成 `\\d`(双反斜杠,应为 `\d`), 于是替换不生效、`patched === src` 成立 → 脚本报"没能改到那一行"就退出了, 掩盖了真正的问题。**PowerShell + 正则 + 反斜杠**在本项目已多次致错。 - **解法**:**别复制模块去改常量 —— 把那个常量变成函数的可选参数。** // 之前:常量写死在函数体里,改不了就只能改文件 export function buildExperienceText(filePath) { ... if (text.length <= INDEX_BUDGET) return text; } // 之后:默认值保持原样(生产代码一行不用改),但可以传 export function buildExperienceText(filePath, budget = INDEX_BUDGET) { ... if (text.length <= budget) return text; } // 量任意档位:直接调用,不碰文件 for (const b of [4300, 4500, 4600, 5300]) { console.log(b, mod.buildExperienceText(FILE, b).length); } **为什么复制文件那条路必坏**:副本里 `import.meta.url` 指向临时目录, 于是所有"相对自己算出来的路径"(默认资源文件、同级 import)都会失效 —— 而失效往往是**静默**的(缺文件 → 返回空 → 看起来像"没有影响")。 **排查这类"量出全 0"的三步**: 1. 先确认函数**真的拿到了数据**(打印 `parseExperienceFile(FILE).ok` 和 `.entries.length`) 2. 然后才看长度/结果 —— 否则你会对着"空输入"下结论 3. 传显式路径 `buildExperienceText(FILE)` 而不是靠默认值 **顺带一条**:正则里的反斜杠在 PowerShell 内联脚本、Shell 引号、 和 JS 字符串里都会被各转义一次。写 `new RegExp` 或字面量时**先打印出来看**: console.log(JSON.stringify(/export const X = \d+;/.source)) // 确认不是 \\d - **细节**:**场景**:想量"某个模块级常量取不同值时,输出会是什么样",又不想动生产代码。 **第一反应(错的)**:把模块文件复制到临时目录,用正则改掉那一行常量,再 `import` 那个副本。 // 复制 → 改常量 → import 副本 const copy = src.replace(/export const X = \d+;/, `export const X = ${v};`); writeFileSync(tmpFile, copy); const mod = await import(pathToFileURL(tmpFile).href); **为什么会失败** —— 两个独立的坑,都会让你量出**全 0**: ① **相对路径全断**:模块里若有 `import './other.js'`,副本在临时目录里找不到同级文件。 本项目的 `experience.js` 只 import `node:` 内置模块,所以躲过了这一劫。 ② **`import.meta.url` 派生的路径会指向临时目录**(真正踩到的那个): // experience.js 里 const HERE = dirname(fileURLToPath(import.meta.url)); // ← 副本里 = 临时目录 export const EXPERIENCE_FILE = join(HERE, 'experience', '经验库.md'); // 于是副本里的默认路径 = %TEMP%/experience/经验库.md —— 不存在 export function parseExperienceFile(filePath) { const path = filePath ?? EXPERIENCE_FILE; // ← 落在这里 if (!existsSync(path)) return { ok: false, entries: [] }; } 症状是 `buildExperienceText()` **静默返回空串**(它内部 `if (!parsed.ok) return ''`), 于是量出来的每一档都是长度 0、截条 0 —— **看起来像"预算随便设都够",完全反了**。 ③ 顺带还有个自伤:正则 `\\d` 写成了双反斜杠(应该是 `\d`), 于是 `patched === src` 成立,脚本直接走 break 分支报"没能改到那一行", 掩盖了真正的问题。**PowerShell + 正则 + 反斜杠**这个组合在本项目已经踩过很多次。 **正确解法:把"要调的量"变成函数参数,而不是去改常量。** // 之前 export function buildExperienceText(filePath) { ... 用到 INDEX_BUDGET ... } // 之后(默认值保持不变,生产行为完全一致) export function buildExperienceText(filePath, budget = INDEX_BUDGET) { ... 用 budget ... } 然后一行就能量: for (const b of [4300, 4500, 4600, 5300]) console.log(b, exp.buildExperienceText(FILE, b).length); **拿到的收益**(这一步真的救了我一次):逐档量出来的数字是 4300 → 4269 字符,**截掉 2 条** 4500 → 4423,截掉 1 条 4600 → 4511,装下全部;再往上(4700/5300)长度**不再增长** **如果没有参数化,我会按"复算"的估算值把预算抬到 5300(+790 字符), 而实测只需要 4600(+211 字符)** —— 估算比真实需求高 3 倍多。 **通用形式**: - 想实验某个常量的影响 → **先看那个函数能不能参数化**;能就别去改文件、更别去复制文件 - 复制模块来实验,会同时破坏"相对 import"和"`import.meta.url` 派生的一切" - 量出**全 0 / 恒定值**时,第一反应应该是"我的测量环境是不是根本没读到数据", 而不是"这个参数不影响结果" --- ## E41 · 用"照着逻辑重算"定阈值:估算比真实需求大 15%,多出来的是每步都付的成本 - **症状**:要给某个**阈值常量**(预算 / 容量 / 上限)定新值,于是**照着实现逻辑自己重算一遍**,得出一个"够用了"的数字 —— 然后按它改。 结果:**估算值比真实需求大不少**,多出来的部分变成每步/每次都要付的固定成本。 本项目的具体一次: 复算(照着装配逻辑重写循环)→ "预算 >= 5300 才整份装下,实际用 5215 字符" 实测(把预算参数化后真跑) → 真实需求 **4600**,实际用 4511 字符 高估 15%。按估算值改,等于每步多注入 789 字符,而真实只需多 211。 **为什么"照着重算"不可信**,机理很具体: ① **漏细节**:重算版容易漏掉真实实现里的固定部分(表头行数与内容、末尾说明文本) ② **搞错分支顺序**:真实逻辑是"截断档位从宽到紧依次试,**第一个装得下的就返回**", 而重算版往往拿"最紧档"当基准 —— 两者算出的长度差很多 ③ **误差单向**:这些遗漏都在临界路径上,不会互相抵消,只会让估算偏大 **更危险的后果**:重算还会让你得出**错误的因果结论**。 本案例我据复算写下"最紧截断档反而更废",而真代码用的是宽松档、条目一个不少 —— 反了。 **另一个同类症状**:把它写成临时脚本去"复制模块改常量",会得到全 0(见 E40)。 - **解法**:**别用"照着逻辑重算一遍"的估算值去定阈值 —— 让真代码接受参数,直接量。** // ① 先把要调的量参数化(默认值不变,生产行为一致) export function buildExperienceText(filePath, budget = INDEX_BUDGET) { ... } // ② 逐档真跑,看哪一档开始"不再截条"、且"长度不再增长" for (const b of [4300, 4400, 4500, 4600, 4700, 5300]) { const text = mod.buildExperienceText(FILE, b); const omitted = Number(/另有 (\d+) 条未列出/.exec(text)?.[1] ?? 0); console.log(b, text.length, omitted); } **读结果的两个要点**: · **找拐点,而不是找最大值** —— "从哪一档开始长度恒定"就是真实需求。 本案例:4300(4269, 截2) → 4500(4423, 截1) → **4600(4511, 截0)** → 4700/5300 仍是 4511。 所以真实需求是 4600,不是"再往上抬一点保险"。 · **余量只给一点**(这里 4600 - 4511 = 89 字符)。余量给太多等于白付每步成本。 **为什么复算会错**(本案例实测:估算 5300 vs 真实 4600,高估 15%): 重算版会漏掉真实装配里的细节 —— header 的行数与内容、截断档位的**尝试顺序** (从宽到紧,第一个装得下就返回)、末尾省略说明那几十字符。 这些细节都在临界路径上,所以误差不会互相抵消,只会单向偏大。 **当心一条衍生错误结论**:复算还会让你得出错误的**因果判断**。 本案例我据复算写下"最紧的截断档反而更废",而真代码在 4600 那一档用的是**宽松截断**、 条目一个不少 —— 结论完全反了。 - **细节**:**真实场景**(本项目 `dev-tools/experience.js` 的 `INDEX_BUDGET`): 注入用的经验索引有自身字符预算。加到 39 条时索引装不下,开始截条 —— 系统提示里出现"**索引已到 4300 字符预算:另有 2 条未列出**"。 **关键决策点**:抬预算,还是精简条目?抬多少? **我第一版的做法是"复算"** —— 照着装配逻辑自己重写一遍循环,估算出: "预算 >= 5300 时可整份装下,实际用 5215 字符" **这个数字是错的,而且错得很有方向性:它偏大。** **第二版改成"实测"**(把 `buildExperienceText(filePath, budget)` 参数化,见 E40),真跑出来: 预算 索引长度 截条 截掉几条 4300 4269 是 2 4400 4269 是 2 4500 4423 是 1 4600 4511 **否** 0 ← 真实需求 4700 4511 否 0 5300 4511 否 0 ← 再往上长度不变 **真实需求是 4600,我的估算说 5300 —— 高估了 15%。** **差在哪**(有明确的机理,不是随机误差): · **复算版漏了 header**:我只用真实索引的前 4 行当表头,而真实装配的 header 更长 (含"来源/版本/更新于/通用条目数"多行),于是每条都算得偏长 · **复算版把截断档位算错了**:我以为"最紧档(症状/解法各 40 字)装得下所有条目", 于是拿它当基准 —— 而真实逻辑是**档位从宽到紧试,第一个装得下的档就返回**; 宽松档位下**条目条数不变、只是描述更长**,所以真实长度反而比"最紧档"更接近临界值 · 我还从这次误算里得出一条**错误结论**:"最紧的截断档反而更废(每条都还在,描述全被切碎)" —— 那是复算逻辑的产物,真代码里 4600 那一档用的是**宽松截断**,条目一个不少 **如果不做实测,我会按 5300 抬预算** —— 每步多注入 789 字符, 而真实只需要多 211 字符。**一个估算就让"每步成本"高了 3.7 倍。** **教训**:**"照着逻辑重算一遍"不是测量,是第二次猜测。** 它和真代码的差别恰恰在那些"你没注意到的细节"(header 行数、档位顺序、末尾说明文本), 而这些细节往往是决定性的 —— 因为它们**都在临界路径上**。 **可执行的判据**: # ✗ 别这样:照着重写逻辑 for (const tier of TIERS) { ... 我自己算长度 ... } # ✓ 这样:让真代码接受参数,直接问它 for (const b of [4300, 4500, 4600, 5300]) { console.log(b, realModule.buildExperienceText(FILE, b).length); } **顺带一个观察方法**:逐档量完之后看"**从哪一档开始长度不再增长**" —— 那个拐点就是真实需求(这里是 4600 起长度恒为 4511)。 **拐点比"最大值"更可信**,因为它不依赖你猜"还需要多少余量"。 --- ## E42 · 校验器报红但代码是对的:一轮里 4 种"断言写错"的形态(否定断言/算术判连续/忽略解析上限) - **症状**:给一个刚改好的功能写校验断言,**校验器报红,于是去查代码 —— 结果代码是对的,是断言写错了**。 一轮里连续踩了 4 种不同的形态,每次都要重新排查,白烧三轮。 四种具体表现: **① 断言假设"实现能看到全部输入"** check('最新的 E259 在索引里', stress.includes('E259')) // ✗ 红 // 而 parseExperienceFile 有 MAX_ENTRIES = 60 的解析上限 —— // 合成库 161 条只解析到第 60 条,E259 **根本没被解析到** **② 把一个"应该存在"的值写进了"不应该存在"的断言** check('保住最新', stress.includes('E118') && !/E1 /.test(stress) && !/E22 /.test(stress)) // ✗ 红 // 而 E22 恰恰是"保留下来的第一条"(区间起点)—— 它本来就该在 **③ 用"编号算术"判"连续",而编号本来就有跳号** nums.every((n, i) => i === 0 || n === nums[i - 1] + 1) // ✗ 红 // 留下 22…118 共 39 条,而 118 - 22 + 1 = 97 —— // 经验库编号本来就不连续(合成库是 E1~E41 + E100~E259;原始库里也没有 E9) **④ 用"聪明写法"代替直白判据** nums[0] > Number(String(nums[0]).charAt(0)) // 想表达"起点不是 1",实际对任何两位数都成立 **共同点**:报的都是"代码坏了",而故障在断言里。 **代价不只是排查时间,还有信任损耗** —— 之后每次校验器报红,第一反应都变成 "是不是又是我断言写错了",而不是"代码坏了"。这比单个 bug 更糟。 - **解法**:写校验器断言时,按下面五条过一遍 —— **报红先怀疑断言,再怀疑代码**: **① 断言里用到的值,先确认它经过了实现的全部过滤** # 反例:断言"最新的 E259 在索引里",而 parseExperienceFile 有 MAX_ENTRIES=60 的解析上限, # 合成库 161 条只解析到第 60 条 → E259 根本没进来 → 断言必错 # 正确:先算出"实现实际能看到什么",再对它下断言 const visible = exp.parseExperienceFile(f).entries.slice(0, 60).map(e => e.id); **② 写"不应该存在"的断言前,确认这个值确实不该在** # 反例:!/E22 / —— 而 E22 是保留区间的**起点**,它本来就该在 # 尤其小心"边界值"(区间起点/终点):它们最容易写反 **③ 尽量用结构性判据,别用数值判据** # ✗ 按编号算术判"连续":nums[i] === nums[i-1] + 1 # —— 而编号本来就有跳号(E9 缺失、合成库中间空一段),必然失败 # ✓ 按数据顺序判:留下的集合 = 解析顺序里从某位置到末尾的一段 const startAt = parsed.findIndex(n => kept.has(n)); parsed.slice(startAt).every(n => kept.has(n)); **④ 判据要一眼读懂,别用"聪明写法"** # ✗ nums[0] > Number(String(nums[0]).charAt(0)) —— 对任何两位数都成立,没表达任何意思 # ✓ nums[0] !== 1 **⑤ 报红时的排查顺序**(这条最省时间) 刚写的断言 + 刚改的代码同时存在时,**先验证断言**: 把真实输出 dump 出来(`console.log` 或写临时文件),肉眼对一眼判据。 本案例里 4 次报红全是断言错、代码对 —— 每次 dump 一眼就看出来了, 而"改代码去迎合断言"会真的把好代码改坏。 - **细节**:**背景**:给"经验索引装不下时保留最新条目"这个改动写断言,一轮里连续写错 4 次, 每次都是**校验器报红、但代码其实是对的** —— 于是白白查了三轮。 **四种形态,逐个记:** **① 断言假设了"实现能看到全部输入"** check('E259 在索引里', stress.includes('E259')) // ✗ 失败。而 parseExperienceFile 有 MAX_ENTRIES = 60 的解析上限, // 合成库 161 条只解析到第 60 条(= E118)。**E259 根本没被解析到。** 判据:断言里用到某个输入值之前,先确认**它经过了实现的全部过滤**。 这里"解析上限 60"就是一层过滤,我把它忘了。 **② 断言把一个"应该存在"的值写成了"不应该存在"** check('保住最新', stress.includes('E118') && !/E1 /m.test(stress) && !/E22 /m.test(stress)) // ✗ 失败。而 E22 恰恰是"保留下来的第一条"(连续后缀的起点)—— 它是**应该存在**的。 // 我把它写进了"不应存在"的分支,等于自相矛盾。 判据:**否定的断言要写在心里过一遍"这个值到底该不该在"**。 尤其是"边界值"(区间起点/终点)最容易写反。 **③ 用"数值算术"判"连续",而编号本来就有跳号** nums.every((n, i) => i === 0 || n === nums[i - 1] + 1) // ✗ 失败:留下 22…118 共 39 条,而 118-22+1 = 97。 // 经验库的编号本来就不连续(合成库是 E1~E41 + E100~E259;原始库里也没有 E9)。 判据:**"连续"应该按"数据的顺序"判,不是按"编号的算术"判。** 正确写法是拿"留下的集合"和"解析顺序"对比: `parsed.slice(startAt).every(n => keptSet.has(n))` **④ 用"聪明写法"代替直白判据** nums[0] > Number(String(nums[0]).charAt(0)) // 想表达"起点不是 1" // 这个条件对任何两位数都成立 —— 它没有表达任何意思,还很难读。 判据:**校验器里的判据要能被一眼读懂。** 想要"起点不是第一条",就直接写 `nums[0] !== 1`。绕弯的写法在出错时无法自证。 **共同点**:这 4 次报的都是"代码坏了",而**真正的故障在断言里**。 它们的代价不只是那一轮排查,还有**信任损耗** —— 校验器报红时我第一反应变成了 "是不是又是我断言写错了",而不是"代码坏了"。**这比单个 bug 更糟。** **可执行的防法**(写断言时逐条过): ① 断言里出现的每个值,先问"它经过了实现的哪些过滤?"(上限、截断、过滤条件) ② 写否定断言前,先确认"这个值确实不该在" —— 拿真实输出对一眼 ③ 能用**结构性判据**(顺序、集合包含、单调性)就别用**数值判据**(加减、等于具体值) ④ 判据要一眼读懂;绕弯的写法优先改成直白写法 ⑤ **报红时先验证断言、再验证代码** —— 尤其是"刚写的断言"和"刚改的代码"同时存在时 **最后一条特别重要**:理想情况下应该是"先确认代码行为,再写断言去固化它"。 我这次是"先写断言、再看代码",于是断言里包含了**我对实现的一堆错误假设**。 **2026-10-12 又添 5 种(同一族:判据太窄,而不是代码错)** —— 这次是在一个 "发布前干净检查"里,一轮报了 29 条,**其中 28 条是假红**: · **正则的前导边界**:写 `[A-Za-z]:[\\/]` 想匹配盘符路径,结果把 `http://` 里的 `p://`、 `https://` 里的 `s://` 也当成了盘符 —— 一次报 39 条,绝大多数是这种噪音。 修法:前面要有"非字母数字"边界,且冒号后只允许**单个**分隔符(不能再跟一个 `/`)。 · **占位符没排除**:`C:\Users\...\Desktop` 里的 `...` 被当成真实用户名。 修法:要求用户名**以字母或数字开头**(`[A-Za-z0-9][A-Za-z0-9._-]*`), 再配上"排除 `< > $ % { ( ( your username example` 等占位写法"的前后查。 · **目录条目语义**:npm 的 `files: ["lib"]` **覆盖 `lib/` 下全部文件**, 而我只做了"精确相等或前缀"匹配 → 把 8 个 `lib/*.js` 报成"白名单漏了"。 这是**最惊悚的一类误报**:它长得像"发布版会崩",实际包是好的。 修法:目录条目按"前缀 + 斜杠"匹配(`p === e || p.startsWith(e + '/')`)。 · **汇总行的写法不止一种**:批量跑时我按 `通过 N 项` 解析结果,而仓里还有 `通过 N / M` 和 `通过 ${stale.length === 0 ? 1 : 0} / 1`。只认一种, 就会把"根本没读到结果"混进"全绿"里(那一行显示成 `?`)。 修法:判据放宽到"「通过」后面紧跟 `${` 或数字",**不再试图解析表达式**。 · **参数的名字里描述了表达式**:`check('不再单独用 openid === ... 判面板', ok)` —— 启发式看见 `===` 就以为传进去的是表达式。修法:**带引号的永远不判为表达式**。 **这一族的总判据**:① 检查器的判据本身才是薄弱环节,写完**要拿真实样本跑一遍** 再看它报了什么;② **宁可少抓,不要误报** —— 假红会让人不再看结果,那比没有检查器更糟; ③ 报红时先问"是我的判据错了,还是客体错了",且**默认怀疑判据**。 --- ## E43 · 校验器把"回退目标"写死成具体路径:在特定 cwd 下必然假红 - **症状**:校验器报红说"没有用那个不存在的路径",但代码明明是对的。特征:**只在特定工作目录下跑才红,换个目录跑就绿**。换个说法:同一条断言,运行位置一变结论就变。 - **解法**:判据:断言里出现 `结果 !== '<某个真实路径>'`,而**被测代码的回退目标恰好是 `process.cwd()`** —— 只要 cwd 可能等于那个路径,这条断言就与运行位置耦合,必然假红。 修法:把样本值换成**不可能成为 cwd 的哨兵**,例如 `D:\__probe-nonexistent-xyz__`,别拿真实存在的目录名当"不存在"的例子。 顺带一条通用检查:**任何"环境相关"的断言(cwd、时区、locale、用户名、端口)都要问一句"换个环境还成立吗"**。 - **细节**:2026-10-09,im-bridge 的 `verify-workspace-resolve.mjs` 分支 B。它测「配置的工作目录不存在 → 回退到 cwd」,样本路径用了 `D:\EasyDSH`(历史上真被写进过 `cordis.patch.yml` 的开发机路径)。 我恰好在 `D:\EasyDSH`(工作区根,而项目 AGENTS.md 就是让人在那里跑校验)下执行 → **回退目标 cwd 与样本路径恰好相等** → `gotB !== 'D:\EasyDSH'` 恒为假 → 代码正确却报红。 它自己的输出里其实就写着矛盾,只是容易看漏: 回退到了进程 cwd —— D:\EasyDSH (✓ 通过,回退目标就是 D:\EasyDSH) 没有用那个不存在的路径 —— D:\EasyDSH (✗ 失败,值等于 D:\EasyDSH) 改用哨兵 `D:\__im-bridge-nonexistent-probe__` 后,在 `D:\EasyDSH` 与 `D:\DSH-dev` 两处跑都是 16/16。 这是**"校验器报红但代码是对的"的第 4 种形态**(前三种见 E42):否定断言写反、算术判连续、忽略解析上限,加上这次的**断言值与运行环境耦合**。 --- ## E44 · 改了 ~/.modlens/config.json,几分钟后字段自己变回去 - **症状**:用命令行 modlens config set 设好 provider / baseUrl / model 之后,过几分钟再 config show,发现 provider 退回了旧值,文件里还多出自己没写过的键(例如 reuse:{})。文件 LastWriteTime 显示被重写过,但自己确实没再动它。 - **解法**:判据:这不是「被增量修改」,而是「被整份重写」。@liustack/modlens 的 DSH 插件 dsh/index.js 里 applyEngineSettings()(约 1767-1882 行)在设置卡提交时读整份 config、只改 patch 提到的字段、然后用 writeFileSync 把整份写回;只要 patch 带 provider 就会覆盖 config.provider,而且卡片可能携带它打开时缓存的旧草稿。三步处理:① 看 ~/.modlens/config.json 的 LastWriteTime 对齐时间线,确认是哪一次写入;② 若是卡片写的,文件里不会有对应的 providers.<引擎名> 条目(那条代码只在 patch.engine 存在时才创建)—— 用它区分「卡片保存」和「CLI 写入」;③ 不要两个写者混用:要么全在 DSH 设置卡里改,要么全走 CLI,改完把卡片关掉重开再操作。 - **细节**:实测案例:22:18:06 用 CLI 设 provider=openai,22:24:31 文件被重写为 provider=antigravity-cli 并多出 reuse:{},而文件里没有任何 antigravity-cli 的 provider 条目 —— 据此判定那次是卡片保存而非 CLI。辅助手段:CLI 的 modlens config show 会在每个值后面标 (file) 或 (default),可以区分值来自文件还是内置默认;CLI 与卡片写的是同一个文件,没有锁。 --- ## E45 · 为读一张图去配 modlens,其实宿主的 read_image 已经能读 - **症状**:当前模型自报是纯文本模型,modlens 自己的 troubleshooting 文档也写「宿主的 read_image 对纯文本模型直接拒绝」,于是先去配 vision provider(装 agy CLI / 申请 gemini-api key / 设代理 / 调 openai 兼容端点),折腾半天。而实际上直接调宿主 read_image 就已经拿到图片内容了。 - **解法**:判据:先无成本试一次再决定要不要配。做法:直接对目标图片调 read_image,路径可以指向无扩展名的规范化副本(只要字节内容是 PNG/JPEG/WebP/GIF 即可)。它成功返回图片内容,就不用碰 modlens;只有当你需要逐字 OCR 全文、版面区块的阅读顺序、不确定性清单,或者要批量/脚本化处理时,才值得去配 modlens 的 provider。 - **细节**:实测(DSH desktop profile,deepseek-flash):同一张 2560x1528 PNG,read_image 返回图片且能读出图中具体文字(包括书签栏的原文拼写,属于不会凭空编出的细节);modlens_read_image 则报 No vision provider is set up on this machine。究竟是当前模型本身具备视觉、还是宿主在返回前做了转换,未能区分,这一点未验证。补充:read_image 只回图片本身,不产出可引用的逐字转录 —— 需要精确字串(配置值、表格数字)时,仍应走 modlens 或另外做 OCR。 --- ## E46 · 插件安装卡 4 分钟才失败:是 pnpm 的 fetch 超时,不是 DSH 的 - **症状**:在 DSH 图形界面里装插件(尤其 GitHub 来源),进度条长时间不动,大约 4 分 15 秒后失败,报 The operation was aborted due to timeout 或 [23] The operation was aborted due to timeout;重试一次耗时几乎一模一样。看操作日志能见到 codeload.github.com 的 tar.gz 请求先 error (23). Will retry in 10 seconds. 2 retries left,再 Will retry in 1 minute. 1 retries left。同一台机器上换另一个 GitHub 包却可能装得上,所以容易误判成「这个仓库坏了」。 - **解法**:第一步看日志,别猜:$DSH_HOME/profiles//.plugin-manager/logs/operation-*/pnpm.log 里有 pnpm 的原始输出和总耗时。判据:DSH 全 asar 搜 fetch-timeout 是 0 命中,说明它不覆盖 pnpm 的下载超时,走 pnpm 内置默认 —— 单次约 60 秒、重试 2 次(共 3 次尝试)、退避 10 秒后 1 分钟,合计约 250 秒。所以「每次恰好 4 分多才失败」就是 pnpm 这条链,不是 DSH 在掐你。DSH 自己的超时都是别的用途,别归错因:githubConnectionTimeoutMs 默认 5 秒(装 GitHub 包前用 git ls-remote 做预检,且只测 github.com)、inspectTimeoutMs 与 LOOKUP_TIMEOUT_MS 各默认 20 秒(pnpm view 查 spec 与 registry)、lockWaitMs 默认 2 分钟(等 profile 锁)、idleTimeoutMs 默认 10 分钟(失败后的修复性 install)。绕开办法按推荐顺序:① 安装写法从 github:owner/repo 改成 git+https://github.com/owner/repo.git(前者走 codeload 的 tar.gz,后者走 git 克隆,实测后者能成);② 在 profile 目录建 .npmrc 放宽 fetch-timeout 与 fetch-retries;③ 给 pnpm 配 https-proxy 或换成 npmmirror 这类 registry。 - **细节**:实测 2026-10-09 晚:22:34 与 22:43 两次装 github:Ebony-Vinyl/dsh-our-free-model,都死在 codeload.github.com 的 tar.gz 上,耗时 4 分 15 秒 / 4 分 14 秒;22:54 那次装 git+https://github.com/tt-a1i/archify.git 成功,日志写 Done in 2m 4.8s using pnpm v11.7.0。60 秒这个值是反推的(3T+10+60≈250 → T≈60),没在 pnpm 源码里读到字面量,别当成读到的一手值。关键旁证:github-connection-*/git.log 是 0 字节,说明 DSH 那个 5 秒预检通过了 —— 因为它测的是 github.com,而真正故障的主机是 codeload.github.com,两者不是一台。DSH 侧默认值出处:@deepseek-ai/dsh-plugin-manager/lib/index.js 第 1356-1359 行,LOOKUP_TIMEOUT_MS = 2e4 在第 298 行。 --- ## E47 · PowerShell 5.1 里 Get-Content 数 UTF-8 文件行数会少算约 40% - **症状**:用 Get-Content 数一个中文 .md / .js 文件的行数,得到的数字明显偏少 —— 实测 175 行的文件报 102 行、215 行的报 165 行,而且中文全部显示成乱码。第一反应是「文件被截断/写坏了」,于是去排查一个根本不存在的问题(比如刚做过的编辑是不是把文件写坏了)。 - **解法**:判据:同一个文件换个读法行数就变了,说明是解码问题,不是文件坏了。正确写法任选:① Get-Content -LiteralPath <文件> -Encoding UTF8;② [System.IO.File]::ReadAllLines(<路径>)(返回数组,.Length 就是权威行数);③ 只看内容不看行数时用 Get-Content -Raw -Encoding UTF8。凡脚本里按行处理中文文件,一律显式带 -Encoding UTF8,不要依赖默认值。 - **细节**:实测环境是 Windows PowerShell 5.1 —— 注意 DSH 的 pwsh 工具在这台机器上跑的其实是 5.1,不是 PowerShell 7(-AsByteStream、?? 这类 PS7 语法会直接报错)。同一文件三种读法对比:AGENTS.md 用 [System.IO.File]::ReadAllLines 得 175 行、Get-Content 默认编码得 102 行、Get-Content -Encoding UTF8 得 175 行;另一份中文 .md 分别是 215 / 165 / 215。成因是 PS 5.1 的 Get-Content 默认按系统 ANSI 代码页解码,UTF-8 的中文字节被按双字节重新配对,行数因此变少、字符变成乱码(具体吞掉哪些字节没深究,只确认了现象与修法)。带 BOM 的 UTF-8 文件不受影响。 --- ## E48 · pnpm 11 的 fetchTimeout 只认 pnpm-workspace.yaml,写进 .npmrc 是静默失效 - **症状**:为了让慢速下载的包能装完,在项目根建 .npmrc 写 fetch-timeout=540000。文件确实被 pnpm 读了(同一文件里的 registry=... 是生效的),但这个键完全不生效:pnpm config get fetch-timeout 返回 undefined,pnpm config list 里也根本没有这一项,而且没有任何警告或报错 —— 表现和「压根没配」一模一样,于是误以为超时没放宽。 - **解法**:判据:先用一个 pnpm 必定会读的键验证文件是否被读到 —— 在同一个 .npmrc 里写 registry=https://example.com/ 再 pnpm config get registry。若 registry 生效而 fetch-timeout 不生效,说明是「键的落点不对」,不是「文件没读到」,别再去查文件权限或路径。正确落点:在项目根(pnpm 11 会把它当 workspace manifest 解析的 pnpm-workspace.yaml)里写**驼峰** fetchTimeout: 540000。短横线写法 fetch-timeout 和 .npmrc 两种落点都返回 undefined。 - **细节**:实测 pnpm 11.7.0(DSH 自带运行时)。三组对照:A)临时目录里 .npmrc 只写 registry=https://example.com/,pnpm config get registry 返回该值 → 证明 .npmrc 这个文件本身是被读的;B)同一个 .npmrc 里再加 fetch-timeout=540000,pnpm config get fetch-timeout 返回 undefined,config list 里也找不到任何 fetch-* 行;C)改成 pnpm-workspace.yaml 里写 fetchTimeout: 540000,pnpm config get fetch-timeout 返回 540000。对照组:故意写坏的 YAML 会让 pnpm 直接报 bad indentation 并给出行号,所以「没报错」不等于「配置生效」。DSH 场景补充:桌面 profile 的 pnpm-workspace.yaml 在 $DSH_HOME/profiles//,而它是 app 管理的文件(pnpm 11 会往里写 minimumReleaseAgeExclude 之类的例外记录),手工追加的键将来会不会被重写覆盖尚未验证。 --- ## E49 · 用 PowerShell 的 Get-Content/Set-Content 改 UTF-8 源码:中文变乱码且换行被吞 - **症状**:只想改一个字符串(例:把 `limit * 4` 改成 `limit * 8`),用的命令是 (Get-Content $p -Raw) -replace 'a','b' | Set-Content $p -Encoding UTF8 结果整个文件坏掉:中文全变乱码(`外键` → `澶栭敭`)、注释和下一行**粘在一行**、 部分字符变成 `�`,`node --check` 报 `SyntaxError: Unexpected identifier`。 文件行数也变了(换行被吃掉)。 - **解法**:改源码一律用编辑工具(write / edit),不要用 shell 文本管道。确实必须在 shell 里读写 UTF-8 时,用 .NET 显式指定编码: $t = [System.IO.File]::ReadAllText($p, [System.Text.Encoding]::UTF8) [System.IO.File]::WriteAllText($p, $t, (New-Object System.Text.UTF8Encoding($false))) 判据:命令里只要出现 `Get-Content` / `Set-Content` / `Out-File` 去改源码文件,就停下来换工具。 若已经改坏,可以尝试逆向恢复(损坏是确定性的:UTF-8 字节被按 CP936 解码后又被写成 UTF-8): $raw = [System.IO.File]::ReadAllText($p, [System.Text.Encoding]::UTF8) $bytes = [System.Text.Encoding]::GetEncoding(936).GetBytes($raw) $orig = [System.Text.Encoding]::UTF8.GetString($bytes) 但**只能救回字符,救不回被吞的换行和变成 U+FFFD 的字节** —— 文件大时不如对照上一版重写。 - **细节**:根因两条叠加:① `Get-Content -Raw` **不带 `-Encoding` 时按 ANSI(本机 CP936)解码** UTF-8 文件,中文立刻变乱码;② 写回时按 UTF-8 编码,乱码就固化了。换行丢失是因为 GBK 解码器把 `\n` 字节当成了上一个多字节字符的后续字节。 为什么值得记:这条命令"看起来只是替换一个字符串",破坏性与"格式整理"相当(同类教训:别为了顺手格式化而重建整个文件)。而且损坏**不是立刻可见的** —— 只有当那个文件被 node 加载时才炸,容易被误判成逻辑 bug。 2026-10-11 实测:lib/storage.js(约 590 行)被这样改坏,50/588 行中招;逆向恢复救回了字符但换行回不来,最终整份重写并用 34 项校验器验证。 --- ## E50 · DSH 插件的名字/图标不显示且不报错:元数据在 locale/en.json,不在 package.json 的 meta - **症状**:给 DSH 插件加了展示元数据,但界面上**看不到名字和图标**,而且**不报任何错** —— 插件功能照常工作。四种写法都会导致这个现象: ① 把元数据写在 `package.json` 的 `meta` 字段里 → **根本没人读**(im-bridge 就是这么写的,无效) ② 建了 `locale/zh.json` 但没建 `locale/en.json` → en 是 base,缺它连 zh 都不查 ③ 没在 `exports` 里放行 locale → 宿主解析 `pkg/locale/en.json` 时得到 `ERR_PACKAGE_PATH_NOT_EXPORTED`,被当作"没有元数据"**静默略过** ④ `package.json` 没写顶层 `icon`(或写成绝对路径 / 扩展名不在 svg|png|jpeg|webp 内) 另外:**UI 侧也是静默回退** —— `PackageArtwork` 在 `src` 未定义或图片 `onError` 时 直接渲染默认图标,所以"没读到"和"读到了但加载失败"在界面上长得一模一样。 - **解法**:做插件卡片元数据时按这五条写,任一条漏掉都是**静默失效**(插件照常工作,只是没名字/没图标,不报错): ① 建 `locale/en.json`:`{"meta":{"title":"...","description":"..."}}`(en 是 base) ② 要中文再加 `locale/zh.json` ③ `package.json` **必须**加 `"exports": {"./locale/*":"./locale/*"}` —— locale 是经 **Node ESM 解析器**读的,受 exports 门控;没放行会得到 `ERR_PACKAGE_PATH_NOT_EXPORTED`, 而 `optionalResourcePath()` 把这类错误当「没有元数据」直接略过 ④ 图标放包内相对路径,并在 `package.json` 顶层写 `"icon": "icon.svg"` ⑤ npm 发布前确认 `files` 数组里含 `locale` 与 `icon.svg` 排查「设了但界面没变」:先重启(元数据在启动时读),再看**侧边栏的「插件」页** (不是设置里的自定义 section —— 那里是你自己写的面板,跟 meta 无关); 仍然没有就用 `diag-plugin-meta.mjs <包目录>` 看宿主实际算出了什么。 - **细节**:规则来源(逐条对着源码读出来的,不是猜的):`dsh-app-boot/lib/index.js` 的 `readPluginMeta()` / `dictionariesOf()` / `localizedText()` / `iconOf()`。 真实契约: · 标题/描述来自 **`locale/en.json`** 的 `{"meta":{"title","description"}}` · **package.json 里的 `meta` 字段根本不被读**(很多第三方插件照抄了这个字段,是无效的) · `en.json` 是 base:它不存在 → 其他语言也一概不查 · 其他语言:`locale/.json`,lang 必须匹配 `/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/` · 返回值是「语言 → 文本」映射,且**始终保留 en 回退** · 图标:package.json 顶层 **`icon`** = 相对路径,svg/png/jpeg/webp,≤256 KiB, realpath 后不能出包目录(绝对路径或带 scheme 会抛) · UI 端:`PackageArtwork` 在 `src` 未定义或 `onError` 时**静默回退成默认图标** 另有 `diag-plugin-meta.mjs` 可复算宿主会返回的 display 字段(打印标题映射 / 描述 / 图标是否被采纳)。 --- ## E51 · 按文档措辞猜返回值形状:listProviders() 返回对象数组不是字符串数组 - **症状**:照文档(甚至官方 README)写的代码,在真机上表现为"拿不到任何东西",而**类型断言/try-catch 都不会报错** —— 因为返回的**形状**和假设的不一样: · `ctx.llm.listProviders()` 我按 README「按注册顺序报告已注册路由」假设是 `string[]`, 于是写了 `.filter(p => typeof p === 'string')` —— 真机返回的是**对象数组**, 结果被我自己的过滤器清空,上层报出「没有可用的模型提供方」,看起来像"这台机器没配模型"。 同类症状:把 `[{id:'x'}]` 当 `['x']` 用、把 `{value}` 当裸值用、把 `Map` 当 `Object` 用。 判据:**"应该返回什么"与"实际返回什么"不一致时,错误会伪装成"环境没配好"。** - **解法**:不要按文档措辞推断返回类型,去读实现:`listProviders()` 的真实实现是 [...this.adapters.values()].map(({ provider }) => ({ ...provider })) 即返回**对象数组**,路由名在 `.id`。 写成两种形态都兼容的归一化: const ids = raw.map((p) => typeof p === 'string' ? p : p?.id ?? p?.name).filter(Boolean) 并**在调用方留形状证据**(本插件的 llm-probe 路由同时返回原始对象和拍平后的 id 列表), 下次出问题一眼就能分清是"没有提供方"还是"有但形状不是我以为的"。 校验器也要按真实形状写:假 LLM 必须实现 `listProviders()` 返回**对象数组**, 否则模拟的不是真实契约,测试会给出假绿。 - **细节**:同类判据(这是"从定义处反查"的第 N 次):**要返回值形状,就读实现或类型定义;文档的措辞只能告诉你"它做什么",不能告诉你"它返回什么"。** 正确做法:`diag-read-doc.mjs` 读那个包的 `lib/index.js`,搜方法名看 `return`。 顺带两个同批查出的事实(都用得上): · `ctx.llm.stream()` 的 **`provider` 不能省** —— 省略会 `NO_ADAPTER: no adapter registered for provider "undefined"`。 框架**没有全局默认提供方**;参照 `dsh-compaction-basic`,它用的是自己配置的 `summarizationProvider`/`summarizationModel`。 · 失败分片形状:`{ type:'finish', reason:{ kind:'error', failure:{ code, message } } }`(是 `reason.failure`,不是顶层 `failure`); 而**正常**结束是 `{ type:'finish', reason:'stop' }` —— `reason` 是**字符串**。把字符串 reason 当失败会导致"每次调用都报错"。 诊断路由:宿主侧 `GET /dsh-memory-lite/api/llm-probe`(本插件自带)能列出实际注册的提供方与模型。 --- ## E52 · 假 ctx 用 Map 存事件处理函数,第二次注册把第一次覆盖,校验器假绿 - **症状**:给 DSH 插件的校验器加断言时报"打不开数据库文件",但插件代码明明是对的。根因不在被测代码:假 ctx 用 `handlers.set(event, handler)` 存处理函数,而 `apply()` 对同一个事件(`agent/pre-step`)注册了**两个**处理函数(召回、压缩后补回),后一个把前一个覆盖了 —— 于是"pre-step 只追加不改写"这条断言一直在测**补回**那个分支,而每轮都在跑的**召回**分支从未被执行过。 - **解法**:假 ctx 改成 `Map<事件, 处理函数数组>`:`on(event, h)` 时 push 进数组;驱动时按真实瀑布语义串起来跑 —— `const step = (i, d) => { const h = list[i]; return h ? h(payload, () => step(i + 1, d)) : Promise.resolve(d); }`,最内层返回基线 decision。另外单独加一条断言把"注册了几个处理函数"钉住(`handlers.get(evt).length >= 2`),这样覆盖问题会在断言层面直接暴露。 - **细节**:通用判据:**假 ctx 里凡是"事件 → 处理函数"的映射,只要用单值容器(Map/对象属性),任何被注册多次的事件都会静默丢掉前面的处理函数。** 这类假绿很隐蔽:插件跑起来是对的(DSH 真的会串起多个处理函数),只有离线校验器少测了一条路径。 发现它的方式是"新断言打不开库" —— 因为那次数据库根本没人建过。同族教训见 E13(校验器通过 ≠ 行为正确)与 E42(断言写错导致的假红/假绿)。 **2026-10-12 补充:同一个坑还有第二个实例,而且方向是反的(假红)。** 给插件的 `installExperienceTool` 加**第二个**工具(`search_experience`)之后, `verify-experience-write` 立刻从 39/39 变成 **19 通过 / 20 失败**。它的假 ctx 写的是 `let toolDef = null; register: (d) => { toolDef = d; ... }` —— **只留得住最后注册的那个**, 于是所有断言都在检查 `search_experience`(症状:`required` 变成 `["keywords"]`)。 修法:`const toolDefs = new Map(); register: (d) => toolDefs.set(d.name, d)`, 断言处按名字取(`toolDefs.get('record_experience')`),**不要依赖注册顺序**。 通用判据扩展:**假 ctx 里凡是"某个键 → 某个对象"的映射,只要用单值容器, 第二份注册就会覆盖第一份 —— 它制造的是假绿还是假红,取决于断言盯的是哪一个。** 所以假 ctx 的容器要按"真实世界会不会有理多个"来选,并且在加第二条注册时 **跑一遍旧校验器**(这次就是跑全套时立刻暴露的)。 --- ## E53 · 先比对后规范化:先清洗再比"包含",包含关系会消失 - **症状**:给记忆插件写"存量日志去重复前缀"的清洗脚本:先 `clean(text)` 再和上一条比包含关系,结果 10 行里 **0 行**被判为重复。但同一批数据在**原文**上明明是 6/9 对满足"后一条完整包含前一条"(这是之前实测出来的现象)。"清洗后包含关系消失"这件事本身不报错,只会让去重静默失效 —— 表现是"脚本跑完了,体积只降了 30% 而不是 70%"。根因:宿主注入的 `` 块会跨行、也会跨越两条日志的边界,两边各自清洗时块边界落在不同位置,于是被删掉的片段不同,包含关系就断了。 - **解法**:顺序改成:先在**原文**上比对(`diff(原文, 上一条原文)`),拿到增量**再去清洗**(`clean(增量)`)。判据:规范化会成块删除或折叠内容时,A 包含 B **不能**推出 clean(A) 包含 clean(B) —— 跨越边界的块只会在一边被整块删掉。凡是要靠"包含/相等"判定关系的地方,都先问一句"我的规范化是不是同态"。 - **细节**:通用判据:**"包含"关系在规范化前后不一定保持** —— 只要规范化会成块删除或折叠内容,A ⊇ B 就不能推出 f(A) ⊇ f(B):跨越边界的块只会在一边被整块删掉。 实测数字:原文上 6/9 对满足包含;先各自清洗再比对 → **0 对**;改成先比对再清洗 → 恢复 6/9。 同族判断:"我要不要先 normalize 再比较" —— 只要 normalize 不是同态(保结构),先 normalize 就可能把信号抹掉。应先比原始、后规范化,或两边用同一份规范化输出再比。 --- ## E54 · 接手别人未提交的改动:先分清归属与是否完工,别直接 add -A 当成自己的提交 - **症状**:进一个会话后,`git status` 显示工作区有一批未提交改动,**而其中有些不是自己做的** —— 上一轮(或另一个并行会话)留下的。典型表现: $ git status --short M CHANGELOG.md M README.md M package.json ?? icon.svg ← 我完全没建过这个 ?? locale/ ← 也没有 而且状态互相"对不上",让人怀疑是不是自己搞坏的: · `package.json` 的 version 已是 `1.2.5`,但**标签只到 `v1.2.4`** · CHANGELOG 里已经有人写好了 1.2.5 条目,但**没有对应的提交** · `git log` 最新一条的主题跟你正在做的事无关 **危险动作**:直接 `git add -A && git commit -m "更新文档"` —— 于是别人的半成品被当成你的文档改动一起定版, **真修复被藏进一个看不出主题的提交里**,以后 blame 不出来是谁做的、为什么改。 - **解法**:进会话先跑 `git status --short`,**逐条分辨哪些改动是自己的**。 出现不认识的文件名时,按这四步查(查清之前**不要 `git add -A`**): **① 文件名就是归属证据** 我这次只改 README.md,却看到 `locale/` + `icon.svg` + `package.json`—— 这个组合明显指向"插件展示元数据",跟文档无关。 不认识的文件名 = 不是我的工作。 **② 读未提交的 CHANGELOG —— 它通常自述了这是什么** git diff CHANGELOG.md 另一个会话往往已经写好了条目(含根因、依据、改动清单)—— 这是判断"这是什么、谁做的、做到哪"最省力的一手材料。 **③ 用"完工三判据"分清半成品与完工** · 新文件与 CHANGELOG 描述**一一对应**(说新增 3 个文件,就去看是不是 3 个) · `package.json` 的 `files` / `exports` 也跟上了(漏了 = 发布时不带) · 版本号已升、CHANGELOG 已写,但**没提交/没打标签** → 完工待定版 **④ 验证后再提交,别"看着像对的就提交"** 给这批改动**补写断言**再提交。我这次写的 12 条: 顶层键无重复、locale 合法且含 meta.title、icon ≤256KiB 且含 ` { child.get('webServer').register({...}) })`),但离线校验器里**关于这些路由一条断言都没有**,而且不是"忘了写"—— 是它们在假 ctx 下**根本没注册**。因为假 ctx 没有 `inject`,`ctx.inject(...)` 直接抛 `ctx.inject is not a function`,异常被插件自己的 `guarded()` try/catch 吞掉,路由数组永远是空的(真机日志里那句 `web-routes-FAILED ctx.inject is not a function` 就是证据,只是平时不会去看)。 - **解法**:假 ctx 补上 `inject(names, cb)` 和一个服务桩(如 `webServer.register` 把定义收进数组),然后**把真实 handler 抓出来直接调**(造一个只有 `writeHead`/`end` 的假 res,从 `end` 的字符串里 JSON.parse 出结果),而不是自己再写一遍调用逻辑。加完之后立刻能测到:新增成功、空内容被拒、非法枚举兜底、越界数值夹取、编辑分支、以及派生数据(FTS)是否同步。 - **细节**:同族第三次(前两次:`Map<事件,fn>` 覆盖同名事件处理器、生成脚本相对路径落错目录)。这次的形态是"整块子系统消失":不是某条断言写错,而是**被测代码根本没被注册**。 通用判据:看到"某个子系统一行断言都没有"时,先查假 ctx 有没有给它那个服务 —— 别先去看被测代码。另:`ctx.inject` 的真实语义是"按声明给服务",假实现里 `scoped.get(name)` 对未声明的名字应当返回 undefined(我这里近似成"声明了就给",够用且简单)。 附带一条:断言要用**真实 handler**(抓出来直接调),不要自己再写一遍调用逻辑 —— 否则测的是复制品。 --- ## E57 · 面板里的旧快照撞上服务端的新规则:报的错指向用户没做过的事 - **症状**:面板上报"少了小节标题 ### 性格 / ### 信任",但文件里那两个标题明明在(用脚本读出来验证过、逐个字符编码都对)。原因不是文件坏了,而是**客户端表单里装的是旧快照**:面板打开的那一刻宿主还是旧代码(返回的文本只含一节),之后宿主被重载成新代码 —— 于是提交时,服务端拿**当前文件结构**去校验**过期内容**,自然报缺标题。mtime 也救不了:文件本身根本没变,变的是代码。 - **解法**:两条一起做:① **提交失败就自动重读**(把面板内容刷成服务端当前状态),并在提示里**明确告诉用户"已重新读取、直接再点一次"** —— 只报错不重读,用户会反复点同一个按钮;② 别急着改校验规则(校验是对的),先按上面那条确认"到底哪边是旧的":直接请求正在跑的宿主路由,比对它返回的形状与磁盘上代码的形状是否一致。通用判据:**只要"服务端校验的是当前状态、客户端提交的是历史状态",报错就会指向用户没做过的操作**(这里是"你删了标题")。 - **细节**:这条与"会话内改了插件代码"同源(ESM 缓存按文件 URL 键 → 宿主进程跑的还是旧模块),但**症状出现在服务端校验里**,很容易被误读成"文件坏了 / 用户把标题删了"。 这次的实际时间线:我先把 lib/persona.js 写成"连续区间"版(人设框只装「### 身份」),随后改成"按小节分组"版;用户在中间那一刻重启了客户端 → 面板拿到的快照只有一节;之后宿主换成了新版 → 用户点保存时,服务端用**新结构**校验**旧内容** → 报"少了小节标题 ### 性格 / ### 信任"。 可用的确认手段:**直接问正在跑的宿主**——`Invoke-RestMethod http://127.0.0.1:19387/dsh-memory-lite/api/persona`,看它返回的形状;比翻日志快得多(宿主日志本来也不落盘)。 --- ## E58 · 把实现细节暴露成用户格式要求:从"### 标题不能删"说起 - **症状**:交付的东西能用,但用起来别扭:界面上一段纯文本,却被要求"不能删里面的 `### 标题`",删了就报错、保存被拒。用户直接说"不合理"。根因不是 bug,而是设计——我把"我怎么定位这段内容"的实现细节,变成了用户必须遵守的格式。 - **解法**:先问一句:**这是需求,还是我的实现偷懒?** 如果是后者,改存储形态让约束消失,而不是把它写进文档让人遵守。具体两条: ① **让"存什么"和"怎么存"解耦** —— 内容存进自己的文件/字段,正文就是正文,不加任何标记(本例:`persona.md` / `rules.md`,文件内容 = 文本框内容,零解析); ② **每轮生效的位置先问框架要** —— DSH 的 system prompt 段落表里已经有 `DEPLOYMENT_PERSONA_PREFIX(0)`、`DEPLOYMENT_PERSONA_SUFFIX(10200)` 与人设槽位名,`ctx.systemPrompt.section({name, order, text})` 就是正规入口,不需要借用户的文件当宿主。 - **细节**:这次的具体形态:我要在设置页提供"人设 / 工作规范"两个文本框,选择了把它们存进用户的 `AGENTS.md`,并用我加的 `### 身份` / `### 核心原则` 当定位锚点。于是校验里多了一句"标题行不能删"。用户的原话是:**「这些###的标题不能去掉吗?不合理。」** 顺着这句查下去,发现了框架本来就有的东西:`dsh-system-prompt` 的 `SECTION_ORDERS` 里 **`DEPLOYMENT_PERSONA_PREFIX = 0` / `DEPLOYMENT_PERSONA_SUFFIX = 10200`** —— 人设是一等公民,`ctx.systemPrompt.section()` 就是"每轮必到"的正规做法。而我把内容塞进用户的文件、再发明锚点,纯属绕路。 同族反问:**"这个字段/标题/格式为什么必须保留?"** —— 如果答案只能是"因为我的解析依赖它",那就是把自己的实现成本转嫁给了用户。 --- ## E59 · 搬数据时手写 SQL 撞上 FTS5 外部内容表:没有 id 列 - **症状**:写一个"把数据从 A 库搬到 B 库"的脚本,用 `node:sqlite` 直连数据库手写 INSERT。跑起来报 `table entries_fts has no column named id`(或类似的"列不存在"),明明是照着 `entries` 表的列写的。 - **解法**:**能调自己的 API 就不要手写 SQL。** 搬数据/改数据一律 `import { openMemory } from '../../lib/storage.js'`,然后 `put/get/update/list`;只在**诊断/只读核对**时才直接开 `DatabaseSync`(那时只 SELECT,不碰表结构假设)。 判据:写操作一旦需要知道"表里有哪些列、索引怎么同步",就说明你应该调 API。 - **细节**:场景:V1.7 新增"用户级记忆库"后,要把 5 条已有条目从工作区库搬进用户级库。我第一版脚本直接 `new DatabaseSync(globalPath)` + 手写 `INSERT INTO entries VALUES (...)`,然后在建 FTS 语句时崩了:`Error: table entries_fts has no column named id`。 根因:`entries_fts` 是 FTS5 的**外部内容表**(`content='entries'`,靠 rowid 关联),它只有 title/content/keywords 三列,没有 id。手写 INSERT 要么用 `INSERT INTO entries_fts(rowid, ...)` 配对 `entries` 的 rowid,要么触发 rebuild。 改法:**别手写 SQL,用插件自己的 `openMemory({dir})` 返回的 API**(`put/get/update/list`)——它内部处理 FTS 同步、字段规范化、敏感判定、id 生成。改完一次通过。 附带一条:脚本要"先复制、后归档",顺序反了中途失败就是两边都没有;且演练(dry-run)默认,`--apply` 才写。 --- ## E60 · PowerShell 5.1 读改含中文的 UTF-8 文件:标签被"吃掉"、文件结构静默损坏 - **症状**:用 PowerShell 对含中文的 UTF-8 文件做"读 → 正则替换 → 写回"后,页面/文件表现为**莫名其妙的结构性故障**:例如 HTML 里 `
Edge 为主,其他浏览器也能做
` 变成 `
Edge 涓轰富锛屽叾浠栨祻瑙堝櫒涔熻兘鍋?/div>` —— 注意行尾的 `鍋?/div>`,**中文字符把 `>` 吃掉了**,闭合标签从 `
` 退化成文本,后面整棵 DOM 错位。表现上不是"报错",而是布局行数暴涨(某卡片量出来 1326px 高)、页脚被裁、测量脚本量出前后矛盾的数字。排查时会误以为是 CSS/flex 的问题,实际文件已经被写坏。 - **解法**:**不要用 PowerShell 的字符串管道处理含中文的 UTF-8 源码文件**(`Get-Content -Raw` + `-replace` + `Set-Content -Encoding UTF8` 也不行 —— 读入那一步就按 ANSI 解过一道了)。 改用下面任一种: · **Node 脚本**:`readFileSync(p,'utf8')` → 替换 → `writeFileSync(p, out, 'utf8')`,字节级安全,实测有效; · **编辑工具**(edit / write):只改目标片段,不做整文件正则; · 若必须用 shell,走 **PowerShell 7(pwsh)**:`Get-Content -Raw -Encoding utf8` → `Set-Content -Encoding utf8NoBOM`。 判据:改完后**先做一次标签计数**再往下走 —— `div: open vs close`、`span: open vs close`,数目不等就是已经被写坏,别继续调样式。 - **细节**:实测案例(2026-10-12,做闲鱼名片长图):为了在无头 Edge 里量真实内容高度,用 PowerShell 把测量脚本注入 HTML(`$src = Get-Content -Raw` → `-replace ''` → `Set-Content -Encoding UTF8`)。结果文件里 `... >` 的闭合尖括号被中文尾字节吃掉,DOM 结构损坏。我因此连续三轮把"页脚被裁"当成 CSS 高度问题去调(缩字号、减 padding、改 flex),全部无效;还写了两版测量脚本,量出的数字(`.card` 高 1326px、`.head` 高 2437px)自相矛盾——因为量的是"被破坏的 DOM"。 最后靠"标签计数"发现 open/close 不等才对上号,改成 Node 注入后一次量准:内容真实高 2060px(改前实际溢出画布),据此一次性压到 1855px 出图。 附带经验:**HTML 长图排查顺序**应为「① 文件结构是否完好(标签计数)→ ② 量真实内容高(`scrollHeight`,不要写死 `height` + `overflow:hidden`,那会把溢出裁掉同时让 flex 子项拉伸)→ ③ 才调字号与间距」。另外量几何时用 `offsetTop/offsetHeight` 而不是 `getBoundingClientRect` 混用,两者在滚动容器里会给出互相打架的数字。 --- ## E61 · 发布前检查扫错了对象:git push 推的是已提交对象,不是工作目录 - **症状**:给插件写了"发布前干净检查",本地跑说"没有必须处理项",于是放心地推 —— 而推出去的包里仍然带着那个必须删掉的文件。判据:你为了"不再发布某个文件"做的 `git rm --cached` **只改了暂存区**,检查器读工作区或 `git ls-files` 时已经看不到它,**但它还在已提交的树里** —— 下一个 clone 的人照样拿得到。 - **解法**:内容从 **git 对象库**读,不要读工作区磁盘。清单用 `git -C ls-tree -r -z --name-only `;内容用**一次** `git cat-file --batch`(stdin 传 `:` 列表,按 ` \n\n` 顺序解析)批量取 —— 别对每个文件起一次 git。pre-push 钩子从 stdin 拿 ` `,用那个 **local sha** 当 ``,扫的就是"这一次真正要发出去的那棵树"。 - **细节**:实测差距 —— 同一个仓库,工作目录口径报 39 条、git 对象口径报 **49** 条,差的 10 条正是那个"暂存了删除、还没提交"的文件。配套纪律:**先提交 → 再体检 → 再推**,因为体检报告描述的是已提交的树,未提交的改动它看不到(所以"体检通过"不等于"我的工作区干净",只等于"这一棵树干净")。另外 pre-push 的 local sha 可能是全 0(删除分支),那种情况要跳过、不能拿去 `ls-tree`。 --- ## E62 · 「改了代码但行为没变」的自证方法:双时间戳 + 双哈希 - **症状**:改了插件代码、行为没变,于是开始瞎猜(ESM 缓存?路径不对?热重载没开?插件没加载?)。根因是**没有任何地方记录"正在跑的这一份模块是什么时候加载的"**。实测到的现场:宿主进程 06:57 启动,两个插件的入口 mtime 是 10:15 / 15:14 —— 跑的都是旧模块,而这件事在界面上**完全看不出来**。 - **解法**:做"双时间戳 + 双哈希"自证。模块顶层(load 期)记下 `LOADED_AT = Date.now()` 与每个关键文件的**内容哈希**;运行时重算比对,不一致就是"磁盘上的代码不是正在跑的那一份"。再取三样佐证:`import.meta.url`(判 link 还是复制)、`ctx.hmr?.config.root`(判热重载范围)、profile 的 `package.json` 里那个依赖串(第三条交叉验证)。进程启动时刻用 `Date.now() - process.uptime()*1000` 反推。 - **细节**:三条实测结论。① **判"改没改"要用内容哈希,不能用 mtime** —— `touch`、或用编辑器重写**同样内容**都会刷新 mtime 而内容没变,用 mtime 会假报"未生效"。② Node 的 ESM 默认对路径做 `realpathSync`,所以 **`import.meta.url` 指向 link 的真实目标(开发目录)**,而不是 profile 的 `node_modules` 路径 —— 据此判安装方式:路径里出现 `/profiles/<名>/node_modules/` 是复制安装,否则是 link(`dsh-hmr` 的 README 也写了 "watched module paths use realpathSync() spelling")。③ **HMR 默认只监听配置、不监听源码**:`@deepseek-ai/dsh-base/cordis.patch.yml` 里 `config: { root: [] }`,而包自身 schema 的默认是 `["."]` —— 被 base 组合覆盖,**两处矛盾以 base 为准**。实测印证:profile 的 patch 改完 2 秒后插件重挂载(配置**会**热生效),而源码不在监听范围。所以"改配置会热生效、改源码不会"这两件事长期被当成一件。 --- ## E63 · 字符级剥注释器会被正则字面量带偏,改成按行判 - **症状**:写了个"剥掉注释再扫路径/标识符"的检查器,结果某个 JSDoc 块里的内容**仍然被当成代码**扫了出来。反方向更危险:真代码被当成注释抹掉 → **漏报**,而检查器会安静地说"干净"。根因是剥注释器按**字符**扫描、跟踪引号与块注释状态,遇到**正则字面量**里的 `/` 就错位 —— `/\.(jpg|png)$/i` 里那个 `/` 被当成了注释开始,此后状态与真实位置不再对应。 - **解法**:改成**按行**判注释。逐行看开头是不是 `//` / `/*` / `*` / `#`(YAML / TOML / shell 用 `#`);块注释靠"起始行含 `/*`、结束行含 `*/`"配对;注释行整行置空但**保持行数不变**,这样行号才对得上。这种写法**不可能被行内的正则或引号带偏**。 - **细节**:代价是**行尾注释**(`const a = 1; // 路径 D:\x`)剥不掉 —— 那个方向是**多报**而不是漏报,可以接受(宁可噪音,不要静默漏)。通用判据:文本级扫描工具要优先选**不会失步**的判据(按行、按结构),而不是"更聪明"的字符级解析 —— 因为解析器失步时你不一定看得出来,而它的失败方向可能是"漏"。同族见 E37(注释里引用的反例被误报)与 E42。 --- ## E64 · 插件能列服务名、列不了方法名:`ctx.reflect.props` - **症状**:想在诊断面板里显示"当前上下文有哪些服务",`ctx.services` 不存在,也没找到官方自省服务;`cordis_inspect_query` 那个 agent 工具背后**也不是活注册表**。 - **解法**:用 cordis 的内部字段(`@deepseek-ai/cordis` v4):`Object.keys(ctx.reflect.props).filter(n => ctx.reflect.props[n].type === 'service')` 就是服务名清单。`ctx.reflect` 是 Context 的 **own property**(构造里 `this.reflect = new ReflectService(self)`),子 ctx 原型继承,所以**任何插件都能用、不需要 inject**。另有 `ctx.reflect.get(name, false)`(strict=false 时连未激活的也返回)与 `ctx.registry.values()` → 每个 fiber 的 `store`。 - **细节**:**三条边界必须跟着一起报,否则面板会误导人**。① 那是 **root 级全局表**,**不按当前插件的上下文过滤**,跨 isolate 域都会出现 —— 它回答的是"整个进程有哪些服务",不是"你这个 ctx 能看到什么"。② **不含四个内建**:`logger` / `events` / `registry` / `reflect`(它们是直接赋值的,不进这张表)。③ **拿不到方法名与签名** —— 那在**构建期生成的静态目录**里(`dsh-tool-cordis/lib/types/api-catalog.js`,头注释写着 "Generated by scripts/gen-cordis-api.ts"),不是活注册表;要查签名只能用宿主侧的 `cordis_inspect_query`。这几个字段在文档里 0 命中(未文档化的 public 字段),**跨版本要重新确认**。 --- ## E65 · 路由里「先 writeHead 再求值」:一个异常就能把宿主进程带走 - **症状**:插件宿主半的 HTTP 路由在 try 里**先** res.writeHead(200)、**再**求值要返回的内容。平时看不出来;一旦求值抛异常(内容函数内部报错,或返回值里有循环引用让 JSON.stringify 抛),就进 catch,而 catch **又写一次头** → ERR_HTTP_HEADERS_SENT 从 HTTP 请求处理器里逃出去 → **宿主进程直接 exit 1** → DSH 外壳判定异常、弹故障恢复框 → 用户只能选「禁用第三方插件」,于是这个 profile 里**所有第三方插件被打包摘掉**(依赖还在,bundles 里没了)。实测:点客户端一个诊断 tab(它加载时第一个请求就打这条路由)→ 一分钟内连崩三次。更坏的是 catch 里没记日志时,**原始异常被吞掉**,现场只剩第二次的 ERR_HTTP_HEADERS_SENT,真因彻底不可见。 - **解法**:两条一起做,缺一不可: ① **先把 body 算完、再写头,只写一次**(顺序是关键): 先 try 求值并 stringify → 失败就在 catch 里换成 {ok:false,error} 的 body → 然后**统一** writeHead + end。 ⚠ JSON.stringify 也必须放进 try —— 返回值里有**循环引用**时它同样会抛(实测报 Converting circular structure to JSON)。 ② **整个请求处理器外面再套一层 try/catch**(纵深防御),并且必须判 res.headersSent:头已经发出就只能 res.end() 收尾,**绝不能再 writeHead**;catch 里要把异常**写进日志**(否则真因永远看不见)。 ③ 顺序纪律:**所有可能抛异常的初始化都放在「开端口」之前** —— 插件没激活就不该有端口在听。 - **细节**:实测 2026-10-12,DSH 0.2.0-rc.2 + dev-tools(宿主半 app-control.js)。 完整链路(四步,缺一环都不会炸): ① index.js 一句日志调用了一个**没 import** 的函数(firstNonEmpty,它定义在 experience.js)→ ReferenceError → dsh 日志写 warning: 1 entry did not activate,即**插件未激活**; ② 但控制接口的启动写在那句日志**之前**,所以端口已经先起来了 → 留下「状态是失败、端口还在听」的僵尸服务; ③ 客户端点一个诊断 tab,它加载时第一个请求就是 /self-check; ④ 那条路由先 writeHead 再求值,求值抛(真因被 catch 吞掉)→ catch 又 writeHead → fatal。 判据(怎么一眼认出这一类):崩溃报告里**同时出现两个异常** —— 上面是真正的原因(ReferenceError / TypeError 之类),紧跟着 fatal uncaught exception: Error [ERR_HTTP_HEADERS_SENT],且堆栈指向**你自己的** HTTP 处理器里的 writeHead(我这里指到 app-control.js 的 catch 分支)。另外宿主日志里最后一行是「apply 开始 / 接口就绪」之类,**没有任何退出记录** —— 因为不是正常退出。 校验器为什么会放过它:原来的校验器**只直接调模块、不走 HTTP**(collectSelfCheck() 返回什么就断言什么)。「覆盖不到的路径等于没测」。修完必须补一条走真实 HTTP 的回归:起真实控制服务,让内容函数抛异常 / 返回循环引用 / 正常返回,断言 ① 都拿到干净的 JSON 错误(ok:false + error)② 进程 **0 次 uncaughtException**。实测三种情况通过。 补充:记录这条经验时 record_experience 工具本身也不可用(它就是被禁用的那个插件注册的),所以走了 tools/diag/record-experience-cli.mjs —— 直接调插件的 appendExperience()。 --- ## E66 · 验收改动生效:客户端刷新 ≠ 宿主重启,判据是「进程启动时间 vs 文件写入时间」 - **症状**:改了插件代码、界面也刷新了,于是以为已经生效 —— 结果**只有一半生效**。客户端半(client.js)是页面加载时取的,刷新页面就换新;宿主半由**宿主进程**加载,而 ESM 缓存按文件 URL 键(同一进程内同一路径永远返回同一模块实例),不整进程重启就永远是旧代码。最容易误判的形态是**部分生效**:同一个进程里有的模块是新的、有的是旧的,于是"已有新行为的日志"和"旧代码的报错"同时出现,看起来像补丁写错了,其实只是没重启。 - **解法**:三条一起做,缺一条就会出现"部分生效": ① **判据用两个时间戳,不用感觉**:宿主进程启动时间 vs 目标文件写入时间。 找 owner:`Get-NetTCPConnection -LocalPort <端口> -State Listen` → `OwningProcess` → `Get-Process -Id ` 的 `StartTime`。 文件比进程新 = 没生效,重启,别去读代码找 bug。 ② **更硬的做法是哈希不是 mtime**:dev-tools 的 `GET /dev-tools/self-check` 就是为这件事写的 —— 它列出每个关键文件 `changed: true/false`。**全部 false 才等于"正在跑的就是盘上那份"**。 为什么不用 mtime:`touch` 或用编辑器原样重写都会刷新 mtime 而内容没变,用 mtime 会假报"未生效"。 ③ **说话时把两半分开**:刷新页面 = 只换**客户端半**(`client.js`,页面加载时取); 重启客户端 = 换**宿主半**(`index.js` / `app-control.js` / 各模块)。 别用"刷新了"代表"重启了" —— 这两件事换的东西完全不同。 - **细节**:实测现场(2026-10-12,DSH 0.2.0-rc.2 + dev-tools,Windows): 时间线(这就是"部分生效"的样子): 19:45:13 app-control.js 写盘 19:45:31 index.js 写盘 19:55:30 **宿主进程启动** ← 上面两个已在盘上 → 生效 19:56:53 client.js 写盘 ← 客户端半,刷新页面即生效 19:57:20 self-check.js 写盘 ← **比宿主启动晚 1 分 50 秒** → 没生效 于是出现最难判的形态:**同一个进程里有的文件是新的、有的是旧的**。 表现是两个矛盾的现象并存 —— `/dev-tools/self-check` 已经会"记日志并返回 JSON 错误"(这是我的新代码在跑), 可它返回的却是 `{"ok":false,"error":"cannot get property \"hmr\" without inject"}`(旧 self-check.js 的 bug)。 看起来像"补丁没写对",实际是"半个进程还是旧的"。 怎么找出 owner 进程(Windows): Get-NetTCPConnection -LocalPort <端口> -State Listen | Select-Object -First 1 → OwningProcess Get-Process -Id | Select-Object StartTime 两个时间戳一比就定性:文件比进程新 → 没生效,必须整进程重启。 重启后的对照(同一台机器,同一条命令): 旧宿主(19:55:30):self-check → {"ok":false, "error":"cannot get property \"hmr\" without inject"} 新宿主(20:16:35):self-check → ok:true,hmr.available=true、root=[],且 10 个关键文件全部 changed=false 反例判据:**重启之后仍报同一个错,那才是真的代码问题**。重启能消失的,都是缓存/世代问题,不是代码问题。 --- ## E67 · 校验器的事实源写死成手工白名单:真 token 被漏、也被误判为不存在 - **症状**:校验器用一份**手工维护的允许清单**当判据("这些 token 是真实存在的")。它一开始能用,但**产物会往前跑**:清单漏掉了多个**真实存在、官方页面正在用**的 token,于是"用对的 token"反而被报红;同时它把某个官方在用的真 token 判成"不存在"。表现就是**代码是对的、校验器红**(与 E42 同一族),越往后越不可信,最后没人看它 —— 而它本来是为了防"token 名写错静默回退到硬编码"这种事。 - **解法**:四条一起做: ① **判据从产物本身抽,不要手抄。** `--dsw-*` 的"定义" = 打包后 CSS 里**名字后面跟冒号**的那些(实测从 app.asar 抽出 405 个)。读 asar:解析头部 JSON → 遍历 tree → 按 `offset/size` 读子串(`diag-grep-asar.mjs` 里有现成实现,照抄)。 ② **分两档,别一刀切。** "有定义" = 权威;"只出现过、没定义" = 可重绑变量 / 别名(`--dsw-focus-ring-color`、`--dsw-elevation-stroke-color`、`--dsw-font-mono` 这类),它们本来就允许无定义,但**必须给 fallback**。只有"在产物里哪儿都找不到"的才判失败 —— 那才是拼错。 ③ **已知错名单独列一条硬断言。** `bg-primary` / `border-primary` / `text-primary` / `fill-primary` 这类"看起来合理但不存在"的,写死一条断言。它不是判据(判据是①),是**留档**:让下一个人知道这几个名字踩过。 ④ **工具要能换目标,专属断言要能门控。** 别把某个面板的断言绑死在所有文件上(实测给第二个插件跑时满屏假红);加"目标含某个标记才跑那组"的条件。 - **细节**:实测 2026-10-12(DSH 0.2.0-rc.2,Windows): `D:\DSH-dev\tools\verify\verify-theme-tokens.mjs` 原来是 **im-bridge 专用 + 手工白名单**(约 40 个 token 名)。给 dev-tools 做界面 token 化时先拿它扫 dev-tools/client.js → 报「未查证 13 个」,而这 13 个在 asar 里**全部有定义**(换事实源后一次抽出 405 个定义)。 另一个方向的实测:dev-tools/client.js 里原有的 `var(--dsw-alias-border-primary, #2b3040)` 是**假 token**(真实叫 `border-l1/l2`),7 处边框一直静默回退到硬编码深灰。没有这条断言时,它在浅色主题下只表现为"边框颜色怪",**永远查不到原因**。 格式细节:查"把前景色当背景色用"**不能用 `includes`** —— `--dsw-alias-label-primary-inverted` 里包含子串 `--dsw-alias-label-primary`,会把合法的反向文字色误报成违规。要按 token 边界匹配(正则 + `(?![a-z0-9-])`)。 把某个面板专属的断言绑死到所有目标上会满屏假红:原来的第 3–5 节全是 QQ 面板的(原生输入框阴影、气泡表面、composer 限宽),对 dev-tools 必然全红。改成"目标带 `qqb-` 或路径含 im-bridge 时才跑那组"。 改完的实测结果:dev-tools **14/14**、im-bridge **25/25**(确认原用途没被改坏)。 --- ## E68 · 服务方法"摘下来调用"丢了 this:注册静默失效,而假 ctx 让它永远测不出来 - **症状**:DSH 插件里注册 systemPrompt 段落(或任何服务方法)后,真机上**功能等于没做**,但什么都不报错:人设/规范不进提示词、记忆索引段是空的。宿主日志是黑洞看不到,插件自己的校验器又全绿(283 项)。查加载日志才看到 `Cannot read properties of undefined (reading 'layers')`。 - **解法**:三条一起做: ① **服务方法一律属性调用**:`ctx.systemPrompt.section(def)`、`ctx.tools.register(t)` —— 不要先赋给变量。 ② **假 ctx 照真实现写**:真实现是类方法就用对象方法(要 this),并加**反向自证**断言"把方法摘下来调用必须抛",保证这个形态不会被后人改宽松。 ③ **注册类日志报事实不报期望**:把实际注册成功的名字收集进数组再打日志(写死期望值等于自欺)。 判据:任何 `const x = ctx..` 都值得警惕;任何被 try/catch 包住的注册,都必须有一条"注册成功了"的**独立**断言。 - **细节**:真机现场:memory-lite 插件里两处注册段落 const section = ctx.systemPrompt.section; // ← 摘下来,this 丢了 return section({ name: 'memory-lite:index', order, text }); 框架实现(dsh-system-prompt/lib/index.js:240): section(section) { if (!Number.isFinite(section.order)) throw new TypeError(...); return this.layers.effect(this.ctx, (layer) => layer.sections.insert(...)); } 于是 this === undefined → `Cannot read properties of undefined (reading 'layers')`,被 guarded 吞掉 → **段落静默不注册**:人设/规范与记忆索引从未进提示词(一整天),而离线校验器 283 项全绿。 假 ctx 里我把 `section` 写成了普通函数(不用 this)→ 这个错在离线永远暴露不了。 修法:`prompt.section({...})`(属性调用保留 this);假 ctx 改成要用 this 的方法 + 加反向自证(摘下来调用必须抛)。 日志侧:apply-done 原来写死了 `sections:['memory-lite:index','memory-lite:persona']` —— 报期望不报事实,段落全挂它也说"注册了"。改成收集实际注册名。 另一个同源坑:dispose 里 `stores.clear()` 没有 close() → SQLite 句柄不释放,Windows 上临时目录删不掉(rmSync EPERM 把校验器带走)。 --- ## E69 · dispose 只 clear() 没 close():SQLite 句柄不释放,Windows 上连临时目录都删不掉 - **症状**:Windows 上删临时目录/缓存目录报 `EPERM`(有时 EBUSY),明明没有任何程序在用;把目录里的数据库文件单独删又能删掉。在插件/服务里表现为"重载一次就多占一份资源",久了句柄耗尽或文件锁死。 - **解法**:① 每个缓存了"带句柄的对象"的容器,在 dispose 里**先逐个 close()、再 clear()**,并 try/catch(已关闭不该影响卸载)。 ② 顺手把释放数量写进日志(`{storesClosed: n}`)—— 没数量就没法判断到底放没放。 ③ 测试/校验器的临时目录清理一律**非致命** + maxRetries:句柄可能还被占着,删不掉不该判定测试失败。 判据:看到"清理临时目录报 EPERM/EBUSY/文件被占用"时,先怀疑**自己的 dispose 没 close**,而不是先怀疑杀毒软件或权限。 - **细节**:真机现场:memory-lite 的 dispose 里只有 `stores.clear()`(Map 清空),SQLite 连接仍开着。 后果 ①:Windows 上文件句柄不释放 → 校验器尾部 `rmSync(临时目录)` 抛 EPERM,**把整个校验器带走**(跑完的 150 条结果都来不及打印,看起来像"校验器坏了")。 后果 ②:插件热重载/禁用再启用会一次次泄漏连接。 修法:遍历 `stores.values()` 调 `memory.close()`(try/catch 包住),再 clear,并把 close 了几个写进日志。 配套:校验器的临时目录清理改成**非致命**(`try { rmSync(...) } catch {}`)+ `maxRetries` —— 临时目录留在 %TEMP% 无所谓,校验结果比它重要。 --- ## E70 · 改完规则别批量重算存量:脚本报"有差异"分不清改进与降级 - **症状**:批量重算/迁移脚本跑完,数据"都对"但**质量集体下降**:原本人写好的名字被规则推的短名字覆盖(`用户画像:十八 / 产品运营 / 深圳` → `用户画像`)。而且这种损失**不可逆**(除非有备份),因为覆盖的正是人投入过的地方。 - **解法**:三条一起做: ① **默认只填空白,不覆盖已有值**(`无标题才补`);重算全部必须是**显式开关**,且在脚本头部写明"只用于复查"。 ② 需要救坏值时,用**有判据的子集开关**而不是全量开关(本例 `--fix-short 6` = 只救"标题比 6 字还短",一次只命中 1 条),并且**先演练打印 old → new**。 ③ 演练输出要能一眼看出方向:把"旧 → 新"并排打,别只打新值 —— 人扫一眼就能发现降级。 判据:**只要"重算"的结果可能比"现状"差,就必须由人逐条过,或者用可解释的判据缩小子集。** - **细节**:现场:memory-lite 的标题规则修好两处 bug(盘符冒号被当标签、兜底可退化成光秃秃的 `…`)后,我想"顺手把 47 条存量标题全部重推一遍",于是给脚本加了 `--all`。演练输出 19 条"差异",逐条看过去: 用户画像:十八 / 产品运营 / 深圳 → 用户画像 (降级) Issue 工作流:五个标签 = 谁的回合(…) → 用户的 GitHub Issue 工作流 · 标签体系(降级) dev-tools 1.3 能力迭代:4 个能力 + 元守卫 → dev-tools (严重降级) memory-lite 真机全线跑通(含蒸馏 4 层…) → memory-lite 于 2026-10-11…(降级) 原因是这些标题本来由模型/人写的,规则推的只是"够用"。真正坏的只有 1 条(标题 4 字)。 改法:加 `--fix-short `(只救短于 n 字的),`--all` 保留但只当复查用并在头部写清"不要用于写入"。 --- ## E71 · 换了个"存在"的主题 token,元素反而变白/透明 - **症状**:把插件的按钮底色从 var(--dsw-alias-button-primary-fill) 换成"深一档"的 var(--dsw-alias-button-primary-hover),结果按钮变成白色(用户报"还是白色")。token 校验器(verify-theme-tokens.mjs)对着同一个文件跑是**全绿**的 —— 它只证明"这个名字在 asar 里出现过",证明不了"运行时那个子树能解析到"。 - **解法**:两条一起做:① 用 token 一律留**多层兜底**,且最后一层绝不留成 transparent/白 —— 例如 background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, color-mix(in srgb, currentColor 14%, transparent)))。② 关键外观同时写一份**内联** style(内联一定生效),把