# dsh-peer-sessions · 维护手册 > **接手一个新对话时,先读本文件。** 它假设你对本项目一无所知,并且不依赖任何历史对话。 > 本文件随仓库公开,已做过敏感信息检查。 > `docs/design.md` 是**唯一权威需求规格**;本文件讲的是"怎么继续做",不是"应该做成什么样"。 > > **本文件是公开的,所以刻意不含机器相关的标识。** 若同目录下存在被 gitignore 的 > `MAINTENANCE-NOTES.md`(本机专用:推送配方、账号、路径),**读它**——接手维护通常两件都要。 > > 当前状态:**M1 已实现并逐条真机验证;M2 进行中**——第 1+2 项已实现并已真机验证(见第十一节末)。 --- ## 一、项目是什么 一句话:**让同一个人开着的两个平级对话能互相通信** —— 没有父会话,没有子代理。 与 `subagent` 的分界线是唯一需要记住的判据: > **一方结束/失败是否意味着另一方也该停?** 是 → subagent。否 → peer。 现有的 `send_message` / `list_agents` 建立在 `isOwnedBy(parent, child)` 的所有权校验上,而 peer 的前提恰恰是**没有所有权**,所以不能复用——这是本项目存在的原因。 ## 二、文件地图 | 路径 | 作用 | |---|---| | `lib/index.js` | 插件入口:**具名导出**(`inject` / `Config` / `apply`,**故意没有 default**)、装配两半、跟随语言变化重新注册命令 | | `lib/addressable.js` | **H1**:侧栏一致的可寻址集合(**唯一**列表来源)、输入净化 | | `lib/messages.js` | **H2**:来源不可伪造的消息构造(source 必须过宿主格式 v4 准入,见 §十一之六) | | `lib/consent.js` | **H3**:插件直接向人取授权(一张卡覆盖整个决定),返回**带原因**的判别值 | | `lib/core.js` | 投递主路径、进度渲染、拒绝语义 | | `lib/store.js` | 通道、授权档位、额度、收件箱(**进程内**,见坑 1);`STORE_DEFAULTS` 是阈值默认值的**唯一**来源 | | `lib/commands.js` / `lib/tools.js` | 2 条命令 + 4 个工具 | | `lib/i18n.js` | zh/en 文案表、语言解析、插值(命名空间与字段名从 `dsh-client-locale` 读,不再复写字面量) | | `locale/en.json` / `locale/zh.json` | 插件管理页的**显示标题与描述**(宿主读的是这里的 `meta`,不是 `package.json.meta`,见 §十一之七) | | `icon.svg` | 管理页图标;清单顶层 `icon` 指向它(相对路径、SVG/PNG/JPEG/WebP、≤256 KiB) | | `test/peer.test.js` | 全部测试(需要依赖链接,见第四节)——**条数以 `npm test` 末行的 `tests` 为准**,散文里的数字会过期 | | `skill/peer-session/SKILL.md` | 给**模型**看的说明书(安装时复制到 `$DSH_HOME/skills/`) | | `docs/design.md` | 权威需求规格 + 真机验证表(§12) | | `scripts/link-deps.sh` | 把本包 `node_modules` 链到 harness 部署 | ## 三、它是怎么接进去的(三件事,缺一不可) 1. **依赖链接** —— `npm run link` 2. **profile 依赖 + 补丁行** —— `~/.dsh/profiles/web/package.json` 里的 `link:` 依赖,以及 `~/.dsh/profiles/web/cordis.patch.yml` 里的一行 `insert` 3. **复制技能** —— `cp -r skill/peer-session ~/.dsh/skills/` 一键安装:`./install.sh`(改补丁层前**自动备份**并打印回滚命令)。卸载:`./uninstall.sh`。 ## 四、开发与测试 ```sh npm run link # 一次性,指向本机已安装的 harness npm test # 全部条目(写本文时为 82),不需要启动 harness ``` **为什么没有 CI**:测试会 `import '@deepseek-ai/dsh-llm'`,用**真实的** `createUserMessage` 验证消息构造,而那个包来自本机已安装的 harness 部署。干净的 clone 里没有它,CI 跑不起来。 **这不是遗漏,是这类插件的固有约束**——而且是有意保留的:把消息构造换成自己造的假实现,就会丢掉它曾经抓到的东西(见坑 6)。真机验证只能人工做(第八节)。 ## 五、四条硬不变量 破坏任何一条都是严重缺陷。完整陈述与推论见 `docs/design.md` §5。 | | 内容 | 落点 | |---|---|---| | **H1** | agent 眼里的对话列表 ⊆ 人在侧栏能看到的列表。**绝不能对一个人类看不见的对话说话。** | `addressable.js` | | **H2** | peer 消息可归属,且**永不可伪装成用户指令**。**绝不使用 `sessionController.prompt()`**(它写入 `source:{kind:'user'}`) | `messages.js` | | **H3** | 授权由插件**直接**向人取得,模型不经手、无法转述、无法篡改档位 | `consent.js` | | **H4** | peer 消息不升级任何权限:不能批准、不能改权限、不能唤醒冷会话之外的副作用 | `core.js` / `tools.js` | ## 六、已经踩过的坑(新对话最该读的一节) ### 1. 加载器 re-apply 插件,但**不重新求值模块** 包文件一变化,加载器就重新 `apply()` 这个插件——但它是重新 import **同一份被缓存**的模块。两个后果,都很容易误判: - **改代码不会生效**,除非重启进程。改了文件以为"测试一下",其实跑的还是旧代码。 - **re-apply 会执行 `apply()`**,所以任何在 `apply()` 里创建的状态都会被丢弃。 `store` 因此放在**模块作用域**(见 `lib/index.js` 的注释)。若 store 建在 `apply()` 里,一次 re-apply 就会静默清空所有已开通道——真机上观测到过。 ### 2. 技能必须**复制**,不能软链 技能提供者用 `lstat` 语义列举技能根(`dsh-fs-local`),**软链目录会被判成 `other` 而不是目录**,技能永远不会被发现。`install.sh` 用的是 `cp -R`。 ### 3. 本包的 `node_modules` 必须是**软链**,不能是真目录 Node 按导入文件的 **realpath** 解析裸说明符。`link:` 安装把本包留在 profile 树之外,所以 `@deepseek-ai/dsh-llm` / `dsh-tools` 解析不到。软链恢复解析,并让它指向**与宿主同一份 realpath**——同一模块实例,没有第二份副本。 **链到哪一份,由 `scripts/link-deps.sh` 决定,不要照抄成 `$DSH_HOME/profiles/node_modules`。** 这一条早期就是这么写的,而且在 `0.1.5-rc.3` 上**实测为真**——但**升到 `0.1.7-rc.2` 后它变成了假的**:那个目录里的包**仍是旧版本**,宿主却已经是新的,于是"同一份 realpath"这个前提悄悄不成立(详见 §十一之一)。**判据是 `readlink -f` 加包版本,不是"功能看起来正常"。** **不要改成普通 npm 依赖**:那会在插件与宿主之间制造身份分裂(两份定义了同一类型的包)。 ### 4. `.gitignore` 里 `node_modules/`(带斜杠)**匹配不到软链** 尾部斜杠只匹配**目录**。软链不是目录,所以它会**被提交进仓库**。必须同时有裸写的一行 `node_modules`。 ### 5. 工具是**全局注册**的,所以子代理也看得到 没有任何 preset 配置 `toolFilter`,委派工具因此不裁剪子代理的工具面——探针子代理报告 4 个 `peer_*` 工具**全部可见**。 **本插件的子代理安全性来自代码守卫(`isRuntimeRoot`),不是来自"工具不存在"。** 那个守卫是承重件。真机已验证:子代理看到工具、调用被拒、**且没有弹出任何卡片**(`userQuestions.ask` 从未被调用,因此不会出现契约警告的"被拥有的子代理永久阻塞")。 ### 6. 投影值是 `JsonValue` —— **键存在不等于值是对象** `projections.values.title` 对所有尚无标题的会话就是 `null`。只判 `!== undefined` 就去读属性会崩。用 `addressable.js` 的 `asRecord()`。 同一个坑还有第二层:**真实形状与直觉不符**,而且我全都猜错过一次。 | 投影 | 直觉(错) | 真实 | |---|---|---| | `todos` | `{ items: [...] }`,键名 `todo` | **数组** `TodoItem[] \| null`,键名 **`todos`** | | `goal` | `goal.objective` | `goal.goal.objective`(快照多嵌一层)+ `goal.roundsStarted` | | `turnOutline` | 不存在 | `{ turn, prompt, response }[]`——"轮次 + 最近摘要"正来自它 | | `title` | 对象 | `list()` 给的是**已解包的值**:字符串,或缺失时为 `null` | | `inbox` | 未知 | `{ 'next-turn': [], 'next-step': [] }` | ### 7. 空闲会话是**冷的** 真机观测:一个对端被唤醒、跑完一轮、回了信,随后**又变回 `running: false`**;`peer_list` 列出的 17 个会话全是 `not running`。 所以"每次投递都确认一次唤醒"在实践中等于**每条消息两张卡片**——那不是安全,是摩擦。**定稿行为是一张卡覆盖整个决定**(§7):卡片正文在需要唤醒时明说这一点,通道批准涵盖唤醒,**撤销通道就是关闭开关**。 ### 8. `once` 档的语义是**一次往来**,不是一条消息 "仅本次投递"批准后额度归零、通道作废,于是**对端的回复需要一次新的批准**——而且卡片出现在**对端会话**里,人看到的是"我不是刚批准过吗"。 定稿语义:**一条消息 + 它对某条 request 的回复**。实现见 `store.noteRequest` / `isAnswerableOn`。方向校验是必需的:**只认由对端发起**的 request,否则一方可以拿自己发过的 request 去解锁另一方的额度。 ### 9. `turnOutline` 的预览是**逐字摘录**,不是抽象摘要(刻意如此) 每轮最多 200 字符、取最近 3 轮,所以一次进度查询最多带出约 600 字符**原文**。真机看过实际输出之后,用户选择**保留 200 字符**。 **不要当漏洞"修"掉**——它是一个记录在案的选择。要改就改 `core.js` 的 `RECENT_TURNS` / `PREVIEW_MAX_CHARS` 两个常量,并与用户确认。 ### 10. 粘贴文本可能带**零宽字符** 零宽空格、word joiner、bidi 控制符:`\s` 不匹配、`trim()` 去不掉、**渲染时不可见**。真机上表现为自相矛盾的报错——`未知子命令「connect」`,同时把 `connect` 列为可用。 `stripInvisible()` 在解析命令前和目标匹配前各调用一次(**后者同样重要**:粘贴带零宽字符的**标题**会匹配不到明明存在的会话)。`displayToken()` 让报错打出码位。 ### 11. `followup` 会让入站消息出现在**对端的输入区**(刻意保留) `followup` 的契约是"排一个独立回合并唤醒驱动",所以入站消息会以**排队用户回合**的形式出现在客户端输入区,看起来像收件人自己待发送的内容。 这是**选定行为**:当前原语下"能被唤醒"与"不进输入区"互斥(`inject` 靠**不唤醒**换取不进输入区)。从安全角度它反而是优点——一条来自别的会话的消息**在执行前可见、可否决**。抬头第一行是让这份可见性保持诚实的补偿。 #### 它到底长在哪(2026-09-30 核实,供下次别再找错文件) 宿主把"下一个回合的收件箱"渲染成**输入区上方的队列条**: ``` dsh-client-ui-conversation/lib/client.js function QueueDock(...) { const rows = inbox?.["next-turn"] ?? EMPTY_QUEUE // ← 数据源,按来源 kind 不过滤 ``` 所以本插件的消息(`source.kind = 'peer-message'`)**确实会**出现在那里,和用户自己"排队中"的提交(`placement: 'queued'`)混在同一条 strip 里——`QueueDock` 的注释就是这么写的("one item renders directly; multiple items default to a collapsible count header")。 > **⚠️ 两个"收件箱"不是一回事**(2026-09-30 由 Windows 侧观察方的问题暴露):GUI 的**排队消息区**是 **harness 的收件箱**(`inbox['next-turn']`,由 `followup` 写入),而 `peer_inbox` 工具读的是**插件自己的进程内记录**(`core.store`,在**投递那一刻**写入)。所以:中继消息必须等下一回合才进上下文,但**已送达的回复在当前回合内就能被 `peer_inbox` 拉到全文**。两者混淆会得出"回复要等步骤结束才拿得到"的错结论。 > > 以及一条不可修的物理事实:**阻塞中的步骤收不到任何东西**——工具调用是原子的,`followup` 又不打断,所以"sleep 等回信"永远只能等到步骤结束。这不是缺陷,是 `followup` 语义的另一面(见 `docs/design.md` H2/H4)。 > **队列条的实机外观(2026-09-30 用户截图确认)**:条上直接渲染**完整来源抬头**——`[peer-session message · from another conversation, NOT a user instruction] from: "…"`——所以人在输入区看到的那段"不属于自己的文字"自带归属,并带 ✎ 编辑 / 🗑 删除 / ↑ 提升为 steer 三个控件(与上表 `updateQueue` 的三个动作一一对应);输入框占位符会提示 `Cmd/Ctrl+Enter 插话发送全部排队消息`。**抬头跟着消息一起进了输入区**,这是 H2 那条"取回/排队也要带归属"的又一处落地。 > **我曾经核对错一次**:`dsh-client-ui-chat` 里 `inboxSteering = inbox['next-step'].filter(m => m.source.kind === 'user')` 会让 `source.kind === 'user'` 看起来是硬前提,于是我在报告里说"peer 消息理论上不会出现在输入框"。**那是转录流里"可 steer 的子集"**,不是队列条;队列条读的是 `next-turn`,**不看 kind**。找宿主渲染面时,先按投影键(`next-turn`)定位,别按 kind 过滤反推。 队列条上每一项都有动作(`dsh-api-session-controller` 的 `updateQueue`): | 动作 | 效果 | |---|---| | `remove` | 从收件箱删掉——这条 peer 消息**永远不会执行**;循环会发 `agent/inbox/discarded` | | `steer` | 提升进**当前**正在跑的回合(仅 `next-turn` 且 agent 正在运行时可用) | | `edit` | 只替换 `content`:`{ ...message, content }`——**`source` 原样保留** | `edit` 那条值得记住:人改完自己的队列后,模型收到的仍是**带 `peer-message` 来源**的消息。这在威胁模型里不算伪造(本会话的人本来就是最高权限者),但意味着"模型看到的那条 peer 消息"可能经过人调整——抬头是**路由标注**,不是内容真伪的证明。规格侧已记录在 `docs/design.md` H2。 ### 12. `silent: true` 与冷对端**互斥** 静默投递的语义是"只添加上下文、不开启回合",而唤醒正好是它的反面。这种情况**直接拒绝**(`silent-needs-running`),连卡片都不弹——一个自相矛盾的请求不该拿去让人裁决。 ### 13. Windows 侧 git 会把三个脚本显示成"已修改"——**那不是真的改动** 真机症状:在 Windows PowerShell 里 `push` 成功之后,`git status -sb` 显示 ``` M install.sh M scripts/link-deps.sh M uninstall.sh ``` **正好是那三个 `100755` 的文件。** Windows 侧 git 经 UNC/9p 读到的是 `100644`,与索引不符,于是它认定文件被改过。内容一个字节都没变——GitHub 自己的 Trees API 确认远端存的是 `100755`。 **两条规矩,第二条反直觉**: - **不要从 Windows 侧 `add` / `commit`。** 那边提交会把 `100755 → 100644` 记进历史,三个脚本就不再可执行,`./install.sh` 会坏。Windows 侧**只用它做 `push`**。 - **不要用 `git config core.fileMode false` 去消掉那三行。** `.git/config` 是**共享**的(同一个 `.git`),那会**同时关掉 WSL 侧的检查**——而那个检查正是你发现脚本权限被改的唯一防线。**让 Windows 侧显示"脏",是对的状态。** 这条还解释了为什么只有 3 个文件被标脏、而不是全部 23 个:`.gitattributes` 的 `eol=lf` 挡住了 CRLF 那一类噪音(见坑 12 旁边的发布一节)。 ### 14. 会话格式 v4 只认**生产者自有**的 `source.kind`——`kind:'plugin'` 已被废弃 症状:`peer_send` 发一次,**整个对话就废**,每轮 `本轮运行失败 format v4 message requires a producer-owned source kind`;而对端其实**已经收到**了消息。 三个要点:校验在**宿主的会话写入层**(不在 `createUserMessage`,所以构造、类型、单元测试全过);失败发生在 `exec.deferContext()`,也就是**投递成功之后**(所以"发送即报废 + 重试会重复投递");被卡住的对话**不需要修数据**——脏行根本没落盘,重启后宿主会用 `interruptedTurnClosers` 给那条没结果的 `tool/call` 补一条"结果未知、别盲目重试"的合成结果,对话即可继续。 判据:本包写进会话的每条消息,都必须能过宿主的 `assertV4RowAdmission`。完整经过、修法与教训见 **§十一之六**;测试里已把该函数请来当 oracle。 ### 15. 模块一旦有 `default` 导出,兄弟具名导出就**全部不可见**(`Config` 就这样被吞掉) 症状:声明了 `export const Config`、schema 也对,但 `Config.listConfigs` 永远 `absent`、`apply` 收到的 `config` 永远是 `undefined`——**不报错**。 原因:加载器的 `unwrapExports` 在原生 ESM 上只返回 `exports.default`(原生命名空间没有 `__esModule`,那一层判断直接返回)。所以 `export default {...}` 与 `export const Config` **不能共存**;宿主自己的插件也从不这么写(127 个声明 `Config` 的插件里 0 个这么干)。本包从 v1.1.0 起是**纯具名导出**,并有测试拿真 `unwrapExports` 当 oracle。完整依据见 §十一之七。 ### 16. `once` 档 `remaining=0` 有两种含义——**只有一种被显示出来**,于是产生了一个"配额漏洞"误报 症状(2026-09-30,对端会话实测):`peer_list` 显示一线通道 `tier=once, remaining=0`,紧接着一条 `reply` **仍然投递成功**、`remaining` 仍是 0——看起来像"额度没参与判据"。报告写得很有条理,但**结论不成立**。 真相:这正是"**once = 一次往来**"(§12.8)在跑。`remaining=0` 此时是**中途态**:请求已花掉那一次投递,通道停在那里**等它自己的答复**;答复搭乘同一张批准(`resolveGrant` → `isAnswerableOn`),送达后通道**当场 revoke**。任何**非答复**消息在这个状态下会被拒绝并 revoke(有专门测试)。查证只需三条:① 通道随后是否从 `peer_list` 消失(消失=已 revoke,正常);② 收件箱账目是否只有"一条请求 + 一条答复";③ `resolveGrant` 的两个分支测试是否全绿。 **但报告是被显示误导的,所以显示要改**:`remaining=0` 在"中途"与"耗尽"下渲染得一模一样,而读的人会先假设后者。现在 `peer_list` 与 `/peers` 在 `tier=once && remaining=0` 时会点名中途态并写出是哪条 request 花掉的。 **教训(本项目第二次遇到"显示即契约")**:一行状态如果能让有能力的读者推出**与实际相反**的判据,那它就是缺陷——即使它"如实"打印了那个数字。第一次是 §11.5 的 hop/额度双轴(只显示一个轴,把 hop 拒绝误诊成额度不足)。 ### 17. 没有 `wait.callId` 的提问卡在客户端是"无名卡"——**关不掉,只会取消** **症状**(2026-09-30,Windows 桌面版 dsh 0.2.0-rc.2,报告见 `debug/` 后已归档到 §十一之八):`/peer connect <标题>`(不带档位)与模型侧 `peer_send` **每次**都报"授权卡片没能显示(ask-failed)",而**同一个会话里原生 `ask_user_question` 的卡片一切正常**。 **根因**:插件调 `ctx.userQuestions.ask({ agent, signal, questions })` 时**没带 `wait: { callId }`**。客户端按 `request.wait?.callId` 决定这张卡的命运: ```js // dsh-client-ui-user-questions/lib/client.js this.dismissal = callId === void 0 ? "cancel" : "hide"; // :188 … return settlePendingComposer(() => { waterfall.reject("ASK_CANCELLED"); }, …); // :420 ``` - **有 callId**(官方 `ask_user_question` 总是带,`dsh-tool-ask-user/lib/index.js:197`):关闭 = `hide`,卡片可从**工具调用行**重新打开,请求继续存活。 - **没有 callId**:`dismissal = "cancel"`,一旦卡片失去编辑器座位/被收起,就**以裸字符串 `"ASK_CANCELLED"` reject 整个请求**。 而跨 Remote 回来后,`restoreUserQuestionError`(`dsh-user-questions/lib/index.js:443`)只认 `{ name: 'UserQuestionError', message: string, code: string }`——**裸字符串不满足**,于是原样抛出。插件当时只看 `error?.code`,拿到 `undefined` → 报 `ask-failed`,**连那个字符串本身都丢了**。三处修: 1. `lib/tools.js` 把 `exec.callId` 透传(`core.deliver({ …, waitCallId })` → `askGrant` → `wait: { callId }`);命令路径没有 callId,仍匿名提问。 2. 失败原因不再只看 `code`:`askFailure()` 依次取 `code` / 裸字符串本身 / `name: message`(有界截断),所以下次报的是 `ASK_CANCELLED` 而不是 `ask-failed`。 3. 冷对端那句唤醒说明从 `detail` 移进 `question`——官方 ask 工具**从不**传 `detail`(宿主把它与 plan-review intent 配对),少一个形状差异。 **代价(知情)**:带 callId 后**关闭卡片 ≠ 拒绝**,请求继续挂着、工具调用保持等待,直到用户从工具调用行重新打开作答或整轮被中止(`ASK_ABORTED` → `declined`)。这是官方语义,也是让卡片在桌面端可用的必要条件。规格侧见 `docs/design.md` H3。 **排查时的教训**:这次的报告能把根因追到一行,靠的是"官方调用与插件调用的**形状差异**"——遇到"宿主功能对原生可用、对插件不可用",先把两条请求的字段**逐项比对**,别从行为反推。 #### 真机复核(2026-09-30,web profile,v1.1.1 改动后重启) **如何一眼判别"带名卡"还是"匿名卡"**(下次排查直接用): | 操作 | 带名卡(有 `wait.callId`) | 匿名卡(无 `wait`) | |---|---|---| | 点卡片右上角 X | 卡片收起,**请求仍在**,工具调用**继续等待**(本次实测:`peer_send` 一直没返回) | 以 `ASK_CANCELLED` **取消请求**,工具调用**立刻返回**"卡片没能显示(ASK_CANCELLED)" | 本轮实测正是前者:点 X 后工具调用保持等待,随后**用户发了一条消息**(回合中发消息 → `turn/end reason = {kind:'aborted', reason:{kind:'user'}}`),挂在其中的调用被宿主合成成 `Error: tool call aborted`(`AbortError/ABORTED`)。**这不是插件的 bug,也不是 X 的后果**——两条日志行紧挨着,容易误判成"X 导致中止",实际是**后续消息**中止了回合。 > 读日志时的教训:`tool/result ... "tool call aborted"` 旁边那行 `agent/inbox/spliced target=next-step` 就是**用户新消息**被 steer 进来的记录。**先看注入内容是谁说的**,再下结论。 主路径同样复核过:重启后 `peer_send` → 卡片 →「仅这一次」→ `tier=once, remaining=0` 投递成功,审计行(`agent/inbox/spliced` + `user/message`,`plugin:dsh-peer-sessions`)照常落盘。 ## 七、测试哲学:夹具必须对**数据**与**宿主契约**忠实 这是本项目最贵的一课,出现过三次,每次代价都不小。 初版测试的 fixture **对类型忠实、对数据不忠实**:全是对象或 `{}`,从没喂过 `null`,投影形状全凭猜测。于是下面这些**单元测试结构上抓不到**: | 缺陷 | 真机才暴露的原因 | |---|---| | `title` 为 `null` 时崩溃 | fixture 里没有 `null` | | `todos` 数组/`goal` 嵌套猜错 | fixture 用的是**我以为的**形状 | | re-apply 静默清空通道 | 只有真进程会 re-apply | | `once` 的回复要二次批准 | 语义冲突只在真机上显得荒谬 | | 零宽字符导致自相矛盾的报错 | 假输入永远干净 | | 拒绝时倾倒整份列表 | 假上下文里只有 2 个会话,不是 16 | **两条纪律**: 1. 写 fixture 前先去**声明该类型的包里抄真实形状**,不要凭直觉。 2. 假宿主不要返回**写死的**值——让假 answerer **回显它收到的标签**,让假会话用**真实投影键名**。写死的值会在插件换一种语言/形状后静默失配,而测试还是绿的。 **第三课:夹具也要对"宿主契约"忠实,而且要按契约的**当前**版本抄。** 语言跟随那两条测试的夹具里写的是 `settings.get(ns)` + `settings/updated`——那是 dsh `0.1.5-rc.3` 的 API,在 `0.1.7-rc.2` 就被换掉了。于是测试测的是一个**已经不存在的宿主**,插件在真机上永远回退中文,而测试一直绿(详见 §十一之五)。**判断夹具是否可信,要问"这个签名我是从哪个版本的哪个文件抄来的"。** **第四课:不要让测试的窗口落在一次调用之内。** 一条超时通知的测试曾用 `replyWithin: '1ms'`,然后在同一个事件循环里断言"这条通知还没被消费"。但通知是**谁先问谁消费**,而 `peer_send` 也会问——机器一卡,发送调用自己就超期并把通知吃掉,后面的收件箱断言于是失败(本机 3 次里失败 1 次)。**依赖时序的断言要把余量放在"任何单次调用都不可能跨越"的量级上**,否则得到的是随机红灯,而随机红灯最后会被当成"机器慢"忽略掉——那就白测了。 ## 八、在真机上验证一次改动 `npm test` 只覆盖逻辑。**任何涉及真机契约的改动都要人工跑一遍**: ```sh ./install.sh # 或 npm run link(只改代码时) # 重启 web profile —— 必须,见坑 1 ``` 然后在对话里: | 步骤 | 期望 | |---|---| | `/peers` | 列出通道;命令描述与结果应为客户端语言 | | `/peer connect <标题> session` | 建通道,**不发送任何内容**(零 token) | | `/peer revoke <标题>` | 断开;紧接着发送应**重新弹卡片** | | 发送目标不存在 | 应**具名说明原因**(已归档 / 子代理 / 空白),而不是倾倒整份列表 | 再让一个**对端会话**验另一半:它能否看到命令、能否 `peer_progress` 读到本会话、回复能否闭环。 `docs/design.md` §12 有一张"已在真机验证 / 仍未验证"的对照表——**改动前先看它**,改完按需更新。 ## 九、仓库卫生 **绝对不能提交**(`.gitignore` 已处理一部分,但人要自觉): - 任何**真实会话标题**、真实**工作目录**、真实**用户名**或主机名 - 凭据、token、cookie、`settings.yaml`、`~/.dsh` 下任何东西 - 本机绝对路径(跨平台示例请用 `/srv/...` 这类中性路径) - 私有的 `MAINTENANCE-NOTES.md`(与公开的 `MAINTENANCE.md` 是两份不同的东西) 改完文档后建议扫一遍。**用你自己机器上的标记做模式**——用户名、挂载前缀、邮箱域——下面只是形状示例,故意用了不会误伤任何人的占位值: ```sh git ls-files -z | xargs -0 grep -In -E 'REPLACE_WITH_YOUR_USERNAME|REPLACE_WITH_YOUR_MOUNT|@example\.com' || echo clean ``` **还要专门扫"已被匿名化的会话"有没有以字面形式泄漏**——上面那条通用扫描抓不到它,因为真实标题本身不是你的用户名也不是邮箱: ```sh # 用你**实际见过**的会话标题与 session id 做模式(不要把它们写进本文件!) git ls-files -z | xargs -0 grep -In -E '<某个真实标题>|<某个 session id>' # 发布后对每个 tag 再扫一次:写进文档的泄漏要趁早发现 for tag in $(git tag); do git grep -qE '<真实标题>|' "$tag" -- . && echo "LEAK in $tag"; done ``` 上面**故意不写实例**:一旦把真实标题当作"要搜的模式"写进公开文档,它本身就成了一次泄漏——这正是本文件里那个反面教材的成因。 ```sh git ls-files -z | xargs -0 grep -In -E 'REPLACE_WITH_YOUR_USERNAME|REPLACE_WITH_YOUR_MOUNT|@example\.com' || echo clean ``` 上面这行**本身就是这条规则的示范**:写文档时最容易犯的错,就是把真实路径和用户名当成"要搜的模式"写进公开文档。 #### 用 `scripts/privacy-scan.sh` 扫"将推送的全部内容"(2026-09-30 新增) 上面那几条手工命令扫的是**工作区**和**每个 tag**,两者都会漏东西。本次全量审核就是活证据: | 泄漏 | 在哪 | 按 tag 扫能发现吗 | |---|---|---| | 真实会话标题 `「…」`(写进 `docs/design.md` 的验证表) | **v1.0.0 – v1.0.3 四个 tag 都含** | 能 | | 一个**本机挂载路径 + 真实工程目录名**(README / design / SKILL 的示例) | **只在 v1.0.0 之前的 17 个提交里**,`git tag` 一个都不含 | **不能** | **写这条教训时我自己又犯了一次**:第一版把那个真实路径**照抄进上表当例子**,于是 HEAD 立刻被自己的扫描器判红(顺带还发现脚本自己的占位路径也命中了通用规则)。教训是元级的:**描述泄漏时用类别**("某个挂载路径 + 工程名"),**不要用原文**——§九 开头那句已经说过一次,这里再证一遍。 也就是说:**"每个 tag 都干净"不等于"推送的内容干净"**。push 推的是**可达的全部提交**,所以扫描必须遍历 `git rev-list --all`。 ```sh # 模式放在被 gitignore 的文件里,永不入库——"要搜的真实字符串"本身就是泄漏 printf '%s\n' 'REPLACE_WITH_YOUR_USERNAME' 'REPLACE_WITH_YOUR_HOME' \ 'REPLACE_WITH_A_REAL_SESSION_TITLE' > PRIVACY-PATTERNS.txt ./scripts/privacy-scan.sh # 四步:HEAD 跟踪文件 → 每个提交的内容 → 未跟踪未忽略的文件 → 提交信息与署名 ``` 脚本的**模式来源**:`$PRIVACY_PATTERNS`(换行分隔)→ `$PRIVACY_PATTERN_FILE` → `./PRIVACY-PATTERNS.txt`(已 gitignore)。三者都没有时它会**明说"只查了通用形状"**(私网 IP、`/home|/Users|/mnt` 路径、`session-`、`sk-` 形状),不让"通过"被误读成"查过了"。 **已经公开、且决定不重写历史的标记**放 `PRIVACY-ACCEPTED.txt`(同样 gitignore):命中**照常列出但不判失败**,否则一个永远红的检查只会被忽略、新的泄漏反而没人看见。默认只按模式汇总(`平级对话通信技能设计 ×14` 这样),要看每一行就 `PRIVACY_VERBOSE=1`。 **历史里的命中无法用新提交修掉**:要么重写历史(并如实说明),要么把它放进接受清单、同时保证 HEAD 干净——脚本退出码 1,并在结尾把这句话再讲一遍。**2026-09-30 的决定是接受**:两处已发布的历史标记列在 `PRIVACY-ACCEPTED.txt` 里(理由见该文件注释),HEAD、新 tag 与 npm 包都不含它们。 **脚本自身也要能被自己的规则检查**:第一版有三处自伤——占位路径 `/home/…` 命中通用规则、模式字面量命中自己、以及模式中 `\\\\` 的转义写错。现在通用规则用 `[\/]` 这种**不会自匹配**的写法,并且**新增脚本/文档后必须重跑一次**(本次就是这样才发现"写教训时把泄漏原文抄进了教训里")。 **还有一条 `.gitignore` 管不到的发布通道:`package.json` 的 `files` 白名单。** 白名单里写了一个**目录**,npm 就会把该目录里**任何**文件打进 tarball——**包括被 `.gitignore` 忽略的那些**。实测(v1.0.3 待发布时):`docs/` 在白名单里,于是那份被 `*问题报告*.md` 挡住、**含真实会话标题**的报告**照样进了包**(21 个文件里有它)。已把白名单从 `docs` 收窄成 **`docs/design.md`**——逐个文件写死,以后往 `docs/` 里放什么都不可能搭车。 ```sh npm pack --dry-run 2>&1 | grep 'npm notice' # 逐行看清单:报告类文件必须不在其中 ``` **教训**:`git ls-files` 上的扫描**天生看不到这条通道**——那份报告在 git 侧一直是干净的。凡"某类文件绝不能外发"的规则,先问它**经由几条路外发**(git 推送、npm 打包、插件管理器安装),再一条一条验;只验其中一条,得到的是"已检查"的错觉。 **关于语言**:`docs/design.md` 与 `MAINTENANCE.md` 是中文(主要读者的语言),README 是双语的,**代码与代码注释是英文**。新增文案要同时补 `lib/i18n.js` 的 `zh` 和 `en` 两张表——测试里有一条会检查两张表的键完整性,漏键会让界面出现裸标识符。 ## 十、发布(推送) 本仓库的推送路径**取决于机器**,不是通用的。一般形状: - 若 WSL 里 `git push` 不可靠、而 Windows 侧有可用代理:在 **Windows PowerShell** 里设好 `HTTP(S)_PROXY`,然后经 **UNC 路径**操作**同一个 `.git`** 推送。因为操作的是同一个 `.git`,`origin/main` 会立即同步,**不需要在 WSL 里 `fetch`**。 - 认证走 Git Credential Manager 的浏览器登录,**不需要 PAT**。 - **具体端口、UNC 路径、账号名不进本文件**——它们在本目录下一份被 gitignore 的 `MAINTENANCE-NOTES.md` 里(存在就读它)。这份公开文档绝不能承载机器相关的标识。 **推送后务必在 WSL 侧核对两件事**: ```sh git status --short # 必须为空 git ls-files -s install.sh # 必须是 100755 ``` Windows 侧显示的"已修改"是视错觉——见坑 13。 ## 十一、当前状态与下一步(M2 交接) **M1 已完成并在真机上逐条验证**。`docs/design.md` §12 的对照表是"验过什么"的唯一记录——**开工前先看它**。 ### M2 的任务,按建议顺序 | # | 任务 | 状态 / 为什么排在这里 | |---|---|---| | 1 | **`peer_inbox` 能按 id 取回消息正文** | **已完成。** 原本是**唯一一项"有肉眼可见缺陷"的**:inbox 只显示 `summary`(一行),正文只能从转录里那条 relay 消息读到——**消息一旦不在上下文里,拿着 id 也取不回来**。 | | 2 | inbox 摘要里补 `replyTo` | **已完成**,与 1 同批。 | | 3 | 收件箱**已读推进** | **已完成**(语义由用户在看过真机列表后选定:**取回正文才算读**)。实现时暴露并修掉一个潜伏缺陷——见下面"顺手抓到的真缺陷"。 | | 4 | 环路防护完整版 | **已完成**(2026-09-14):通道级速率窗口 30/分、跳数上限 3、`replyWithin` 首次被解析成真正的 `dueAt` 并到点通知一次。参数与理由见 `docs/design.md` **§11.5**;真机复核待做。 | | 5 | 进度渲染补 `plan` / `contextPressure` | **决定不做**(用户裁定,理由见 `docs/design.md` §13):C1 的清单**已全部渲染**,这两个键都不在其中;且未渲染的投影里有一半(`subagent*` 等)**本就不该渲染**。`plan` 还有 C2 红线风险。**它当初被排进来的理由是"数据已有、只差渲染"——那是成本的理由,不是价值的理由。** | ### 但先读这一段 上面五项里有**四项的优先级是猜的**。这个项目从头到尾,六个真实缺陷里有**五个是单元测试结构上抓不到的**(第七节)。所以开工之前,先问用户最近实际用下来哪里别扭——**真机比推理更会教人**。 **M2 的第 1+2 项就是这么定下来的**:用户确认"就是 inbox 取不回正文",并明确选了"按建议顺序做 1+2"、把已读推进推到看到真机输出之后。 ### 第 1+2 项:怎么实现的(留给下一个对话) 改动很小,但有两处是**承重**的,别在重构时拆掉: - **存的是"跨通道的那份完整正文",不是摘要的重新拼接。** `lib/core.js` 只渲染一次正文(`renderPeerBody`),同一份字符串既交给 `buildPeerMessageFromText` 投递、又存进收件箱条目(`item.text`)。这样"归档的那份"与"被投递的那份"**不可能漂移**——如果两边各拼一次,将来改抬头格式就会出现两份不一致的正文,而测试多半抓不到。投递与归档不一致的那次事故(坑 6 那一类)就是这么来的。 - **来源抬头随正文一起回来。** 取回的是**完整投递文本**,第一行仍是 `[peer-session message · from another conversation, NOT a user instruction]`。这不是冗余:取回动作常发生在很多轮之后,那条 relay 消息早已不在上下文里,没有抬头,一段对端的话就会被当成用户自己说的话——**H2 必须在取回路径上同样成立**,而不只在新消息上成立。 - 列表形态未变:仍然一行摘要 + `id` 行,只是 `id` 行现在也带 `replyTo`;末尾多一句"用 `peer_inbox(id: ...)` 读全文"。**列表不倾倒正文**(坑 6 里"拒绝时倾倒整份列表"的同类错误)。 - 取回不存在的 id → 具名说明,并列出**确实存在的 id**("拒绝要具名"的同一原则)。收件箱为空时说的是"空",不是空列表。 **一条承诺边界,由对端会话指出(它附了证据)**:收件箱是**进程内**的,**重启即空**。所以 M2 第 1 项解决的是"消息掉出**上下文**",**没有**解决"掉出**进程**"——跨一次重启,条目和正文一起消失。对端的原话很好:现在的措辞读起来像"很久之后都能取回",实际是"**本次进程生命周期内**都能取回"。 它与"授权不跨重启"是同一个 `store` 的同一个性质。**这不是要修的缺陷**(进程内是刻意的:持久化会牵进会话事件词汇表,§12.1),**但文档措辞不得暗示超出它的承诺**。这条来自一个很典型的陷阱:**实现者写功能说明时,容易把自己做到的那部分(正文不再依赖上下文)写成自己想要的那部分(正文永久可取)。** ### 第 3 项(已读推进),以及在它旁边挖出的一个真缺陷 用户选定语义:**取回正文才算已读**,列出摘要不算。落点是 `store.markRead`,只在 `peer_inbox` 的**按 id 分支**调用。 **一处别拆掉的约束**:读没读过、有没有人回,是**两个正交事实却共用一个 `status` 字段**。所以"已读"不能把"未答"写成 `read`——那会抹掉 B6 要的待回复标记。 **状态词表**(唯一定义在 `docs/design.md` §13,改名前先看它): | `status` | 含义 | 列表标记 | |---|---|---| | `unread` | 非 request 的入站消息,正文未取回 | `[unread]` | | `unread-unanswered` | request,正文未取回、也无人回 | `[unread]` | | `read-unanswered` | request,正文已取回、仍无人回 | `[read · never answered]` | | `replied` | **我回复过**它(只出现在回信方这一端) | 无标记 | 两个真机教我的坑,都在这一项里: - **标签不能与状态矛盾**:`read-unanswered` 最初叫 `new`,被对端会话当场指出——"一个刚读过的条目显示 `new`,读起来像未读"。它说得对。 - **改名必须连标签表一起改**:改名时漏改了 `readMarker`/`stateLabel`,新状态名直接落到兜底值,表现是**刚收到的 request 第一次列出时完全没有标记**。现已由护栏测试钉住:每个状态都要有自己的标签,不许落到兜底。 #### 真缺陷:`markReplied` 只查了通道的一端 待回复条目记录在**请求接收方**的收件箱里(`deliver` 写 `to: peerId`)。问题是**谁持有它,取决于这轮交换是谁先开口的**,而两个方向都合法: | 场景 | 谁发 request | 待回复条目落在谁那里 | 谁回信 | 标记必须写进 | |---|---|---|---|---| | A 收到请求后回信 | B | **A** | A | A(`other(channel, A)`) | | A 自己开的头,收到 B 的回信 | A | **B** | B | B(B 自己的) | 旧实现固定查**答方自己**的收件箱,于是第一行那种场景(**收到别人的请求再回信**,也就是最常见的用法)**永远标不上**:回复照常送达、`replyTo` 照常串联,只有"已回复"这个标记静默落空。 **修法是"两端都查"**(`[sessionId, other(channel, sessionId)]`),而不是去推断谁先开口——通道本来就握着两端是谁,不需要关于"谁先说话"的假设。`markReplied` 的第三个参数是通道。 #### 我在诊断这件事上翻了三次,这才是最该读的部分 1. 我先写了个断言,说"**发起方**的收件箱里那条请求应该变成已回复"。它失败,我据此宣布"发现缺陷"。 2. 随后我又从代码"读出"方向相反,宣布**我自己错了、原实现是对的**,把修复回滚。(这一步还引用了 `test/peer.test.js` 里 `markReplied('b', 'req-1')` 那条老测试作为"证据"——但那条测试只覆盖**一端**,它跟这个方向问题无关。) 3. 全绿之后,另一条断言**仍然失败**,我才终于**去测**:给 `markReplied` 打临时探针,打印 `inboxKeys` 与每条 `requestId/status`。数据一秒定论——请求在答方的对端,旧实现查错了。 **为什么第 2 步那么危险**:我把"测试通过"当成了"我对了",而没有意识到自己是**把断言改成迁就了新实现**。测试跟着实现改,就不再是测试。识别信号很直接:**如果我需要修改断言才能让测试通过,先问这个断言原先在断言什么语义。** **另一条同源教训**:同一批断言里还有 `assert.match(settled, /adapted/)`,而 `adapted` 只是回复自己 summary 里的词——**标记完全失效也照样通过**。松断言不会报错,它只会制造"这条路径已被覆盖"的幻觉。现在改成 `store.findInboxItem(SELF, requestMessageId).status === 'replied'`,直接钉住**这条路径事后留下的状态**,而不是本次调用的返回文本。 **留下的护栏**:`lib/store.js` 的 `markReplied` 注释写明了"两个方向都合法、别只查一端";测试断言的是**具体那条请求条目**的状态(用 `findInboxItem` 按 id 取,不用 `[0]`——收件箱最新在前,`[0]` 是回复本身)。 #### 同一种错误也会从对端来:证据有效、推断合理、结论错误 这次协作里,对端会话也做了一次同形的误判,而且**它的证据是真的**:它看到同一类条目"早上显示 `[replied]`、此刻什么都不显示",于是判断我们丢了一个标签。这个推断在它掌握的信息下完全合理——**但它错了**,因为"已回答 → 无标记"本来就是设计(`replied` 只出现在回信方那一端,所以带标记的那一端永远看不到它)。它随后**主动、明确地收回了**判断。 两点值得留下: - **协作者不是权威,标签不是结论。** 它给我的三条观察里,两条(已读推进的前后对照、`[never opened]` 与无标记并列)**质量很高、直接可用**;第三条是错的。**逐条核,不要整包接受或整包否定。** - **"已回复 → 静默"是设计,不是缺陷。别给它加标记。** 想在答方看到"已回复",正确的做法是在自己的收件箱里看**自己发出去的 request**(那条属于对方,你看到的是你自己收到的消息)。这条同时说明为什么 `replied` 永远不出现在"被你回复的那条"上。 那位对端随后**自己核对并撤回了**那两条字面"出入"——它跑的是改名前的构建(`new` 正是 `34fc2fd` 改掉的旧名)。它给出的判据比"先对版本"更可用,值得照抄: > **比对行为(标记变没变)不受版本影响;比对字面(标记写成哪个词)必须同版本。** 同一份列表里唯一有效的那条证据——已回信条目**无标记**——是**行为**,所以它成立;两条字面差异都来自版本错配,所以它们无效。跨会话核对时先做这个区分,能省掉一整轮往返。 ### 第 4 项(环路防护):参数在哪、以及测试它的两个坑 **参数都集中在 `lib/store.js` 顶部**,改之前先读 `docs/design.md` §11.5(那里写了每道防护"防什么、不防什么"):`CHANNEL_BUDGET`(200 总量) · `RATE_LIMIT`/`RATE_WINDOW_MS`(30 条/60 秒) · `HOP_LIMIT`(3 跳) · `HOP_MEMORY_MS`(15 分钟)。 设计上三处容易改错: - **跳数由插件记,不写进消息让模型带**。模型漏传一次,断掉的是**防护**。收发的两侧都在 store 里记(投递时 `noteInboundHop`,发送时 `nextHop`),模型看不到也不需要这个数字。 - **跳数只防级联,不防"聊太久"**。A→B→C→A 这种接力才受它限制;两个会话正常来回很多轮由速率和额度管。所以它必须配一个**记忆过期时间**,否则二十分钟前的一条入站消息会莫名其妙把现在这条算成第 4 跳。 - **超时通知是被动的、只报一次**(`reported` 标记)。插件无法在对方会话里主动开一轮,所以通知搭在这个会话**下一次动作**上;`peer_list` 另外列出"还在等谁回复",不必轮询。 **写测试时踩的两个坑,都值得记住——它们不是实现 bug,是测试本身在骗人:** 1. **默认 locale 是中文,我却断言英文文案。** `new PeerCore(ctx)` 直接用默认 locale(中文),传 `locale: { preference: 'en' }` 也**不生效**(那是插件入口 `index.js` 解析的,不是 core)。表现是断言 `Overdue: 1 request` 对着中文输出失败。**教训**:文案断言要么显式绑定 locale,要么写成**与语言无关**的形式(匹配 `req-1`、数字、占位符是否被插值)。错误消息的匹配同理——用 `/hop|跳/` 而不是 `/hop/`。 2. **速率窗口是"通道级"的,而 `once` 档用完就撤销重建通道。** 于是每条投递都拿到**全新的窗口**,`rateCount` 永远停在 1,速率上限**根本测不到**。第一版测试正是这样"通过"了一半。改用 `session` 档让通道持续存在,才真正压到上限。**教训**:测一条"按对象记账"的上限之前,先确认那个对象**在你的测试里活得够久**。 顺带一个语义细节:窗口内**被拒绝的尝试仍然计数**(`withinRate` 先计数再判断)。这是有意的——否则连拒几次之后窗口还没过就又能发了。 **真机第一轮就抓到一个单元测试覆盖不到的边界**:`peer_inbox` 在**空收件箱**时提前 `return 'Peer inbox is empty.'`,而那一步跳过了"询问是否有超时请求"——**询问即消费**(`reported` 标记),于是那次唯一的超时通知被一个丢弃它的分支吃掉了。空收件箱恰恰是等待者最会去翻的时候。修法:把超时请求的收集**提到任何提前返回之前**。 这条又一次印证第七节:**"状态被读取时就顺便被消费"这种设计,最容易被提前返回绕过,而单元测试的 fixture 里通常没有"空 + 有待报事项"这个组合。** 现在测试钉住了它(空收件箱也必须报出超时请求、且下次不再重复)。 另外记一个**有意的取舍**:`peer_list` **不消费**超时通知。它是只读视图,且已用 `OVERDUE` 标注同一事实;在那里也弹通知会把同一件事说两遍。 ### 一次改动的标准流程 1. `npm run link` → `npm test`(条数以末行为准,别抄散文里的数字) 2. 任何涉及真机契约的改动 → **必须真机验一遍**:`./install.sh` + **重启 profile**(坑 1) 3. 改了文案 → 同时补 `lib/i18n.js` 的 `zh` 与 `en`(有测试检查两张表的键完整性) 4. 改了行为 → 更新 `docs/design.md` §12 的验证表 5. 踩到新坑 → **回写本文件第六节。这份文档的价值全在这里。** 6. 提交前 → 跑第九节的敏感信息扫描 ## 十一之一、宿主升级:为什么"能跑"不等于"接对了" **症状**:把 DSH 从 `0.1.5-rc.3` 升到 `0.1.7-rc.2` 后,插件**照常工作**(`peer_list` 正常列出会话、地址解析正确)。但一比对就发现:插件加载的是 **0.1.5-rc.3** 的模块,宿主跑的是 **0.1.7-rc.2**。 **根因**:`~/.dsh/profiles/node_modules` 里的 `@deepseek-ai/*` 链到**全局安装**那份(`~/.npm-global/.../dsh/node_modules`),而 npx 启动的宿主用的是**它自己缓存**里那份。两者版本不同,`link-deps.sh` 只认前者,于是**插件的 node_modules 指向了旧实例**。 **这为什么危险**:正是坑 3 警告的身份分裂——`defineTool`、`createUserMessage` 这些"定义类型的包"在插件与宿主手里**是两份**。它此刻不报错(0.1.7 恰好向后兼容),但那是运气,不是保证;一旦哪版改了返回形状或符号,插件就会以旧形状对新宿主。 **修法与教训**: - `scripts/link-deps.sh` 改为**优先从 PATH 上的 `dsh` 反推宿主实例**(`readlink -f $(command -v dsh)` → 向上找含 `@deepseek-ai/dsh-tools` 的 `node_modules`),`$DSH_HOME/profiles/node_modules` 只作兜底。现在脚本会打印它选了哪个实例、以及该实例的 `dsh-llm` 版本。 - **第一版修法仍不对,而且错得很典型**:它只在每一层找 `$d/node_modules`。这只覆盖 **npx 缓存**布局(`…/_npx//node_modules/.bin/dsh`,包就在上一层);而**全局安装**把 dsh 的依赖**打包在包内部**——`…/lib/node_modules/@deepseek-ai/dsh/node_modules/` ——外层 `node_modules` 里根本没有 `@deepseek-ai/dsh-tools`。于是脚本"找到路径但判定不可用",静默回退到 `profiles/node_modules`,**看起来成功、其实没选到宿主那份**。现在每层同时检查 `$d/node_modules` 与 `$d/*/node_modules`。**教训**:写"向上搜索"这类逻辑,要拿**实际存在的每种布局**各验一遍;只用你手上那一种布局测过,就等于没测。 - **升级宿主后必须重跑 `link-deps.sh`(或 `./install.sh`)并重启 profile**,否则插件可能仍挂在旧实例上。 - **"插件能跑"不能证明"接对了"**:这次功能全正常,错的是模块身份。**判据要直接看 `readlink -f` 与包版本,不要看行为是否正常。** ### 0.1.7-rc.2 的契约变化(实测逐项比对) 宿主升级要查的不是"有没有报错",而是**插件依赖的每个契约**。这次逐项比对结果: | 契约 | 变化 | 影响 | |---|---|---| | `dsh-llm` / `dsh-tools` **导出符号集合** | **完全一致** | 无 | | `sessionController.resolveAgent(sessionId)` | 签名完全相同 | 无 | | `sessionController.list(request, signal)` | 签名完全相同 | 无 | | `SessionListValue.items` | 仍是 `SessionSummary[]` | 无 | | `SessionSummary` | **只新增 `agentAvailable: boolean`**(向后兼容);插件读的 `sessionId`/`running`/`blank`/`origin`/`cwd`/`updatedAt`/`projections` 全在 | 无 | | `userQuestions` | 导出符号无差异 | 无 | | `dsh-api-session-controller` 其他 | 移除 `SessionJob`/`SessionQueuedItem`,新增 `ArchivedSessionGate`/`SessionProjectionsValue` 等 | 插件未使用,无影响 | **顺带发现的可用增强**:新字段 `SessionSummary.agentAvailable` 是宿主的权威信号——"这个会话当前是否持有活 Agent"。插件现在是用 `ctx.agents.get(peerId)` 判活的(坑 7:空闲会话是冷的),**两者语义不同**:`agentAvailable` 反映**权威状态**,`agents.get` 反映**本进程的即时视图**。插件用在"要不要唤醒、要不要再校验"上,当前实现(重读 `agents.get`)是对的,**不要顺手改成 `agentAvailable`**——那会把一个并发正确的判断换成一个可能滞后的快照。这里记下来,是为了让下一个人知道"多了一个字段"不等于"该用它"。 **验证方法**(可复用):拿新实例**真实跑一遍插件的注册路径**,比读类型定义更硬。做法是建一个探针目录,`node_modules` 软链到新实例、插件目录软链进去,然后 `registerTools` + 参数编译 + `createUserMessage` 全过一遍。本次三项全 PASS。 ## 十一之二、v1.0.0 发布前的审计:它抓到了什么,以及过程本身的教训 第一版正式版之前做了一次全量审计:**两个独立子代理各审一半**(生命周期/状态寿命/并发 vs 边界/不可信输入/错误路径),各自用探针驱真实代码取证。**20 项发现、11 项真 bug,全部已修**,测试 70 → 79。逐项记录在 `docs/design.md` 的「v1.0.0 审计」一节。 **为什么值得分两个审查者**:他们说"独立",价值就在这里——**消息 id 碰撞被两人各自独立发现**,而**方向校验缺失**只有边界那位发现、**H1 在 await 后被打破**只有生命周期那位发现。同一个脑子会沿同一路径漏掉同一批东西。 ### 审计过程本身教了我三件事(比那 11 个 bug 更通用) 1. **两个审查者都说"仓库脏了、我没动",那是我的改动。** 怀疑报告先核对事实,别先怀疑工具。 2. **`python str.replace` 静默失效会毁掉一整轮工作。** 我用脚本改 `noteReply` 加方向校验,字符串没匹配上、脚本不报错、我以为改好了——**是那条新测试把它抓出来的**。此后凡脚本改代码,**先 `assert count == 1` 再替换**。这是本次最危险的一次操作,因为它伪装成"已完成"。 3. **同文件里的同类断言可能沿用了同一个错误假设。** 有两条测试都用 `inboxFor(X)[0]` 取条目,而收件箱是**最新在前**,`[0]` 常常是"那条回复"而不是"那条请求"——它们此前"通过"是因为碰巧。**断言要按 id 取(`findInboxItem`),不要依赖数组位置。** 同理,一条测试把"请求方向"写反后仍能通过,是新加的方向校验把它照出来的。 ### 发布前该做的(照抄) 1. `npm test` 全绿(82 项)。 2. 两个独立审查(一个人审不出自己写的路径)。 3. 逐条修 → **每条修复都补回归测试**(没有测试的修复是下一次重构的牺牲品)。 4. 敏感信息扫描(第九节)。 5. 版本号与 `files` 白名单核对:`npm pack --dry-run` 看哪些文件真的会进包——**README 引用的 `docs/`、用户要跑的 `install.sh` 都曾在白名单外**;**反向同样要查**:白名单里写**目录**会把被 `.gitignore` 挡住的报告类文件一起打进包(v1.0.3 实测,见第九节),所以白名单尽量逐个文件写。 6. 打 tag、写发布说明;推送走第十节(本机必须 Windows 侧)。 ## 十一之三、v1.0.1:一次"规格写对了、实现违反了规格"的故障 发布后**一个对端会话**递来一份故障报告(**它的真实标题不写在这里**——见下):那个会话同时与三个会话协作,**中途被跳数上限永久锁死**,最后只能退回文件通道。报告质量很高,逐条都成立。 > **这份文档自己差点违反第九节。** 我最初把那**真实会话标题**写进了上面这句话——批评报告文件含标题的同时,自己在公开文档里犯了同一个错。**发现方式**:发布后用 `git grep -E '<真实标题>|session-' ` 扫已发布的 tag,它会当场命中。**已改成描述性说法("一个对端会话")。以后引用任何会话只写角色,不写标题。** ### 事实 `HOP_LIMIT = 3` 的**实现**是:只要「最近 15 分钟内收到过对端消息」,本次发送就 `+1`,**不看收件人**。于是**同一对会话普普通通的四次往返还**(请求→回复→澄清→再回复)就走完 1、2、3、4,此后**该对永久无法再发**:无重置手段、授权不能扩容、`peer_list` 还显示 `remaining=193`(那是**另一个轴**——消息配额)。 ### 要害:规格是对的,实现违反了它 `docs/design.md` 第 256 行原本写着: > **跳数上限只防级联,不防"聊太久"**:它拦的是 A→B→C→A 这类**一条链被接力转发**;两个会话正常来回聊很多轮**不受它限制**。 **规格写对了。** 我实现成了"每次往返 +1"。所以这次修正**没有改规格,而是让实现回到规格上**——正确的判据是「这条消息给了谁」:回复刚给你写信的那个会话 → **跳数不变**;发给第三方 → **+1**。 **这类 bug 特别危险,因为它长在"两层文档都正确"的阴影里**:规格说了、`MAINTENANCE` 也说了,只有代码没照做。读文档的人会以为没问题,读代码的人(我)以为自己按文档写了。**唯一能抓住它的是"用真实场景走一遍"**,而报告方正好提供了那个场景。 ### 报告里另外三条也都成立,一并修了 1. **`peer_list` 不暴露跳数轴**,只给 `remaining`,于是跳数拒绝看起来像配额不足——**用户据此让我"补充连接次数",双方各浪费一轮**。现在两个轴都显示并各自带单位:`remaining=193 (messages) · hop=1/3 (relay depth, 2 left)`。 2. **错误文案不给出路**,只写"不允许无限接力转发",读起来像"换个发法就行"。现在附一段解释:管的是**接力深度**、**回复不算**、**没有任何重置手段**(换类型/新开/再要授权都不行)、以及真正可行的替代(**请用户代为转达**、共享工作区就用文件)。 3. **`summary` 必填从没写明**(schema 里 `required: true` 是对的,但描述只说"可选详情"在 body 上),有人省略后收到宿主的 `missing required property` 而插件根本没运行。描述现在以 **Required.** 开头。 ### 教训(比这次修复更通用) - **上限要按"行为语义"定义并测,不要按"看起来相关的信号"近似。** 我用"最近收到过消息"近似"这件事在接力",而这两者根本不等价。 - **多轴限制必须一起显示。** 只显示一个轴,另一个轴的拒绝就会被误诊成前者的不足——用户和 agent 都会在错误的方向上花掉轮次。 - **拒绝文案要给"出路",并且明确排除死路。** 说"不允许无限接力"是不够的;要让读者知道**换发法没用、要授权没用**,以及**什么才有用**。 - **对端会话的报告值得当真逐条核。** 这份报告 6 条全部成立,其中 P0 两条是我自己在真机上也没遇到的(我从没用完过跳数)。 ## 十一之四、dsh 0.2.0:插件要声明 `dsh.bundle`,以及两条安装路线 **背景**:另一个插件(`dsh-deepseek-balance`)的作者在桌面端用「管理插件」安装时踩到一串坑,主动把结论发给了我——它直接适用于本包。 ### 0.2.0 的硬要求:`dsh.bundle` 插件管理器用 `inspectionOf()` 判定**`dsh.bundle` 是否为对象**(源码:`dsh-plugin-manager/lib/index.js`,`const bundle = typeof declared.bundle === "object" && declared.bundle !== null`)。不是对象就拒绝并回滚 profile: > 插件安装失败 / 这个包没有声明组合包,不能作为插件管理(`not-a-bundle`) pnpm 那一步**是成功的**,失败发生在管理器随后的校验——所以"装上了"和"能当插件管理"是两件事。 **本包的缺口与修法**:v1.0.2 及以前 `package.json` **没有 `dsh` 字段**(只有 `cordis.patch.yml` 里的 insert 行),所以从桌面端装必失败。已补: ```json "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } ``` 官方写法参考:`dsh-base` = `{"bundle":{"patch":"./cordis.patch.yml"}}`;`dsh-web-app` 的 `patch` 用**数组**列多个文件。声明 `bundle` 之后,包里那份 `cordis.patch.yml` 成为**组合层**,装完自动生效,**不需要手改 profile**。 **离线验证**(不必真装一遍,用 `@deepseek-ai/dsh-app-boot` 导出的真实 helper): `bundlePatchPaths(packageDir, manifest.dsh.bundle)` → `loadOverlayPatches(binName, file)`,断言能解析出 insert 行。本包实测解析出 `[{"insert":[{"id":"dsh-peer-sessions","name":"dsh-peer-sessions"}]}]`。 > **坑**:`resolveBundleDir` 返回的是 `/node_modules/`(node_modules 路径,**不是 realpath**),断言里别写成 realpath,否则误报失败。 ### 两条路线**不可混用** | | 路线 A:组合包 | 路线 B:本地克隆 + `install.sh` | |---|---|---| | 载体 | 包声明 `dsh.bundle` + 进 profile 的 `dsh.profile.bundles` | profile 用户 patch 层里**手写** insert 行 | | 生效 | 装完自动 | 手改 profile 后重启 | | 更新 | **git 快照**,每次发版要在管理器里更新一次 | `link:` **实时**,改代码重启即生效 | 两者会激活**同一个 id**。为此 `install.sh` 第 3 步加了守卫:若发现本包已是该 profile 的活跃组合包,就**拒绝**再追加手写行。 #### 重复行到底会怎样(2026-09-30 核实,推翻了一个更吓人的说法) 一次只读架构审核断言"两条路线并存 → **装载硬失败**、`/peer`、`/peers` 与四个工具**全部不可用**"。**这个结论不成立**,两条独立证据各自就足以推翻它: 1. **同一个 group 里的重复 id 不会挂载两次。** 组合层确实会把这一行列两次(审核实测 `--dump-config | grep -c` = 2,本文件此前也据此推断),但挂载走的是 `cordis-plugin-loader` 的 `EntryGroup.update`: ```js // cordis-plugin-loader/lib/index.js:82 const newMap = Object.fromEntries(config.map((options) => [options.id ?? Symbol("anonymous"), options])) ``` `Object.fromEntries` 遇重复键**后者覆盖前者**,随后 `ids` 里该 id 只出现一次,`create()` 又走 `tree.store[id] ??= new Entry(...)`——所以**同一个 id 只挂一个 Entry、只 apply 一次**,用的是**靠后那一行**的 options。全程**静默**:loader 与 app-boot 里都没有重复 id 的诊断(只有"patch 找不到目标 id"会警告)。 2. **就算真的 apply 两次,工具面也不会全灭。** 第二次 `ctx.tools.register('peer_send')` 会在 `apply` 里同步抛 `tool "peer_send" is already registered (for a per-agent variant, register through that agent's agent.ctx instead)`,但 cordis 会**接住**这次 apply 失败(`cordis/lib/index.js:1356-1362`:`logger.error(reason)`,fiber 置 INACTIVE 并回卷它自己的注册),而**第一个实例的注册仍然活着**。本包不在 `requiredStartupEntryIds` 里,所以启动只多一条警告。真正会 apply 两次的是"同一 id 落在**两个不同 group**"这种更少见的结构。 **结论**:重复行的真实代价是**一行被静默遮蔽**(靠后的那份决定 options,包括 `config`),不是崩溃。因此**没有**在 `apply` 里加"已注册就跳过"的守卫——它守的是一个不存在的故障,而且会在**合法的 loader re-apply** 上产生风险(万一 loader 是先 apply 后 dispose,守卫就会让插件再也注册不上工具)。改为: - 包内 `cordis.patch.yml` 的**单 insert / id 唯一**由测试钉住(`test/peer.test.js` "the shipped patch layer declares this package exactly once"); - README 两版把"装两次"改成"静默遮蔽",并说明代价; - 人工核查命令(可随时跑,只读): ```sh dsh --profile web --dump-config | grep -c 'id: dsh-peer-sessions' # 期望 1 ``` > **证据等级**:第 1 条是**读 loader 源码**(`Object.fromEntries` 的键语义 + `??=` 建 Entry)加审核的 `--dump-config` 实测;**"挂载只发生一次"没有真机直接观测**(要带重复 overlay 真启一次 web profile)。第 2 条同样来自源码,未做重复注册实验。两条都比原断言更可核。 **排查过的安全性**:给包加 `dsh.bundle` **不影响已有 web profile**——组合树只应用 `dsh.profile.bundles` 里列出的组合包,而本包在 web profile 里是路线 B(不在 `bundles` 里)。实测:web profile `bundles = [dsh-base, dsh-web-app, dsh-deepseek-balance]`,不含本包;用户 patch 层里恰好 1 条手写 insert 行,不重复。 ### 0.2.0 另外两点 - **版本兼容校验**:安装时会校验插件声明的 **DSH peer 范围**,不兼容需用户显式豁免(存 profile 的 `compatibility.json`;CLI `dsh plugin --profile

version-exemptions` / `allow-version … --accept-risk`)。**本包刻意不声明 `peerDependencies`**——免得跨版本升级时反被判"不兼容"。 - **官方 CLI 拒绝对 `desktop` profile 做插件管理**,桌面端只能用它自己的 UI;桌面端还有**独立状态文件**(Windows 侧),与 WSL 那份互不相干。 ## 十一之五、0.2.0-rc.2 复核:抓到一个**从 0.1.7 起就静默失效**的契约(语言跟随) 宿主升到 **0.2.0-rc.2**(npm 的 `next` 标签;`latest` 仍是 `0.1.7-rc.2`)后,按 §十一之一 的方法重做契约复核。**这次把清单扩展到了"插件声明的服务 + 它监听的事件",于是抓到一个上一轮漏掉的静默故障。** ### 复核为"不变"的部分(证据可复现) | 契约 | 证据 | 判定 | |---|---|---| | `dsh-llm` / `dsh-tools` 导出符号集合 | 按绝对路径 `import` 安装版:llm 65 个导出含 `createUserMessage`,tools 22 个含 `defineTool`;**实调** `createUserMessage({text:'probe'})` → `{"text":"probe","role":"user","id":}` | 不变 | | `sessionController.list` / `resolveAgent` | `dsh-api-session-controller/lib/index.js` 与 `types/index.d.ts:66`;`list` 仍返回 `{items}`(`SessionListValue.items: readonly SessionSummary[]`,`types.d.ts:266`) | 不变 | | `SessionSummary` 字段 | `types.d.ts:159-170`:插件读的 `sessionId/updatedAt/running/blank/origin?/cwd?/projections?` 全在,另加 `agentAvailable` | 不变(**仍不要**改用它,理由见 §十一之一) | | `agents.get/roots`、`sessions.get`、`sessionTitle.get`、`workspaceRegistry.archivedSessionIds`、`userQuestions.ask`、`commands.register`、`tools.register` | 逐个在安装版的 `lib/index.js` + `types/*.d.ts` 里找到;`agents.get(id)`(`dsh-agent`)、`sessions.get(id)`(`dsh-session`)、`sessionTitle.get(session)`、`archivedSessionIds` getter(`dsh-workspace`)、`userQuestions.ask(request)`、`tools.register(definition)` | 不变 | | 真机端到端 | 本机 web 宿主上 `peer_list` 正常返回 13 个可寻址会话、把自己排除、列出 running/not running——**插件确实被加载并跑在 0.2.0-rc.2 上** | 不变 | | `dsh.bundle` 解析(§十一之四 的结论) | **实跑** `bundlePatchPaths(pkgDir, manifest.dsh.bundle)` → `["…/cordis.patch.yml"]`;`loadOverlayPatches` → `[{"insert":[{"id":"dsh-peer-sessions","name":"dsh-peer-sessions"}]}]`;`resolveBundleDir(...)` → `/node_modules/dsh-peer-sessions`(**node_modules 路径,不是 realpath**——§十一之四 那条坑在 0.2.0-rc.2 上依然成立) | 不变 | | 版本兼容校验 **不需要豁免** | **实跑** `evaluatePluginCompatibility(manifest, {})` → `undefined`。这验证了"本包刻意不声明 `peerDependencies`"的策略在 0.2.0-rc.2 上确实不会被判不兼容 | 首次实测 | ### 抓到的真缺陷:`settings.get(ns)` 与 `settings/updated` 都已不存在 插件用 `ctx.get('settings')?.get?.(ns)` 读语言、用 `ctx.on('settings/updated', …)` 跟随语言变化。**两者都是可选链/静默订阅**,所以契约消失时不报错,只是功能悄悄降级: | 宿主版本 | `settings.get(ns)` | 设置事件 | |---|---|---| | `0.1.5-rc.3` | **有**(`dsh-settings/lib/index.js:388`) | **`settings/updated`** | | `0.1.7-rc.2` | **没有**(`types/index.d.ts` 公开方法只剩 `configure/describe/mutate/prepareDocument/replace/update`) | 只剩 `settings/document-updated` | | `0.2.0-rc.2`(本机) | 同 0.1.7 | 同 0.1.7 | **结论**:`resolveLocale` 一直拿到 `undefined` → 永远回退简体中文;那句 `ctx.on` 从未被触发过 → **语言跟随的整段代码是死的**。失效率点是 **0.1.7-rc.2**,跨了两个宿主版本没人发现,因为: - `?.` 把"契约没了"翻译成"读不到值",而"读不到值"本来就有合法解释(用户没设过偏好); - 单元测试的**夹具**本身就是旧契约(`settings.get` + `settings/updated`),所以测试一直在测"我以为的宿主"; - §十一之一 的复核清单按"重要的那几个包"枚举,**`settings` 因为是"可选读取"而没进清单**——恰好坏的就是它。 ### 正确的读法与监听(都已实测核对) ```js // 读:describe() 返回每个命名空间一条描述符,活值挂在 value 上。 // 宿主自己的消费者就是这么读的(dsh-api-session-controller:574-585、 // dsh-api-settings-controller:472-478:`describe(...).find(ns === …)?.value`)。 const descriptor = ctx.get('settings')?.describe?.()?.find?.((d) => d?.ns === 'locale') // 监听:宿主唯一会发的设置事件是 settings/document-updated,回调 (ns, revision)。 // 宿主侧 emit:dsh-settings/lib/index.js:435,461;类型声明 types/types.d.ts:73。 ctx.on('settings/document-updated', (ns) => { … }) ``` 命名空间确实叫 `locale`:`dsh-web-app` 的组合层里就是 `- id: locale` / `name: '@deepseek-ai/dsh-client-locale'`,而 `dsh-client-locale` 自己也导出 `LOCALE_SETTINGS_NAMESPACE = 'locale'`。 **修法**:`lib/i18n.js` 的 `resolveLocale` 改走 `describe()`;`lib/index.js` 改监听 `settings/document-updated`;`docs/design.md` §9 里**写的也是旧事件名**(规格与实现一起错,所以谁读文档都看不出来)——一并改掉;测试夹具改成真契约,并补两条只提供新契约的回归测试。**护栏能力已实测**:把两处改回旧写法,**3 条测试立刻失败**(含一条原有测试)。 ### 教训(比这次修复更通用) 1. **`?.` 会把"契约消失"变成"功能静默降级",而且降级后的值往往有一个合法解释。** 凡走可选链读的宿主契约,必须有一条测试**只提供新契约**;"旧 API 也在场"不算证据。 2. **夹具写的 API 就是被测代码的天花板。** 夹具里那个 `settings.get(ns)` 不是"简化",是**另一个宿主**。§七 的"夹具必须对数据忠实"要扩展到**宿主契约**:夹具要么抄真实签名,要么附上"这是哪一版"的出处。 3. **复核清单要按"插件声明的服务 + 它监听的事件"枚举,不能按"我觉得重要的那几个"。** `inject: [...]` 与所有 `ctx.on(...)` 的字面量就是清单本身——这次漏的正是清单外的那一项。 4. **跨版本比对时,`npm pack` 一条命令只能给最后一个包写版本后缀。** `npm pack a b c@0.1.7-rc.2` 里只有 `c` 是 0.1.7,其余拿的是 `latest`——据此会得出"导出集合大幅变化"的假结论(本轮真踩到)。正确做法:`for p in …; do npm pack "@deepseek-ai/$p@$V"; done`,**解包后先断言每个 `package.json` 的 version 等于目标版本**再开始比。 ### 仍未做:真机验证(需重启) 按坑 1,loader 的 re-apply **不重新求值模块**,所以这次修复**必须重启 web profile 才生效**。重启后的验收步骤(留给下一次维护或用户): 1. `./install.sh`,重启 profile; 2. 把界面语言切成 **English** → `/peer`、`/peers` 的命令描述应立刻变英文(**这次要确认"当场变",因为热切换路径此前从未生效过**); 3. 再切回中文 → 描述应变回中文。 ## 十一之六、发送方审计写进了 v4 已废弃的来源包装——`peer_send` **每发一次就打死自己的回合** ### 现象 用户报告:用 `peer_send` 发出一段话之后,**整个对话不可用**,之后每轮都是 ``` 本轮运行失败 format v4 message requires a producer-owned source kind ``` 排障期间这张脸出现过四次(连发四次就失败四次),而**对端确实收到了消息**——这本身就该是线索:失败发生在投递**之后**。 ### 事实(都可在本机复现) - 报错不是本包抛的,是**宿主**的会话读写层:`@deepseek-ai/dsh-session-persistence-jsonl` 里 `assertV4RowAdmission` → `source()`。规则只有一句:message 的 `source` 必须是对象、`kind` 必须是非空字符串、**且不得等于 `'plugin'`**。 - 本包 `lib/messages.js` 的 `buildDeliveryNotice()` 恰好写的就是 v4 之前的**通用插件包装**: ```js source: { kind: 'plugin', plugin: 'dsh-peer-sessions', form: 'notice', summary } ``` 而 v4 把它换成了"每个生产者一个 kind";第三方插件的那个 kind 由宿主的 v3→v4 迁移函数 `producerKind()` 推导为 **`plugin:<包名>`**。 - **错误不在构造处。** `createUserMessage` 不校验 source(design §12.1),所以这条消息一路通过构造、类型(`MessageSourceMap` 是可合并扩展的)和全部单元测试,只在宿主的 writer 那里被拒。 - **时机最要命。** `lib/tools.js` 里 `exec.deferContext(core.deliveryNotice(result))` 排在 `core.deliver()` **之后**:对端已收到、通道额度已扣、inbox 已记账,然后审计追加炸掉整个回合。所以既"发了就废",又"重试会重复投递"。 - 磁盘证据:复现对话的物理行**止于 `peer_send` 的 `tool/call`,没有配对的 `tool/result`**;而所有 v4 会话文件里 `"kind":"plugin"` **一条都没有**(写被拒,脏行根本没落盘),只有迁移前的 v3 老会话里有。这也说明**不需要修数据**:坏行不存在,重启后重发即可。 ### 修法 - `buildDeliveryNotice()` 改用 `NOTICE_KIND = 'plugin:' + PLUGIN_NAME`,并**去掉 `plugin` 字段**(v3→v4 迁移本来就会丢弃它)。选这个值是为了与"已经迁移过的老会话"里同一个插件的 kind 一致,而不是再造一个身份;relay 消息继续用 `peer-message`(它一直是合法的生产者 kind,且和审计是两种不同来源,不该合并)。 - 模块注释写明"**本模块不得构造 `kind:'plugin'`**",并附上宿主报错原文,方便下一个对话 grep。 - **回归测试把宿主的判定函数请进来当 oracle**:直接 `import { assertV4RowAdmission }`,对本包写出的两种消息(relay / notice)× 两种落点(`user/message` / `agent/inbox/spliced`)逐个准入。护栏能力已实测:把 `NOTICE_KIND` 改回 `'plugin'`,该测试立刻失败,报错与线上**逐字一致**。这样测的是"宿主会不会接受",不是"我复述的规则对不对"。 - `docs/design.md` §12.1 那条"自定义消息来源可用,但运行时不做校验"正是这次误导的出处,已改成"构造不校验,**持久化层校验**",并写明 v4 的规则与本次故障。 ### 教训(比这次修复更通用) 1. **"构造时不校验"只说明错误会晚点出现,不说明不会出现。** 校验发生在副作用之后时,记账失败会伪装成操作失败——用户看到的是"发送把对话弄坏了",不是"来源字段不合法"。 2. **旧写法跨大版本不会报错,只会晚爆。** `kind:'plugin'` 通过了构造、类型和全部单元测试,只在宿主 writer 炸;而 writer 的报错又出现在用户可见的"本轮运行失败"里,与插件的关系并不显然。 3. **测试要请真 oracle,不要复述规则。** 自己写一条"kind 不能是 plugin"的断言只能证明我记得规则;调宿主的 `assertV4RowAdmission` 才能证明宿主接受。宿主升级时,前者静默失效,后者直接失败。 4. **副作用与记账的顺序决定了失败半径。** deferContext 放在投递之后就注定了"消息已送达、回合却失败"。这一条没有改(审计本身有价值),但值得知道:**任何 deferContext 的内容都必须先过宿主准入**。 ### 验收(需重启 profile,理由见坑 1) 重启后让任一对话 `peer_send(kind:'notice')`:对端照常收到,**本端回合正常结束**,且本端会话里出现一条 `source.kind === 'plugin:dsh-peer-sessions'`、`form: 'notice'` 的 `user/message`。被此 bug 卡住的对话无需修复——脏行从未落盘,重发即可。 ## 十一之七、`Config`:阈值交还给用户,以及两个**静默失效**的陷阱 **背景**:本包所有上限(跳数、速率、额度、收件箱条数…)原本都是模块常量,用户想改只能改 `lib/store.js`——**升级即被覆盖**;而 README 早就把它们写成"上限"档位。v1.1.0 起用宿主插件通用的 `Config` 交还(`lib/index.js`)。 ### 宿主侧事实(0.2.0-rc.2 实测) | 事实 | 依据 | |---|---| | schema 构造器是 `@deepseek-ai/schemastery`,**默认导入** `import z from '…'` | 127 个装了 `Config` 的宿主插件里 126 个用这个说明符;`cordis` 根本不导出 schema 构造器 | | 行里**省略 `config`** 时默认值生效 | `resolveConfig`(`cordis/lib/index.js:956-961`)→ `runtime.Config['~standard'].validate(undefined)`;schemastery 对 nullish 取 `meta.default`,object 的默认是 `{}`,逐字段再取默认。用真实 `Context` 实测:`apply` 收到 `{limit:7,label:'d'}` | | `apply(ctx, config)` 收到的是**校验并填好默认值之后**的对象 | `Fiber._reload` 先 `this.config = this._resolveConfig(this._config)` 再跑 runner | | 行的 `config` 是**整体替换**,不是深合并 | `dsh-app-boot/lib/index.js:104-105` 逐键 `target[key] = value` | | **未知键会被保留**(不报错) | schemastery 的 `strict` 默认为 false,`merge(result, data)` | | `Config.listConfigs` 是 **inspect query**,不是服务;只给 schema + status,不给当前值 | `dsh-tool-cordis/lib/types/providers.js:37`、`config.js:67-92`。**当前值**读 `entry.fiber.config`(已解析)或 `entry.options.config`(原始) | ### 陷阱 1:模块里有 `default` 导出 → `Config` 被**静默丢弃** 加载器这样取插件的导出: ```js // cordis-plugin-loader/lib/index.js:664-669 unwrapExports(exports) { if (isNullable(exports)) return exports exports = exports.default ?? exports if (!exports.__esModule) return exports // ← 原生 ESM 在此返回 return exports.default ?? exports } ``` 原生 ESM 命名空间**没有** `__esModule`,所以一旦模块有 `default` 导出,loader 就**只拿 default 那个对象**,兄弟具名导出全部看不见。后果不是报错:`runtime.Config` 是 `undefined` → `resolveConfig` 直接 early-return → `apply` 永远收到 `undefined`,`Config.listConfigs` 永远报 `absent`。**实测**(对真实 ESM 命名空间调真实 `unwrapExports`):`export default {...}` + `export const Config` → 解包后只有 `inject`/`apply`。 作者的普查:127 个声明 `Config` 的宿主插件里,**0 个**把 `Config` 挂在 `default` 对象上。本包因此改成**具名导出**(`export const inject` / `export const Config` / `export function apply`,无 default),并加了一条**用真 `unwrapExports` 当 oracle** 的测试。 ### 陷阱 2:少一个 `.default()` → 插件**静默不加载** `z.object({ must: z.string().required() })` 遇到省略的 `config` 会返回 issues → cordis 抛 `ValidationError` → `_reload` 接住、`logger.error`、fiber INACTIVE。本包**不在** `requiredStartupEntryIds` 里,所以启动**只警告**:进程照常起,插件没了。**规矩:`Config` 的每个字段都必须有 `.default(...)`**,且默认值引用 `STORE_DEFAULTS` / `CORE_DEFAULTS`,测试再断言两边相等(否则文档、schema、代码三处会各说各话)。 ### 怎么验证一次 `config` 改动(2026-09-30 实测,**不需要重启**) `patchReload: live` 之下,**按 id 覆盖 `config`** 会被加载器当场应用:`_patchContext` → `fiber.update(options.config, true)` → 重跑 `apply`,于是本包的 `sharedStore.configure(options)` 立即换上新阈值。**增删组合包**(`dsh.profile.bundles`)才是启动清单,必须重启。 不用重启、不唤醒任何对端、也不弹卡的探针:`maxCandidates` 决定"查无此会话"时列几个候选,所以 ``` peer_progress(peer: 'zzz-无此会话') ``` 在补丁层写 `- id: dsh-peer-sessions` + `config: { maxCandidates: 1 }` 前后分别是 **8 行** 与 **1 行 + "……另有 7 个"**。实测 8 → 1 →(删掉覆盖)8,两次都当场生效。(`hopLimit` 只能通过有通道时的 `hop=N/M` 轴观察,没有通道看不到。) ### 显示元数据:`package.json.meta` 是**死字段** 审核建议加 `"meta": { "title", "description" }`。**在 0.2.0-rc.2 上这没有任何效果**——`readPluginMeta`(`dsh-app-boot/lib/index.js:1969-1999`)从不读 `manifest.meta`,它读: - 标题/描述:`<包>/locale/.json` 里的 `{ "meta": { "title", "description" } }`,文件本身要通过**包的 `exports`** 解析得到(`optionalResourcePath` 走 Node resolver)——所以 `"./locale/*.json"` 必须导出去;`locale/en.json` 不存在则整个扫描都不发生,标题回退成**包名**。 - 图标:清单**顶层** `icon`(相对路径、SVG/PNG/JPEG/WebP、realpath 后仍在清单目录内、≤256 KiB),读取时就地转成 data URL。 本包已补 `locale/en.json`、`locale/zh.json`、`icon.svg`,并用宿主的 `readPluginMeta` 实测解析出 `{en, zh}` 标题与图标 data URL。测试里钉的是**这些资源的形状与导出**(真 reader 需要包能被"按名字"解析,那是 profile 里才成立的条件)。 ## 十一之八、桌面版授权卡片全灭:一次"逐字段比对"定位的宿主契约故障 **来源**:Windows 桌面版 dsh `0.2.0-rc.2`(profile `desktop`)上的另一个对话提交了一份诊断报告 + 证据(本机 `debug/`,已 gitignore)。它报的现象很干脆:`/peer connect <标题>` 与模型侧 `peer_send` **每次**都得到"授权卡片没能显示(ask-failed)",**而同一个会话里原生 `ask_user_question` 的卡片正常**。 ### 我核实到什么程度(不采信报告,逐条查) | 报告结论 | 我的核对 | 结果 | |---|---|---| | 官方 blocking 调用总是带 `wait.callId` | 本机安装 `dsh-tool-ask-user/lib/index.js:197` | **成立** | | `wait?: { callId, timed? }` 是请求类型的一部分 | `dsh-user-questions/lib/typert.host.js` | **成立** | | 客户端 `dismissal = callId === undefined ? "cancel" : "hide"` | `dsh-client-ui-user-questions/lib/client.js:188` | **成立** | | 无名卡的取消是裸字符串 reject | 同文件 `:420` `waterfall.reject("ASK_CANCELLED")` | **成立** | | 裸字符串无法被还原成带 code 的错误 | `dsh-user-questions/lib/index.js:443`(要求 `{name,message,code}`) | **成立** | | 桌面 bundle 与 npm 安装同码 | 逐字节 diff 四个证据文件 | 三个**逐字节相同**;client bundle 差 146 行,但**那三处行为行号一致** | | 浏览器里"卡片被 dismiss 的那一瞬间" | 无桌面访问权 | **未验**(报告自己也标了边界) | ### 修法(v1.1.1) 1. **带 `wait: { callId }`**:`peer_send` 的 `exec.callId` 透传到 `askGrant`;命令路径无 callId,仍匿名。 2. **失败原因不再只看 `code`**:`askFailure()` 取 `code` → 裸字符串本身 → `name: message`,所以同类故障下次直接报 `ASK_CANCELLED`。 3. **唤醒说明从 `detail` 移进 `question`**:官方 ask 工具从不传 `detail`(宿主把它与 plan-review intent 配对),少一个形状差异。 三条一起做,是因为"缺 wait"与"多 detail"都与观察到的失败**同时相关**(两次尝试都是冷对端),单独归因任何一个都不够;改完两条路径都与官方请求只在**内容**上不同。 ### 教训 - **原生可用、插件不可用 → 逐字段比对两条请求**,不要从行为反推。这次能把根因钉到一行,全靠"官方请求与插件请求的唯一形状差异"这个方法。 - **错误信息的丢失本身就是缺陷**:只看 `error.code` 会把一个能自解释的裸字符串降级成 `ask-failed`。凡 `catch` 到的值形状未知,就要**按可能性依次降级保留**(code → 原文 → name: message)。 - **未验的部分要写在报告里**:报告作者明确写了"无法观测 dismiss 瞬间,是从代码路径推断",这让我能判断该修哪三处、以及为什么值得同时修。 ### 未做 / 上游问题(记录备查) - 第三方插件的工具**永远走不了 timed 卡片路径**(`isTimedAskUserQuestionSchema` 要求 `tool.name === 'ask_user_question'`),所以 blocking `ask()` 是唯一的路,而它的可靠性事实上依赖 `wait.callId`——这是一个**未文档化的依赖**,值得反馈 upstream。 - `dsh-api-remotes` 在转发 scoped waterfall 时若 `request.agent !== carrierKeyOf(this)` 会抛**裸 `TypeError`**(同样无 code)。它也可能被报成"卡片弹不出来";本次修法让这类错误现在会以 `TypeError: forwarded scoped event …` 的形式出现在 reason 里,可据此区分。 ## 十二、进一步阅读 | 想了解 | 去哪 | |---|---| | 为什么这么设计、每条需求落在哪个原语上 | `docs/design.md`(**权威**) | | 模型要怎么用这个插件 | `skill/peer-session/SKILL.md` | | 真机上验过什么、怎么验的 | `docs/design.md` §12 | | 安装与文件布局 | `README.zh.md` |