# 变更记录 > 本文件**只追加**,不维护正文:每次"功能确认完成"或"修复确认完成且要提交"时, > 在表头之后插一行、并把下面这行最新版本号改掉(三步动作见 AGENTS.md §4.6)。 > 最新版本:v0.20 | 版本 | 日期 | 变更 | | --- | --- | --- | | 0.20 | 2026-10-13 | **新增调试模式的状态 tag(chip)**:坐在**输入框工具行**里(`conversation.input.left`,list / session,`+` 与回形针之后)——黄色色丸(`state-warn-tertiary` 底 + `state-warn-label` 字,明暗主题各一套)、文案「调试」、内联 SVG 的叉(悬停转 90°,不用字符图标),`aria-label` / `title` 说清动作;没有任务或没有 sessionId 时返回 `null`(座位服务所有会话,必须自过滤)。**叉 = 「问题已解决」这个表态本身**,不是另一套"取消":它 POST `/dsh-debug-mode/resolve` 但多带一个 `anytime: true`,于是**不受 `awaiting-user` 那道门约束**——面板按钮保持原有窗口语义,`anytime` 只对 `resolved` 生效(任务存在 / 属于本会话 / 未关闭三条校验一条不少,幂等照旧)。为什么要有它:两道门叠起来(S5 要求 `awaiting-user` + `dsh_debug_end` 要求 `userVerdict === 'resolved'`)会让用户**进了调试就出不来**(实测反馈原话是「一旦进入很难退出」),这个叉把出路还给用户而不新增第二套会漂移的表态。实现细节两处:`anytime` 那条路的状态迁移走**两步**(`active → awaiting-user → awaiting-agent`,因为 `lib/task.js` 的 `TRANSITIONS` 里 `active` 只能去 `awaiting-user` / `closed`,非法迁移是抛错而不是兜底);该轮给 Agent 的指令明确要求"**不要**为了凑流程假装验证过,没验证就把 `verified` 填 false"。顺带修一个真 bug:`postSignal` 原先硬挑 `hash`/`sessionId` 两个字段,`anytime` 会被**静默吃掉**;现在只放进调用方真要表态的字段。回归守卫:`test/client-bundle.test.mjs` 里那条用 `SlotCore` 判据做的替身测试(座位选型不许踩单占用座位)、一条断言叉真的 POST 出 `anytime: true` 且点一次只发一次;`test/service.test.mjs` 里一条断言同一个 `resolved` 表态在 `active` 上被拒、带 `anytime` 时成立、且重复只唤醒 Agent 一次。全量测试 117 条(116 pass / 1 skip)。 | | 0.19 | 2026-10-13 | 产物改落用户临时目录(os.tmpdir()/dsh-debug-mode;临时根不可写时回退 /.dsh/debug-mode/ 且回退必须出声),浏览器桩改由宿主 GET /dsh-debug-mode/stub.js?h= 即时伺服,任务新增 artifactRoot/artifactRootKind,provisionArtifacts 幂等补供以抗临时目录被清理,哈希目录防撞,probeStatus 增加 logPath;删除 DESIGN.md,§17 变更记录迁入本文件,契约真相回到代码与 AGENTS.md;写死版本号纪律(CHANGELOG.md 顶部最新版本 == 表里最新一行 == package.json 的 version,三步同一次编辑,见 AGENTS.md §4.6);全仓库去 emoji 并新增 test/style.test.mjs 守卫(非 emoji 的排版字符保留,见 AGENTS.md §4.4);面板的图标改用**内联 SVG**(折叠箭头、警告图标,`aria-hidden` + CSS transform 表示状态),纯文本通道仍用 ASCII 标记 `[!]`/`[ok]`/`[x]`,规则见 AGENTS.md §4.4。 | | 0.18 | 2026-10-11 | **一次真实收尾的第三轮反馈:我的 §7.2 表有一行是错的,而且指南示例正好教 Agent 走进那个失败布局**(用户实测,站点 A 两次一致):① 我原先写"[ok](空行在块内)+ 原地有 1 个空行 ⇒ 那 1 个空行原样保留"——**错**。真相是**空行的命运只取决于它落在标记的哪一侧**:标记**外**宿主不碰(活下来),标记**内**跟块一起消失。所以有一种清理逻辑**兜不住**的错误:**把原有的空行包进标记里** ⇒ 它被连带删除(吃掉用户的空行,`collapsedBlankLines` 甚至是 0,因为它在删除区间里,不叫"收敛")。而我的 [ok] 示例恰好把块插在原空行的位置上,**照抄就会吃掉一个空行**——示例旁边的注释还写着"逐字相同"。修法来自用户的一句话:**原来就有的空行留在标记外,只有自己为好看加的空行才放进标记内**;机械做法是"`start` 插在原有空行的后面"。指南 §7.2 重写为四象限表(含两个错误方向的代价)、示例改成两种子情形各一份、注入准则同步;② 用户还指出**行数会骗人**:实测出现过「一处少 1 行 + 一处多 1 行 ⇒ 总行数与基线相同」,只看总数会判成"全对",实际是一个空行从 `import` 区搬到了 `create_order` 里。校验必须比**位置**——`scripts/probe-selfcheck.mjs` 因此新增**空白演练**(第四节):5 个布局(含专门的"行数相同、位置不同"用例)按位置打出 before/after,并进退出码;③ **截断只写了触发条件、没写结果**(用户实测:触发线是**整行** 8 KiB,但旧实现裁完只占约 2.3 KB——**只用掉约四分之一预算**,10 KB 的值白扔四分之三的证据)。这不只是文档缺口,是**行为不值得**:`encodeRecord()` 改为**把预算用足**(先按固定档位找可行解,再二分找最大的能放下的 clip 长度;二分是确定性的,所以三处实现仍逐字一致)。实测:10000 字符的 msg → 整行 8192 字节、值保留 8100 字符(旧:2153 字节 / 2061 字符)。`MAX_RECORD_BYTES` 的文档补上"触发线 vs 结果"两件事;④ 自检脚本的**三处一致性**用例暴露出一个测试自身的坑并已修:`ts` **参与 8 KiB 预算**,宿主侧若给个 7 字符的 `'FIXED'`、桩侧是 29 字符的 ISO 时间,两者会二分出不同的 clip 长度而"不一致"——而事后归一化 `ts` 补不回来。测试改用等长 `ts` 并断言长度;⑤ `probe.http.md` 新增 §七:**要读回一个数字,就把它放进一条独立的小记录**(`read` 的显示层会对 >240 字符的 `data` 缩写,把数字塞进大记录会被吃掉——用户上一轮就这么失手过) | | 0.17 | 2026-10-11 | **新增 `scripts/probe-selfcheck.mjs`(探针边界自检)**:把"构造边界值 → 逐条 POST → 读日志 → 对脏行/标记/字节"这套被人工做过好几轮的动作固化成一条命令、退出码可进 CI。两种模式:**本地**(临时工作区 + 真实 `DebugService`,不需要 DSH 在跑)与 **`--endpoint`**(打活的 DSH,验证路由/CORS/真实宿主)。三道判据:边界矩阵(值是否完整 / 是否有 truncated 标记 / 值里是否说清省略多少 / 行字节是否越线 / 是否新增脏行)、协议不变量、三处实现(宿主摄入 / Node 桩 / 浏览器桩)逐字一致。**它第一次跑就抓到一个真 bug**:`encodeRecord()` 的**骨架兜底分支**里写着 `probe: String(out.probe).slice(0, 200)`——9000 字符的 probe 走到那条路时值被砍了、`truncated: true` 也有,但**值里没有任何省略说明**,与 v0.16 那个静默预截断是同一类缺陷,只是藏在更深的兜底分支里;修法是让 `probe` 也参与逐档降级(`clipValue`),并把骨架里的裸切换成 `clipValue(…, 200, 0)`(三处实现同步)。② 同时纠正一处**被我自己写错的契约**:自检脚本第一版断言"投递方伪造 ts 无效",实测发现宿主**原样采纳**投递方给的 `ts`——而这**是必需的**:浏览器桩批量发送(200 ms 一批),排队中的记录必须保住**事件时刻**,否则整个时间序错位。真相是"省略 ⇒ 宿主补到达时刻;提供 ⇒ 采纳",已写进 §7.6.3 与 `probe.http.md`。③ 自检脚本还会**自检矩阵**:每条用例的请求体必须 ≤ HTTP 的 64 KiB 上限,否则活模式 400、本地通过(实测踩过:9000 个小键),矩阵写错会直接指出来而不是误导人去查被测代码。④ 新增测试:`test/wiring.test.mjs` 会真的跑这个脚本并断言退出码 0 + 矩阵没被削薄 + 三处一致性真的比过——自检脚本本身不会腐烂 | | 0.16 | 2026-10-11 | **「顶层 msg 10000 没被标 [!]截断」的真相:既不是没截、也不是 8 KiB 那条路漏标,而是两条没人知道的静默预截断**(用户实测反馈,并已自己排除了两个假设):`ingestProbe` 里写着 `probe.slice(0, 200)` 与 `String(raw.msg).slice(0, 2000)`——10000 字符的 `msg` 被悄悄砍到 2000,**8000 字符无声消失且没有任何标记**;而那一行只有 ~2 KB、**根本没碰到 8 KiB 上限**,所以从"行是否超限"这个角度看一切正常,它能藏住的原因正在这里。更糟的是 **Node 桩没有这两条预截断**:同一条记录走文件桩会正确地标 `truncated`,走 HTTP 却不会 ⇒ 同一协议在三处实现给出不同结果(正是本仓库最防的漂移)。修法:**删掉两条静默预截断**,把截断权威收敛到唯一一处 `encodeRecord()`(它省略超长值、重新序列化、永远留 `truncated: true` 与 `…<省略 N 字符>`)。现在 10000 字符的 msg 走 HTTP:2061 字符落盘、2184 字节、`truncated: true`、0 脏行;② 新增守卫测试 **"宿主摄入路径不许有任何静默截断"**:对长 `msg`(10000/30000/中文)与长 `data` 逐个断言"要么值完整、要么带标记且值里写清省略了多少",并单独断言长 `probe` 标识不被砍(它是分组计数的键,砍了会让两组悄悄合并);超限矩阵补上中文 `msg` 与 `msg@30000`,三处实现继续逐字比对;③ **空白收敛的策略边界实测并写死**:缝上**收敛到 ≤1**、"远离缝的双空行一个字都不动"、"[ok] 写法(空行放进块内)在原地本来就有空行时**不会**把它吃掉,且原地没有空行时能逐字还原"——三条都进了 `test/scan.test.mjs`。诚实记下两条能力边界:(a) 缝上"原本 0 个空行"与"原本 1 个"无法区分,所以 [x] 写法(块外加填充)在原无空行处会留 1 个空行(比修前的 2 个少,但不是 0);(b) 缝上出现 2 个以上空行时**一律收敛到 1**,包括用户自己写的双空行——代价是可能动到用户的排版,收益是"不再留 2 个",这正是连续五轮的 bug。要改成"[x] 站点也收敛到 0"只需把 `collapseSeamBlanks()` 的 `keepOne` 在"块内没有空行"时置 false,属于策略选择,留给用户定 | | 0.15 | 2026-10-11 | **一次真实收尾暴露的三个 bug(用户报告)+ 一个被它解开的悬案**:① **超限截断写出半个 JSON ⇒ 脏行**:老实现是 `line.slice(0, 8*1024-32) + '…","truncated":true}'`,它只在切点**恰好落在顶层字符串字段**(如 `msg`)里时才成立——补上的 `"` 与 `}` 正好闭合字符串和对象,所以能解析;切点落在 `data`(嵌套对象/数组/数组元素)里时那些 `[`/`{` 永远合不上,读端只能算脏行。实测因此呈现"坏点在 8000–8200 之间、与嵌套深度和容器类型都无关、只与值在不在顶层有关、顶层 msg 10000 反而可解析"的诡异形状。改为 **`encodeRecord()`:省略超长值 → 重新序列化 → 实测字节 → parse 往返校验**(`lib/protocol.js` + 两个桩**三处逐字同构**,`test/protocol.test.mjs` 喂同一批负载比对字节);顺带把"按字符数算长度"改成真正的 UTF-8 `byteLength()`(中文 3 字节、代理对 4 字节);② **摘除探针块留下多余空行**(用户说连续五轮同一模式):Agent 为视觉分隔在块**外部**前后各留一个空行,而宿主只删标记之间的行 ⇒ 空行留下、文件里凭空多一处空白。`removeBlocks()` 现在在**每一处摘除缝**上收敛空白:只删纯空白行、只在缝上删、至多留一行(文件首行折叠成 0),并返回 `collapsedBlankLines` 供收尾报告如实说明;同时把**预防**写进指南 §7.2(要分隔就把空行放进块内)。诚实记下能力边界:宿主**无法区分**"这里原本 0 个空行"与"原本 1 个",所以"与插入前逐字相同"只能靠插入纪律;③ **`…` 同时表示"宿主真的截断了"与"只是显示层缩短了"**——用户为此设计了整组实验去区分。查代码后确认:`summary.js` 对任何渲染后超过 **240 字符**的 `data` 都加 `…`,所以那是纯显示层行为,**并不存在**"正常的整值省略路径"。现在两者长得完全不同:记录里带 `truncated:true` 的显示 `[!]截断`(不可恢复),显示层缩短的显示 `…(显示省略 N 字符,日志里是完整的)`;④ `probe.http.md` 的 Python 片段把响应 `close()` 掉了——而在"shell 被沙箱挡、`web_fetch` 拒绝 `127.0.0.1`"的环境里,**读回执是唯一的自检手段**。改成 `with` 块读完并返回回执,新增 `echo=True`,并写明"把回执再发一条探针进日志"这一招;⑤ 摄入响应新增 `degraded` 计数(降级了几条),与 `accepted`/`rejected` 一起构成可自检的回执;⑥ 测试 92 条(新增:超限矩阵 vs 三处实现逐字一致 + 脏行反例守卫、缝上空白收敛的四种形状 + 不动 `fix` 周围空白、`[!]截断` 与 `显示省略` 必须可区分、`byteLength` 的 UTF-8 语义) | | 0.14 | 2026-10-11 | **修掉「Agent 知道 origin 和哈希、却不知道路径」——注入段必须自带本任务的具体地址**(用户反馈原话:"Agent 明确 http://127.0.0.1:19387/<路径>/5d0341c9,但插件没有告诉它这里的 <路径> 是什么"):① 核实了地址的**两个**来源——`dsh-web-app` 的 `localWebUrl()` 就是 ``http://${LOOPBACK_HOST}:${webServer.port}``,也就是说 `DSH_WEB_URL` 本身是从 `webServer.port` 算出来的,**而且宿主已经把同一个地址注进了模型自己的系统提示**(`app:web-surface` 段)。所以 Agent 手里一直有 origin,缺的只是**路由路径**;② `endpointBase()` 增加 `ctx.webServer.port` 作为来源(环境变量优先,部署可能改成代理/隧道地址)——端口就在本插件的硬依赖里,"宿主地址未知、请让用户告诉你地址"那条路本来就是多余的,它把插件自己能回答的事推给了用户;③ **注入段(`debugGuidance`)改为自给自足**:它是唯一每次模型调用都在的文本,现在带上本任务的**全部地址**——摄入端点、**固定的路由路径形状**(`POST <宿主地址>/dsh-debug-mode/probe?h=`,并点明哈希走**查询串**而不是路径段、宿主地址就是它系统提示里那一段)、文件桩路径、日志路径、指南路径。只给一个拼好的 URL 不够:URL 会随上下文压缩而丢,路径不会;④ 拿不到端口时才降级,且**降级也要给路径形状**让 Agent 自己拼,而不是反问你;⑤ `assets/probe.http.md` 顶部补一句"路径固定、只有目标不在本机时才换前半截"(容器/异地场景);⑥ 修掉上一版引入的一个渲染 bug:把条件块当**单个元素**塞进数组,`.join('\n')` 会用 `Array.prototype.toString()` 把它变成"A,B",逗号就留在正文里了——必须 `...` 展开;⑦ 测试 87 条:端点来源两种顺序 + 非法端口不猜、注入段在**两种**情况下都含路径形状(这是本次修复的核心回归守卫)、注入段含端点/桩/日志/指南路径、逗号粘连的回归守卫;⑧ 净效果:Agent 不再需要问任何人地址,`dsh_debug_start` 的返回值也不再是唯一的信息来源 | | 0.13 | 2026-10-11 | **修掉「敲了 `/debug` 还得再补一句『开始』」——命令的返回值根本不进模型**(用户反馈):① 已核实 `@deepseek-ai/dsh-commands` 的 `execute()`,注释原文 "Parse and execute a known command **without sending it to the model**",`result` 只落进 `command/run` / `command/done` 两个 log-only 事件(同一段注释:"no turn wraps them")——也就是说 v0.12 及以前 DESIGN 里写的「`kind:'success'` 的 `text` 就是投给 Agent 的指令正文」**是错的**:`/debug <描述>` 的实际效果是签了票、UI 上出现一段文字,而**模型什么都没收到**,用户不补一句「开始」流程就不动,而且那一句里没有他的描述;② 新增 **§3.5.1** 记录这个契约(含原文证据)与修法:签完票后显式 `ctx.sessionController.prompt({ mode:'queue' })`(`queue` = `agent.followup`:空闲开一轮、忙时排队),把**插件写死的指令 + 用户描述原文**作为这一轮的内容投出去;③ 对应地把「持票阶段的指令从哪来」写成一张表:持票期走这一轮、有活跃任务时走 `systemPrompt.section`(§3.9 的措辞一并精确化:条件是**没有活跃任务**,不是"没任务且没票"——写错会让人以为持票期为空是缺口,然后在系统提示里重复注入一份完全相同的指令);④ `DebugService.wakeAgent` 泛化为 **`deliverTurn({sessionId, text, tag})`**,命令与面板唤醒共用同一条通道;⑤ 指令正文加入"**这一轮就是完整的请求**,不要回复『好的,请描述一下现象』、也不要等用户再说一句『开始』";⑥ 命令的返回值改为**给人看的短句**("已进入调试模式 · 本轮已开始"),投递失败时返回 `kind:'error'` 并给出下一步("直接把描述发送,票据已签好")——绝不报"已开始"然后让用户干等;⑦ 测试:`/debug` 必须开出一轮且内容含描述原文与"不要等「开始」"、命令返回值必须是短句、投递失败要如实说且票据仍有效、没写描述时也要开一轮让 Agent 主动问;⑧ AGENTS.md §6 的核实清单同步纠正(这条错误认知正是 bug 的来源) | | 0.12 | 2026-10-11 | **HTTP 探针通道从"给浏览器的补丁"升为「语言无关的统一埋点入口」**(用户指出:写日志应该是 fetch 本机调试服务,而不是在源码里为每种语言手写写文件的代码):① §7.6 重写为七个小节——**写日志只能有一份实现**(字段序/时间戳/8 KiB 截断/16 MiB 上限/并发追加全归宿主管),否则冻结协议会被复制成 N 份并必然漂移(`lib/protocol.js` ↔ 探针桩之间那份"两处实现对账"测试就是前车之鉴,那还只是两种语言);② 新增 **`GET /probe` 通道自检**(§7.6.2)与 **`report` 的通道状态行**——投递是尽力而为的,"通道死了"与"用户还没复现"的现象一模一样(日志一片空白),现在这个问题可判定;③ 新增 **桩加载即写 `debug-mode:ready`**(两个桩都写),它把"日志为空"从一个谜变成**一个事实**:看得到=桩通了去查调用点,看不到=桩没加载去查 import;④ 宿主生成第三份产物 **`runtime/probe.http.md`**(§7.6.1):curl/Python/Node/Ruby/Go/PHP/PowerShell/Java 八份**端点与哈希已烘死**的现成片段,Agent 不再需要为任何语言照协议写桩;⑤ 指南重写 §五/§六:HTTP 是首选,**自写文件桩降级为"最后手段"**并明确列出三种例外(热路径 / 崩溃前落盘 / 无网络栈),§零 补一条"不要为了能 import 文件桩而起本地服务器";⑥ 注入准则同步;⑦ **措辞纠正**:`report` 报"本轮日志 N 行"而不是把它冒充成"文件桩写入条数"(文件桩直接写文件,宿主看不见——报一个自己不知道的数字比不报更糟),并把 HTTP 累计计数与**本轮**日志行数分开表述;⑧ 顺带修掉三处仍写着"输入框下方"的面板文案(v0.11 已把面板移到上方);⑨ 新增/改写测试 8 条:多语言片段占位符与语言覆盖、`probeStatus` 的累计计数与提示、`report` 的通道状态两种分支(通道已验证/未验证给的是**不同**建议)、浏览器桩的加载即报到与 NDJSON 批量、`task` 不可伪造与最小调用(只有 `probe` 一个字段);⑩ **用真 HTTP 客户端(undici)跑了一遍全链路,当场抓到一个 bug**:摄入成功原先是 `204` + JSON 体,而 204 按规范不许带体——严格客户端直接拒绝读取这段自检信息(`Unexpected end of JSON input`),浏览器桩因为不看响应所以一直没暴露。改为 **200 + JSON 体**(`OPTIONS` 预检仍是 204) | | 0.11 | 2026-10-11 | **面板重做:从"挤在输入框下面"改成"贴在输入框上面的一张可折叠卡片"**(用户反馈"挤兑了原有的提示词输入框的位置"):① 座位从 `conversation.composer.dock`(渲染在卡片**下方**)改为 **`conversation.input.dock`**(首方在 `composerStack` 里把它渲染在 `inputBar` **之前**,是文档流兄弟节点 ⇒ 贴近输入框、**不可能遮挡**、也**不会挪动**输入框),`order: 30` 排在 todo/goal/queue 之后紧贴输入框;② 新增**折叠**(§9.1.3.1):默认收起,只在 `awaiting-user` 自动展开;头部整行是 `