# dsh-peer-sessions 设计说明(v1 · 冻结) > 状态:**需求已冻结,M1 已实现**(见 §13),待真机验收。 > 本文件是唯一权威需求来源。代码与它不一致时,以本文件为准——或者先改本文件。 > §12 记录了实现期实测到的运行时事实,其中两条修正了设计初稿(列表服务名、授权载体)。 --- ## 1. 一句话 让**同一层级的两个会话**(都由同一个人直接指挥、互不隶属)在**用户明确授权**的前提下互相通信、查看进度。 **不存在父会话,不存在子代理。** ## 2. 与 subagent 的分界 - **subagent**:派出去的临时分身,为当前这条线服务,没有自己的目标。 - **peer**:用户自己开的两个长期会话,各自有目标和工作目录,同时被指挥。 判据:**一方结束/失败是否意味着另一方也该停。** 是 → subagent;否 → peer。 现有的 `send_message` / `list_agents` 建立在 `isOwnedBy(parent, child)` 的所有权校验上,而 peer 的前提恰恰是**没有所有权**——因此不能复用,必须另做。 ## 3. 使用场景(定稿) **跨目录代码协作。** 两个不同工作目录的长期会话(例:`/srv/orders-api` ↔ `/srv/web-client`),契约变更时互相通知、回复闭环。 ## 4. 用法示例(定稿) 1. 在 B「web-client」里说:「和后端数据那个会话建立个通道,契约有变它通知我,我改完回它一声」 2. 目标解析命中唯一 → 模型调 `peer_send` → 弹出授权卡片 → 用户选「本对话内允许」 3. 双方日志各留一条痕迹 4. A 改完契约,模型调 `peer_send(kind:'notice')` → 进 B 的 inbox;B 正忙则**排队不打断**,空闲后自动醒来 5. B 看到带来源标注的消息 → 自己读文件核细节(读不受限)→ `peer_send(kind:'reply')` 6. `/peers` 可见通道;`/peer revoke` 断开 --- ## 5. 硬不变量 ### H1 · 侧栏一致(最高优先级) **agent 眼里的对话列表 ⊆ 人在侧栏能看到的列表。一个 agent 绝不能对着一个人类看不见的对话说话。** 权威定义(`dsh-client-ui-workspace/lib/client.js` 的 `sessionVisible`): ``` visible(session) = origin !== 'subagent' && !archived.has(session.id) && (!blank || session.id === current) ``` 落地算法(宿主侧,**唯一的**列表来源): ```js const archived = new Set(ctx.workspaceRegistry.archivedSessionIds) const summaries = (await ctx.sessionController.list({}, signal)).items // 与侧栏同源 const addressable = summaries.filter(s => s.origin !== 'subagent' && !archived.has(s.sessionId) && !s.blank) ``` 这是侧栏规则的**真子集**(额外排除了 `blank` 占位行——它是"新建会话"行,且 `current` 是客户端状态,宿主侧拿不到)。**排除得更多 ⟹ 规则成立。** 推论: - **H1.1 双向校验** —— 发送方与接收方**都**必须在集合内。一个已被归档的会话,人也看不见它,它同样不该能对外说话。 - **H1.2 每次投递都重新读取**,不在配对时缓存一次就完事 → 归档即时生效,无需任何监听器(天然 fail-safe)。 - **H1.3** 不得使用 `sessionQuery.listSessions()` 这类**全量语料**作为来源。 - **H1.4** 目标不在集合内 → 直接拒绝,并说明具体原因(已归档 / 空白占位 / 子代理 / 无工作目录)。 - 无 cwd 的会话本就不在侧栏(`list()` 里被跳过),自动不可寻址。 ### H2 · 来源不可伪造 peer 消息进入接收方时**必须**带:`kind: 'peer-message'`、`form: 'relay'`、`senderSessionId`,并明示"这不是用户指令"。 **绝不使用 `ctx.sessionController.prompt()`** —— 它把消息构造成 `source = { kind: 'user', rpcId }`,会让对端模型的话以"用户指令"身份进入本会话,构成可劫持该会话的注入通道。 投递只走 `agent.followup()` / `agent.inject()`。 **H2 的边界(宿主机制,2026-09-30 核实)**:`followup` 排的是**下一个回合**,所以这条消息会先出现在接收方**输入区上方的队列条**里(`dsh-client-ui-conversation` 的 `QueueDock`,数据源就是 `inbox['next-turn']`),并且**可以被人删除 / 改建 / 提升为 steer**——也就是说,接收方的人能在它执行**之前**看到并处置它。这是 H2 想要的性质(可见、可否决),不是漏洞:这里的人本来就是本会话的最高权限者,能改自己看到的一切;而 `peer` 侧**无法**借这条路径把话变成"用户指令"。 ⚠️ 一个由此产生的细节:队列的 `edit` 动作**只替换 `content`**(`dsh-api-session-controller` 的 `updateQueue`:`{ ...message, content }`),所以**编辑后的消息仍带 `peer-message` 来源**。人改自己的队列是正当的,但这意味着"模型看到的那条 peer 消息"可能已被本会话的人调整过——抬头那行因此更该被当成**路由标注**,而不是内容真伪的证明。 ### H3 · 授权不经过模型 授权决策由**插件直接**调用 `ctx.userQuestions.ask()` 取得,用户答案直接回到插件手里。模型无法转述、无法篡改档位。 前提约束(契约原文):`ask(agent)` 只在 `agent` 是**确切的活着的运行时根**时有效——被拥有的子代理没有人类回答者,会永久阻塞。因此:**发起方不是运行时根时一律 fail-closed,不弹任何问题。** **请求形状是契约的一部分(2026-09-30 桌面端故障后补)**:由**工具调用**发起的提问**必须**带 `wait: { callId }`(就是那次 `exec.callId`),与官方 `ask_user_question` 完全一致;由**命令**发起的提问拿不到 callId,只能匿名提问(宿主没有为命令准备命名位)。 原因不是风格:客户端按 `request.wait?.callId` 决定这张卡的**命运**——`callId === undefined` 时 `dismissal = "cancel"`(一旦失去编辑器座位就**取消整个请求**,并以**裸字符串** `"ASK_CANCELLED"` reject,跨 Remote 后无法还原成带 code 的错误),有 callId 时 `dismissal = "hide"`(关闭只是收起,可从工具调用行重新打开)。桌面版正是前者:每次投递都在卡片被收起时变成"弹不出来"。 ⚠️ **由此产生的产品语义是知情决定**:带 callId 的卡片**关闭 ≠ 拒绝**——请求继续等待,工具调用保持挂起,直到用户从工具调用行重新打开并作答,或整轮被中止(`ASK_ABORTED` → `declined`)。这正是官方 `ask_user_question` 的语义;选它是为了让卡片在桌面端可用,代价是"点 X 就干净地结束"变成"点 X 只是收起"。**客户端取消(`ASK_CANCELLED`)一律不上报为"用户拒绝"**——无法归因给人的取消,不能告诉模型"不要重试"。 ### H4 · 无隐式升级 peer 消息**不能**:批准任何东西、修改任何权限、发起新的 peer 配对(防扩散)、替对方做任何有副作用的操作。 --- ## 6. 授权模型 | 档位 | 语义 | 载体 | |---|---|---| | `once` | **一次往来**:一条消息 + 它对某条 request 的回复,然后作废 | 插件进程内的 Map | | `session` | 仅本对话内长期有效,进程重启即失效 | 插件进程内的 Map,按 sessionId 索引 | **为什么用进程内的 Map 而不是会话事件**(实现时的修正):插件是**进程级**的,而"一个对话"由 sessionId 标识——所以按 sessionId 索引的内存 Map 正好具有所需的生命周期:对话转冷再被唤醒时授权仍在,进程重启时授权消失。这正是文档要求的语义,不多不少。 原先设想的"写一条自定义 log-only 会话事件"被否决,理由是 `dsh-session` 导出了 `KNOWN_SESSION_EVENT_TYPES`:为一个内存 Map 已经提供的能力去触碰会话事件词汇表,是拿契约风险换零收益。 其余规则不变: - **不跨对话、不跨重启。** fork / 子会话**不继承**(子代理根本不是运行时根,工具直接 fail-closed)。 - **单点批准覆盖双向收发**;批准即建立双向通道。 - 撤销即时生效;`once` 档在通道花完后自动作废。 ## 7. 冷会话 **真机事实(改变了本节的设计)**:在这个部署里**空闲会话就是冷的**。一次真机观察里,「Peer B」被唤醒、跑完一轮、回了信,随后又变回 `running: false`;`peer_list` 列出的 17 个会话全是 `not running`。 于是"投递时当场确认一次唤醒"在实践中变成了**每条消息两张卡片**(授权 + 唤醒)。这不是安全,是摩擦。 **定稿行为:一张卡片覆盖整个决定。** - **可寻址**:侧栏里 `running: false` 的会话同样在列表中、可作为目标。 - **首触只弹一张卡**:卡片正文在需要唤醒时**明说这一点**("「X」当前未运行。同意会同时把它唤醒,那会在那边开启一轮并消耗 token。"),人读到的就是完整代价。 - **通道批准涵盖唤醒**:通道已建立时,后续投递直接 `ctx.sessionController.resolveAgent(id)` 唤醒,**不再提问**。撤销通道就是这道权限的关闭开关。 - 唤醒失败(`resolveAgent` 返回 error)→ 明确报告,不静默降级。 - **`silent: true` 与冷对端互斥**:静默投递的语义是"只添加上下文、不开启回合",而唤醒正好相反。因此这种情况**直接拒绝**(`silent-needs-running`),连卡片都不弹——那是个自相矛盾的请求,不该让用户去裁决。 --- ## 8. 功能需求 ### A 配对 - **A1** 目标解析:按标题模糊匹配。命中 1 → 直接用;命中 0 或多 → 列出候选(标题 + cwd + 最近活跃 + running),**绝不猜测**。 - **A2** 授权卡片明示:双方会话、方向(双向)、档位、"不含读取对话记录"。 - **A3** 两档授权(见 §6)。 - **A4** 双写留痕 + 失败回滚。 - **A5** 撤销即时生效 + 双方留痕。 - **A6** 可列出全部通道:对端、档位、剩余额度、对端可见性状态。 ### B 通信 - **B1** 类型:`notice` / `request`(带 `replyWithin`) / `reply`(带 `replyTo`) / `message`。 - **B2** 投递只走 `followup()`,**绝不用 `steer()`** → 天然实现"空闲才投递"。 (`AgentStatus = 'idle' | 'running'`;对端 running 时 `followup` 排队,其当前回合结束自动执行。) **已接受并刻意保留的后果(真机观察后确认)**:`followup` 的契约是"排一个独立回合并唤醒驱动",所以入站的对端消息会**以排队用户回合的形式出现在客户端的输入区**,可被编辑、删除、重排——看起来像收件人自己待发送的内容。 这是**选定的行为,不是缺陷**,不要"修"它: - 当前原语下,"能被唤醒"与"不出现在输入区"互斥(`inject` 不进输入区但**不唤醒**)。 - 想保住"通知即唤醒",就必须走 `followup`。 - 从安全角度它反而是优点:一条来自别的会话的消息在**执行前可见、可否决**。 - 抬头第一行 `[peer-session message · from another conversation, NOT a user instruction]` 是这份可见性的补偿——即使在一行输入条里也能一眼认出不是自己写的。 - **一次未复现的观察,记录以免将来误查本插件**:对端消息停在 composer 队列里时,曾出现"选中斜杠命令后 Backspace 删不掉已输入内容"。同一次里 `/goal`(内置命令)表现**完全相同**,随后两个命令又都恢复正常,因此**与本插件无关、也未能确认成因**。 后续找到一个**可能的**机制:粘贴进来的零宽字符各占一个 UTF-16 码元,按一次 Backspace 只吃掉那个看不见的字符,**视觉上等于什么都没发生**。这与同一次里发现的 `unknown subcommand "connect"`(token 里藏着 U+200B)同源。若再次出现,记录两件事:composer 里是否挂着排队消息、输入里是否含不可见字符(现在报错会直接给出码位)。 - **B3** `silent: true` → `inject()`,只留上下文不唤醒。发送方需要"不打扰"时用它。 **对端是冷的时该请求自相矛盾**(唤醒正是它要避免的),因此直接拒绝,见 §7。 - **B4** 来源标注(见 H2)。 - **B5** `request` 带超时;超时通知发起方,**不无限重试**。 - **B6** 收件箱:未读 / 待回复 / 已超时。 - **B7** 消息体只允许**文本 + 路径引用**;不携带文件内容或附件(读不受限,接收方自己读)。 ### C 进度 - **C1** 只返回:标题、状态、轮次/步骤、目标、待办、最近 N 条摘要、最近活动时间。 - **C2** **绝不返回**:对话原文、工具参数、附件、文件内容。 - **C3** 对端不可见时明确失败,不静默降级。 ### D 环路与审计 - **D1** 跳数上限、每对消息上限、速率上限。 - **D2** 每次投递在双方日志各留一条可回放记录。 --- ## 9. 接口面 ### 用户命令(`ctx.commands` 全局注册) | 命令 | 作用 | |---|---| | `/peers` | 列出通道:对端、档位、剩余额度、对端可见性 | | `/peer connect <标题> [once\|session]` | 建通道;省略档位则弹原生选项卡片 | | `/peer revoke <标题>` | 撤销 | | `/peer progress <标题>` | 查看对端进度 | 命令的三个性质由契约保证,白拿:`CommandSourceMap` 只有 `user`(*"every executor caller is a human-facing UI surface dispatching a human-typed line"*);handler **不经过模型**执行;每次调用自动写 `command/run` / `command/done` → **免费审计**。 ### 模型工具(`ctx.tools` 全局注册) | 工具 | 作用 | |---|---| | `peer_send` | 发消息;无授权时弹授权卡片 | | `peer_inbox` | 读收件箱:每条一行摘要;带 `id` 则取回该条的完整投递正文 | | `peer_progress` | 读对端投影 + 摘要 | | `peer_list` | 查通道与权限 | 无授权时返回"未授权"而非报错,且**零副作用**。 ### 零客户端代码 - 命令结果 → 现有 UI 直接渲染 `CommandResult.text` - 授权卡片 → `dsh-client-ui-user-questions` 渲染原生选项 - 消息来源标注 → 现有消息渲染处理 `form: 'relay'` 因此 **没有 `lib/client.js`,`package.json` 不声明 `dsh.client`。** ### 语言跟随 面向人的文案跟随客户端的语言设置。 - **来源**:`locale.preference`(`dsh-client-locale` 拥有的设置命名空间,宿主侧的行 id 就是 `locale`)。**只读,不注册。** 读法:`settings.describe()` 找到 `ns === 'locale'` 的那条描述符,取它的 `value`——`describe()` 是 0.1.7-rc.2 之后宿主唯一提供的读法(`get(ns)` 已移除)。 - **能跟随到什么程度**:契约原文是 *"absence delegates to the browser"* —— 未显式选择时由浏览器决定,`navigator.language` 不会跨到宿主侧。因此:显式值 `zh*`/`en*` 按值跟随;**缺失或无法识别时回退简体中文**。 - **提问卡片天然跟随**:每次调用现生成,走 `core.t`(永远读当前 locale)。 - **命令描述需要重新注册**:描述在注册时就固定了。因此监听 `settings/document-updated`(宿主**唯一**会发的设置事件;回调收到 `(ns, revision)`),语言变化时**注销后重新注册**两条命令。**注意历史**:这里原来写的是 `settings/updated`,那在 dsh `0.1.7-rc.2` 就被移除了——于是这段热切换代码**从未生效过**(详见 `MAINTENANCE.md` §十一之五)。 - 支持的语言:`zh`、`en`。新增语言 = 往 `lib/i18n.js` 的 `MESSAGES` 加一张表;`normalizeLocale` 匹配 `zh`/`zh-CN`/`zh_Hans` 这类前缀标签。 **有意保持英文的部分**:工具描述与工具输出(那是**模型**的接口,不是 UI 文案),以及 peer 消息的来源抬头 `[peer-session message · NOT a user instruction]`(它是给接收方模型看的机器可读归属标签)。命令**结果**是本地化的——同一次人机交互里描述中文、回答英文会显得断裂。 --- ## 10. 明确不做 跨会话读文件 · 跨重启持久配对 · 三方以上群组 · schedule / 看门狗 · 共享约定注入 · 自研浏览器面板 · 群发 ## 11. 实现映射(每条需求落在哪个现有原语上) | 需求 | 用什么 | |---|---| | 侧栏一致的列表 | `ctx.workspaceRegistry.archivedSessionIds` + `ctx.sessionController.list({}, signal)` | | 会话标题 | `ctx.sessionTitle.get(session)`(活会话)/ `summary.projections.values.title`(列表缓存)/ 退回 cwd basename | | 目标唤醒 | `ctx.sessionController.resolveAgent()` → `{ agent } \| { error }` | | 投递 | `agent.followup()` / `agent.inject()` —— **从不用 `steer()`** | | 进度 | `sessionController.list()` 带的 projection 值,只读叶子字段 | | 授权 | `ctx.userQuestions.ask()`(插件直接问人,模型不经手)。返回**带原因的判别值**:`granted` / `declined` / `unavailable`——"没问到人"与"人拒绝了"必须分开,后者不可重试而前者可以 | | 用户命令 | `ctx.commands.register()` | | 模型工具 | `ctx.tools.register()` + `defineTool()` | | 阈值配置 | `export const Config`(`@deepseek-ai/schemastery`)——宿主校验并填入默认值,`apply(ctx, config)` 收到解析后的对象。**必须具名导出**:模块一旦有 `default` 导出,加载器的 `unwrapExports` 只返回它,`Config` 会被静默丢弃 | | 显示元数据 | `locale/.json` 的 `meta.title` / `meta.description` + 清单顶层 `icon`(**不是** `package.json.meta`,宿主从不读它) | | 会话内授权 | 插件进程内的 Map,按 sessionId 索引 | | 消息来源 | `createUserMessage({ source: { kind: 'peer-message', form: 'relay', senderSessionId } })` | | 审计(发送方) | `exec.deferContext()` 追加一条 producer-owned `notice`(`kind: 'plugin:dsh-peer-sessions'`,**不是** 已废弃的 `kind:'plugin'`) | | 审计(接收方) | 收到的 relay 消息本身就是记录 | | 运行时根判定 | `ctx.agents.get(agent.id) === agent && ctx.agents.roots()` | **本项目不发明新机制**:所有能力都落在已有服务与原语上。 ## 11.5 环路防护(§8 D1 的落点) 三道上限加一个超时通知。它们都**在投递之前**判定,被拒绝的投递**不消耗通道额度、也不计入 hop**;只有速率计数据实记录,理由见下。 | 防护 | 参数 | 落点 | 拒绝时的语义 | |---|---|---|---| | **每对速率上限** | `RATE_LIMIT = 30` 条 / `RATE_WINDOW_MS = 60s`,**按通道**、固定窗口 | `store.withinRate` | 风控这对会话互相刷屏;窗口内被拒的尝试**仍然计数**(否则连拒几次后窗口没过就又能发),但**不花额度** | | **每对消息上限** | `CHANNEL_BUDGET = 200`(M1 已有) | `store.spend` | 一条通道一生可投递的总量 | | **跳数上限** | `HOP_LIMIT = 3` | `store.nextHop(sessionId, toId)` / `noteInboundHop` | 一条**接力链**跨会话传播的深度。**计的是「接力」,不是「回复」** | | **请求超时通知** | `replyWithin` 解析成 `dueAt`;默认窗口 `HOP_MEMORY_MS = 15min` 只用于 hop 记忆 | `store.notePending` / `overdueRequests` | 到点后**通知一次**发起方,**绝不自动重发**(B5) | **表里每个参数都是可配置项,默认值就是表中值**(§12.9)。宿主插件可以用 `Config` 把这类阈值交还给用户,本包从 v1.1.0 起这么做:上表的 `hopLimit` / `rateLimit` / `rateWindowMs` / `channelBudget` / `hopMemoryMs` / `inboxLimit` / `requestMemory` / `pendingGraceMs`,加上两个展示偏好 `maxCandidates` / `recentTurns` / `previewMaxChars`。**默认值只有一份**(`STORE_DEFAULTS` / `CORE_DEFAULTS`),schema 引用它们,测试再断言两边相等——文档里的数字、schema 的默认值、代码实际用的值不会各说各话。 ⚠️ **行的 `config` 是整体替换,不是深合并**:只写 `hopLimit` 的话其余字段由 schema 默认值补齐(这一点有测试),但**同一行的层叠覆盖**是整块替换的,写文档时要提醒用户列全要保留的字段。 **跳数为什么由插件记、而不是写进消息让模型带**:模型看不到也不需要维护这个数字。若靠模型在 `peer_send` 里传递,它漏传一次链条就断,而**断掉的是防护**。做法是收发的两侧都由 store 记:投递时 `noteInboundHop(接收方, hop, 发送方)`,发送时 `nextHop(发送方, 目标)`;没有入站记录(或记录已过期)时按 **1** 起算——那是「用户自己起的话头」。 **计数的判据是「这条消息给了谁」,不是「离上次收到多久」**: - **回复刚给你写信的那个会话 → 跳数不变。** 它是同一条对话的延续。 - **发给第三方(把消息接力出去)→ 跳数 +1。** 这才是这条上限拦的传播。 **这条判据是被一次真实故障纠正过来的(v1.0.1)**:初版实现只要「最近 15 分钟内收到过对端消息」就给每条发送 +1,不看收件人。于是**两个会话之间普普通通的四次往返还**(请求→回复→澄清→再回复)就走完 1、2、3、4 并**永久锁死该对**——没有重置手段、授权也不能扩容;而与此同时 `peer_list` 还显示 `remaining=193`,因为那是**另一个轴**(消息配额)。报告方被锁死时正与三个会话协作,最后只能退回文件通道。**规格第 256 行原本就写着「两个会话正常来回不受它限制」——是实现违反了规格**,所以这次修正没有改规格,而是让实现回到规格上。 **跳数上限只防级联,不防"聊太久"**:它拦的是 A→B→C→A 这类**一条链被接力转发**;两个会话正常来回聊很多轮**不受它限制**(那是速率与额度在管)。所以它必须配一跳记忆过期时间,否则二十分钟前的一条入站消息会莫名其妙地让现在这条变成第 4 跳。 **超时通知是被动的、且只报一次**(`reported` 标记):插件无法主动在对方会话里开一轮,所以通知**搭在这个会话下一次动作上**(`peer_send` / `peer_inbox` / `peer_list` 的输出里)。`peer_list` 还会列出"还在等谁回复",不需要靠轮询。 **B6 的"已超时"是叠加的、不是替换的**:一条超时的 request 仍然是"未回答"的,所以列表标记是 `[timed out · …]`,原标记保留在后面,而不是被覆盖。 **一处只有真机才会暴露的边界(已修)**:`peer_inbox` 对**空收件箱**会提前返回 `Peer inbox is empty.`,而那正好跳过了"询问是否有超时请求"这一步——询问本身会把通知标记为已报。结果是:**空收件箱时 B5 的通知被吞掉**,而空收件箱恰恰是等待者最会去翻的时候。修法是把超时请求的收集**提到任何提前返回之前**,空收件箱时与那句提示一起返回。 **`peer_list` 故意不消费通知**:它是只读视图,且已经用 `OVERDUE` 标注同一事实;在那里也弹一遍通知会把同一件事说两次。唯一的那次通知搭在**真正做了动作**的路径上(`peer_send` / `peer_inbox`)。 --- ## 12. 实现期已验证的运行时事实 以下都是实测结论,不是推断;它们取代了设计初稿里的"待验证"清单。 1. **自定义消息来源可用;`createUserMessage` 不校验它,但会话持久化层校验。** `createUserMessage` 接受 `source = { kind: 'peer-message', form: 'relay', senderSessionId }`——`MessageSourceMap` 是可合并扩展的,没有运行时白名单。 ⚠️ 但它**同样接受缺少 `senderSessionId` 的来源**。也就是说 H2 无法靠运行时保证,只能靠代码强制:`lib/messages.js` 在缺少发送方时直接抛错,绝不降级。 ⚠️ **构造不校验不等于没人校验。** 会话格式 v4(dsh `0.1.7-rc.2` 起)在**每次追加写入、也每次读取**时都会用 `assertV4RowAdmission` 检查 source:`kind` 必须是非空字符串,且**不得是已废弃的通用包装 `'plugin'`**(报错原文 `format v4 message requires a producer-owned source kind`)。第三方插件自己的生产者 kind 由 v3→v4 迁移推导为 `plugin:<包名>`。**2026-09-29 的故障就是发送方审计写了 `kind:'plugin'`**——构造、类型、单元测试全过,直到宿主的 writer 拒绝该行;而 `exec.deferContext()` 在投递**之后**,于是整回合连同已完成的投递一起失败。详见 `MAINTENANCE.md` §十一之六。 2. **命令注册与工具注册的形状。** `ctx.commands.register()` 每个调用自动写 `command/run` / `command/done`(审计白拿),handler 不经过模型。 `ctx.tools.register()` **不做标记校验**,只检查 `output.{schema,render}` 与保留名 `run_code`。 3. **`link:` 安装不会安装依赖,裸导入会解析失败。** Node 按导入文件的 **realpath** 解析裸说明符;`link:` 安装把本包留在 profile 树之外,因此 `@deepseek-ai/dsh-llm` / `@deepseek-ai/dsh-tools` 解析不到。 解法(`install.sh` 第 1 步):把本包的 `node_modules` 软链到 `${DSH_HOME}/profiles/node_modules`。这既恢复了分辨率,又让它指向**与宿主同一份 realpath**——实测两侧 realpath 完全一致,因此是同一模块实例,不存在第二份副本或身份分裂。 4. **`AgentStatus` 只有 `'idle' | 'running'`,"空闲才投递"不是新机制。** `followup()` 排一个独立回合并唤醒驱动;对端 running 时它排队、当前回合结束自动执行。所以 B2 的实现就是**永远用 `followup`、永远不用 `steer`**,零新机制。 5. **`sessionController.list({}, signal)` 返回 `{ items }`**,是两参数签名;`resolveAgent()` 以 `{ agent } | { error }` 返回,不抛异常。 6. **投影值是 `JsonValue`,且真实形状与直觉不符。** `SessionProjectionValue = JsonValue`,所以**键存在不等于值是对象**——`projections.values.title` 对所有尚无标题的会话就是 `null`,首次真机运行正是在这里崩溃。实测到的真实形状: - `title`:`list()` 给的是**已解包的值**——有标题时为字符串,缺失时为 `null`。(这条由崩溃本身反推:若传的是缓存原始记录 `{ver, seq, val}`,`val: null` 就是个对象,不会崩。)磁盘上的投影缓存另存为 `{ver, seq, val}`。 - `todos`:`TodoItem[] | null`,条目为 `{ content, status }`——是**数组**,不是 `{ items }`。 - `goal`:`GoalProjection | null`,目标文本嵌在 `goal.goal.objective`,配 `goal.goal.phase` 与 `goal.roundsStarted`。 - `inbox`:`{ 'next-turn': [], 'next-step': [] }`。 - `turnOutline`:`TurnOutlineEntry[]`,条目为 `{ turn, seq, prompt, response }` 的**有界预览**——C1 要的"轮次/步骤 + 最近摘要"正来自它。 教训:单元测试的 fixture 必须用**真实形状**。初版测试全部用对象或 `{}`,对类型忠实、对数据不忠实,因此上面每一条都漏过了。 7. **加载器会「重新 apply」插件,但不会重新求值模块。** 包文件一变化,加载器就重新 apply 这个插件——但它是重新 import **同一份被缓存的模块**:文件不会被重新求值,`apply()` 却会再跑一遍。 两个后果,都在真机观测到: - 在 `apply()` 里创建的 store **每次 re-apply 都会被丢弃**,静默清空所有已开通道(实测:一次投递刚在某个通道上成功,下一次调用该通道就不见了,中间没有重启)。因此 store 放在**模块作用域**(见 `lib/index.js` 的说明)。 - **改代码仍需重启进程**才会生效:re-apply 拿到的是缓存里的旧模块。 顺带说明:这意味着"改文件 → 自动生效"是不成立的,但"改文件 → 通道被清空"是成立的——一个很容易误判的组合。 8. **`once` 档的语义已定为「一次往来」。** 实测发现:「仅本次投递」批准后 `remaining=0`、通道立即作废,于是**对端的回复需要一次新的批准**——而且那张卡片出现在**对端会话**里,用户看到的是"我不是刚批准过吗"。这不是实现缺陷,是档位定义的问题。 定稿语义:**`once` = 一条消息 + 它对某条 request 的回复**。实现上,通道记住自己承载过哪些 request(`requestId → 发起方`),当一条 `reply` 的 `replyTo` 命中**由对端发起**的 request 时,它搭同一次批准送出,随后通道作废。 方向校验是必需的:只认"由对端发起"的 request,否则一方可以拿自己发过的 request 去解锁另一方的额度。 卡片文案同步改为 "One exchange — the message and its answer — then the channel is spent"。 ⚠️ **由此产生的显示义务(2026-09-30 补)**:这条语义让 `remaining=0` 具有**两种含义**——**中途**(请求已花掉那一次投递、正等它自己的答复,通道仍活着)与**耗尽**。两者若渲染成同一个 `remaining=0`,读的人(模型或人)会先假设"已无额度",于是一次合法的搭乘投递看起来像配额漏洞:真机上有对端据此提交了"once 档剩余为 0 仍可投递"的误报。因此 `peer_list` 与 `/peers` 必须在 `tier=once && remaining=0` 时**点名它处于中途态**,并写出是哪条 request 花掉的。**显示即契约的一部分**——不能让人从一行状态里推出与实际相反的判据。 | **宿主两处布局都要覆盖**(同一次修复的第二轮) | 第一版 `link-deps.sh` 的"向上搜索"只认 **npx 缓存**布局,而**全局安装**把依赖打包在 `@deepseek-ai/dsh/node_modules/` **内部**,外层 `node_modules` 里没有 `dsh-tools`——脚本于是回退到 `profiles/node_modules`,看似成功却没选到宿主。现在两层都查(`$d/node_modules` 与 `$d/*/node_modules`)。实测:改后插件与宿主 realpath **完全一致** | | **宿主升级会悄悄拆散模块实例**(v1.0.2 后实测) | 把 DSH 从 `0.1.5-rc.3` 升到 `0.1.7-rc.2` 后,`link-deps.sh` 指向的 `$DSH_HOME/profiles/node_modules` **仍是旧版本**(它链到全局安装那份),而宿主跑的是 npx 缓存里的新实例:插件加载 `dsh-llm 0.1.5-rc.3`、宿主是 `0.1.7-rc.2`。**正是坑 3 警告的身份分裂**,而且**不报错**——只有比对 `readlink -f` 才看得出来。已修:`link-deps.sh` 改为**优先从 PATH 上的 `dsh` 反推宿主实例**,`profiles/node_modules` 降为兜底 | | **0.2.0 要求声明 `dsh.bundle`**(v1.0.3) | dsh 0.2.0 的插件管理器用 `inspectionOf()` 判定 `dsh.bundle` **是否为对象**,否则报 `not-a-bundle` 并**回滚 profile**——而 pnpm 那一步是**成功**的,所以"装上了"与"能当插件管理"是两件事。本包 v1.0.2 及以前没有 `dsh` 字段,从桌面端装必失败。已声明 `"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}`(官方写法:`dsh-base` 用单文件,`dsh-web-app` 用数组)。**离线验证法**:`bundlePatchPaths` → `loadOverlayPatches` 能解析出 insert 行即可,不必真装 | | **两条安装路线不可混用**(v1.0.3) | 路线 A=包声明 `dsh.bundle` 并进入 profile 的 `dsh.profile.bundles`(装完自动生效,但桌面端是 **git 快照**、发版要更新);路线 B=profile 用户 patch 层**手写** insert 行(`link:` **实时**)。两者激活**同一个 id**,同时存在就是**装两次**。`install.sh` 已加守卫:若本包已是该 profile 的活跃组合包,就**拒绝**再追加手写行。**排查过的安全性**:给包加 `dsh.bundle` 不影响已有 web profile——组合树只应用 `bundles` 里列出的包,而本包在 web profile 走的是路线 B | ### v1.0.0 审计(两个独立审查者 + 真机) 发布第一版正式版之前做了一次完整审计:两个独立子代理各管一半(**生命周期/状态寿命/并发** 与 **边界/不可信输入/错误路径**),各自用真机驱动 `lib/*.js` 的探针取证,不看我自己的解释。**共 20 项发现,其中 11 项是真 bug**,全部已修并补了回归测试(测试 70 → 79)。值得单独记的几项: | 发现 | 为什么严重 | |---|---| | **`deliver` 在 await 之后不再校验可寻址性** | 授权卡片是人类尺度的等待;人在读卡片时归档对端,批准后消息**照样投递**——**直接打破 H1**("绝不能对一个人看不见的对话说话"),也让"归档即时生效"变成假话。修法:拿到批准后**重读**两端可见性,并重读 liveness(对端可能在卡片打开期间变冷,此时把消息交给已释放的 loop 会抛内部错或静默丢失,而卡片还少报了"会唤醒"这一代价) | | **并发首次建联覆盖通道** | 双方同时 `peer_send` 时弹出**两张卡片**、`store.open` 静默覆盖第一条通道:违反"单点批准覆盖双向",第一条授权的档位/额度/`openRequests` 全部失联,已投递的 `channel:` id 指向不存在的通道。修法:卡片后若发现通道已存在则**复用**,绝不覆盖 | | **`noteReply` 缺方向校验** | 用自己的 requestId 回信会**撤销自己的超时**(B5 通知永不出现)并把对端收件箱里那条请求标记为"已回复"——对端看到一条"没人回答却已了结"的请求。方向校验此前只加在 `once` 额度上。修法:查独立记录的**发起方**,只认对端发起的请求(这张记录必须独立于 `pending` 与通道,二者都会消失) | | **消息 id 会碰撞** | id 借用了**通道**计数器 + 毫秒时间,同毫秒两条投递同号:`peer_inbox(id)` 取回**错误正文**,另一条永久无法按 id 取回。两个审查者**各自独立**发现。修法:专用单调计数器 | | **`silent` + 冷对端只在"无通道"时被拒** | 有通道时会真的**唤醒**对端,还回一句"未唤醒"——与事实相反。修法:检查提到分支外 | | **`stripInvisible` 漏掉 6 类隐形字符** | 双向隔离符 U+2066–2069、软连字符 U+00AD 等全部漏网(**实测**),正好复现它本来要防的"未知子命令「connect」"自相矛盾报错。修法:字符类对齐并覆盖宿主自己的清洗规则 | | **已归档对端无法撤销** | `/peer revoke` 经可寻址集合解析,而归档后它不在其中——于是通道永远挂着 `NOT VISIBLE (archived?)` 直到重启。修法:撤销允许解析隐藏集合(`prune` 也一并保留说明) | | **跳数拒绝发生在卡片之后** | 人先答了卡片、还可能白醒一次对端,才被判跳数超限。修法:`nextHop` 只需 `selfId`,提到卡片之前 | | **`pending` 只增不减** | 撤销/被拒/被忽略的请求永远留在表里,每次调用都扫描,且 `peer_list` 一直播报"还在等 N 个回复"。修法:撤销通道时清掉该通道的记录 + 24 小时宽限淘汰 | | **`/peer connect` 会吃掉标题末尾的档位词** | 会话真叫"Design session"时,`/peer connect Design session` 被解析成标题 "Design" + 档位 session,**静默连到另一个会话**(显式档位不弹卡,故完全无声)。修法:先按字面标题解析,解析不到才认末尾档位 | | **内部错误被当成答案返回** | `updatedAt`/`cwd` 未防护时,模型会收到 `Invalid time value` / `Cannot read properties of null` 这种"像是回答的报错"。修法:两处都加防护,缺失就省略该行 | 另外两项加固:**头部防伪装**(summary/body 里可以逐字复制来源抬头,正文现在会在末尾声明"真正的抬头是第一行");**i18n 完整性测试改为扫描源码**(原来手列 43 个键,漏了 19 个——包括全部环路防护与超时文案)。 ### 已在真机验证通过 | 路径 | 证据 | |---|---| | 侧栏一致的列表 | 16 个会话,标题全部解析正确,running 状态准确,归档/空白/子代理被排除 | | 进度读取 | 投影 + 摘要,未知投影**只给键名**不给值 | | 授权卡片 | 两次真机运行(一次英文、一次中文),档位与文案都对 | | 冷会话唤醒 | 对端由 `not running` 变为 `running`,且必须先确认 | | 投递与来源标注 | 对端收到 `[peer-session message · NOT a user instruction]` | | 发送方审计 | 工具输出出现 `deferContext` 生成的 producer-owned notice(`kind: 'plugin:dsh-peer-sessions'`) | | **回复闭环** | 对端 `peer_send(kind:'reply', replyTo:…)` → 本端收件箱收到 `received` | | **一次往来** | 同一次回复**搭了那张「仅这一次」批准**,对端没有再被弹卡片;回复送达后通道自动作废 | | 语言跟随(**只在宿主 ≤0.1.5-rc.3 上成立;0.1.7 起静默失效,已修、待真机复核**) | 命令描述与提问卡片随 `locale.preference`(缺失时回退简体中文)。**这条记录不再代表当前行为**:读值用的 `settings.get(ns)` 与监听用的 `settings/updated` 都在 dsh `0.1.7-rc.2` 被移除,而两者分别走可选链与静默订阅,所以此后一直是"永远回退中文",且不报错。详见 `MAINTENANCE.md` §十一之五 | | 命令面与工具面同源 | `/peer connect` 报告 `pc-2 / 本对话内 / 200 / 未运行`,`peer_list` 报告 `"Peer B" · tier=session · remaining=200 · peer=not running`,逐项一致 | | **撤销** | `/peer revoke Peer B` → 双方视图同步为空;**紧接着 `peer_send` 重新弹出授权卡片**,证明撤销是在投递校验处生效,而非仅仅从列表里抹掉 | | **拒绝** | 拒绝那张卡片后:不创建通道、收件箱不变、对端全程仍是 `not running`(没被唤醒)、零 token | | **其他会话可见** | 在一个新建对话里 `/peers` 可用且显示自己的(空)通道状态;`peer_list` 的列表随之从 16 变 17,说明新建对话也在集合里 | | ~~`/peer progress <本对话标题>` 读到本对话的进度~~(**记录有误,已更正**) | 审计发现代码**永远**不会把自己当作目标:`resolveTarget` 从一开始(`bef99fc`,M1 首次提交)就 `filter(entry.sessionId !== selfId)`,其注释也写着 self "never a valid peer"。所以当时那次真机观察要么匹配到了一个**同名对话**,要么记录不准。**裁定:自己不是有效的对端目标**——`peer_progress` 读的是*别的*会话,读自己没有任何意义(模型直接就知道自己的状态)。这条记录已删除,避免下一个对话照它去"复现"一个不存在的路径 | | **归档即时拒绝** | 归档该对端后它**立刻从列表中消失**(17 → 16),按名字投递给它被拒绝、未发送任何内容。这条同时证明 H1.1(投递前重读)与 H1.2(不缓存) | | **子代理拿不到授权** | 探针子代理报告 4 个 `peer_*` 工具**全部可见**(没有任何 preset 配置 `toolFilter`,所以全局工具被完整继承),但 `peer_send` 被 `callerGuard` 拒绝:*"only a top-level conversation can hold a peer channel…"*。**没有弹出任何卡片**——`isRuntimeRoot` 先返回 false,`userQuestions.ask` 从未被调用,因此不会出现契约警告的"永久阻塞" | | **收件箱按 id 取回正文**(M2) | 真机:对端回了一条 `reply`,列表是 `Peer inbox (1 item…)` + `- [unread] reply from "…" · 0 min ago` + `id: pm-1-mu1en5e4 · replyTo: req-1-mu1em7ux` + 一行摘要——**正文没有被倾倒进列表**。`peer_inbox(id:"pm-1-mu1en5e4")` 返回完整投递正文:取回头两行是 `Message … · channel pc-1` 与 `Delivered … · status: unread · in reply to: req-1-mu1em7ux`,其后**逐字节等于**收到的那条 relay 消息(来源抬头、`from:`、`channel:`、`answering request:`、正文三行全部一致)。多行 body 未被截断 | | **未知 id 具名拒绝**(M2) | `peer_inbox(id:"pm-does-not-exist")` → `No inbox message with id "pm-does-not-exist". Known ids, newest first: pm-1-mu1en5e4.` —— 具名说明,且列出**确实存在**的 id(收件箱为空时说的是"空",不是空列表) | | **重启后新模块生效的判据** | 重启前 `peer_inbox(id:"pm-probe-does-not-exist")` 返回 `Peer inbox is empty.`(旧实现根本不看 `id`);重启后同一调用返回 `No inbox message with id …`。新实现在给了 id 时**不可能**说出旧回答,所以这是一个确定的"代码是否已加载"探针 | | **`markReplied` 只查了通道的一端**(真缺陷,已修) | `awaiting-reply` 条目落在**请求接收方**的收件箱,而"谁持有它"取决于这轮交换**谁先开口**——两个方向都合法。旧实现只查**答方自己**的收件箱,于是"**收到别人的请求再回信**"这个最常见用法**永远标不上**:回复照常送达、`replyTo` 照常串联,只有"已回复"静默落空。修法是**两端都查**(`[sessionId, other(channel, sessionId)]`),而不是推断谁先开口 | | **已读推进不能吃掉"待回复"** | 加已读推进时暴露的边界:`markRead` 把"未答"写成 `read` 会抹掉 B6 的待回复标记。故 request 取回正文后的状态是 `read-unanswered` 而不是 `read`,而 `markReplied` 也接受它 | | **`replied` 标记在真机上生效**(M2 修复验收;对端两度核对后确认) | 对端会话贴回它收件箱的完整列表,两条并列:刚收到的 request 显示 **`[never opened]`**(**有标记**,这是**当时那一版**的实际字样,现名 `[unread]`),而 5 分钟前那条**已回信的** request 显示 `- request from …`(**完全没有方括号标记**)。修复前那个标记**永远不会变**,所以这条同时是"`markReplied` 两端都查"的真机验收。这里要分清**行为证据的形态**:`replied` 在**取回抬头**上表现为 `state: read · still unanswered` → `state: replied`(对端引述过),在**列表**上表现为标记消失——不是列表里出现 `[replied]`。两份并列还顺带证明"改名漏标签"的回归已修好:该有标记的在、该静默的静默 | | **"已回答 → 无标记"是设计,不是丢标签** | 那位对端先在一条消息里判断我们"丢了一个标签"、随后**主动收回**(它当时看到同一类条目早上是 `[replied]`、此刻什么都不显示)。收回是对的:`replied` 只出现在**回信方**那一端,含义是"我回复过它",标在那里才诚实;带标记的方永远看不到它。**记录此事是为了防止将来有人把它当缺陷"修"掉——不要给它加标记** | | **新词表逐字通过**(M2 措辞改名验收) | 同版本下由对端会话走完整对照:取回**前** `- [unread] request from …`,取回**后** `- [read · never answered] request from …`,取回抬头 `state: read · never answered`——与新词表**逐字一致**。`still unanswered` 已统一为 `never answered`。本端亦独立走通另一条:"`[unread] reply …` → 取回 → 无标记" | | ~~**跳数在真机上的递进**~~(**基于旧实现,已作废**) | 那条记录说「对端回答 hop 1 时发出 hop 2」——那正是 v1.0.1 修掉的错误规则。**回复不再加深链条**:同一对之间的来回**恒为 hop 1**,只有接力给第三方才 +1。正确的实测见下一条 | | **超时的三个轴在真机上叠加显示** | 对端观测同一条 request 的完整演化:`[unread]` → 越过期限后(**无需任何工具调用**,因为超时是实时判断的)`[timed out · unread]` → 取回正文后 `[timed out · read · never answered]`。三轴并存、互不顶掉,与正交设计一致 | | **超时通知在真机上出现,且只出现一次** | 发一条 `replyWithin: 3s` 的 request,到点后下一次 `peer_send` 的输出末尾出现:`已超时:你发出的 1 条请求始终没有回复。` + `req-1-mu1fnak0 -> 「<对端标题>」(约定的时间是 3s)` + `不会重发,也不应该重发。…`——**没有重发**。`peer_list` 另显示 `Waiting for 1 answer(s)`,过点后带 `· OVERDUE`。**对端标题是真实会话标题,按仓库卫生规则(`MAINTENANCE.md` §九)替换为占位符**(引文其余部分逐字保留) | | **取回抬头与列表在超时上已经对称**(修复验收) | 真机取回一条 `replyWithin: 1s` 的入站 request:抬头为 `Delivered … · state: read · never answered · request: req-3-mu1fsbv4` + **独立一行** `deadline: OVERDUE`;同一份列表为 `[timed out · read · never answered]`。**两边都有**,对端指出的"列表有、抬头没有"的不对称消除。期限保持为**独立轴**,没有被折进 `state:`(提出者本人也这么主张) | | **回复不加深链条**(v1.0.2 真机三处一致) | 与一个真实对端会话走完整往返:① 本端起头 `hop: 1`;② 对端**回信**时它看到的也是 `hop: 1`——**决定性证据**,因为旧规则下它应当是 2(它收到我的 hop 1 时正处在「有入站记忆」状态);③ 本端**再回复**仍是 `hop: 1`,而此时本端已收到过它的入站消息,旧规则下这里该是 2。三处独立观测一致。回归测试 `replying to the other side does not deepen the chain` 另用**六次往返**钉住同类行为 | | **`peer_list` 两轴真机显示** | `- "…" · tier=session · remaining=198 (messages) · hop=1/3 (relay depth, 2 left) · peer=running`。报告方原话:"`(relay depth, 2 left)` 和单位 `(messages)` 都在——猜配额那处现在有名字了" | | **对端帮我挡掉一个无效证据**(方法学) | 我说"我这条 request 的 hop 是 1",对端指出**这不能作证据**:重启把 store 清空了,**新旧规则在无入站记忆时都产出 1**。它是对的——我差点用巧合当证据。**它自己那条回信的 1 才是证据**(旧规则下它有入站记忆,会产出 2)。判断一个数字是否算证据,要问"另一套假设在这个状态下会给出什么值",若与观测相同则它不是证据 | | **超时通知在空收件箱路径出现**(吞噬缺陷的修复验收) | `peer_inbox` 空收件箱时同时返回 `Peer inbox is empty.` 与 `已超时:你发出的 1 条请求始终没有回复。` + 请求 id + 对端标签 + 约定时间 + "不会重发";且同一次调用即消费、下次不再重复 | | **取回抬头曾缺 `deadline`**(对端指出,已修) | 列表显示 `[timed out · …]`,而同一条的取回抬头里**完全没有超时痕迹**——恰好在最需要它的地方没有:人刚取回正文,正是会问"这条过期了吗"的时刻。修法是给期限**单独一行** `deadline: OVERDUE`,而**不塞进 `state:`**(那是第三个轴,塞进去会把三件事混成一件)——这一点由提出者本人指出 | | **空收件箱会吞掉超时通知**(真机发现,已修) | 同一轮验证中:`peer_inbox` 在空收件箱时返回 `Peer inbox is empty.` 就结束了,从未询问超时请求——而**询问即消费**,于是那次唯一的通知被一个丢弃它的分支吃掉。已把收集提到提前返回之前;新增测试钉住「空收件箱也必须报出超时请求」与「同一次调用消费、下次不再重复」 | | **收件箱的承诺边界:进程内,重启即空** | 对端指出的(并附了证据:它重启前的两条已回复条目重启后**不在了**)。`store` 是进程内的,这与"授权不跨重启"是同一个性质,但对 inbox 后果更重:M2 第 1 项解决的是消息掉出**上下文**,**没有**解决掉出**进程**——跨一次重启,条目与正文一起消失。所以"很久之后仍能取回"的真实边界是**本次进程生命周期内**。**这不是要修的缺陷**(进程内是刻意的,持久化会牵进会话事件词汇表,§12.1),但**措辞不得暗示超出它的承诺** | | **跨版本比对:行为可以比,字面不可以** | 那位对端先报了两条字面"出入"(`[never opened]`、`[new · never answered]`),随后**自己核对并全部撤回**:它跑的是改名前的构建,`new` 正是 `34fc2fd` 改掉的旧名。**它给出的判据值得照抄**:比对**行为**(标记变没变)不受版本影响;比对**字面**(标记写成哪个词)必须同版本。同一份列表里真正有效的那条证据——已回信条目**无标记**——是行为,所以它成立;两条字面差异都来自版本错配,所以它们无效 | | **诊断方向问题要测,不要读** | 定位上面那个真缺陷时我翻了三次(先判断对、又从代码"读出"相反结论并回滚、最后打探针才定论)。教训记在 `MAINTENANCE.md` 第十一节:**"测试全绿"不等于"判断正确"——如果我为了让它绿而改了断言,那说明我改的是语义** | | **`install.sh` 第 3 步的"两条路线"守卫**(v1.0.3 补验;真机文件 + 隔离目录) | 把第 3 步原文抽出,在三种条件下跑:① 本机 patch 层的副本(已有一行手写 insert)→ `already present — leaving it alone`,**文件 md5 不变**;② 伪 profile 的 `dsh.profile.bundles` 含本包、patch 层无该行 → 打印"已是活跃组合包…跳过",**不追加**;③ 两者都没有 → 备份 + 追加,`js-yaml` 解析出 `[{"insert":[{"id":"dsh-peer-sessions","name":"dsh-peer-sessions"}]}]`。**这条守卫此前只有离线解析验证**(见 `3f2a388` 提交说明),这是它的第一次实测 | | **安装脚本在隔离 HOME 里端到端跑通**(去掉重复前置检查之后) | `env -i HOME= DSH_HOME=/.dsh DSH_DEPLOY_NODE_MODULES=<真实部署> bash install.sh`:① 链接到该部署并打印 `dsh-llm 0.2.0-rc.2`;② `dsh` 不在 PATH → 打印手改依赖的警告(末行按设计非零退出);③ 手写行已存在 → 不动;④ 技能是**复制**(真目录、非软链)。**零真机 profile 写入**。同时确认:删掉那条重复的部署前置检查后,无部署时的失败信息改由第 1 步给出(`error: no DeepSeek Harness deployment …` + `DSH_DEPLOY_NODE_MODULES` 提示) | | **`uninstall.sh` 曾认不出自己建的软链**(v1.0.3 发现并修) | 它按 `readlink == $DSH_HOME/profiles/node_modules` 判断"这条链是不是我们建的",而 §十一之一之后链接指向**宿主实际用的那个部署**——本机是全局安装那份,于是旧逻辑**放着自己建的链不管**并打印 `no node_modules link of ours to remove`(假否定,已在本机链上复现)。改为"是软链就算我们的"之后,在隔离目录实测:软链被删、技能被删、patch 层的行被删,且**其余条目与 YAML 结构完好** | > 关于上表「子代理拿不到授权」一行:本插件的子代理安全性来自**代码守卫**,不是"工具不存在"。这是有意的(D4:无授权时工具返回说明而非报错),但也意味着若 `isRuntimeRoot` 判断出错,工具就会落到子代理手里。守卫因此是承重件,不是装饰。 ### 仍未在生产中验证的部分 **一项待验(2026-09-29 的语言跟随修复引入)**:修复必须**重启 profile** 才会被加载(坑 1 的同一个性质),所以本轮无法在自己正在服务的会话里验收。步骤:`./install.sh` + 重启 → 语言切 **English** → `/peer`、`/peers` 的描述应**当场**变英文 → 切回中文。判据要落在"当场变"上,因为热切换路径此前**从未生效过**(它等的那个事件在 0.1.7-rc.2 就被移除了)。 **新增待验清单(2026-09-30 的架构审核整改)**: | 待验项 | 状态 | 判据 / 备注 | |---|---|---| | `Config` 生效 | **已验(真机,无需重启)** | 在 profile 补丁层写 `- id: dsh-peer-sessions` + `config: { maxCandidates: 1 }`,用 `peer_progress(peer:'zzz-无此会话')` 当探针:候选列表从 **8 行**变为 **1 行 + "……另有 7 个"**;删掉覆盖后回到 8 行。同一份覆盖里的 `hopLimit: 5` 也进了组合树(`--dump-config` 可见),但 hop 轴要有通道才显示,未单独观测 | | 省略 `config` 仍加载 | **已验(真机)** | 组合行没有 `config` 时插件照常工作(工具全注册、往返正常),且 `remaining=199` / `hop=1/3` 说明阈值取的是默认值而不是 `undefined` | | 管理页卡片 | **已验(真机目视)** | 插件管理页显示「平级会话通道」+ 描述 + 图标;中文来自 `locale/zh.json` | | 一条安装路线 | **已验(真机)** | 已迁到路线 A:`dsh.profile.bundles` 含本包;补丁层无手写行;`--dump-config` 恰好 1 行,且来源标注为 `# == dsh-peer-sessions`(组合层) | | 卡片文案精简 | **已验(真机目视)** | 卡片只剩问题 + 三选项 + 一行唤醒说明;**没有正文段**(`detail` 仅在冷对端时存在,热对端时 `undefined`,有测试钉住);三行选项描述各自一行内放下 | | 顺带验证:卡的标签→档位映射 | **已验(真机)** | 改文案后点「仅这一次」,工具输出 `tier=once, remaining=0`——本地化标签仍按值身份回落到正确的档位 | | 顺带验证:`once` 的"一次往来" | **已验(真机)** | 点「仅这一次」→ 投递后 `remaining=0` → 对端回信**没有再弹卡**(答复搭同一张批准),回信抬头为 `user-granted for one exchange` → 事后 `peer_list` 显示通道已自动作废、无挂起请求 | | 授权失败的措辞 | 待验 | 需要让卡片无法显示(例如在没有 answerer 的路径上调);已有单元测试 | | 卡片带名(`wait.callId`)生效 | **已验(真机,2026-09-30)** | 点卡片 X 后**工具调用保持等待**(请求存活);匿名卡会立刻以 `ASK_CANCELLED` 返回。见 `MAINTENANCE.md` §十一之八的判别表 | | 带名卡的主路径 | **已验(真机)** | 重启后 `peer_send` →「仅这一次」→ `tier=once, remaining=0` 投递成功、审计行落盘 | | 命令路径仍匿名 | **已验(单测)** | `/peer connect` 没有 callId,请求里不带 `wait`(真实命令路径只能在 GUI 里跑) | > **热生效这条值得记住**:`patchReload: live` 之下,**按 id 覆盖 `config`** 会被加载器当场应用到运行中的插件(`fiber.update(config)` → 重跑 `apply`),**不需要重启**;而增删**组合包**(`dsh.profile.bundles`)是启动清单,必须重启。上面 `Config` 那一行就是靠这条免掉了一次重启。 其余没有已知未验证项:曾经唯一待验的"措辞改名后的显示",已由对端会话在**同一版本**上逐字确认(见表内「新词表逐字通过」「超时的三个轴在真机上叠加显示」「取回抬头与列表在超时上已经对称」三条——它们记的都是改名**之后**的真实输出)。 两处限制是**刻意设计、不打算修**,不要当成欠账:收件箱与授权都是**进程内**的,重启即空(见上表"收件箱的承诺边界"与 §6)。 ## 13. 里程碑与进度 ### M1 · 已完成(真机验收) 侧栏一致列表(H1)· 2 条命令 + 4 个工具 · 插件直接取授权 · `followup` 投递(空闲才投递)· 冷会话唤醒(当场确认)· 双向审计 · 单元测试(当时的 54 项,现在 82 项)。 因为会话内授权改成了进程内 Map(§6),原本排在 M2 的"仅本对话授权"与"冷会话唤醒"也随之落进 M1。 ### M2 · 已完成(真机验证) 五项都有归宿:**1–4 项已实现并逐条真机验证**,**第 5 项决定不做**(理由见该条)。 - ~~**`peer_inbox` 只能给摘要,取不回正文**~~ —— **已实现**。真机暴露的原始缺陷:对端回了一封**结构完整、很长**的答复,而 `peer_inbox` 只显示 `summary`(一行),正文只能从转录里那条 relay 消息读到。于是"收件箱"其实只是提示而非索引——**那条消息若不在当前上下文里,正文就取不回来**。 实现方式:投递时把**跨通道的那份完整正文**(含来源抬头,与收到的消息**同一份字符串**)存进收件箱条目,`peer_inbox({ id })` 取回它。存在正文而不是摘要的拼接,是为了让"归档的那份"与"投递的那份"**不可能漂移**;来源抬头随正文一起回来,则是为了让一次取回**仍然写明这不是用户指令**(H2 的延寿,而不是只在新消息上生效)。 **边界(此处措辞不许含糊)**:收件箱是**进程内**的,重启即空——所以它解决的是「消息掉出**上下文**」,**不是**「掉出**进程**」;所谓「之后」的真实范围是**本次进程生命周期内**。详见 §12 的「收件箱的承诺边界」一行。 - ~~`peer_inbox` 摘要里补上 `replyTo`~~ —— **已实现**(正文里本来就有,摘要里漏了)。 - ~~`peer_inbox` 的**已读推进**~~ —— **已实现**,语义由用户在看过真机列表后选定:**取回正文才算已读**,列出摘要不算。`markRead` 只在**按 id 取回**那条路径上调用。 **状态词表(改动前先看这张表,这里是它的唯一定义):** | `status` | 含义 | 列表标记 | 取回抬头 | |---|---|---|---| | `unread` | 非 request 的入站消息,正文还没被取回 | `[unread]` | `unread · never opened` | | `unread-unanswered` | request,正文还没被取回,也没人回 | `[unread]` | `unread · never opened` | | `read-unanswered` | request,正文取回了,还是没人回 | `[read · never answered]` | `read · never answered` | | `replied` | 本条**由我回复过**(只出现在答方那一端) | 无标记(已了结) | `replied` | 两条容易踩的约束: - **读没读过、有没有人回,是两个正交事实共用一个字段**,所以已读**不能**把"未答"写成 `read`——那会抹掉 B6 要的待回复标记。于是有 `read-unanswered` 这个状态,而不是把 request 的已读态也叫 `read`。 - **`replied` 只出现在"回信方"那一端**(回复落在对方的收件箱,标记留在自己这里),所以它的含义是"我回复过了",**不是**"我被回复了"。任何写"answered"的措辞都会指反,这也是它没有列表标记的原因。 - 早期版本把 `read-unanswered` 叫 `new`,真机被对端当场指出:"一个刚读过的条目显示 `new`,读起来像未读"。**标签不能与它标注的状态相矛盾**。改名时又漏改了标签函数,导致**刚收到的 request 第一次列出时完全没有标记**——现已由一条护栏测试钉住(每个状态都要有自己的标签,不许落到兜底)。 - ~~`peer_progress` 深化:渲染 `plan` / `contextPressure` 等投影~~ —— **决定不做**(2026-09-14,用户裁定)。 理由不是"做不到",而是**它不在需求里、且当初的排序理由是错的**: 1. **C1 的清单已经全部渲染完毕。** C1 明确列出"只返回:标题、状态、轮次/步骤、目标、待办、最近 N 条摘要、最近活动时间"——`plan` 与 `contextPressure` **都不在其中**。所以 `peer_progress` 早已**满足规格**;这一项是可选增强,不是欠账。 2. **它是特例,不是一类需求。** 真机上未渲染的 12 个投影里,`subagentCatalog` / `subagentTiming` / `subagent` / `sessionListMetadata` 是**根本不该渲染**的——子代理不可寻址(H1),把它们呈现给模型等于递一个用不了的把手,纯噪音;`agentPreset` / `modelSelection` / `imageLimits` 对协作决策几乎没有价值。逐项能站住的只有 `contextPressure` 这类"对端还剩多少余量"的信号,以及**边界不明的** `plan`。 3. **`plan` 对 C2 有红线风险。** C2 明令"绝不返回对话原文"。若 `plan`(很可能)是模型生成的计划**自由文本**,渲染它等于把对端**从未打算给你看**的内部草稿搬过来——这与 `peer_inbox` 取回正文性质完全不同:后者是明确**发给你**的消息,带来源抬头;前者不是。 它当初排在 M2 里,理由写的是"数据已在投影里,只是没渲染"——那是**实现成本**的理由,不是**价值**的理由。**这条区分值得留着:投影里有数据,不等于这个视图该把它显示出来。** **C1 的清单是底线,不是起点。** 将来若要扩展 `peer_progress`,先问"它帮我做了什么决定",而不是"还有什么键没画"。 - ~~环路防护完整版~~ —— **已实现**,参数与理由见 **§11.5**:通道级速率窗口(30/分钟)、跳数上限(3,由插件自记)、请求超时通知(`replyWithin` 首次被真正解析成 `dueAt`,到点**通知一次、绝不重发**),以及 B6 的「已超时」叠加标记。 **尚待真机复核**:三道拒绝路径与超时通知在真实双会话下的表现(尤其"跳数记忆过期后重新起算"这一点,只有真机跨时间才能看到)。 - **真机复核(措辞改名后要再重启一次)**:① 已读推进生效(列表 `[unread]` → 取回后无标记);② 收到请求的一方回信后,那条请求的标记消失——**这一条此前从未生效过**(见 §12 的 `markReplied` 一行),同时也是那个缺陷的修复验收;③ 刚收到的 request 第一次列出时**必须**带 `[unread]` 标记(改名漏标签那次的回归点)。 ### M3 · 待做 - 双语文档完善、GitHub 发布 ### 单元测试规模 `npm test` 当前 **82 项**(M1 首次交付时为 54 项,本节曾写作 25 项——那是 M1 中途的数字,未随实现更新;**每次补回归测试都要顺手核对这一行**)。 ### M1 验收(真机) 两个真实会话 → `/peer connect <标题> session`(重名时列候选)→ `/peers` 可见通道 → A 调 `peer_send`(B 忙 → 排队不打断)→ B 空闲后自动醒来看到带来源标注的消息 → B 用 `peer_send(kind:'reply')` 回信 → A 醒来 → `/peer revoke` → 再发被拒 → **归档对端后立刻被拒**。