# 更新日志 ## 0.5.1(2026-10-19) - **修复切换工作目录后的相对路径捕获**:文件工具的前镜像与宿主使用同一会话有效目录,派发前重新获取目录与目标;已记录的绝对路径不随后续目录切换变化。父子目录同名文件不会混用恢复依据;仅切换工作目录不新增文件修改归属。 - **目标不一致改为逐操作持久恢复缺口**:工具派发结果或最终结果回报的规范路径与捕获目标不同时,排除该操作的不可信恢复收据,不拼接不同文件的前后态,也不覆盖原工具结果。独立持久记录调用 id、原始轮次、工具及预期/回报的规范路径,不保存文件正文;后处理失败前已发现的不一致也保留为缺口。 - **只阻断跨越存活缺口轮次的回退**:后续正常轮次继续记录检查点,满足其余恢复条件时可回退;已知缺口在预览与执行时、文件或对话表层发生变更前阻止 `fromTurn <= 缺口原始轮次` 的整个回退后缀,并报告缺口及之后可用的起始轮次。轮次列表只提供缺口之后的起点。重启后限制不变;同轮后续成功、no-op、净变化为零及检查点裁剪均不清除缺口。不提供跳过缺口的部分回退或仅撤回对话模式。恢复记录写入、读取等原有失败仍保守拒绝,可能阻止更大范围的回退,不承诺后续每次回退必然成功。 - **恢复事务重查晚到缺口**:旧预览不能绕过新增缺口;恢复事务在文件操作前与对话标记提交前重查。如果文件恢复途中才发现缺口,停止继续恢复、保留事务与对话,允许 `/rollback abort` 补偿已执行的文件操作,不将中途失败误报为完全没有修改文件。 - **新版宿主回归**:覆盖目录切换后的创建、编辑、重启恢复、绝对路径、旧宿主目录回退分支、目标不一致,以及真实宿主的版本放行和旧入口加载;上下文分类统计验证更新为新版相邻 prune 定价协议。 - **当前适配目标更新为 DSH 0.2.1-rc.2**:开发用 DSH API 包精确锁定到 `0.2.1-rc.2`,Cordis 更新为 `4.0.5`;新增 `dsh-working-directory` 的开发依赖与纯类型增强,支持宿主可选读取会话有效工作目录。旧宿主保留目录回退路径,原兼容范围未收窄;旧版本在本次适配中未重新验证。 - **兼容闸门使用当前字段**:将无效的嵌套 `dsh.engines.dsh` 移至顶层 `engines.dsh`,与保留的宿主 peer 范围保持一致;版本放行不等于功能验证。 - **移除退役 invariant 服务依赖**:删除 `dsh-invariants` 的 peer、开发依赖和类型导入;公开 `./invariant` 入口保留为已弃用的空操作,无必需服务注入,避免旧配置因入口消失而加载失败;主插件不依赖 companion。 - **文档与检查入口同步**:中英文 README 说明当前目标、旧版验证边界及遗留入口;宿主类型检查说明更新到当前 API 版本,继续保留全部诊断。36 个测试文件、728 项测试以及类型检查、双语文档检查与构建全部通过;尚未进行 profile 安装、宿主重启或 GUI 端到端验收。 ## 0.5.0 > 本节即 **0.5.0**(已发布到 npm 与 GitHub Releases):收录 0.4.0 之后累积的全部工作树修订。0.4.0 及以前条目保留为历史,不作为当前恢复保证。 - **正常回退结果加固**:直接文件成功结果按原派发轮次记录;无结构化后态的编辑器复用派发后观测(含 no-op),不吸收后处理/结果回调间的外部写入。新收据带 callId,重启按逐轮调用覆盖检查,缺少某文件或重复调用的依据不再被整轮其他检查点掩盖;旧无身份收据只在没有混合 shell/未知改写工具时按精确路径及原始数量保守兼容;下一轮开始前合并未正常结束轮次的持久修改,直接调用缺少最终结果及精确收据时拒绝不完整回退。旧 JSONL 同一文件中的重复修改不再被元组去重吞掉,迁移仅匹配跨旧/新文件的副本数量,原基线键不变。真实 rc.2 Cordis 派发回归覆盖晚到结果、外部间隙、重启一致性与实际磁盘恢复,已接受客户端保持不变。 - **跨度净变化归属**:目标跨度内每个路径只取首个 before 与最后一个 after;before 缺失/after 存在列为删除,before 存在/after 缺失列为找回,两者都存在且不同列为恢复。首尾相等(包括创建后删除、更新后还原)从文件列表和实际 I/O 中同时省略,即使之后又有外部改动;纯外部路径不新增,净受影响路径仍沿用现有冲突检查。执行 WAL 可选动作保留预览标签以支持外部改动后的重试;缺失旧记录的 after 不能证明净变化为零,继续使用旧动作的保守恢复。Hero、菜单和右侧刻度条保持不变;并发未知 shell 派发仍不能可靠区分外部修改者。 - **Net-change attribution across a target span**: keep the first `before` and final `after` per path. Missing before/present final is delete, present before/missing final is recover, and differing present endpoints are restore. Equal endpoints—including create-then-delete and update-then-revert—are omitted from both the file list and actual I/O, even after later external edits; external-only paths are not added, and existing conflicts remain on net-affected paths. An optional WAL action preserves preview labels for retry after external edits; missing legacy after cannot prove net zero, so the conservative old-kind restore remains. Accepted Hero/menu/rail behavior is unchanged; concurrent external edits during unknown shell dispatch remain indistinguishable. - **删除前镜像拒绝有损文本**:原生探测拒绝 NUL/非无损 UTF-8 字节,保留合法 BOM;读取前后核验文件身份与时间/大小,链接不作普通文件。已登记文本的同步 shell 删除可以恢复,未登记/递归未知路径、后台删除与二进制不承诺恢复。 - **错误结果仍记录实际目标变化**:direct 文件工具部分失败及写入后 post-execute 抛错/拒绝/晚取消仍依据派发目标前后态捕获,包括新建/未登记目标;不扫描归属无关路径。no-op 不新增变更,后态未知则锁定回退且不替换原工具错误。磁盘支撑的版本化 fs fixture 验证 shell 删除→预览→恢复完整 Unicode/CRLF 文本,不等于实际 sandbox GUI 端到端验收。 - **外部改动不再污染后续轮次**:取消用户消息/轮次结束时的修改归属扫描;纯聊天、已知只读工具与同内容写入不新增文件变更。外部修改保留在最后真实文件修改轮的冲突范围,不覆盖历史 after。文件工具仅捕获声明目标(包括部分失败/最终错误);Shell/未知工具使用已登记路径的独立前后快照,恢复起点包含工具执行前的用户修改;未知、竞态、权限、超限或无法映射仍 fail-closed。不承诺后台/并发写入的修改者归因,旧误归属缺少来源证据而不自动迁移。 - **持久文件救援点**:提交回退后、清理 WAL 前写入独立 `rescues-v1`,保存事务真实 before/after;同 id 同 payload 重试幂等,冲突、损坏、版本、数量或大小超限拒绝覆盖并保留恢复日志。救援点仅是文件状态,不是完整会话 redo、附件备份或 provider-memory 擦除。 - **命令与回执优化**:移除旧拼写,当前只保留 `/rollback latest`;`/rollback doctor` 更名为 `/rollback diagnose`;成功回退及 retry/abort 执行回执在客户端对话中隐藏;裸 `/rollback` 的原轮次弹窗仅保留上一轮和轮次;手动 `/rollback` 空格认领后打开独立子命令菜单,选择以版本守卫仅追加参数,保留蓝色命令并等待回车执行;preview 显示嵌套轮次菜单,Esc 返回保留选中状态。原有命令输入仍保持有效。 - **审批后补捕获**:新增 `tools/execute` 观察及待恢复事务阻断;审批 waterfall 短路也会在实际分发前捕获,审批期间改动会刷新,旧前镜像先丢弃。成功文件工具缺少可信前镜像时禁用回退,不接受自报空内容作为依据;不吞掉下游工具错误。 - **只读 diagnose**:`/rollback diagnose` 输出事务阶段、检查点、策略及方法存在性,不含文件正文、不写恢复文件/会话;观测/扫描失败仍返回净化状态,不清除失败锁;Session 查询不可用报告 unknown;不以方法存在证明删除或后端语义。 - **可选上下文压力定价**:使用可选 tokenMeter 的固定 heuristicTokens,对确切表层位置区间同步追加 prune + user 标记;异常/缺失计量退回原标记路径。覆盖分段追加失败后的安全重试、flush 重试不重复标记及真实 rc.2 codec/meter 回归;WAL 按位置而非序号大小验证端点。 - **保留边界**:裸 idle 空 developer 不满足 v4 恢复生命周期;createIfAbsent 不能替代 abort 的删除授权;隔离/native unlink/Git 不等于条件原子删除;文件救援不等于完整会话 redo 或 KV 擦除。不扩展任意 shell/全工作区修改者归因,不改右侧刻度条,不拦截带参数命令,不承诺真正的多文件掉电原子性。 ### 界面决策:消息照常隐藏,刻度条不处理(2026-10-04) - **撤销:右侧轮次刻度条不再由插件处理**。曾经实现过「轮次 ≥ 回退边界就隐藏刻度」,现已整体移除(含选择器、轮次读取、隐藏逻辑与对应测试)。 - **原因**:刻度条是等距绝对定位的列表(TURN_SPACING_PX = 10、RAIL_INSET_PX = 6),隐藏一个刻度只会在原位留下一个空槽;回退最后几轮再发一条消息,空槽就停在保留刻度与新刻度之间。要合上必须从外部重排组件布局,而组件每次重渲染都会把自己的位置写回去。 - **取舍**:两个方案摆给用户后,用户选择刻度条原样不动。 - **仍然生效的部分**:被回退的对话消息照常隐藏,改由宿主给出「回退边界」这一个数字驱动(/rollback state),不依赖回退标记是否到达客户端切片;隐藏的是普通流式布局的行,后面内容自然上移、不会留空档。 ### 正确性修复:重复回退与标记的可加载性(2026-10-04) - **重复回退同一轮**:目标已被回退过时明确拒绝并说明原因,且不写入任何事件(此前会写入零宽区间、替换掉上一条标记,连带销毁其隐藏窗口 —— 每退一次反而撤销上一次的隐藏)。 - **标记的 sourceEventSeqs 写法**:改为非升序(末位序号提到最前)。会话 JSONL 存储会把「严格升序且含连续 ≥3 个序号」的清单压缩成区间对,而运行时的同一个字段只接受扁平整数 —— 以压缩形式落盘的会话再也打不开(一直停在「载入历史…」);运行时只要求「互不重复、都早于标记自身」,没有升序要求,因此语义完全不变。 ### 回退执行与恢复边界 - **冲突预览与知情执行**:实时内容对比最后记录的 after 状态,并在可用时对比最后成功工具版本。版本仅有内存记忆,不可靠归因修改者;未观测修改、同内容修改和重启间隙仍可能漏检。已授权内容冲突不再单独拒绝 execute/retry/abort;文件类型、路径、策略、会话、WAL 及受支持的读到写版本守卫继续有效。 - **盘根条件发布不降级**:原文件服务成功时守卫原样透传;仅发生盘根 mkdir EPERM 的兜底路径无法用原生 rename 保证条件发布,因此拒绝带守卫的写入/编辑,保留既有 stale/missing 诊断。版本守卫恢复不再降级成无条件覆盖;失败保留事务日志且不继续截断。 - **最终 flush 后复核会话**:持久化等待结束后再次检查生命周期与精确 Session 身份,会话被关闭/替换时保留 marker 日志,不清理日志或完成旧 fold;可在有效会话上 retry。 - **基线重放成员查询**:每次加载临时构建按路径的 Set,避免对同一路径多次线性扫描 checkpointKeys;持久格式不变,保留未定义键、空数组、旧哈希键、空内容及缺失状态语义。未声称已测得启动时间收益。 - **从「跳过并截断」改为 all-or-no-truncate**:完整前置内容、目标窗口、会话版本、当前策略及持久化屏障均须通过预检;已知不可恢复项不再允许跳过后截断。恢复写入失败可能留下部分文件已变的状态,但不会继续提交截断。 - **持久恢复事务**:执行前捕获真实文件状态并保存独立日志,再按文件版本条件恢复;`/rollback retry` 继续,`/rollback abort` 在标记未写入时尝试按真实操作前状态补偿。标记已写入或已提交时拒绝 abort,只能重试完成持久化;日志独立于 10 轮目标窗口保留。 - **受限本地普通文件删除与缺失找回**:helper 要求已证明的 Host 本地映射,反复核验身份、原生规范路径及普通文件类型;允许 `danger-full-access` 与包含于工作区的 `workspace-write`,拒绝 `read-only`、受限策略下工作区外路径、远程后端、符号链接及目录。撤销新建可在授权范围内执行,缺失文件找回也可执行,但预检必须授权其补偿删除。没有版本条件原子删除保证,检查到原生删除之间的残余竞态明确保留;重复检查不能消除该风险。 - **运行、并发与生命周期守卫**:打开的轮次禁止执行;恢复日志存在/不可读时阻止模型和工具继续。按会话互斥,并用跨插件实例的进程内锁防止重载时旧执行与新实例恢复交错;卸载中止后续操作并保留恢复依据。真实循环的完整 blocked 空轮次仅在输入队列净变化为零且无对话/工具活动时不使恢复陈旧;普通预览版本仍严格校验。`committed` 日志重试仅完成持久化/清理,不重新覆盖文件。 - **会话持久化屏障**:执行前与标记写入后要求 `sessions.flush` 返回 `true`;观察者通知不是落盘确认。恢复日志写入采用文件同步与原子替换,POSIX 追加目录项同步;Windows 的 Node 目录同步不可用,只面向进程崩溃恢复。检查点、独立基线和恢复日志都不承诺 Windows 突然掉电持久性,更不承诺多文件原子回滚。 ### 捕获、基线与存储 - **准确区分创建与空内容**:文件工具预读目标存在状态,再合并成功结果;有效 `after: ""` 保留,不把占位前置空串当作已观察的文件清空。捕获/记录持久化失败禁止相关回退。 - **边界扫描不伪造缺失**:按会话串行排队并等待扫描;仅 `ENOENT` 认定不存在,权限/I/O 错误、非普通文件、超限及无法证明本地映射时保留基线并报告。扫描失败保留可观察失败状态,不能静默把不完整扫描当成成功。 - **独立回退后基线**:成功恢复/补偿通过 `resetAfterRollback` 安装精确内容/确认不存在,并在内存替换前独立持久化;重放过滤死轮次不能清掉新基线,失败路径不被错误重置。 - **会话存储隔离**:新检查点/基线使用会话 id 的 UTF-16LE 小写 hex 编码,避免清洗与大小写不敏感文件系统碰撞;旧清洗文件名只作精确 session id 筛选的只读迁移来源,记录去重。损坏/不可读存储及压缩失败报告并拒绝,而不是静默按空状态加载。 - **明确覆盖及隐私限制**:跟踪只覆盖已观察路径、有效基线与保留记录,不保证 shell 新建文件或全工作区恢复;策略外路径不自动放行,原生扫描不凭 cwd 猜测。完整文本 sidecar/恢复日志不是加密备份,分享 DSH 存储前需考虑敏感内容。不按目录出生时间证明所有权,也不自动清理祖先空目录。 ### Web 客户端与文档 - **轻量冲突提醒**:受影响文件动作旁缓慢闪烁黄色圆点,减少动态效果设置下保持静态;底部中文警告准确为「警告:检测到文件有人为/外部应用修改痕迹,请谨慎回退」。不将提示扩展成可靠修改者归因或额外操作入口。 - **普通文件历史字节回填**:Host 按轮次与附件 id 提供有界历史原始字节命令,客户端经 `conversation.createDrafts` 以二进制 `application/octet-stream` 走普通上传;每文件上限 8 MiB、客户端合计 32 MiB,不复用上传回执、不读取当前源路径。legacy 不支持及存储/上传失败均诊断;不保证图片上传前不归一化,不宣称已部署或端到端通过。 固定宿主将命令结果正文写入 `command/done`,导出因而在持久会话日志中另存附件字节的 base64 副本;回执仅在日志中而非模型可见表层。隐藏回执不删除字节,需考虑存储增长及日志分享/导出隐私。 - **缓存审计口径**:本地投影重建不等于 provider 缓存 purge;下次 wire 保留历史 + marker + 新提示,且可能有上下文/系统投影变化,同一 sessionId 不保证缓存命中。按每请求 input/read/write 观察,比例为 `read/(input+read+write)`;原生 DeepSeek 的 `cache_read_input_tokens` / `cache_creation_input_tokens` 与 pi completions 对应别名分开说明,不宣称远端 KV 或记忆擦除。 - **有限且会话绑定的隐藏状态**:`/rollback state` 携带 `sessionId`、`version`、已撤回轮次及有限序号区间,不再授权「轮次 ≥ 边界直到无限远」。匹配会话、非过期状态和官方 `data-chat-turn` / 节点身份才可隐藏;未知行保持可见,新轮次不沿用旧边界。React 管理的消息节点不删除,不按文本猜测或压制读命令回执,右侧轮次刻度条继续原样保留。 - **异步弹窗守卫**:选择轮次打开确认弹窗,不直接执行;预览/提交绑定会话与版本,关闭或切会话使旧请求失效,执行中禁止关闭/重复确认。数字 `/rollback ` 仍直接执行且不回填消息/图片。 - **修正当前契约与证据说明**:Host 只注册人工命令、不注册模型工具;页脚槽首选 `conversation.chat.turnTail` list/`id`,仅明确形状拒绝时回落历史 chain/`select`。中英文 README 同步删除旧固定测试计数、全量恢复/无条件回填及「陌生版本最多外观退化」承诺;兼容区间放行与历史测试记录分开说明。 - **开发前提与运行验证**:锁定构建工具链要求 Node `^22.18.0 || >=24.11.0`,不是包元数据 `>=18` 所能保证。区分源码检查、本地构建、profile 安装、实际宿主重启/页面刷新与真实 GUI 端到端验证,不能互相替代。 ## 0.4.0 ### 界面与交互:对齐官方 - **回退弹窗的文件列表改为只读**:那几行不再是控件。它本来就是「这次回退会动哪些文件」的回执,不是导航入口;要打开文件走右侧文件树。 - **回退按钮对齐官方槽内按钮**:圆角由 28px 的圆形改为 `var(--dsw-radius-sm)`(8px,与官方动作条按钮 `.xD_KDq_action` 同值)。盒子仍是 **28×28**——官方动作条本身只有 28px 高,按钮再高就会顶出行外——所以变大的是**图标:15→17px**,内边距 6→5px。17px 取自官方同一个按钮的 `[data-clock=end]` 变体,不是自拟尺寸;描边按官方常规图标由 1.4 降到 **1**(`ICON_REGULAR_STROKE`,原先的 1.4 比官方的「中等」1.3 还粗,这正是它看着比旁边按钮重的原因)。 - **回退弹窗圆角对齐官方**:12px → `var(--dsw-radius-panel)`(28px)。这个值是官方 Modal 与输入框共用的同一个,所以「按官方来」和「和输入框一样」是同一个答案。 ### 指令菜单与轮次选择弹窗 - **`/rollback` 弹出官方的轮次选择弹窗**(`popupSelect`,与 `/权限`、`/模型` 同一个组件):`回退到上一轮` 加每个可回退轮次一个数字选项(宿主窗口最多 10 轮)。文案全部走 `locale` 词典、**随界面语言切换**,选项 id 唯一(框架拿它当 React key)。**选择不会往输入框插入任何文字**——官方弹窗本来就如此。 - **选中轮次后不再直接回退**:改成打开**和「回退」按钮完全相同的那套确认弹窗**(列出受影响文件、可取消/确认),因此连带继承「回退后把该轮的图片回填到输入框」。真正的执行只发生在确认之后。 - **去掉命令示例**:菜单不再展示 `/rollback list | preview | ` 这类语法,占位符改为说明「(从弹窗选择轮次)」。占位符不能为空——注册表会拒绝(`normalizeDefinition`: "input hint must not be empty")。 - **菜单只有一行,描述写成双语**。这是取舍,不是能力不足:菜单里**必定**有宿主命令那一行(客户端到宿主唯一的通道就是它,而 profile 插件**不能**运行时 import 核心包去自建 Remote——实测直接 `failed to import`),而那一行**拿不到**图标或本地化文案(官方那六个命令的图标与中文名来自 `dsh-client-ui-commands` 内部的硬编码表 `HOST_FACES`,第三方加不进去;客户端唯一的接口 `decorate` 只带行为、不带文案)。唯一能自定义文案的客户端条目**不能与宿主命令同名**——同名会在生成菜单时抛错,把整个「指令」分组带崩。所以「一行」与「带图标」只能二选一;本次选一行,描述写成 `回退到某轮对话发起前 / Roll back to before a turn`,两种界面都读得懂。 ### 命令 - **`/rollback <轮次号>` 保留**,但它**没有确认弹窗、也不回填消息/图片**。原因同上且无法绕过:确认弹窗与图片回填都在客户端,而**带参数的命令行到不了客户端**——客户端条目与装饰只对「裸」命令词生效,宿主命令一旦声明 `input`,参数就直接交给宿主。**要确认就用不带参数的那种。** - 弹窗与按钮两条路走内部拼写 `/rollback --apply `,不对外宣传;辅助命令不变:`/rollback list`、`/rollback preview `。 ### 移除 - **「在编辑器中打开」整体移除**:弹窗行不再是控件之后这条路已无入口,随之删掉 `openFileInEditor`、`dsh-resource://file/` 地址构造与工作区根路径读取(约 120 行),客户端也不再动态查找 `workspaces` 服务。这里要分清**两个不同的 `inject`**:`dsh.client.inject`(清单字段)**本次没有改动**,仍是 9 项——它从头到尾就**没有**出现过 `workspaces`(逐提交核过:首次出现时 3 项,之后增到 9 项,全历史都不含该名);本次真正减项的是**宿主半边导出的 `inject`**(必需服务):**5 → 4**,去掉的是 `tools`(见下文「工程卫生」一节),不是 `workspaces`。 - 两个 README 同步:兼容矩阵里 0.1.1 的回退分支清单去掉 `workspaces.openPath`;功能表把「点击文件可在编辑器打开」改为只读回执;使用说明改为「两种入口、各自有没有确认」;回归测试清单改为 **15 文件 / 222 用例**(补上一直漏列、且已增长到 18 例的 `log-replay`,修正 `truncation-plan`、`rollback-guard`、`turn-entry`、`boundary-scan` 的过时计数,并加上本轮新增的 `sidecar-replay`)。 ### 界面修复:右侧轮次刻度条残留(回退后仍显示已回退的轮次) - **症状(用户实测)**:回退之后,右侧那根刻度条上**属于被回退轮次的刻度仍在**,而对话区里那些消息已经看不见了。 - **根因**:**刻度条不是由聊天切片渲染的**。官方 `dsh-client-ui-chat` 把宿主的 `turnOutline` 投影与当前加载窗口合并(`mergeTurnRailItems`)后逐轮渲染一个刻度,而那份投影列举的是**会话日志里全部轮次**——回退遮蔽的是一段区间、**并不删除日志记录**,所以日志里还有那些轮次,刻度条就继续给它们留刻度。这个残留**任何节点侧计数都看不见**(`contentLeft`、`contentKinds` 全都正常),因为问题根本不在聊天切片里。 - **修复**:插件改不了宿主的投影,于是**像隐藏其它行一样在 DOM 上隐藏这些刻度**。两处读数是精确的,不是猜的: - **边界轮次**读自**刚被隐藏的那些行**——官方轮次视图把自己的轮次号公开在 DOM 上(`TurnTailNodeView` 的 `data-turn-tail`、`TurnProcessNodeView` 的 `data-turn-process`),被隐藏行里最小的那个轮次号就是回退的起点;标记窗口覆盖的节点若在自己 `data` 里带轮次,也算一路来源; - **每个刻度的轮次号**读自它自己的 `aria-label`(`跳转到第 {turn} 轮` / `加载并跳转到第 {turn} 轮` / `Jump to turn {turn}` / `Load and jump to turn {turn}`)。判据是**数字**而不是整句文案,所以插件没见过的语言照样能用;同时要求元素带该组件自己的 `_mark` 类(CSS Module 的 `_mark`),**避免误伤导航里其它带序号的按钮**——那正是这一遍最不能犯的错。 - 轮次 ≥ 边界的刻度被隐藏(`display:none` + `data-rbk-hidden`),**读不到边界时整条刻度条原样不动**(宁可不动也不猜着删),会话没有回退标记时则把之前隐藏的刻度全部还原。 - **取舍(写在代码注释里)**:刻度是**绝对定位**的(`.…_marks{position:relative}` 配 `.…_mark{position:absolute;height:10px}`),所以隐藏一个刻度**只留空档、不会把相邻刻度上移**。要填补空档就得从外部重排 React 的虚拟列表,会在下一次滚动时和组件对冲,为纯观感不值得。 - **诊断**:`[rollback] hides:` 新增 **`railMarks`**(这一遍读到几个刻度)、**`railHidden`**(其中几个属于已回退的轮次)与 **`boundaryTurn`**(读到的边界轮次;`null` = 什么都没报轮次号、刻度条未动)。 ### 工程:补上一直缺失的「DOM 那几遍」的探针 - **此前的所有探针 DOM 都是空壳**(`querySelectorAll: () => []`),所以插件的 **DOM 各遍从来没在浏览器之外真正跑过**——而最近三次用户报告(欢迎页残留状态行、刻度条残留、以及它们的两次"修了还在")**全都出在这条缝上**。注册类探针看不见它:`syncHides` 只能经插件注册在 `conversation.input.dock` 的 driver 触达,而它需要一棵对话树、一个聊天切片和一根刻度条。 - **新增 `_adapt/rail-hide-probe.mjs`**:在**真实元素树**上驱动**构建产物**——模拟槽核心的注册规则、够用的 React(渲染一次并按声明顺序跑 effect)、以及插件真正会问的那些选择器(照它自己的源码转写,不是猜),刻度条的标记结构照 `dsh-client-ui-chat/lib/client.js` 转写。**10/10**,其中两条专门钉住"必须不动":不是刻度标记的 `nav` 按钮不得被碰、没有回退标记时不得隐藏任何刻度。 - **新增 `src/core/turn-rail.ts` + `tests/turn-rail.test.ts`(16 例)**:把容易出错的读取逻辑(哪个属性、哪句文案、哪个类)从只有浏览器能跑的客户端文件里提出来,放进无依赖的 `src/core` 并单测——包含英文/中文/未见过的语言、`data-turn-*` 的直接与嵌套两种形状、以及"不是本组件标记"的若干诱饵。 - 两个 README 同步:回归测试清单改为 **15 文件 / 222 用例**(加上 `turn-rail` 的 16 例),并新增界面探针的说明。 ### 正确性修复:「新建文件」曾被记成 `update`,回退时被「恢复」而不是删除 - **症状(用户实测)**:①单轮新建文件后回退 → 该文件**识别不到**;②建文件 → 再改它 → 回退到建之前 → 本该被**删除**的文件反而被**恢复**。 - **根因**:`src/core/session-fold.ts` 相信工具结果里的 `operation` 字段(`'create'` 才算新建),但 **0.2.0 的 `write` 根本不发 `create` 标记**;实测真实会话的 sidecar 里,新建的文件被记成 `operation:"update"`、`before:""`——而**空串无法区分「新建」与「写入一个原本为空的文件」**,所以只靠工具结果永远推不出这件事。 - **修复**:新建与否改由**插件自己**在工具运行前判定。`tools/pre-execute` 的守卫由只认 `str_replace_editor` 扩到 `write`/`edit`(原先这两个名字被守卫挡在外面,一个 `pending` 都不会生成);`captureBefore` 对 `str_replace_editor` 仍看 `command`,对 `write`/`edit` 只看 `path`,用一次 `ctx.fs.stat` 把「目标此前是否存在」记进 `pendingBefore`;`tools/result` 的 `operation` 由此决定——状态为「此前不存在」就是 `create`,否则沿用它自己报的(**`remove` 仍以工具自报为准**,那是它才有资格说的消息)。 - **回归**:宿主探针新增一条**照真实 0.2.0 形状**喂入的检查(`before:""`、无 `create` 标记)——它曾长期绿着,只因旧检查喂的是插件**希望**收到的形状(`{ operation:'create', before:null }`)且只断言观察者不报错;现在断言落到 sidecar 里真正写下的 `"operation":"create"`,失败时会直说「回滚会**恢复**这个文件,而不是删除它」。这条差异**只有宿主半边能覆盖**(`src/core` 的单测看不见工具结果),所以它留在探针里。 - **新增 `tests/sidecar-replay.test.ts`(7 例,后补 1 例)**:把**真实桌面会话**的侧车记录形状(同一个 turn 里既有 `remove` 又有 `create`、`before:""` 的已知空文件、以及 26 轮历史)喂进「重启后重放」这条路径,钉住三件以前没有测试守着的规则:**同一轮里多条记录都要折叠**(只看到一条就会「复活被删的文件」或「删掉新建的文件」)、**`before:""` 是已知空内容而不是未知**(塌成 `null` 会让「被清空的文件」静默跳过恢复)、以及保留窗口是**最近 20 轮**(`cutoff = maxTurn - KEEP_TURNS + 1`)。写这个测试时我自己的两条预期都写错了(以为窗口是「≤20 轮」、以为 `before:""` 会进 `skipped`)——**代码是对的、预期是错的**,这正说明这些规则此前只能靠读源码记住。 - **欢迎页之上的「过程行」不再残留:判据从「名单」翻转成「白名单」**(桌面端实测后修,改了三轮才修对): - **症状**:回退一个**被中断的轮次**后,状态/过程行(先是 `已停止`,后是 `已完成分析`,即 `message.stepProcess.done.thinking`)会留在已清空的对话区上方 ✗,既挡住欢迎页、又不被隐藏。 - **为什么修了三次**:原判据是一份「**基础设施名单**」(`turn-tail` / `command` / `system-prompt`…),**名单外的一律算内容**。而聊天快照里的状态/UI 种类是**开放集合**——桌面端实测中它**连续漏了三种**:`turn-process`、无文本的步骤、以及第三种(两者都修完后仍然残留)。每漏一种,用户就要多报一次、多跑一轮构建。 - **现在的判据**:改成 **`CONTENT_KINDS` 白名单**(`user` / `assistant-step` / `system-prompt` / `compaction` / `skill` / `apply`)——**只有名单内的种类才算内容**,其余一律视为 UI 脚手架。方向是安全的:欢迎页有两道闸(日志必须证明回退清空了对话 **且** 屏幕上没有内容座位),而新消息一定是 `user`(在名单内),它一出现 `contentLeft` 立刻非零、对话照常留在屏幕上 —— 所以「漏判」最多只损失一行滞留的状态行,**不可能隐藏回退之后到达的消息**。 - **三处判定共用同一函数** `isUiOnlyNode()`(是否清空、是否属脚手架、可见座位计数),因此**日志读数与 DOM 读数不可能再互相矛盾**。 - `isTextFreeStep()`(blocks 可读且一个 `text` 块都没有的步骤视为过程 UI)保留为白名单内的细化判据。 - **第四处、也是真正的根因:`syncHides` 只遍历「聊天切片里的节点」** ✗。桌面端实测拿到决定性数据:`contentLeft: 0`、`contentSeatsVisible: 0`、`emptied: true`、欢迎页 `wanted` —— **插件认为一切都清空了,屏幕上却仍有一行**(`已完成分析`)。也就是说那一行**在聊天切片里根本没有对应节点**:表层替换把它留下的 DOM 座位变成了**孤儿座位**,而 Pass 1–3 全部基于 `nodes` 循环,**从来没有考虑过它**。现在新增 **Pass 4**,并把 `nodeLessSeats` / `nodeLessSample` 打进诊断。 - **第五处:光"隐藏"不够,孤儿座位必须移除** ✗。用户次日实测:那行**消失了一次、随后一发新消息就又回来了**——因为隐藏挂在"已清空"状态上,而来了新消息就不再是"已清空"。对这类座位**不存在**任何"应该显示"的状态(切片里没有节点,本快照的数据根本渲染不出它,只能是表层替换遗留的 DOM),所以 Pass 4 改为**直接 `remove()`**:**幽灵座位不再能活过下一条消息** ✓。范围严格限定在"切片里没有节点的座位",真实消息一定有节点、绝不受影响;页面重新加载后一切照旧。 - **修这一条改了三轮,教训值得记下**:前两轮都在**猜节点种类**(先补 `turn-process`,再加"无文本步骤"),而真正的答案是**问题根本不在节点上**——`contentLeft: 0` 已经说明"按日志没有内容了"。**用户展开的那两个诊断对象才是收敛点**:`contentKinds: {}` 与 `contentLeft: 0` 一起把问题从"哪种节点"改判成"哪个 DOM 座位"。这也是为什么诊断值得**无条件打印**,而不是藏在调试开关后面。 - **扫描发现的改动一律记在「发现它的那次边界」上(`findingTurn` 规则修正)** —— 这是本次最实质的一处数据丢失修复: - **实测(桌面端 2026-10-02)**:Cow Come 会话里,第 1 轮用 **`pwsh` 工具**建了 `a.txt` 与 `c.txt`(不是 write 工具,所以只有边界扫描能看见它们),第 2 轮 `Remove-Item a.txt` + 改写 `c.txt`,第 3 轮建 `b.txt`。侧车记录却是:`turn=6 create a.txt`、`turn=7 remove a.txt` —— **`a.txt` 的删除被记在了"发现它的前一轮"** ✗。 - **后果**:用户回退**第 3 轮**(= 会话 turn 8),计划窗口是 `turn >= 8`,而那条 `remove` 挂在 turn 7 → **落在窗口外** → 本该显示「找回」的 `a.txt` **整条从确认弹窗里消失** ✗(用户反馈「本应一恢复一删除一找回,找回不见了」);改退第 2 轮时窗口含 turn 7,于是显示正常 —— 与用户观察完全一致。 - **根因**:`findingTurn(lastSeenTurn, scannedTurn)` 原规则是「**确认过该文件的下一轮**」(`lastSeenTurn + 1`),理由是"先发现不等于发生在那一轮"。但它**在真正要紧的方向上错了**:扫描发生在某轮**结束时**,所以一个改动**只可能**在"被扫描的那一轮"或更早被得知;把它记到**更早**一轮,等于声称"一个自己边界上明明还看见文件完好的轮次删掉了它"——不可能,而且**会丢数据**。原单测甚至把这条写进了断言(`findingTurn(5, 9) === 6`),说明它是被当成规格实现下来的。 - **新规则**:**一律记在被扫描的那一轮** ✓ ——改动因此**总能**被"回退到该轮之前"撤销,这正是用户要的。取舍写在注释与测试里:若某文件其实是很久以前删的、而插件因为稍后才 prime 才第一次发现,它会被记为首次扫描的那一轮,代价只是**多一条"恢复了其实没东西可恢复的文件"**(文件本就不在、写回的是删除前的原文),而旧规则付出的代价是**删除它的那一轮少一条找回** —— 一个是观感,另一个是把用户的文件从预览里弄丢 ✗。 - **诊断不再静默**(同为实测产物,共三处):①`[rollback] hides:` 增印 **`contentKinds`**(还立着什么、各几个),第三种形状出现时**控制台自己报出它是谁**;②回退后**无条件**打印 `[rollback] rolled-back turn message: { turn, blocks, restorable, beforeRead }`——`blocks` 是该轮消息**真实的块类型统计**;③确实有附件时打印 `[rollback] attachments: { carried, rebuilt }`,composer 拒收则 `console.warn` 并释放草稿。**这条是被自己的缺陷逼出来的** ✗:第一版诊断写在 DEBUG 门控的 `log()` 里,于是**最需要它的那种情况恰恰不打印**,白费了一轮人测。 - **回填附件时改为「重试 append」而不是只调一次**(同类插件的做法):`addAttachments(ids)` 原本一次调用、返回 false 就释放草稿。但**输入框的动作面是聊天座位交给插件的**,而回退刚刚替换过表层 —— 那一瞬间它可能还是**上一个实例**,于是一次被拒就表现为**文字回来了、芯片没回来** ✗。现在最多重试 **8 次 × 150 ms**(与 `dsh-recall-plugin` 的 `fillDraft` 同参,读自它发布的 bundle),成功且重试过就在控制台留一行 ✅,8 次都失败才 `console.warn` 并释放。释放也改用**官方那条** `conversation.releaseDraftAttachments(descriptors)`(产品自己的 composer 用的就是它 ✓),插件自己的 `releaseAttachments` 只作兜底。 ### 已知限制(本次实测补记) - **`@路径` 提的文件,回退后回来的只有那段文字**:`@路径` 提及在消息里就是**一个 `text` 块**(实测 `blocks: {text: 1}`,形如 `@C:\dir\file.dll 这是什么文件`),**框架不会把它变成上传附件**,所以既没有附件可回填、也无从重建芯片 ✗。它与「参考芯片回不去」是同一族限制的另一种表现,但原因不同:**那个附件从来就不存在**。两个 README 的已知限制小节都已写明,并给出用上面第②条诊断区分两者的方法。 ### 验证 - 仓库自带检查:`typecheck` 通过、**15 文件 / 222 用例通过**、`check:readmes` 通过、构建通过。 - **指令菜单探针 10/10**:把构建产物装进 vm,喂一份照核心源码转写的 `commandUi` 契约,真的调用注册与选项函数——断言「只注册装饰、不注册客户端条目」(单行是硬约束)、装饰字段约定、弹窗文案随语言切换、11 个选项的 id 与文案、选中执行对应轮次、坏 id 与缺 sessionId 时不发命令、以及没有弹窗时降级必须出声。 - **宿主探针 18/18**:在真实 0.2.0 会话上验证写轮次号仍执行、内部 `--apply` 仍执行、**真实形状的新建文件被记成 `create`**、**注入给模型的系统提示词确实把内容改动引向文件工具**(见下),以及既有全部观察者路径。 - **系统提示词那条提示词以前没人断言过**:探针只是**记录**了注册项,从不断言它——于是一个坏掉的 `text()` 会**在每个会话里静默削弱回退覆盖面**(模型改用 shell 改文件 → 插件看不见 → 回退「看起来成功」却没覆盖)。现在断言它:非空、**点名 `write`/`edit`**、**点名要避开的 shell**、**不作出「总是/从不/所有/保证」这类捕获能力兑现不了的绝对承诺**、且总长 ≤220 字符(它进每个请求,长度是逐次成本)。并**反向验证过**:`_adapt\host-verify\hint-check-reverse-probe.mjs` 用 7 种坏变体(空文本、缺工具名、缺 shell、三种绝对措辞、超长)证明该判定**确实会拒**。 ### 工程卫生与兼容闸门(同类插件调研产出) 按《dsh同类插件调研流程》线上调研了 5 个同类插件(`dsh-undo-savepoint` 168★、`dsh-turn-rewind` 123★、`dsh-filesnap` 43★、`dsh-recall-plugin` 35★、`dsh-checkpoint-rewind` 21★),吸收其中可迁移的工程做法: - **诊断改走 `ctx.logger`**(新增 `src/log.ts`):插件的 `console.warn` 只到启动 DSH 的那个终端,到不了应用自己的日志——那些「观察者失败但宿主照常工作」的诊断因此在应用里**看不见**(宿主验证探针不得不劫持全局 console 才读得到)。现在 13 处全部走框架 logger,没有 logger 或 logger 抛错时回落 console,绝不吞掉;`store.ts` 是纯模块,改为由调用方传入 `report`。 - **拔掉 `tools` 死依赖**:它只被当作**事件**用(`tools/pre-execute`、`tools/result`——`ctx.on` 不需要服务就位),全仓库没有一处 `ctx.tools`;它是 0.2.0 移除模型工具时留下的残骸。必需 `inject` 由 5 项降为 4 项,少一个「核心退役该名字就把整个宿主半边停在 pending 且不报错」的雷。宿主探针新增回归断言。 - **补上兼容闸门**:`dsh-app-boot` 的闸门只检查 `peerDependencies` 里 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 的项,以 `semver.satisfies(runtime, range, { includePrerelease: true })` 与运行时版本比较,**一项都没声明就完全不检查**。本插件此前只声明了 `react`,等于**闸门对我们完全失效**,而 `dsh.engines.dsh` 它根本不读。现声明 `@deepseek-ai/dsh`、`dsh-session`、`dsh-invariants` 三项,范围为 `>=0.1.5-rc.2 <0.2.0-0 || >=0.2.0-rc.2 <0.3.0-0`。两处上界都是必须的:单写 `>=0.1.5-rc.2` 在**默认** semver 下**不包含** `0.2.0-rc.2`(这正是同类插件普遍写成 OR 链的原因);上界必须写 `<0.3.0-0` 而非 `<0.3.0`,否则 `includePrerelease` 会**放行** `0.3.0-rc.1`。两个 README 里那两句既过时又错误的说明("闸门只看到 `react`,所以挡不住"、"任何区间都覆盖不到下一个预发布")已改写。 - **双语文档一致性脚本**:新增 `scripts/check-readmes.mjs`(`pnpm check:readmes`,并接进 `prepublishOnly`)。它只比对**两类**东西——结构(标题层级、表格数量与每张表的行列数、代码围栏数)与**语言无关的事实**(版本号、反引号标识符、路径),**不比对行文**(两种语言本来就不该一样)。理由是这套 README 是手工双语维护,同一个会话里就漂移过两次(一处过时的兼容声明、一份对不上的回归测试清单),而这两类恰好是肉眼审阅抓不住的。脚本用反向验证过:故意在英文版塞入 `9.9.9` 后它确实以退出码 1 报出漂移。 - **发布门**:`prepublishOnly` = typecheck + test + check:readmes + build。 - **类型检查归零:宿主半边从「静音 18 条」变成「一条都不静音」**。`scripts/typecheck-host.mjs` 原本把 `@deepseek-ai/*` 缺失包引发的诊断(TS2307/7006/7016)整体忽略——**18 条**,足够把真缺陷藏住。现在缺失被消除了:9 个本插件真正接触的 DSH 包(`dsh-fs`、`dsh-commands`、`dsh-sandbox-policy`、`dsh-system-prompt`、`dsh-tools`、`dsh-session`、`dsh-invariants`、`dsh-client-ui-primitives`、`cordis`)以 **devDependencies 精确 pin 到 `0.2.0-rc.2`** 装入。**注意这几个包在 npm 上有两个线:`latest` 是过期且损坏的 `0.0.1-rc.1`,真线是 `next`**——不精确 pin 就会装到坏线(官方脚手架的核心价值正是这件事)。安装本身还不够:模块增强只有在被程序**载入**时才生效,所以新增 `src/dsh-types.ts` 用**纯类型导入**把它们载入(`verbatimModuleSyntax` 会在产物里全部擦除,插件「宿主零运行时核心 import」的性质不变,已核验产物)。再由 `@types/react` / `@types/react-dom` 让客户端半边也进入检查。 - **检查面**:3 个文件 → **5 个**(新增 `src/dsh-types.ts`、`src/invariant.ts`);**静音 18 → 0**。 - **修掉的真实问题**:①`SessionId` / `SessionSeq` 是**品牌类型**,而 `src/core` 刻意零依赖、只认 `number`/`string`,品牌在边界丢失——3 处按本文件既有惯例在边界显式转换并写明理由;②官方 `Tooltip` 把 `children` 声明为**必需 prop**,而新版 `@types/react` 不再从 `createElement` 的可变参数推导它——2 处把子元素放进 props 对象(运行时语义完全相同,只有这种写法能与宿主实际发布的那份组件类型对齐)。这两类都是**以前被静音盖住**的错误。 - **反向验证**:故意引入一个不存在的核心模块(TS2307,**以前正是被静音的码**)→ 检查失败;故意删掉一个真实 import(就是曾以 `ReferenceError` 逃逸的那一类)→ 检查失败。两次均还原。 - 未采纳:竞品的「注册去重 + 精确降级」经评估判定我们已具备——观察者全部各自 try/catch 且失败被包含(探针验过「host keeps working」),客户端已有只对形状拒绝降级的精确判定,apply 失败也不会拖垮 profile(实测 `1 entry did not activate`,其余照常)。不为低概率事件增加分支。 **新增验证**:兼容闸门探针 **6/6**(在 0.2.0-rc.2 / 0.1.5-rc.3 / 0.1.5-rc.2 上放行,在 0.3.0-rc.1 / 1.0.0 上拦下)。 ### 适配 DSH 0.2.0-rc.2(官方桌面版) - **`conversation.chat.turnTail` 由 chain 变 list**:0.2.0 把这个座位从「chain + `select` 选举」改成「list + `id`」,而槽位核心对错误的拼写是**当场抛错**(`list slot "…" requires options.id`)。原来的注册会被插件自己的 try/catch 吞成一行 console.error——表现为**被中断轮次的回退按钮静默消失**,其余功能看起来全正常。现在按座位实际形状注册(先试 list,仅在**形状被拒**时回落 chain),一条产物继续同时服务两代;轮次页脚那条自查也从 `select` 移进组件,且 hook 提到早返回之前(list 座位对每个结束的轮次都会渲染)。 - **回退标记的 source 改为 v4 的 producer-owned 拼写**(本次最严重的一处):0.2.0 新增会话格式 v4,它**拒收** `source.kind: 'plugin'`(`format v4 message requires a producer-owned source kind`),而插件写出的回退标记正是那个形状。`Session.append` 自己的校验只要求「非空字符串」,所以**回退当场看起来是成功的**;真正被拒发生在持久化**落盘编码**时,于是拒绝在**下一轮**才冒出来——「本轮运行失败」,之后该会话每一轮都失败。标记改用框架自己的 v3→v4 迁移会把它抬成的那个形状(`{kind:'plugin:rollback'}`);读取同时识别旧拼写,升级前写下的标记照样算得出被替换的范围。**因为从未落盘,受影响的只是进程内状态,重启即恢复,没有不可逆损坏。** - **验证方式也补了一课**:先前所有探针都停在 `Session.append` 这一层,而拒绝发生在格式层,所以「框架收不收这个方法调用」全绿、写下去的行却不可用。现在探针套件里加了「真实标记载荷过真编码器」的往返检查(含一项反向验证:旧拼写必须连编码都过不去),仓库里也补了对应测试。 ### 兼容性声明 - **`dsh.engines.dsh` 与 `peerDependencies` 同为两段子句**:`>=0.1.5-rc.2 <0.2.0-0 || >=0.2.0-rc.2 <0.3.0-0`。本次适配一度把它收窄成只认 0.2.0 线,随后补兼容闸门时又放宽——因为**它其实不被任何东西读取**,而真正生效的闸门(`dsh-app-boot`)看的是 `peerDependencies`:两处写同一个区间,人读到的说明才和运行时实际放行的范围一致,少一个「声明说只支持 0.2.0、实际却放行 0.1.5」的自相矛盾。两处上界都必须带 `-0`(理由见「工程卫生与兼容闸门」一节)。两个 README 的兼容矩阵同步更新:0.2.0-rc.2 移入「已实测」,0.2.0 线其余构建标为**未验证**;矩阵与「0.1.5-rc.2 是上一代逐项验证过的构建」仍是权威说明,区间只决定闸门放不放行。 - 本次**没有**新增或删除功能;`dsh.client.inject` 未变,但 0.2.0 起它会参与装载顺序(不再是纯信息)。 - 已知(**非本次引入**):0.2.0 与 0.1.5 都用同一条规则把 `source.kind !== 'user'` 的 `user/message` 渲染成「上下文」行,所以回退检查点那句文本**在对话里是可见的**——插件的 marker 视图本身渲染为空,但挡不住框架自己的 input-message 定义(两版行为一致)。 ## 0.3.1 ### 兼容性声明 - **`dsh.engines.dsh` 去掉虚构的上界**:由 `>=0.1.5-rc.2 <0.2.0` 改为 `>=0.1.5-rc.2`。npm 上从未发布过任何 0.2.0 构建——已发布的 DSH 构建止于 0.1.7 的预发布版——写死 `<0.2.0` 等于声明一条并不存在的约束;开区间下界也是生态里的通行写法(如同类插件 `dsh-claude-style` 的 `>=0.1.5-rc.2`)。两个 README 中引用该字段的句子同步改写,逐版本矩阵仍是权威说明。 - 本次发布**只改声明与文档**:代码、行为、依赖与 `dsh.client.inject` 均未变动,bundle 内容与 0.3.0 一致。 ## 0.3.0 ### 适配 DSH 0.1.5-rc.2 - **对话节点注册表换了去处**:0.1.5 不再提供 `conversationEvents` 服务,同一个角色改由 `uiConversation.events` 承担。插件改为按序动态查找(`ctx.get`):有 `uiConversation.events` 就用它,否则回落旧服务。**这两者都不能写进 `inject`**——cordis 把数组当"必需集合",写出对方版本没有的名字会让整个插件永久停在 pending,表现为"装了什么也没发生"。 - **聊天数据改走 `useChat` 钩子**:0.1.5 把 `chat` 从会话快照里拿掉,改由会话级插槽的 `useChat` 标准钩子下发。插件优先用钩子,取不到再回落 `snapshot.chat`(0.1.1 的路线)——回退按钮能否定位轮次、隐藏与欢迎页判定能不能读到日志,全看这一处。 - **"在编辑器中打开"改走右侧栏**:`workspaces.openPath` 在 0.1.5 已删除,改为 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`(官方文件链接、文件树点开走的就是这条路);`sidebarRight` 动态获取,旧版仍回落到 `workspaces.openPath`。资源地址的构造是从官方实现逐字抄进插件的:`@deepseek-ai/dsh-util-workspace-path` 不是客户端模块(既没有 `dsh.client`,也不在前端平台种子里),`require` 它会让整个客户端半边在加载时就失败。 - **输入框附件动词改名**:`addImages` → `addAttachments`、`pruneImages` → `pruneAttachments`(`setDraft` 不变)。两个名字都做特性检测。 - **回退后的图片重新挂回输入框,改用新接口实现**:`conversation.resolveImage` 与 `conversation.createDraftImages` 在 0.1.5 已删除;现在用 `uiConversation.imageUrl(sessionId, ref)` 取回该图片的字节,包成浏览器 `File`,再由 `conversation.createDrafts(sessionId, files)` 登记为草稿附件——和官方"选文件 / 粘贴图片"是同一条路,字节会随下一次提问一起发出。输入框正忙而拒绝登记时,按官方做法释放刚建好的草稿(旧的 `resolveImage` + `createDraftImages` 路线原样保留给 0.1.1)。图片清单还会在回退**发起前**先读一次:回退成功后再读会与客户端自己的表层替换赛跑,晚一步就什么都恢复不了——而这个功能存在的意义正是"图片要回来"。 - **折叠行不再参与 DOM 扫描**:0.1.5 用 `hidden="until-found"` 折叠行(官方自己的行扫描也写 `:not([hidden])`)。插件所有 `[data-chat-flow-key]` 扫描都加上 `:not([hidden])`,不再把用户看不见的行算进"屏幕上还有什么"。 - **回退标记的表层替换改用新字段名**:0.1.5 把 `SurfaceOp` 的区间两端从 `start`/`end` 改名为 `startSeq`/`endSeq`,而校验是**按 key 集合精确匹配**的(`isReplaceOp`),旧拼写被直接拒绝(`carries an invalid replace surfaceOp`)——标记写不进日志,回退的对话截断整个失效。现在按 0.1.5 的拼写写,且**只**在框架按形状拒绝时才用旧拼写重试一次(拒绝发生在 `log.push` 与持久化观察者之前,重试不会留下半个事件),这样旧版主机也照旧可用;读回时**两种拼写都认**,升级前写下的旧标记照样能算出被替换的区间,不会把已回退的轮次当成幽灵重新放出来。 - **首轮回退不再被 0.1.5 的"表层 0 号节点"规则拒绝**(本次修复的阻断性 bug):0.1.5 新增一条保护——`replace` 的影子区间**起点若落在表层 0 号节点**且该节点是系统提示词,只有"恰好只替换这一个节点的 `system/message`"才被允许,否则整条事件被拒(`surface replace: node 0 holds the system prompt and may be rewritten only by a system/message over exactly that node`,`dsh-session/lib/index.js:379-383`)。而 0.1.5 的系统提示词是在**第 1 轮的 step 内部**才写下的,序号落在第 1 轮区间里、节点位置却正好是 0 号——于是回退第 1 轮时标记把系统提示词一起圈进区间,写入被拒,表现为"文件已恢复、对话截断没写成"。现在规划器按**类型**认出 0 号节点上的系统提示词,把区间起点后移一个节点(系统提示词因此始终留在模型可见历史里,这正是框架保护它的目的);客户端从 `surfaceOp` 读到的是**收窄后的起点**,可见范围判定照旧。收窄后若已无节点可替换(区间里只剩系统提示词),标记改为**不带替换的纯追加**:文案照旧进入模型上下文,表层一个节点都不少、也不隐藏任何内容。 ### 兼容与可观测性 - **0.1.1-rc.2 继续支持**:以上每一处都是"有则用新路线、无则回落旧路线",同一个 bundle 同时服务两个版本。插件自身使用的名字在两个版本里都没有变,未作改动:字典命名空间是它自己的 `rollback`,插槽名是 `conversation.*`(0.1.5 只是把这些插槽的定义搬到了新包 `dsh-client-ui-chat` 里),定位"回到底部"按钮用的是渲染出来的文案「回到底部 / Back to bottom」(0.1.5 里来自 `chat.toBottom`,值未变)。 - **清单更新**:版本 0.3.0,`dsh.engines.dsh` 改为 `>=0.1.5-rc.2 <0.2.0`(旧范围在 npm 预发布语义下不承认 0.1.5-rc.2),`dsh.client.inject` 补齐 0.1.5 实际存在的客户端包并保留旧条目(加载器会静默跳过不存在的名字)。 - **失败改为大声报出**:缺框架契约(节点注册表、`chat` 数据、图片重建接口、打开文件的两个入口)都会在控制台**点名**报出,而不是表现为"插件什么也没做";页面加载时照旧打印一条 `client bundle rev`,用于确认浏览器跑的是不是新包。 ## 0.2.2 ### 修复 - **回退命令的回执不再出现在聊天里**:客户端为"超出 10 轮窗口就置灰"读取范围时会自动执行一次 `/rollback list`,而命令回执(`command/run` + `command/done`)会写进会话日志并显示——于是每打开一个会话底部就多出几行 rollback 命令。现在只隐藏**本客户端自己发起**的命令回执(按命令 id 匹配),你手打 `/rollback <轮次>` 的回执照常显示。 - **不再按文本隐藏任何行(事故修复)**:0.2.1 之后曾短暂使用"行文本同时含 rollback 与 list/preview 就隐藏"的规则,而助手回复正文里本就包含这些词,导致**对话消息被隐藏**、每次重绘又隐藏一行。内容匹配已彻底移除,并加入硬保险:**文本超过 500 字符的行永不隐藏**。 ## 0.2.1 ### 正确性修复 - **文件列表不再出现"本轮没动过"的文件**:边界重扫在轮次结束时复查被监视文件,一发现"文件不见了"就记成**当前这一轮**的变更——可那个文件往往在**很多轮之前**就没了。于是回退一条纯对话轮次时,列表里会冒出早已消失的文件,甚至把更早回退删掉的文件重新"找回"。现在的归属规则是:记到**最后一次确认该文件存在的那一轮之后**的那一轮;若那一轮已经是被回退过的死轮次,**直接不记录**(无法归属就不猜,绝不生成错误的恢复)。 - **已回退轮次不再被重扫监视**:监视清单改为只认"创建/修改"记录,并按日志里的回退标记丢弃已死轮次的记录——被更早回退正确删除的文件,不会再被"发现"一次并复活。 - **压缩检查点文案**:331 字符(约 83 token)→ **136 字符(约 34 token)**,省 59%;仍完整保留四件事(机器生成、此后的消息已移除、工作区文件同时被还原、继续且不要提及)。新增字符预算测试防止它再变长。 ## 0.2.0 ### 回退入口 - **每轮结束后都有回退按钮**:正常轮次在回复的动作条上(位置不变),**被中断的轮次**在轮次页脚——被中断的轮次没有收尾回复,动作条上不可能有按钮 - **运行中一律禁止回退**:任何一轮处于进行中都整体拒绝并给出可操作的提示,等它结束或暂停后再回退 - **移除模型工具**:模型调用回退必然发生在"某一轮进行中",与上一条冲突;回退始终由人发起 ### 文件回退 - **标签分四类**:`恢复`(文件还在、写回旧内容 · 绿)、`找回`(已被删除的文件放回来 · 蓝)、`删除`(撤销该区间新建的文件 · 红)、`跳过`(无法恢复) - **边界重扫 + 最后已知内容**:每轮结束时复查文件工具碰过的路径,把被 shell **改写或删除**的变化记到该轮 → 「模型用 `write` 建、之后被 `pwsh` 删掉」的文件也能回退了 - **空目录清理**:回退删掉它创建的文件后,把**已清空且创建时间落在被回退时间段内**的祖先目录一并删除(更早创建的目录会保留) - **盘根写入兜底**:Windows 上 `write`/`edit` 无法操作盘符根目录正下方的文件(文件系统层写前会 `mkdir` 父目录,而盘根 `E:\` 会 EPERM);插件包装 `ctx.fs.writeText` / `editText` 让这两个工具可用,并保留原文件的字面匹配语义、行尾风格与权限位 - **跳过并继续**:某个文件恢复不了**不再中止整次回退**——能恢复的照做、对话照样截断、结果里列出跳过项,且保留其记录以便下次回退重试(文件锁释放后即可成功) ### 正确性修复 - **超出保留范围的回退目标改为拒绝并告知范围**(此前会静默按最旧的保留记录恢复,得到错误状态且无提示) - **不再重放已被回退的轮次**:日志只追加,回退只是追加一条标记;重启后从日志重建会把早已回退的轮次带回来(表现为文件列表出现旧文件,甚至可能删掉你后来重建的文件) - **预读捕获的占位空串不再被当作"文件已清空"**(此前可能让回退把文件写空) - **空列表不再声称"无文件变更"**:插件看不见 shell 直接写出的文件,这种话它保证不了 ### 界面 - **欢迎页**:把整段对话回退掉之后正常显示,不会残留在对话中间或被带到别的会话 - **回退弹窗**:逐条列出受影响文件及其动作,点击文件可在编辑器打开 - 回退按钮的注册带自检与故障上报,"按钮静默消失"不再可能无声发生 ## 0.1.0 - 首个版本:按轮次检查点(10 轮滑动窗口)、文件恢复/删除、用 `user/message` 表层 `replace` 做原位截断、`/rollback` 命令与 Web 回退按钮