# 更新日志

`dsh-wsl-workspace` 的完整版本历史（最新在前）。返回 [README.zh.md](README.zh.md)；本文件为中文记录（0.4.3 及更早为摘要），英文原文见 [CHANGELOG.md](CHANGELOG.md)。

> **这是一份只追加的历史，不是一组链接。** 较早条目点到的仓库内文件（`src/…`、`tests/…`、`docs/…`）与维护者本机上的读数文件，都是"当时量到了什么"的记录；它们以及本包之外的任何路径都**不随包发布**。本包发布的是 `lib/`、两份 README、`TESTING.md`、两份 CHANGELOG 与 `docs/driving.*`。

## 0.7.13 — 2026-11-04

- 取消调用时先核对会话令牌并停止 Linux Bash，再终止 Windows 启动器。WSL1 上原先的顺序可能先打断前台子进程，使 Bash 继续执行下一条命令。

- 延长对持久 Bash 自身等待终端输入的观察时间，让带短时限的读取先返回，避免 Bash 5.1 上 watchdog 提前重启并重跑命令。

- WSL bash 前台调用会在启动命令前拒绝为零或负数的 `timeoutMs`。此前这些值会让调用失去超时限制。

- 受影响的 DSH 宿主在生成 WSL 变体时，会移除实际挂载 subagent 包的整个委派组，避免重复注册，也避免组内工具在失去依赖服务后仍被挂载。仅在提示词或普通配置中提到包名的无关条目会保留。感谢 @stallenlu 提供复现、@Ruler4396 验证宿主行为并提交初版修复，以及 @raylei50653 提供误删反例（[#132](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/132)、[#133](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/133)、[#134](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/134)）。
- 将 js-yaml 最低版本提升至锁文件已使用的 4.3.2，继续使用 4.x API。
- 两份 README 补充了 WSL 文件轮询唤醒 WSLg 的原因，以及无需 Linux GUI 应用时如何规避焦点切换。感谢 @friedwm 的排查（[#130](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/130)）。

## 0.7.12 — 2026-10-31

**别的 agent 现在能只凭包里发布的东西把这枚插件用起来**，不需要仓库，也不需要维护者的记忆。

- **`describe` 带上运行所需的事实**：它现在回 `route`（本进程真正在服务的那条路径）、`loopbackOnly`、`methods`（权威方法表：名字／是否写／参数／返回）、`installProof`（一个去装侧 `lib/index.js` 里 grep 的字面量——版本号证明不了加载的是哪批字节）与 `driving`（启动／登录／composer／provider）。方法表与派发器读同一个常量，两者不一致时构建红。
- **`docs/driving.md` 与 `docs/driving.zh.md` 随包发布**，回答五问：① 无头启动，含**必须删掉**的 `ELECTRON_RUN_AS_NODE`——带着它那个 exe 会以 Node 身份运行，裸启动退出码 0、零输出；② 登录 URL 与 token，并写明实测的重放行为（进程存活期间不会被用掉；错的 token 才 401）；③ 路由、信封与怎么枚举方法；④ composer 注入（编辑器是 Lexical：只发一发 `beforeinput`，绝不用 `execCommand`，也不要两个都发；脚本按结构与「插入后 disabled→enabled 的翻转」认首启模态与发送按钮，**主判据不依赖界面语言**（文案正则只作兜底））；⑤ 默认 provider 报 401 时改哪一处——`cordis.patch.yml`，`apiKeyEnv` 只放变量名、key 留在启动环境里；另写明怎么把本包装进同一枚临时 home（优先 tarball：裸目录是 `link:`，可能解不到自己的依赖），以及为什么改 `Config.route` 必须连客户端一起改。
- **两份 README 声明哪些路径只在仓库里**（`src/`、`tests/`、`scripts/`、`ci/`、`docs/` 的其余部分），不再把它们写成好像随包发布。
- **两把文档闸挂在 `npm run test:unit` 末尾**：`scripts/check-doc-paths.mjs`（文档点到的每个仓内路径必须真实存在——也含**裸文件名**：既不随包发布、又没有被仓库专用横幅覆盖就红；以及**具体的本机绝对路径**（占位形状 `<install>`、`D:\path\to\…` 不算）——未标 `maintainer-local` 就红；两份随包 CHANGELOG 必须带"只追加的历史"横幅。`--pack <dir>` 对发布产物施加同一规则）与 `scripts/check-discoverability.mjs`（构建发布产物，五问任何一问答不出来即红，方法表与派发器漂移也红）。
- **判定凭据**：三枚冷启动代理只读发布产物作答。改前五问里四问答不出、第五问只能读代码；改后五问全都能从 `docs/driving.md` 答出并带 file:line。

**`/mnt/<盘符>` 工作区终于可以取消登记了。**

- **新增宿主路由 `removeWorkspace`**（[#129](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/129)）。`registerWindows` 从第一版起就往 `wsl-workspaces.json` 写盘符键，而登记表里唯一的删除动作先把键解析成 WSL UNC 路径——于是这条记录在删会话、在宿主里删工作区、甚至删掉目录之后都还在，该路径下每个新会话仍被改写成 WSL 模式，且没有任何退回办法。新路由接受两种键形（`canonicalWslUnc()` 再 `canonicalWindowsPath()`，与 `listWorkspaceRecords` 给出的同一对），删不到时如实报错，不静默返回"成功"。
- **模式选择器不会再只剩一行**（[#129](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/129)）。0.7.11 加入的裁剪按宿主的显示名匹配；当某个 WSL 会话的同族在菜单里没有第二行时，裁剪后只剩已选中的那一行——那等于一个没得选的选择器。这种形状现在整份放行菜单，与"无法识别的菜单"已有的处理一致。
- **普通 Windows 目录不再被当成 WSL 扫一遍。** 技能目录提供者的 `workspaceOf` 过去把盘符 cwd 沿 `登记表 → DSH_WSL_DISTRO → 默认发行版` 一路退下去，于是只要机器上配了默认发行版，**每一个没登记的 Windows 工作区**都长得像 WSL 工作区，并按 4 层 / 64 个技能根 / 4096 个目录的额度去走一遍，符号链接还逐条交给 `wsl.exe`。现在盘符形状必须有对话框写下的登记记录才算数；env/默认那条链只留给没有 `distro` 字段的旧记录。原有的"非 WSL cwd 返回空"测试看不见这件事——它断言的是"空"，而"扫了但没找到"也是空——所以新锁数的是 `readdir` 调用次数，一次就红。变异验证过：把闸门去掉，它会说 `the provider made 1 listing calls into a plain Windows folder`。
- **文件引用翻译跟着会话走，不再只看目录。** 侧栏导航的改写钩子原来只按 cwd 判断，于是被用户退出 WSL 世界的会话（跑的是宿主工具、写的是 Windows 路径）仍会被按 WSL 规则改写地址。现在宿主已写明是普通模式的会话原样放过；宿主还没写出模式的会话照旧翻译（那只是尚未写入，不是选择）。
- **登记会随着它描述的那个工作区一起过期。** 又给了两条出口，都取自报告自己的复现清单。浏览器把插件的登记和宿主的工作区列表对一遍，宿主已经没有对应工作区的就清掉——走的是列表自己的订阅，不是六十秒轮询（后台标签页会把定时器冻住，删掉的工作区不该等一个 tick）。"列表算不算加载完"这个判据被逼着重写了一次：第一版用宿主的 `baselinesReady`，而 Desktop 自带的 bundle 里**根本没有这个字段**（实测：`baselinesReady`、`recentWorkspaceId` 在其 `app.asar` 里 0 次命中，同接口的 `archivedSessionIds` 有 136 次），于是真机上对账永不执行，而单测照样是绿的——因为假宿主是我自己写的、带着那个字段。现在的规则与版本无关：**非空列表本身就是"已加载完"的证据**（加载中或加载失败的列表是空的），空列表只有在"这一页见过工作区、又看着它们消失"时才允许清理。盘符记录还带上登记时那个目录的创建时间，于是"删掉目录再建同名目录"会被认成没登记过。**但"目录不在"本身不构成退役**：U 盘/网络盘这一刻没挂上、共享短暂抖动、目录还没来得及重建，这三种形状读起来和"用户删了"一模一样，而第一版就在一次普通读取里把登记删掉了。缺席不是证据——只有"那里有目录，且创建时间对不上"才是——一次读取也不该写盘写出用户没要求的东西。`route-envelope` §12 把两半都锁住（同文件里那条活记录就是阳性对照），同名替换那一格分层在 win32，其他平台是**显式 skip**、不是悄悄通过；`client-dom` 从四个方向锁住对账：孤儿的被清、宿主还在的不动、这一页没见过填充的空列表不碰、最后一个工作区被删时该清。真机验过：登记写下的 `born` 与目录自身一致；在 DSH 里删掉工作区后几秒内记录被清、目录没动。这一段还倒着付了两笔账：先前被改成真实 `F:\TechEnergy\…` 目录的夹具全部退回合成的盘符键，因为盘符路径只在有盘符的平台上存在——ubuntu runner 把整片盘符格子读成"没登记"（`test:node` 红四格，`test:world` 的登记表被那次破坏性读取清空），而我本地的闸全是绿的；以及 `lib/` 必须把新的哈希分块一起提交，只提交重建后的入口而漏掉 `lib/wsl-bash-tool-*.js` 与 `lib/wsl-credentials-*.js`，会让所有 lib-plane 闸死在 `ERR_MODULE_NOT_FOUND`。
- `host-api.mjs` 覆盖注销动作与它对记录列表的影响，探针地板从 13 抬到 15。`tests/route-envelope.mjs` 在对话框真正使用的那条 HTTP 缝上锁住盘符键注销（阳性对照：`setUser` 仍能删 UNC 键、仍删不掉盘符键），`test:client` 锁住"只剩一行"的菜单形状。

**退出是两件事，于是给了两个出口。**

- **会话菜单不再挂出口行——量过之后撤掉**（[#129](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/129)）。曾经把一行挂进宿主公开的槽位 `sidebar.workspaces.session.menu.item`（order 460，槽位直接交出该行的 `sessionId`，不用猜"当前打开的是哪一行"）。真机量下来它只会给一次拒绝：*空白*会话那一行根本不渲染行操作条（宿主源码 `!row.blank &&` 那一支；实测侧栏只有 slot + title），而有菜单的会话都已经 started、宿主拒绝改写。槽位贡献已撤，单会话的退出改由模式选择器承担（见下）。
- **工作区菜单的出口行是镜像，且按身份瞄准。** 那份菜单由宿主私有 `items` 数组生成、没有槽位，只能镜像；目标取自项目行自己的 `data-row-key="workspace:<id>"` 加上宿主给"菜单已打开那一行"打的 `menuOpen` 标记，不靠显示名（重命名就会打断）。克隆来的垃圾桶图标换成宿主自己的 `IconRightUpOutlineRegular`（就从宿主行引用的那个平台模块取），塞进原生行的图标格里保持对齐——这一行是"退出"，不是"删除"。
- **用户退出去的会话不会被绑回来。** 绑定器每次列表变动都会重扫空白会话，而 roster 与工作区登记表按设计就是晚到的：只发一次 `select(standard)` 会在下一趟扫描被推翻（真机实测过：一个仍然空白的会话，在工作区重新登记后被改回 `wsl-standard`）。退出按会话 id 记在 `sessionStorage`——只属于做这个决定的那个标签页——绑定器跳过它。
- **被拒绝时要说出来，用宿主自己的话。** agent-preset 通道不再只回 `ok:false`，而是带上宿主的 `error.message`；被拒（会话已经产出过内容，preset 被锁）时回滚 opt-out、让绑定器恢复正常行为，并把原因写在那一行上、再在浮层里重复一次——一行点了什么都不发生的出口，正是原缺陷换了顶帽子。
- **空白会话的出口就是模式选择器，而选择器不再把它藏起来。** 出货前对着真宿主量过：*空白*会话那一行根本不渲染行操作条（宿主源码里 `!row.blank &&` 挡住了，实测侧栏只有 slot + title），而有菜单的那些会话都已经 started、宿主拒绝再改写。所以上面那条菜单行只会回报"做不到"；真正还能改模式的那个会话反倒没有入口——族裁剪偏偏在最需要放行普通模式的地方把普通模式藏了。现在空白会话的选择器列出它仍能接受的全部模式，并且"在屏幕上的那个会话上选了普通模式"会被记成用户自己的选择，不再被绑回去；`client-lifecycle` 里为这一条单独上了锁。
- opt-out 名单的存储探测把 `undefined`（这个 realm 没有 sessionStorage）当成了"可用"，于是读走内存、写去了不存在的地方；一次没能保存选择的运行把它抓了出来。现在两侧一起回退。
- **回执用宿主自己的 toast、宿主自己的座位。** `shell.overlay` 是 list 槽位，也是文档指定的"自己的浮层"该放的地方；放进去的内容现在是 ui-primitives 的 `Toast`——就是"会话已归档""复制失败"背后那个组件——于是衬底、语气、停留时间与收起全部归宿主。第一版自己画了张卡片、还**猜**了宿主的颜色变量，屏幕上就是"文字没有衬底"。改后实测：提示带 `_toast_*` 类、`background: rgb(53, 54, 56)`、位置由宿主决定。之所以还要这个座位：工作区浏览器自带的提示由它内部 store 驱动，kind 是一个封闭集合，第三方加不进去。创建工作区留下的轻提示会点名两个出口，每个 `wsl-*` 变体的 description（模式菜单第二行）说的是同一件事。
- **退出文案收成一行、并且只说一次。** 每个变体的 description 过去把"自己是什么模式"又说了一遍，而那行标题本来就已经写着 `WSL · PTC mode（PTC 模式）`，八行读起来就是同一句话的八份拷贝。现在是一句共用的中文（`bash 与文件工具在发行版内运行 · 退出：模式选择器（单会话）／工作区菜单（整个工作区）`），`host-declare` 把两半都锁住：各变体完全一致、且描述里不出现模式名。创建回执也删掉了"宿主锁模式"那段长尾。
- `test:client` 锁住其余形状：opt-out 的会话不被绑定器碰、空白会话的选择器整份放行、被撤掉的会话菜单座位不再有贡献、工作区行按 `data-row-key` 命中且对非 WSL 工作区不出现（同一装置上的阴性对照）、提示文案。第一版写错的两处正是这些锁存在的原因：镜像里用了 `instanceof HTMLButtonElement`——在本该运行它的 realm 看不见平台构造函数，于是在自己的 observer 里抛异常、什么也没画；测试装置又让每个已挂载 client 的镜像都活在同一个文档上，两个实例对同一份菜单意见不合时，会在每次变动上互相拆台。

## 0.7.11 — 2026-10-25

**WSL 入口终于成为 Workspace UI 的一部分，模式选择器也只展示当前工作区能用的那一族模式。**

- **W 不再单独站在侧栏左下角（[#114](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/114)，修 [#96](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/96)）。** 宽侧栏里，入口位于 Workspace 区块标题右侧、紧邻原生的“添加工作区”按钮；首页工作区的“添加工作区…”菜单中也新增了同一入口。折叠成轨道式侧栏时仍回退到设置旁的 W 按钮，所以所有侧栏形态都有可用入口。
- **模式选择器一次只显示一个世界。** 普通会话只列出 Standard / PTC / Minimal / Creator，WSL 会话只列出对应的 `WSL · …` 变体；隐藏项同时退出键盘顺序，不会出现“看不见却还能被方向键选中”的行。
- **菜单隐藏只在预设选择器上生效。** 修掉了运行时探测的一个边界：其他插件或宿主以后新增的异步菜单即使也带 `_itemName` 标记，也不会被本插件的预设过滤逻辑永久藏起来。对应回归已加入 `test:client`。
- **首页工作区 Chip 现在可以二次点击关闭菜单。** 首次点击打开，第二次点击不再触发 outside-pointerdown 后又被 Chip 的 click 重开。

本版在 DSH `0.1.5-rc.2` 与 `0.2.1-rc.2` 的隔离实例上验证了宽侧栏、折叠侧栏、菜单入口、普通/WSL 双向模式过滤和二次点击关闭；构建、客户端、单元、node、world、contract、install、profile 与 artifact identity 门禁均通过。

## 0.7.10 — 2026-10-24

**Desktop 会话在第六个组合上重新活过来，WSL shell 带上了脚本赖以分支的会话事实，世界也不再因 `read_image` 重名而拒绝挂载。** 三个面向用户的修复，一个更瘦的安装包，一套会自己盯上游的构建。

- **Desktop 会话在第六个组合上创建失败已修（[#60](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/60)）。** 上游在每个 `agent/created` 事件上安装 `@deepseek-ai/dsh-tool-subagent` 且没有组合门（上游讨论 6751），于是每个保留顶层委托行的拷贝预设都会把该工具多注册一次——到第六个组合（三个出厂预设加本插件的三个变体）时，Desktop 创建会话当场失败：`tool "subagent" is already registered in this scope`。WSL 变换现在为没有组合门的宿主家族剥掉这一行（当前兼容窗口内逐版本核验过不存在；读不到版本也按同方向处理——代价不对称：少一个 subagent 工具对上少一个会话）。**代价写明：在上游补上门槛之前，WSL 会话没有 `subagent` / `list_subagent_models` 工具**；门槛出现时谓词自动收窄，本仓无需再发版。
- **WSL shell 携带 `DSH_SHELL` 与 `DSH_SESSION_ID`（[#115](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/115)，社区贡献者 Bright000026）。** 宿主的持久 bash 在每个子进程里设这两个变量；WSL 持久 shell 此前列了键却从未设值，按它们分支的脚本悄悄走错路。现在两者都随调用进入发行版，`DSH_SHELL` 已列入 `WSLENV`。
- **WSL 变体不再因 `read_image` 重名拒绝挂载（[#105](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/105)）。** 0.2.1 起宿主工具层拒绝两行注册同名工具；`dsh-tool-fs` 默认注册 `read_image`，而本世界加的那条 realm 安全的读取才是会话内真正应答的那条——于是用一行配置关掉宿主那份，世界正常挂载。
- **安装包只带英文与中文。** 其余七种翻译 README 移到仓库 `docs/translations/`——保留（原作者的刻意选择），但不再随包分发：安装后的任何东西都不读它们，且自 docs-parity 闸退役后它们一直靠手工同步、无人看守。文件内所有互链已按新位置重写。
- **维护者侧：** 每帧 CI 快了约一分钟（钉版宿主树跨运行缓存、未变化时不再重装——本机实测 42 s → 0.2 s，CI 每 job 22 s → 1 s）；每周上游漂移雷达盯 registry，首跑就抓到 `0.2.1-rc.3` 在 dist-tag 未动时已发布、且全部门禁对它绿——窗口随时可升；tag 现在只发布有绿色 `checks` 运行的 commit；会话 bash 的生命周期（发帧→结算、卡死命令恰好执行一次、死壳重放、abort 语义、串行化、dispose）有了离线回归测试 `tests/wsl-bash-session.test.ts`；重放尺子的类级得分站上 **20 of 20**。

## 0.7.9 — 2026-10-20

**盘符路径的工作区终于能读到自己的技能，键盘门也能翻译那里的 Linux `cwd`。** 两处都是静默失败：注册在 Windows 盘符路径下的 `/mnt/<drive>` 工作区（9P 服务不了 drvfs）技能目录恒空且无报错；`wsl_terminal` 则以“发行版未知”拒绝 Linux `cwd`，尽管对话框早已把它存了下来。技能 provider 现在按其余 provider 的方式解析这个工作区，并把 `/mnt` 路径按**盘符拼写**读取；键盘门读同一份登记。这两条是**世界一致性表先发现、而不是用户先报**的——本仓第一次由闸领先于报告。

- **浏览失败不再留下残留消息。** 切回一个可用发行版时，之前那句“无法浏览该目录”会盖在刚刚加载好的列表上：一个 `error` 字符串说不出自己装的是哪种失败，于是浏览失败由“取代它的那次成功浏览”退休——只退休这一种，预设失败、发行版列表失败是另一件事。
- **`/mnt/<drive>` 只有一种拼写。** `C://项目//src`（粘贴来的路径，或某个工具把分隔符写重了）过去会产出 `/mnt/c//项目/src`，同一个目录得到两个缓存键，技能扫描与工作区存储可能对“这是哪条路径”各执一词。
- **回答“下一个问题能不能抓到”的测试。** 今天早先落地的五道闸之外，本版加入 `scripts/mutation-sweep.mjs`：把套件从未针对过的缺陷种进去，看哪道闸注意到。它第一次运行就找到一处真缺口——glob 内联上限的边界从未被断言过——锁已补进 `tests/wsl-search.test.ts`。`@deepseek-ai/dsh-jobs` 已钉进闭包，让后台作业工具在会话探针里存在；字节断言仍需要活着的 agent，因此报告为**声明式未测量**，而不是通过。
- **本版不声称修复**：[#78](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/78) （`run_code` 在 `\\wsl.localhost\…` 根 + `workspace-write` 下）保持原状：护栏会点名拒绝这个形状，给 WSL 世界一个真沙箱是产品决策，不是测试问题。

## 0.7.8 — 2026-10-20

**这一版把 0.7.7 自己那份记录里已经写下的修复真正发出去。** registry 上的 0.7.7 tarball 是在那些修复落地之前构建的；[#85](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/85) 把它们补进了**已经发布**的那条记录——改的是文字，不是字节。其中两条今天从 tarball 本身就能证：`dsh-wsl-workspace@0.7.7` **没有 `lib/wsl-image-tool.js`**（在 WSL 世界里应答 `read_image` 的那一行，[#83](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/83)），而它的 `lib/index.js` 里 `PERSONA_APPEND` 仍以 `Your working directory {{cwd}} is inside a WSL …` 结尾——正是让严格渲染器报 `unknown prompt variable "{{cwd}}"`（[#95](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/95)）的那句。本版携带的各条修复，说明就写在下面 0.7.7 那一段（#79、#81、#83、#84、#86、#87），原文不改；加这一行只是让记录与字节一致。

- **`wsl-*` 会话现在声明并实测针对 DSH 0.2.1-rc.2。** 兼容窗口的头、`dshReleases` 与两份 README 一起前进，测试树所依据的宿主族钉在 `0.2.1-rc.2`（[#92](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/92)）。那个版本在这插件底下改了什么写在那条 PR 里，而不是留给运行时去发现——前台 `bash` 与 fs 工具套件各多了一只 `workingDirectory` 手——而消费方台账新增的五对，现在每一对背后都有一次真调用（[#94](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/94)）。

- **不作声明**：[#89](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/89) （0.2.1-rc.2 上「五个 `file:` 行 never started」）在本包、web profile、Desktop 自带宿主三面都不复现，全部绿；在报告者答复前保持打开。

## 0.7.7 — 2026-10-07
- **stderr 那一侧的溢出文件，现在和 stdout 那一侧守同样的承诺。** 四条都在真实发行版上量过（2026-10-11，Ubuntu，`seq 1 200000 >&2`），四条都已修。（1）*文件是短的*：流共 1 288 895 字节，文件只装了 1 011 753 字节，末尾断在数字中间——因为 stderr 的落盘发生在 `settleStderr()` **之前**，而 stdout 的那次早已挪到之后；于是还在管道里的字节经 `takeStderr` 进了答案，却从没进过文件。现在两次落盘都在等待之后，文件就是整条流。（2）*截断是看不见的*：settled 的 run 把 `stderrTruncated` 写死成 `false`（标志位在建 run 之前就被清零），而渲染器那句 `[output truncated; full output: …]` 只有 stdout 有——于是一条往 fd 2 写了 1.29 MB 的命令，模型读到其中约 400 kB，既没有任何字说还有别的，也拿不到路径。现在 run 带上它真正置过的标志，正文里会出现 `[stderr truncated; full output: …]`。（3）*我们自己的回显进了文件*：shell 的行编辑器会把它刚读到的那一行写到 stderr，所以文件第一行是 `… } | base64 -w0 )" # __DSH_WSL_BASH_REC`；现在第一个窗口会先过一遍答案用的那个 `dropProtocolEcho`。（4）*命令写的 NUL 被吃掉*：读取端过去会滤掉 chunk 里**每一个** NUL，于是 `printf 'a\0b\n' >&2` 回来是 `ab`，而同样的字节走 stdout 却保留着。现在按标记的真实形状识别——它是携带它的那个 chunk 的最后一个字节，因为这一帧不会再往 fd 2 写别的东西。
- **用户装到的现在就是插件本身，而不是整个仓库的一份拷贝。** 旧 `package.json#files` 把 `src/` 和两张 README 配图一起打包：对改动前的树做一次全新的 `npm pack`，得 **2,757,545 B / 79 个成员**，其中 2,265,091 B（82.1%）是 30 个源码文件和那 2 张图——运行期没有任何一方读它们（插件加载 `lib/`；读源码的那些门禁读的是仓库）。同一条命令现在得 **492,454 B / 47 个成员**，两份成员清单的差别恰好就是被移除的 32 项。九份 README 的配图在同一枚提交里改成绝对地址 `raw.githubusercontent.com/…/HEAD/…`，所以 npm 页面不靠仓库去改写相对路径也能显示图。

- **"故意红"账本已清空，它记的十条债全部还清。** 每一条都是带修法的复现；十条现已全部修好，而机制留了下来—— 任何**未声明**的新红仍然让门禁失败。

  - **`check` 把每一次 `stat` 失败都折成 `{exists:false}`**（issue #44 §6）。没人能读的目录、或 9P 上的
    `EIO`，都被报成"不存在"——这是调用方唯一无法与真相区分的情形。现在只有 `ENOENT` 答`exists:false`，
    其余一律抛出，由信封带上操作系统自己的话。
  - **解码策略是"缓冲区里有 NUL 就是 UTF-16LE"**，于是 NUL 分隔的 UTF-8 流（`find -print0`、`grep -Z`、
    `git ls-files -z`——本插件的搜索路径自己就这么说）会静默地变成双倍长度的乱码。判定改为结构性的：BOM，
    或 NUL 出现在固定奇偶位且密度足够高。已知编码的调用方直接传入，完全跳过启发式。
  - **`reg.exe` 的输出被硬编码成 UTF-8 解码**，而同一个模块就拥有那个解码器，于是 UTF-16LE 的回答变成
    `undefined`——空选择器、不抛错、不留日志。现在所有捕获流共用一套解码策略。
  - **搜索路径在捕获点就解码了 stderr**，导致失败详情每个字符之间夹着 NUL、300 字符的预算按字节算、
    无效模式分类器永远匹配不到。`WslRun.stderr` 现在是字节；解码发生在用文本的地方，预算按字符切，
    分类器对解码后的文本判定。
  - **启动失败丢掉了 `wsl.exe` 自己那句话**（是哪个发行版找不到），因为详情只来自 stderr。现在会在
    stderr 为空时回落到 stdout 的首个解码行。
  - **三处调用点把参数列表交给了命令解释器。** `verify-install.mjs`、`verify-artifact-identity.mjs` 与
    `ci/install-pinned.mjs` 在 `npm_execpath` 缺失时都回退到 Windows 上的 shell——这正是 Node 如今拒绝
    （DEP0190）、且会把带空格的路径重新分词、把 `& | > < ^ %` 交给解释器的形状。现在按 三个地方找 npm 自己的 `bin/npm-cli.js`
    ——`npm_execpath`、`node.exe` 旁边、以及 `PATH` 上的 `npm`——找不到就明确报错而不是猜。
    第三个是因为第二个在 hosted tool cache 上不成立：那里一个本来没坏的 job 死于
    "npm's entry script was not found"。
  - **`install-pinned.mjs` 通过 shell 建junction 且丢弃了它的输出**，丢掉了链接器自己的话，只留下一个裸
    退出码。`fs.symlinkSync` 是同一个 junction，错误自带 `EEXIST`/`EPERM`。

  隐患 A、B、E 测的是 **Node 与操作系统**，不是本仓库：每个测试都复现某个形状、看着信息消失，所以修完产品它们仍然是红的。它们现在改为对本仓库自己的调用点断言，一旦站点回来就失败；上方保留着原始测量，作为规则为何如此书写的理由。`tests/deliberate-reds.test.mjs` 现在用自带的合成条目验证账本机制——因为读真实数组的控制用例，会在最后一条债还清的那一刻同时失去覆盖。

- **DSH 0.2.x 下 `bash_background` 的 `job_output` 始终为空（issue #56）。** `bash_background`（以及复用同一生产者的 `bash` 的 `run_in_background: true`）能正常起任务、`job_list` 也能追踪，但 `job_output` 每次读取都返回 `(no new output)`——运行中、结束后如此，完成通告也不带正文。命令**确实**执行了、stdout 也落了盘，所以死的是读取通道，不是任务本身。

  根因是注册表在两个版本线之间的契约变更。`@deepseek-ai/dsh-jobs-local@0.2.x` 只从 `spec.output` 这个 pull-source 数组填充任务输出环（`start()` 在后续 tick 逐一 drain 每个 source 的 `read(from)`），**从不**读取 `run()` 返回的 `readOutput`。而本插件的生产者只设置了 `readOutput`（即 0.1.x 契约），于是 `spec.output` 为空、pump 从未被武装，输出环始终为空。单元测试用的假注册表实现的是旧的 `run()`-`readOutput` 语义，所以测试全绿、真实 0.2.0-rc.2 宿主却红。

  生产者现在**双通道齐发**，各自版本线读取自己理解的那一个：`spec.output` 内两个 pull-source——`stdout` 与一个带 `stderr` 标签的 source——各自从 shell 进程自身的逐流 `observed` reader 读取（与 `readOutput` 所 drain 的是同一组独立游标，互不抢字节），同时保留 `run()` 返回的 `readOutput` 给 0.1.x 注册表使用。`spillPath` 原样透传，因此溢出流仍会报告其 spill 文件，`stderr` 也借由 channel 标签留在自己的通道上。

  新增一条单元门禁，复刻 0.2.x 的 drain 循环去拉取生产者产出的 `output` source，在旧生产者（无 source，或 source 不吐字节）上必定失败，故此类回归无法再静默复现。
- **一条答案里出现两个文件时，现在会说明哪个装着全部内容。** 被截断的 `bash` 结果可能同时带着我们的 `[output truncated; full output: …]` 路径和宿主追加的 `Full formatted result stored at:` 路径，原先没说哪个算哪个。现在 stdout 与 stderr 两臂都在那句之后跟一句：`[the path above holds the whole stream; if this answer also names a "formatted result" path, that one is the host's excerpt and can hold fewer lines]`。在客户端自带构建的真实会话里量过（2026-10-13，`seq 1 250000`）：我们点名的那条装着 **1 638 895 字节／250 000 行**，与裸命令同尺；宿主的格式化切片是 524 511 字节／74 892 行；stderr 臂是 1 638 895 对 357 165／50 985。客户端识别的那两句原文一字未动，原先能解析的现在照样解析。
- **`read_image` 在 WSL 世界里重新能答了（issue #54）。** 宿主注册这个工具时所在上下文的 realm 链到不了我们切出的世界，所以 WSL 会话里每次读图都在约 12 ms 内回 `cannot get property "fs" without inject`——连路径都还没解析（不存在的路径回的是同一句）。现在世界在自己的行旁边挂上本插件的读取，按 realm 键解析 `fs`，用真实字节回答：真实会话里量到 **296 ms、`image/png` 446x568／61992 字节**（文件在发行版内）。这件事付了两笔离线门禁看不见的学费，在此记明：返回值必须过**宿主自己的输出校验**（第一版真的读到了图，却死在 `"value.image.name" is not a declared property`——附件服务回七个字段，我们的 schema 只声明了五个），而真实会话是按 agent 解析工具的（`dsh-tools` 的 `get(name, agent)`），所以同一进程里的非 WSL 会话仍拿宿主那条，复测 22 ms 正常。接受的图像格式与宿主同为四种，也同样拦下未声明图像输入的模型档位，换成这条读取不丢任何检查。
- **`run_code` 在"根本起不来"的那一格现在会明说，并给出出路（issue #78）。** Windows 上的文件沙箱靠读取工作区根的安全描述符来授予写权限，而 `\\wsl.localhost\…` 是 9P 共享、没有这个东西，所以 PTC 会话在 `workspace-write` 下一律死在 worker 启动阶段，报 `GetNamedSecurityInfoW failed (Win32 1)`——一个裸 Win32 码，没有成因、没有出路、重试必然相同。现在 `tools.guard()` 只拒那一格（这个工具名、这个档位、根在 `\\wsl.localhost\` 或 `\\wsl$\`），并点名三条真能跑的设置：把会话切到「仅可查看」、切到「完全访问」，或用世界自己的工具。同一份已安装产物在本机实测：被拒那一发 **2 ms** 给出我方那句（内含被拒的根）；同一程序在「仅可查看」829 ms、在「完全访问」478 ms 仍回 `1`；被拒档位下的 `bash` 仍打到发行版（448 ms，内核 `6.18.40.1-microsoft-standard-WSL2`）。这**没有给 WSL 世界一个真沙箱** ——要站得住的强制必须搬进发行版内部；在那之前，一句可读的拒绝才是诚实的状态，而成因本身在宿主的 Windows ACL runner。
- **给维护者的：一张消费方台账、两枚探针，以及一枚"套件死了还报绿"的门禁。** `docs/CONSUMER-COVERAGE.md` 每次运行都从树上重新派生"哪些 `(服务, 宿主包)` 在向我们要被隔离的服务"，`tests/consumer-coverage.test.mjs` 把没跑过的数量钉住（45 对里 43 对从未被执行）——这正是让 #54 和 #78 溜过去的缝：仓里所有装置都从"我们自己注册的工具"进去。`npm run test:realm-probe` 与 `npm run test:confine-probe` 在真启动的宿主上打两枚 issue 的两臂；`run-seams` 现在把 "非零退出却没吐任何可登记的红"也算一枚观察到的红——此前两枚探针在 ubuntu runner 上就这么在绿帧里死掉过。两枚探针都在 ubuntu runner 上 **SKIP**（钉住的闭包里没有 `@deepseek-ai/dsh-attachment`），只在维护者机器上真测：把检查放进一个跑不动它的 job 不叫覆盖。

- **WSL 那句说明不再带提示词变量，于是 `wsl-*` 会话不会再死在发出第一次模型请求之前**（[#87](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/87)）。`PERSONA_APPEND` 原文是 `Your working directory {{cwd}} is inside a WSL …`，而 `appendPersona()` 会把这句话写进 persona 面向模型的那个标量。persona 是一份模板：`@deepseek-ai/dsh-system-prompt` 会拿本次组装注册的变量去解析每一个 `{{…}}` 组，缺一个就抛 `unknown prompt variable "{{cwd}}" in section "deployment:persona-prefix"`；而 persona 段位于 order 0，所以整个变体在 turn 1 step 1 就倒下。DSH 是在两个 release 之间撤掉 `cwd` 的，这一点按已发布的包量出来、不是推断：`dsh-agent-loop@0.2.0-rc.2` 注册 `provider`、`model` **和 `cwd`**（`lib/index.js:1566`），而 `0.2.1-rc.2` 只注册 `provider` 与 `model`（`lib/index.js:1591-1592`）。这句话现在写作 `Your working directory is inside a WSL …`：对模型是同一个断言，却不再有任何一组的含义依赖别人家的注册表。**中招的是四个变体里的三个，不是四个**——`wsl-minimal` 的 persona 带 `complete: true`，从不被追加；issue 原文那句「4 个变体均如此」因此也要改正。报告里有一处是报障者对上游文档的转述、不是这些包能给我们看的：0.2.x 改由某个服务把目录交给模型。本机量到的只是另一支的代价——一个连 token 都没花出去的死会话。真机验证（Desktop `0.2.1-rc.1`，其内置 loop 只注册 `provider`/`model`、不含 `cwd`）：同一句提示词，在 `\\wsl.localhost\Ubuntu\home\ruler` 工作区、`wsl-standard` 档位下，装旧产物时回的是 处理失败 + `unknown prompt variable "{{cwd}}"`、`1 轮 1 步`、零 token；换上这份产物后同一发 **6 秒／16.3K tok** 完成，而该会话自己的转录里带着修好的那句、68,810 字节解压文本中**字面 `{{cwd}}` 为 0**。`PERSONA_APPEND` 是 `src/` 里唯一的 `{{`，所以整个注入面已被 [PR #90](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/90) 锁住：四种自带文本里没有 `{{` 的 persona 形状要求产物也没有，变体的引用多重集必须等于源的，生成的那句还要过宿主自己的 `renderPrompt` 且注册表为空——并把修复前那句留在同一格作正对照，证明这把尺真的会咬。
- **`grep` 与 `glob` 在 `/mnt/<drive>` 型 WSL 工作区里开始回答，而不是把它当成门外**（[#86](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/86)）。按 Windows 盘符路径登记的工作区（`F:\TechEnergy\Code\Scanner`——因为 9P 无法导出 drvfs），模型**每一次** 调用都会拿到 `grep is only available in a WSL workspace session`，而同一会话里 `bash`、`read`、`write`、`edit` 全部正常：会话是好的，只有搜索以为自己不在 WSL 里。`resolveTarget()` 只认两种 cwd——WSL UNC 与 Linux 绝对路径，盘符 cwd 两者都不是，于是返回 `undefined`，两个工具一起抛。现在它接住插件其余部分早就在服务的那第三种形态：Linux 工作目录走 `windowsToMntPath`，发行版取自对话框写下的登记项，顺序与常驻 shell 走的那条一致（`src/host/wsl-relay.ts`：配置 → 登记项 → 注册表默认），因此 `grep` 搜的就是会话 `bash` 所在的那个发行版，而不是注册表恰好叫"默认"的那一个。相对路径与空 cwd 仍然不指名任何世界，仍然被拒。**在把它当成一行补丁之前要知道的一件事**：`tests/wsl-search.test.ts` 里有一格名为 "refuses a cwd that is not in a WSL world"，断言 `resolveTarget('C:\\Users\\mille\\ws') === undefined` ——缺陷被写成了契约。所以这一格是**拆开**而不是改掉：真正不指名世界的仍然拒，盘符形态各拿自己的用例。两半都量过：退回未修源码时，三枚新的盘符格全红，第四枚（去调已登记的 `grep`/`glob` 工具、断言回来的不是那句拒绝）报 `grep refused a WSL drive workspace`；修后该文件 36/36、`test:unit` 226 枚、`test:node` 宿主桶 111 枚且两档 materializer 均 PASSED、`typecheck-gate` 209＝基线、`test:docs` 11/11。同一发也打在**打好的** `lib/wsl-search.js` 上、不是源码：main 那份逐字复现两句报错，本产物 `grep` 回 `Found 1 match … ISSUE86-NEEDLE-…`、`glob` 回 `needle.txt` ——真 `wsl.exe`、真发行版、真盘符 cwd，且登记项**只**给发行版，正是生成的 `search-wsl` 行在真实会话里的形状。

## 0.7.6 — 2026-10-01

- **DSH Desktop 0.2.x 上 WSL 工作区里每一次 `bash` 调用都失败（issue #51），修的是三件事。** (1) *shell 接缝*：0.2.x 走 `ctx.shell.execute(spec)` 再 await `result()`，本插件实现的是 0.1.x 的 `resolve`/`run`/`start`——所以**常驻和回退两条路一起断**，不是一条。现在一份原语喂三个面，`onExpiry` 与 `observed` 补齐；一直放着它没管的闸门是 typecheck 的**错误计数预算**（那行 `TS2515` 本来就在输出里），现在改成"禁用错误码出现即红"、自带哨兵，且基线只准下调（212 → 209）。(2) *就绪契约*：宿主把 `PS1`/`PROMPT_COMMAND` 作为 Windows 变量注入，而 WSL 不在 `WSLENV` 里具名就不会带进发行版，于是常驻 shell 用的是发行版默认提示符、宿主认不出来——中继里加一道桥即可，真 ConPTY 上验过（改前：默认提示符；改后：`prompt=[dsh> ]` 且带 `133;D;` 标记）；契约值现在与装着的宿主包做对照，不再手抄。(3) *持久 shell 本身*：宿主的 PTY 工具判定"跑完"的方式是在终端文本里找哨号行，并要求退出码后面紧跟换行；而交互式 Linux shell 用 `ESC[<n>X` 重绘，会把哨号之后的格子填成空格—— 在真实 Desktop 会话里量到**连续三次调用各挂 303.8 秒**，之后宿主把 shell 重置。现在这个世界挂的是本插件自己的 `bash`：一只长命的 `bash --norc -i`，**经管道**驱动，完成信号是带着本次调用独有 nonce 的 NUL 分隔记录；登录环境静默 source、history expansion 关闭（顺带把 `!` 那一类失败在这条路上关掉）、被读到"正在等输入"的命令会拿到一只属于自己的伪终端（`script -qec`，由读进程决定而不是由命令名决定，`tty: true` 可一上来就要）、命令卡住时重建会话并回放 `cd` 与 export 的变量，而**只是跑得久**的命令不会被第二次执行。本机两档构建平面实测：首发含启动约 0.5 秒，其后**中位 8 毫秒**（2026-10-06 实测：答案不再等那份状态记录；同样一条命令走"每条起一次 `wsl.exe`"是 222-236 毫秒，人在开着的终端里敲是 16 毫秒），`cd`/`export`/退出码/中文/`!`/`sed -i`/`tar`/`git commit`/`sudo` 全部验证通过，而同一条命令走 PTY 那一档仍然超时——这个对照是新闸 `bash-session-real` 的最后一格，所以被换掉的行为不能悄悄回来。端到端是在**真宿主工具派发**上验的（宿主的 `bash` 工具调我们的执行器，见 `tool-bash-real`）；`DSH_WSL_PTY_SHELL=1` 保留旧档可挂载。**已把这个构建装进 DSH Desktop 点过**，点击查出两处离线闸门看不见的缺陷：宿主加载器会剥掉 `exports.default`，于是模块级 `inject` 到不了 fiber，每次调用都报 `cannot get property "subprocess" without inject`；shell 对帧的 echo 只有**行尾**会到 stderr，于是每次调用的正文里都带着我们自己的协议字节。两处都已修，且都由"走产品真正用的那条通道"的新格子中住。

- **第二轮把这只会话 `bash` 修到与宿主的 `bash` 逐字段同形。** 34 行常见命令矩阵抓到四件"不像日常 bash"的行为，全部改掉：`run_in_background: true` 原本是被静默忽略的参数（现在转交 `bash_background` 用的同一枚生产者）；相对 `workdir` 原本解析成空、就在上一次调用留下的目录里跑（现在按会话目录拼接，目录不存在时让 bash 自己的 `cd` 报错，与宿主 `resolveWorkdir` 同形）；超过上限的输出原本只留一个布尔、头部丢掉（现在整条流入库，并写 `[output truncated; full output: …]`，文案与目录和一次性路径一致，且**每条命令一个文件**）；重建时脱离父进程的子命令会留下（现在按会话令牌精确收尸，另配一枚"必须活下来"的对照进程）。journal 除 `cd` 与 export 外还回放 `set`/`shopt`/别名/函数——**分帧回放**，因为 `eval` 会先把整串解析完再执行，第 50 行的 `shopt` 救不了第 1623 行需要 extglob 才能解析的补全函数（症状：重建报告成功、91 个 rc 函数回来了、用户两轮前定义的函数没了）。差异现在是一张被两枚闸读的表：`tests/wsl-bash-parity.test.ts` 对着装着的宿主包比参数与输出字段，`scripts/compatibility/bash-parity-real.mjs` 用同一份探针脚本在真发行版上把两个工具各问一遍再逐字段对答案——没写进台账的差异、或写了却已消失的差异，都是构建红（见 `docs/bash-parity.md`）。占用实测：每只会话约 9.1 MB Windows 工作集（两枚 `wsl.exe`）加发行版内 3.4 MB，`vmmem` 不随会话数变化，运行期在输出溢出前不落任何文件。

- **一只 agent 的 shell 现在随这只 agent 一起结束。** 会话 `bash` 原先只在插件作用域上登记清理，于是凡是调用过 `bash` 的 agent 都留下一只 shell——两枚 `wsl.exe`、约 9 MB Windows 工作集——直到整个世界被 dispose。发现它的格子正是用同一枚注册好的工具驱动第二只 agent，然后问"其中一只停下来会发生什么"。其余隔离边界本来就已成立、只是从没被测过：第二只 agent 不继承第一只的目录、export 与别名；同时发出的两次调用各自带回各自的答案；重建时的进程清扫只停携带本会话令牌的那些进程。`sudo` 现在也说明它缺什么——发行版要密码时，正文补一句"这只 shell 没人替它敲密码"并给出两条出路，而不是把 sudo 自己那行字留给模型重试。别名在定义它的那一行里被使用会返回 127，这与同一发行版上 `bash -ic` 的表现一字不差——量过，并记成 bash 的规矩而不是我们的缺陷。

- **伪终端这一档有三处接缝，是把它真开出来跑出来的，不是推出来的。** 不会上色的程序拿到终端时用"重打"表示强调，实测 `man` 一整页以 `N\bNA\bAM\bME\bE` 到达模型——人坐在真终端前读到的 `NAME` 在那里是乱码；折叠规则现在按终端自己的做法处理这台发行版**实测出现**的两种形状（`X\bX`、`_\bX`）。升级判定原先只读命令的第一个词，于是 `bash -c 'sudo true'`（模型非常爱写的形状）耗到期限、返回 `(no output)` 外加一次会话重建，而裸 `sudo true` 46 毫秒就答；现在会往里读一层 `bash`/`sh`/`zsh`/`dash` 的 `-c` 包装，读不动内容的包装就**不**升级，而不是猜一个。`tty: false` 原先什么都不做：只认 `true` 时，`man ls` 照样从伪终端回来，模型没有任何办法回到普通管道，现在 `false` 是否决。有两件事**没修**，因为它就是终端的样子：升级后的调用只有一条流，所以普通路径写的 `[stderr]` 段在那里不可能出现（宿主自己的 PTY 档同样——这条已进 `docs/bash-parity.md` 台账并配了探针）；而帧给伪终端的输入是 `/dev/null`，不能让程序去吃下一条命令的字节——这一点不但保留，还正是第二次尝试能让程序把自己的话讲完的原因。需要一块永远等不到的键盘的程序，现在由"读进程"那一档处理（见下面关于读数的那条），不再由程序的名字决定谁算等待。

- **等键盘的命令现在靠读发行版来诊断，不再靠对名字——它拿到的是答案，不是期限。** 这一档在本版本里走过的路是：一张首个词的白名单 → 三个"类别"（凭据 / 键盘 / 分页器）并给其中一类加 8 秒上限 → 全部删掉，换成在调用进行中从会话外面看第二眼。每一步都留在账上，因为每一步都是被一次测量逼出来的：白名单在连字符处停住，于是 `ssh-copy-id`/`ssh-keygen`/`redis-cli` 永远匹配不上；只读第一个词在真会话里花掉 **121 703 毫秒**——模型发的就是 `{"command":"printf x; vim note.txt","timeoutMs":15000}`，两分钟默认期限烧完，交给模型一屏原始转义；尊重调用写的期限在那种"永远等不到"的等待上一分钱也不省，因为 agent 不会把 16 889 毫秒的沉默读成"这个程序需要手指"，它读成 "这个环境很慢"，然后拿这个结论去规划后面每一次调用——而 `tar -cf /dev/null /usr` 确实要 15 秒、`vim -es` 处理 **100 万行** 只要 1 秒，所以任何"安静就是卡住"的规则都必须能把这两种分开。现在每次采样（200–280 毫秒、约 2 Hz、且只在静默 1.2 秒之后）读的是：进程状态、这个作业是不是终端的前台进程组、`/proc/<pid>/wchan`、它的描述符里有没有终端、以及它的 CPU 有没有在涨（`/proc/<pid>/schedstat`）。`sleep`、网络等待、正在编译的程序各被其中一列排除掉；"睡在终端读上"的程序排除不掉。于是先把它停下——先发 `SIGCONT`，因为被停住的进程会无视 `SIGTERM`——再在**同一次调用里**用属于它自己的伪终端重跑一遍；在那里那次键盘读会撞上文件尾，程序把自己的话讲完。真会话实测（`D:\Temp\issue51-s0\v5-run.txt`）：`sh -c 'read x < /dev/tty'`——程序名是 `sh`，过去任何一份名单里都没有它—— 628 毫秒被停下、1 760 毫秒答话；同样安静的 `sleep 4` 一步没被碰；要密码的 `sudo`（它清掉 dumpable 标志，因此把 `/proc` 条目藏起来）改由发行版 root 权限读同一枚探针（只读 `/proc`、每次调用最多跑一遍，root 不可用的发行版退回"无法确认"那一档并如实说明），2 412 毫秒带回 sudo 自己的话；一条为键盘等待写了 60 000 毫秒期限的调用 1 733 毫秒就返回了。两件事是说出来而不是悄悄做掉的：**这条命令已经跑了两次**（"它在提示之前已经做过的事情，现在做了两遍"），因为不声明的第二次执行正是本票早前抓到过的那类缺陷；以及如果那趟 `/proc` 行走一次都没回答，正文会写"这项检查没能运行"，而不是留下一段没有理由的期限——消失的前提不许伪装成一个结论。而如果那趟行走回答了、但这台内核不报等待位置——WSL1 那档就是如此，每个进程的 `wchan` 与 `/proc/<pid>/syscall` 都是空的，也不点名哪个是前台作业——期限的正文会把它读到的那几行抄出来并标明 `w=not-reported`（与在 CPU 上的 `w=running`、读不得的别人的进程的 `w=0` 分列三档），而不是留下 "timed out" 让人读成"查过了，没什么异常"。`tty: true` 一上来就要终端，`tty: false` 否决第二次尝试；分页器与实时画面那些类别名单原本要编码的行为，就是管道自己的样子（`man` 吐出整页、`top: failed tty get` 687 毫秒拒画），哪句症状属于哪一层写在 [docs/tty-triage.md](docs/tty-triage.md)（该件为英文）。

- **重复失败会被点名，但仍然照跑。** 同一串命令字节在同一只 shell 里第二次失败时，正文加 `[this exact command has failed 2 times in this shell with nothing succeeding in it since: …]`，而**任何**一次成功都会清空全部计数——那句话讲的是"这之后什么都没成过"的 shell，所以两次 `npm test` 之间夹一次成功的 `npm install` 不能被说成死循环。**故意不做拒绝**：一个"不执行被告知的命令"的工具正是这张 ticket 要修的形态，而最常见的第三种情形——装完依赖后 `npm test` 第三次通过——恰好是拒绝会打断的那个。

- **`sudo` 那句说明把"人"排在第一条出路。** 产品在右栏有面向人的交互终端标签（`dsh-client-ui-sidebar-terminal`，走宿主自己的 PTY，本插件不碰），人在那里能正常输密码；配 NOPASSWD / `DSH_WSL_USER=root` 退居第二条，含义是"让 agent 能自己跑"而不是"先救现场"。

- **"这条命令在等一次没人敲的键盘"这句说明只看读数，不看"上限是不是我们施加的"。** 装机桌面端第一发真会话并没有发 `vim note.txt`，模型发的是 `{"command":"printf x; vim note.txt","timeoutMs":15000}` —— 它自己写了期限，而旧规则下 "调用自己写了期限"就换不到那句解法，只拿到一句通用说明。现在说明挂在"看见了什么"上：调用写多长的期限都不改变它是不是一场等键盘的等待。第二格让超时文案自己互相对账：只有会话真的重建过时才允许声称"已重启"，而那一格的实测是—— `tty: true` 的 `sleep` 超时确实会重建会话。

- **常用命令清单是真跑进了一枚真 dsh 会话，121 703 毫秒就是这么抓到的。** 真 DSH 0.2.0-rc.2 实例（自己的 `DSH_HOME`、自己的端口、本地脚本化提供方，因此没有买任何一次推理）：27 行日常命令，读数取自这枚会话自己的落盘日志，而不是需要谁转述的界面。多数行按设计答——要密码的 `sudo` 带回它自己的判断和那句说明、分页器 73 毫秒出内容、`man`/`less` 吐出整份文档。两行没答对：上面那条"前面加个 `printf` 的编辑器"，以及一格把期望写在了错的介质上。同一趟还抓到工具说明里一句假承诺：实时画面在管道上并不吐出文档，它是直接拒画（实测 `top: failed tty get`，退出码 1，687 毫秒），所以说明现在照实写，并让模型去要 `top -bn1`。

- **一个本工具没预料到的失败现在会自己报是哪一层。** 走了伪终端、又不落在已知形状（要密码、要按键）里就收尾失败的调用，会补一行 `[this call ran on a pseudo-terminal (script -qec, one stream): re-run the same command with "tty": false to rule this layer out before looking anywhere else]`，同一个决策还以 debug 级写进宿主日志（`wsl-bash: pseudo-terminal for class=… deadline=…ms`），使 `dsh web`/`headless` 的日志也能回答"这发是不是 pty"（装机桌面端把子进程 stdout 只留在内存，所以那一档可读的一半仍是转录正文）。哪种症状属于哪一层的对照表（含宿主 PTY 档自己的哨号——issue #51 最初就是冲着它提的）在 [docs/tty-triage.md](docs/tty-triage.md)，那张表点名的每一行都由 `bash-session-real` 的一格钉住（现在 47 格，两档平面都跑）。

- **一个真模型跑出了我们的格子没能覆盖的形状：读终端的 *builtin* 卡住的是 shell 自己。** 用真凭据被真模型端到端驱动时（四条命令全部由模型自己选），它写的是 `read -r line < /dev/tty`，而本仓所有格子写的都是 `sh -c …`：子进程阻塞能被"走后代"找到，*builtin* 没有子进程，于是一个都没分类到、那一发挂满 30 秒期限且没有说明（模型自己在总结里点出了这一点）。实测那一刻 shell 自己的行是 `Ss+ pgid==tpgid wchan=wait_woken fd0=/dev/tty` 且 CPU 不涨——同一枚签名，只高了一层——所以 shell 现在也在走路列表里，并带标记（命令之间的它在管道上等待，不能当成"在等键盘"）。解放它靠的是测量而不是想当然：`kill -INT` **不生效**（bash 接住信号后 read 系统调用被重启，实测信号已送达而调用仍跑到期限），所以它和任何卡死的进程一样被停掉、会话按 journal 重建、再由同一条重试路径把命令放到终端上重跑。同一句提示的第二趟：那一发 **3 104 毫秒**回来，正文 `read-exit=1 x=''` 加"第一次是靠重启 shell 结束的"那句说明，模型的收尾总结原文引用了它。落地过程中又被格子咬出三处缺陷：停机集合原本点名了样本里的每一行（样本现在永远包含 shell）、特权那一档的停机集合取自读不到的用户面行而不是 root 面见证的、以及被看门狗停掉的一发被报成了"调用方取消"——后者让真机闸把自己的这一轮中止了。

- **终端由一次读数给出，不由名单给出，而且第二次尝试会声明。** 三集合（凭据、键盘、分页器）曾经决定一发调用要不要伪终端、以及它被允许等多久，现在它们全部不存在：这一档的维护成本来自它们，两处真缺陷也来自它们，任何一枚发行版都可以和它们意见不同，而它们替我们"提前判断"的那件事本来就可以直接观测。替代它们的是一个函数（`src/host/wsl-bash-starve.ts`）在调用进行中从会话外面读发行版，外加一次"停下并重跑"。每一枚阈值背后的测量、三种读数各自能说什么、以及调用方可用的门，都写在上面那条读数条目里和 [docs/tty-triage.md](docs/tty-triage.md)（该件为英文）。


- **DSH Desktop 上一个 WSL 变体都生成不出来（issue #47）**。生成器在调用时向宿主借两样东西：条目清单的方言，和解析它的 YAML 引擎；前提写的是"宿主和插件共用一棵 `node_modules`"。Desktop 把宿主打在归档包里，而 Node 找裸包名是沿文件系统一层层往上走，这条路永远走不到宿主里面。于是第一个健康的源预设就抛错，抛出循环，循环之后的一切——包括对已退役机制残留物的清扫——一起被跳过。界面上只剩"未找到健康的 wsl preset"这一句，原因躺在宿主控制台里被 `catch` 吞掉。这一条在本机用**真包**复现，不是推断：按伞形依赖提升的那个版本建好方言、再经兄弟插件提升的那个版本去加载，会失败在加载器内部，报出的话既没有包名也没有版本和路径。
- **方言现在由插件自己构建**（`src/index.ts`），并且和 `load()` 出自同一个引擎命名空间，规则与引擎不可能再分属两个大版本。那份对外导出的 schema 我们只用得到"读"的一半，另一半服务的是本插件不走的那条写出路径。六行，不新增依赖。
- **引擎改成本包的正式依赖**（`js-yaml: ^4.1.0`，解析到 4.3.2，正是宿主伞形包自己提升的那个版本）。把它声明成可选同伴依赖、范围写 `*`，等于把版本决定权交给 profile 里恰好躺着哪一个；而 `autoInstallPeers` 关闭时，可选同伴依赖根本不会被安装。同一个 profile 里照常工作的那个兄弟插件，恰恰是因为它声明的是正式依赖——现在两边形状一致。已经没人 import 的两条 peer 声明一并删除，清单不再描述一段不存在的引用。
- **一个源失败只算它自己那一个变体的失败**。生成改成逐源容错，退役目录的清扫无条件执行，并把结果作为一行计数写出来——`WSL preset variants: n/m registered`——因为"路由能答"从来证明不了"生成了东西"。这行在 `dsh web` 上进启动日志、兼容矩阵已把它当成判据；**在 Desktop 上它不落任何地方**：实机量到宿主日志目录里只有 crash 包，而那些包只收渲染进程的 console 与非零退出的子进程 stderr，主进程的 `console.error` 没有落点。所以"把失败原因显示到界面上"（报告者的建议 #3）从"锦上添花"改判成单独一票，依据是量出来的，不是审美。改之前还有一处是靠事故成立的：源目录中途消失时，上一次完整发布的变体之所以还在，是因为中断让那趟清扫没跑；现在契约保留，事故拿走，`tests/host-materialize.mjs` 在第一跑就把这个差别咬了出来。
- **方言相关的失败会报出它实际站在哪一份上**——包名、版本和路径。版本这件事由用户机器上的安装器决定；那一格后来用真安装器量过了（见下条），但失败文案仍然自报包名、版本与路径，好让在别种安装器上出问题时日志本身就带得走结论。
- **新增闸门** `tests/host-profile-isolation.mjs`（`npm run test:profile`）：在临时目录里搭出与真实 profile 同形状的树，逐臂启动插件自己那份拷贝——借来的 schema 不在、提升到的引擎是错的大版本、两者同时、以及健康源里夹一个读不到的源。它的对照臂、对照伞形包里钉住的那份宿主 schema 做的方言等价校验，以及"声明里每条 provider 路径都能真的被 import"的探针，与它写出来就该亮的八枚红**出现在同一帧**；对修复做的三次变异彩排各自拧红的是不同的子集，这正是把两个缺陷分开报的依据。`ci/install-pinned.mjs` 额外物化第二棵树放那个敌对版本，且**永不**链进仓根。
- **两道旧闸门问错了问题**。`scripts/verify-install.mjs` 现在还断言装好之后的运行面：每条正式依赖都要从安装位置的 `lib/` 可达、且在声明的那条版本线上；`dependencies` 为空本身就是拒绝——它以前的绿是"引擎缺席"这件事**造出来的**，而那正是本 issue 报告的形态。`scripts/verify-dsh-compat.sh` 对矩阵里每个版本断言那行计数：少于可供的源数判 `VARIANTS_FAIL`，行没出现或 `0/0` 判 `VARIANTS_NOT_VERIFIED`，不许算通过。
- **本机定得了的和确实定不了的**：用真 pnpm、`nodeLinker: hoisted`、`autoInstallPeers: false`（报告者环境表里那套设置）量过了——已发布的 0.7.5 装进"兄弟插件把 `js-yaml` 顶到 5.x"的 profile 树里，生成数为零；本构建在同一形状里拿到属于自己的那份引擎嵌套，并把交给它的源全部注册出来。同一对随后在这台机器**自己那份 Desktop profile 的形状**里又跑了一遍，结果发现真件就在这台机器上：`C:\Users\<机主>\AppData\Local\Programs\DeepSeek Harness` 是装着的，`.dsh/profiles/desktop` 里是 0.7.5、用的正是那两个设置、它的上溯路径上既没有引擎也没有 include 包——报告里那份"缺料"一直摆在维护机自己的目录里。**仍未测到的是宿主进程本身**：读那个 profile 零成本，而在机主正在用的机器上起一个 GUI 加它的自动更新器不是，那一格等点头再走。报告者那行宿主控制台输出也不再是能让他去 grep 的东西——健康的 Desktop 启动不落宿主 stdout（见上条），所以那条判据只有带 shell 的维护者侧取得到；回帖改成请他报**能观测的那件**：升级后对话框里有没有 `WSL · *`。限度与支撑它的帧都写在 `docs/compatibility-evidence.md`，没有折成覆盖。
- **WSL 会话里的文件引用又能预览了（issue #49）**。会话里的文件链接、工具行的行号引用、回合末尾的「已改动文件」都走右侧栏的导航控制器，而地址里带的是模型原样写下的 **Linux 路径**；宿主用 `node:path.resolve(cwd, path)` 解析它，POSIX 绝对路径在 Windows 上是「当前盘根目录下的相对路径」——`/mnt/d/x` 落到工作区所在的盘（`D:\mnt\d\x`），发行版内的路径落到 cwd 共享的根下，而 UNC cwd 下的 `/mnt/<盘符>` 引用会变成 `\\wsl.localhost\<发行版>\mnt\<盘符>\…`，9P 对它报 `EPERM`。修复前在真 DSH Desktop 上两种注册方式都复现到了：`write` 工具刚写成功的文件点开显示「文件不存在，可能已被移动或删除」，UNC 工作区里同类引用显示 `EPERM`。
- **修复落在客户端半边，因为宿主平面没有插件能用的钩子**：`fs` 与 `workspaceFiles` 属于别的插件，cordis 不允许第二个 `provide` 抢别人 fiber 持有的名字，两边也都没有发布路径解析的缝。客户端半边在地址交给侧栏之前翻译——`/mnt/<盘符>/…` 换回 `X:\…`，其余 Linux 路径走该发行版的 UNC 共享——挂在 `openResource` / `openResourceIn` 这个所有引用入口都经过的地方，于是 tab 的内容与同一地址下的元数据一并修好；落在工作区内的翻译结果会变成与文件面板一致的相对地址，所以是揭示已打开的 tab 而不是多开。
- 新增宿主路由 `listWorkspaceRecords`（客户端会缓存）：发行版内的路径需要知道发行版，UNC 工作区自带，`/mnt/<盘符>` 工作区只在 `wsl-workspaces.json` 里有。`host-api.mjs` 覆盖它，探针地板从 12 抬到 13。
- 地址语法是**镜像**的而不是 import 的——十一个已声明版本里有六个不带 `@deepseek-ai/dsh-util-workspace-path`、或带了却没有这套语法——只要那个包能被解析到，就有一条单测把镜像与它逐字节对齐。逐字节重建不出来、发行版未知、或插件不认定为 WSL 会话的地址，一律原样透传，不做猜测。
- 只有 `0.1.5-rc.1` 及以后才有右侧栏、文档预览与资源模型；六个更早的已声明版本没有可修的引用面，钩子不安装。
- 本条目的实测依据：十一个已声明版本在 harness 上 14/16（与既有两项基线相同）、`host-api` 在真 0.2.0-rc.2 前端上 13/13、真实浏览器里在 `0.1.5-rc.1` / `0.1.5-rc.2` / `0.1.7-rc.1` / `0.1.7-rc.2` / `0.2.0-rc.2` 上端到端点开引用，以及真 DSH Desktop 上四种组合（盘符与 UNC 工作区 × `/mnt/<盘符>` 与发行版内路径）。
- **agent 现在能在一个属于自己的终端里敲键**（`wsl_terminal`，issue #51 的最后一块缺口）。管道无法被敲键，所以凡是会提问的程序——`sudo` 要密码、`ssh` 首次连接的指纹、REPL、编辑器、TUI——都只能被诊断、停下、改用一次性伪终端重跑，让模型读到程序自己的抱怨，剩下的由人接手；而本票的合同是**人能跑的，agent 都要能跑到**，于是这一类补上了一扇门。这个工具是宿主自己的 PTY 注册表与 `dsh-terminal-bash` 后端（指向本插件的中继）之上的模型面形状——没有任何新的伪终端机械，与右栏终端标签背后那套完全同一——动作为 `open`/`send`/`read`/`signal`/`close`/`list`，挂在管道 `bash` 旁边（管道仍是默认：它给的是每个字节，终端屏幕只是 160 列渲染加有限滚动缓冲）。有两个事实是读宿主代码才拿对的：shell 跑在**工作区配置的用户**下——宿主自己构造 PTY 子进程的环境、会把 `DSH_*` 全部丢掉，所以中继改从工作区存储里读回来（同一个修法也补给了会话 `bash`：它此前只从环境解析用户）；后端的静默窗口从默认 3000 毫秒下调到 1200 毫秒（来源注在 `src/host/variants.ts`），于是宿主没认出提示符的那一发约 1.2–1.8 秒返回、而不是约 3.0–3.6 秒——一发 `send` 会说明自己落在哪一档，而不是把安静的屏幕说成提示符。真发行版、两档平面、`root` 与 `ruler` 实测：`bash-session-real` 现在带着这扇门自己的格（66/66），其中包含**敲键能到达阻塞在 `/dev/tty` 上的程序**（`GOT=…`）、`submit: false` 只打字不执行而下一次回车执行、`SIGINT` 结束 `sleep 30` 且 shell 存活、`close` 之后不留 `bash -i`——`open` 实测 477–490 毫秒。顺带发现：`tests/host-profile-isolation.mjs` 只在 Windows 上红（它把一条启动**诊断**当成失败，于是那两个「健康帧不说话」的格在 CI 上绿、在本插件真正面向的平台上红）；这两格现在把诊断与失败分开，并写明了原因。

- **一次外部的对抗式复跑找出了三处本机看不到的问题，三处都已关掉。**（1）*就绪契约原先依赖发行版的启动文件不去动它*。带 `/etc/profile.d/80-systemd-osc-context.sh` 的发行版里，那个文件执行 `PROMPT_COMMAND+=(…)`，而 bash 不导出数组，中继 `exec` 出来的交互 shell 于是完全继承不到 `PROMPT_COMMAND`——本机用模拟的 profile 量过：`env | grep -c '^PROMPT_COMMAND='` 从 `1` 变 `0`，`133;D;` 标记到不了线上，`wsl_terminal` 每敲一键都要付满静默窗口，`DSH_WSL_PTY_SHELL=1` 那一档永不结算。中继现在把契约再用一个启动文件不认识的名字桥进去（`__DSH_READINESS_PROMPT_COMMAND`），并在登录段与 `exec` 之间重新断言，且先 `unset`——对数组赋值只写元素 `[0]`，照样导不出去。四档实测：旧命令+坏 profile `0`/无标记（报告的那格）、旧命令+干净 profile `1`/有标记（本机为什么是绿的）、**新命令+坏 profile `1`/有标记、`prompt=[dsh> ]`**、新命令+宿主没注入契约 `0`/无标记（那种宿主仍旧拿到它自己的 shell）。（2）*函数快照的 64 KiB 上限是全有或全无*，于是一个自有函数总量 86,954 字节的发行版上，整段快照——包括模型刚定义的那一个——被一起丢掉，台账里 `alias, function, shopt and set options survive a restart` 那一格也因同一原因变红。现在上限是按函数分配的预算，且只覆盖发行版自己的启动文件没有定义过的函数（重建出的 shell 会自己重新 source 那些文件），装不下的按名字报出来。（3）*一次有东西要报告的重建，不被检查它的那格相信*：正文在什么都没落下时写成 `was restarted and its directory …`，有落下时写成 `was restarted; not restored: …`，而那条检查只匹配第一种，于是在会报告的机器上「声明」与「事实」都为真、格子仍判它们不一致。现在两种措辞都算数；由读终端触发的重建也会把没恢复的东西带进那一发的正文，而不是跟着第一次尝试一起丢掉。
- **命令现在能拿到喂给它的输入**（`stdin`）。会话 shell 自己的 stdin **就是**协议通道，所以命令的 stdin 天然是 `/dev/null`——对多数调用是对的，对"从管道读输入"的那一类是致命的（`cat`、在 stdin 上提问的构建工具、读数据的脚本）。调用的 `stdin` 文本现在随同一帧送达，落成命令 stdin 重定向读取的临时文件，读完退出码即删除；升级到伪终端（`tty: true`）的那条路同样拿得到，因为 `script` 会把它自己的 stdin 转发给它创建的 pty；后台任务则经一次性执行器自己的 `stdin` 字段拿到。超过 32 KiB 的调用会被点名拒绝且什么都不执行——帧的单价是实测的（64 kB 约 3.8 秒、256 kB 约 59 秒），而被喂了半份输入的程序会以看起来像它自己的错的方式失败。真机格：`cat` 打出喂进去的两行**且这一发仍照常结算**（结束调用的记录不能被命令自己的读吃掉——这条通道就是围绕这个失败设计的）、在真伪终端上同样成立（`read x; echo GOT=$x`）、以及拒绝语本身。

## 0.7.5 — 2026-09-30

- **DSH Desktop 上持久 shell 又能用了（issue #40）**。Desktop 的宿主进程**本身就是**打包后的 Electron 可执行文件、以 node 模式运行（`ELECTRON_RUN_AS_NODE=1`；Desktop 自己的代码是 `new DesktopHostProcess(resources.node, …)`，而 `resources.node = process.execPath`），插件却把 PTY 后端的 `shellPath` 指向 `process.execPath`、`shellArgs[0]` 指向 `lib/wsl-relay.js`。Electron 二进制在 **ConPTY** 下不输出任何字节：中继的 `wsl.exe` 子进程继承到一条死流，中继 0 字节退出 0，后端的就绪探测永远看不到提示符，于是**每一次** `bash` 都以 `PTY shell exited during startup` 失败；而 `0.1.0-rc.7` 那条一次性 bash 回退路径不受影响，故障正好被一个能用的模式盖住了。用宿主自己的 node-pty 实测：同一份中继、同一个 `\\wsl.localhost\…` cwd、同一份环境，真 node 给出 bash 提示符，Electron 是 0 字节并干净退出。
- **中继的解释器改为显式解析**（`src/shared/relay-node.ts`）：先取 Desktop 传给宿主进程的 runtime payload 路径（`process.argv` 里的 `…/resources/runtime/primary-runtime`，那份 payload 里带着它自带的真 node），再退到可执行文件旁的同一路径（macOS 包内是 `Contents/Resources/…`）、`DSH_DESKTOP_NODE_EXECUTABLE`、`PATH` 上的 `node`，最后才回到 `process.execPath`。每个候选都要被**问一次"你到底是什么"**（`-e` 打印 `process.versions.electron`）：只看 `--version` 分不出来——继承 `ELECTRON_RUN_AS_NODE` 时 Electron 回答的是 node 版本号（实测 `v24.18.1`），任何 `^v\d+\.\d+\.\d+` 判断都会选中那个坏的。非 Electron 宿主（`dsh web`）直接返回 `process.execPath`、不启动任何探测进程，行为与 0.7.4 完全一致；Desktop 上则把选中的解释器与被拒绝的候选写进启动日志。
- 同方向的 PR #39 诊断是对的（根因确实是 Electron 解释器），但它的候选链第 1 项 `DSH_DESKTOP_NODE_EXECUTABLE` **指向的就是 Electron 可执行文件**——Desktop 自己的 `resources/runtime/bin/node.cmd` 就是 `set ELECTRON_RUN_AS_NODE=1` 后运行这个变量——而它的 `--version` 校验分不出两者。本修复采用同样的方向，但把 payload 路径放在最前，并用真正的判别器把 Electron 挡掉。
- `tests/relay-node.test.mjs`：候选推导（argv / 可执行文件旁 / 环境变量，含去重与排序）与"是不是 Electron"的判定，包括 `--version` 那种形似版本号、但必须被拒的输出。
- `scripts/compatibility/conpty-relay.mjs`：**新的常驻门禁**——在真实 ConPTY 下用宿主自己的 node-pty 跑中继，要求解析出的解释器给出活的 bash 提示符。这正是 #40 破坏的那条不变量，此前没有任何检查覆盖它。它注册进 `Run-Checks.ps1` 与 harness，每个已声明版本都跑。
- **顺带修掉了 issue #40 里的第二个报错**：`bash_background` 交给 jobs 注册表的 `owner` 形状不对，在 `0.1.7` 及以后每次都报 `session "[object Object]" has no live agent`。实测这条契约**在 `0.1.7-rc.1` 变过**：之前 `start()` 收 agent 对象本身（注册表读 `owner.id` 与 `owner.ctx`），之后收**会话 id**（用 `agents.get(id)` 解析）——宿主自己的生产者也是同时从 `owner: parent` 改成 `owner: parent.id`。现在按注册表是否提供 `resolveOwner` 选择该传哪种形状；`tests/wsl-jobs.test.ts` 原本把错误的那一半当成正确行为断言了下来，现在两种契约都钉住了，十一个已声明版本也逐一实测过。
- `dsh.compatibility.dshReleases` 增加 `0.2.0-rc.2`（DSH Desktop 0.2.0-rc.2 自带的 DSH 版本），这份构建声明的版本从十个变成十一个；README 也新增「兼容性」一节列出这十一个版本，并有单测保证它和 `package.json` 的声明不会各说各话。
- **顺带修掉了 issue #40 里的第二个报错**：`bash_background` 把 agent 对象当成 job 的 owner 交给 jobs 注册表，而注册表要的是**会话 id**——它用 `ctx.agents.get(owner)` 去找活着的 agent，拿到对象必然找不到，于是每次都报 `session "[object Object]" has no live agent`（宿主自己的生产者传的是 `agent.id`）。`tests/wsl-jobs.test.ts` 原本把这个错误契约当成正确行为断言了下来，现在改成断言会话 id。
- **验证**：十一个已声明版本各跑十六项 harness 检查（一律 14/16，两项失败是既有基线：`typecheck`，以及需要**运行中前端**的 `host-api`——对着真实实例单独跑是 12/12）；十一个版本各跑一遍 runbook 的六项前端验收（发行版下拉、创建并打开、写、读、一次性/持久 bash、skills、前端重开），`web.err` 全为 0 字节，文件在 Linux 侧独立复核。
- **另外在真的 DSH Desktop 里跑了完整一遍**（把官方安装包解包后直接启动那个 Electron 应用，用 CDP 驱动窗口，并走桌面版自己的插件面板安装/启用插件，`DSH_HOME` 指向隔离目录）：装 **0.7.4** 时 `bash` 每次都报 `PTY shell exited during startup`、`bash_background` 报 `session "[object Object]" has no live agent`，而对话框、发行版列表、技能都正常；换成 **0.7.5** 后 `bash` 返回 `6.18.33.2-microsoft-standard-WSL2` / `/home/mille/fx-3381` / `mille`，第二次独立 `pwd` 仍是 `/tmp`（持久性成立），`bash_background` 返回 `bash-1`、`job_list` 报 `bash-1 [bash] running`、`bg.txt` 里确实是 `BG_3381_OK`，刷新窗口后工作区仍能重开；启动日志里是 `persistent shell: relay interpreter is …\runtime\primary-runtime\dependencies\node\bin\node.exe — node 24.21.0 from the runtime payload named in argv`。完整过程记在 `docs/compatibility-evidence.md`。

## 0.7.4 — 2026-09-28

- **在 DSH Desktop 上对话框又能用了（issue #35、#36）**。Desktop 宿主会在插件加载**之前**包装 `child_process`：它装上普通的 `exec`/`execFile` 包装函数，再用 `syncBuiltinESMExports()` 把它们回写到内建模块。这份 `execFile` 上没有 `util.promisify.custom`，于是 `promisify(execFile)` 退回通用实现——它只用回调的**第一个**参数 resolve，也就是 stdout 字符串——结果每次调用拿到的 `result.stdout` 都是 `undefined`。发行版查询去读 `.stdout`，抛出 `Cannot read properties of undefined (reading 'includes')`，前端把它吞掉、渲染出一个空的发行版列表：对话框能打开、下拉框里却一个发行版都没有，「创建并打开」走不完；而同一台机器上 `wsl.exe -l -q` 在终端里列得好好的。三处调用现在都改用 `execFile` 的**回调**形式，任何包装都改不了这个签名。
- **`wsl.exe` 的查找改成候选列表**。先试 `PATH` 上的 `wsl.exe`，再试绝对路径 `%SystemRoot%\System32\wsl.exe`；查找失败时报出它试过的每一个候选以及各自的原因，而不是从解码器里冒出一个类型错误。
- 新增 `tests/exec-shape.mjs`：它复刻 Desktop 的包装方式（普通包装 + `syncBuiltinESMExports()`），分别带与不带 `--import` 起一个探针进程，断言被包装的形态确实是坏的、新助手是对的，并把真实的 `listDistros`/`defaultDistro`/`resolveLinuxSymlink` 在两种形态下各跑一遍。它注册为 `scripts/compatibility/Run-Checks.ps1` 里的 `exec-shape` 检查，避免以后有人又把它改回 promisify 写法。
- `dsh.compatibility.dshReleases` 也声明了 `0.1.7-rc.2`，这份构建声明的版本从九个变成十个。
- **验证**：十个已声明版本各跑十五项 harness 检查（一律 13/15，两项失败是既有基线：`typecheck`，以及需要**运行中前端**的 `host-api`——对着真实实例单独跑是 12/12）；十个版本各跑一遍 runbook 的六项前端验收（发行版下拉、创建并打开、写、读、一次性 bash、持久 bash、skills），`web.err` 全为 0 字节，文件在 Linux 侧独立复核。Desktop 包装的复现过程、pack 一致性哈希与纯 npm 安装门禁都记在 `docs/compatibility-evidence.md`。

## 0.7.3 — 2026-09-23

- **插件在 DSH `0.1.7-rc.1` 上重新可加载，模式也回来了**。`0.1.7` 这条线改了宿主预设接口——`read()` 变为 `readDocument()`，返回的是文档（`{agentPreset, content, name, description}`）而不是组合文本，且 `AgentPreset` 不再有 `path`——于是变体生成器每次启动都抛 `agentPresets.read is not a function`，选择器里一个 `wsl-*` 模式都不会出现。现在改按**能力探测** roster 接口，两代都服务：有 `readDocument()` 就用它，否则回退 `read()`。
- **在这条线上，变体是一条声明行**。`0.1.7` 不再扫描 `$DSH_HOME/.agent-presets/`——那里的预设是一条声明式的 `@deepseek-ai/dsh-agent-preset` 行，而插件原先写变体的那个目录已经**没有任何代码会读**。生成器现在把组合好的变体展开回 entry list，通过 `ctx.agentPresets.register()` 发布；注销器由插件的 effect 持有，因此卸载或热重载会注销这些变体，而不是留下下一次 apply 无法替换的孤儿（`Duplicate agent preset: wsl-<mode>`）。更早的版本仍走原来的目录通道，行为不变。
- **该通道下，世界自己的提供者要以 `file:` URL 命名**。由声明挂载的预设由注册表自己的 entry tree 导入，而它——与启动期的 Include 不同——不会把绝对路径翻译成 `file:` URL。没有这层改写时那些提供者行根本不会启动，审计会逐条报 `never started`，整个变体被判为不可用而拒绝。
- **`0.1.7` 上 PTC 变体重新有名字了**。给已发布模式拼双语 `WSL · …` 名字的那张表里，PTC 这个模式只登记了旧 id（`code`），没有 `0.1.1` 起在用的 `ptc`。只要版本自己发布了显示名就看不出来——查不到会落到那个名字上——但 `0.1.7` 根本不发布显示名，于是模式以 `WSL · ptc` 和通用描述 `WSL execution world for ptc: …` 进了选择器。现在两个 id 都登记了，变体在各条通道上都显示为 `WSL · PTC mode（PTC 模式）`，与其余三个已发布模式一致。
- `dsh.compatibility.dshReleases` 增加 `0.1.7-rc.1`。声明通道需要的两个模块（`@deepseek-ai/cordis-plugin-include`、`js-yaml`）在调用时动态解析，并声明为**可选**同伴依赖：万一某个版本没有它们，只让一个变体失败，而不是让整个插件无法加载。

## 0.7.2 — 2026-09-21

- **WSL 技能目录不再在请求路径上重走（issue #25）**。宿主会在请求期间重建技能目录并等待每个 provider 的 `list()`，而本插件只把答案保留 10 秒——于是每次重新收集（新会话、新作用域，或距上次查询超过 10 秒）都会让那个请求付一次整棵工作区的走查，而且是逐个目录：每目录两次 `stat` 加一次 `readdir`。本机 `\\wsl.localhost\…` 9P 共享的单次 `readdir` 实测 3~16 毫秒，走查预算固定 4096 个目录，所以大工作区那次走查是 20.4 秒、并且付在请求路径上。现在已发布的目录直接照原样返回，只有插件自己的变更探针能丢弃它：重复查找 1~3 毫秒、完全不碰文件系统。新鲜度契约不变——新增的嵌套技能目录仍在 30 秒内出现，新增、删除或改写技能仍在 3 秒内生效。
- 走查本身也变便宜了：BFS 同一层并发探测（有上限）、按 frontier 顺序发布以保证目录顺序确定，且只在目录自身的清单里出现 `.dsh` / `.agents` 时才探测对应 `skills`。同机实测命中预算的走查从 20.4 秒降到 4.8 秒；真正的并行度由 node 的文件系统线程池决定。

## 0.7.1 — 2026-09-20

- **`npm install dsh-wsl-workspace` 不再失败**。校验已发布产物时发现了这次发布自己引入的回归：npm 会自动安装缺失的同伴依赖，而 0.6.0 新加的 `@deepseek-ai/dsh-tool-fs-search` 同伴自己又依赖 `@deepseek-ai/dsh-retention`，后者**并未公开发布**——于是纯 `npm install` 直接以 `E404 … @deepseek-ai/dsh-retention` 失败（0.4.3 装得没问题，所以是我们引入的）。`dsh plugin add` 走 pnpm，对未满足的同伴依赖**只警告**，这正是所有门禁与真实安装都没抓到的原因。十个宿主同伴依赖现在都在 `peerDependenciesMeta` 里标为 optional：包仍然声明宿主必须提供什么，但 npm 不再尝试去下载它们。

## 0.7.0 — 2026-09-20

WSL 世界现在在"会话能察觉到的每一处"都与宿主一致，最后两条已知问题也关掉了。以下内容一起发布：WSL 变体拿到 Linux 符号链接、会话的访问模式、发行版内的搜索、实时的技能目录、有状态的 shell，以及可跟踪的后台任务。

- **把宿主 `bash` 的契约写进工具描述**：持久工具把每条命令包成 `eval -- $'…'`，所以命令以 `&` 结尾会把**整条**包装命令后台化——调用立刻返回 exit code 0、没有输出，真正的输出晚到，甚至落进下一次调用的输出里；而整轮会话只有这一只 shell，`cd` 会带到下一次调用。宿主默认描述两条都没提，DSH 自己的极简模式还建议危险写法（`sleep 10 &`）。世界现在覆盖 `description`（八个已声明版本都支持这个键），写明这两点与安全写法。
- **`0.1.0-rc.7` 回退到能用的无状态 shell**：那个版本的 `dsh-subprocess-local` 没有 Windows 进程检查器，宿主的 PTY 持久 shell 在 Windows 上根本起不来——每次 `bash` 都报 `subprocess-local: terminal inspection is unsupported on platform win32`（宿主自己也有这个缺口：它那个版本的极简模式挂 `persistent-bash` 时没有 Windows 守卫）。插件改为在启动时**探测**底座——把一个不存在的程序交给 `spawnTerminal`，只会走到检查器那一步——答案是否就保留一次性的 `dsh-tool-bash` 行：一个能用的无状态 shell，而不是每次调用都报错。
- **可跟踪的后台任务回来了**：用持久 shell 取代一次性 bash 工具时，也把唯一会启动注册表任务的东西去掉了，于是 `job_list` 永远回答"没有后台任务"，而传给 `bash` 的 `run_in_background: true` 被静默忽略（参数 schema 允许额外属性，没人报错）。世界现在挂 `bash_background`（`src/host/wsl-jobs.ts`），一个架在宿主 `ctx.jobs.start` 与本插件 `ctx.shell.start` 之上的薄生产者：返回 job id，`job_list`/`job_output`/`job_kill` 照常工作。只在源模式本身挂了 `job_*` 工具、且持久 shell 存在时才挂载。
- **按最坏输入复查新代码抓到六个缺陷**：显式点名的隐藏文件被守卫排掉（`grep path=.env` 返回 0 条）、`find` 退出码被丢弃（`glob path=/nope-missing` 看起来像空目录）、glob 头部按行结尾（名字含换行的根被截断）、Windows 路径没翻译（`grep path='D:\proj'` 失败而 `read` 能读）、落盘 schema 闭合（会让每次"超限且有落盘后端"的调用在返回时校验失败）、技能变更探测会叠加慢 pass。外加新生产者里的两个：它曾被挂在没有 `job_*` 工具的模式里、并且把任务的工作目录默认成了宿主进程而不是会话工作区。
- **验证**：八个已声明版本（`0.1.0-rc.7` … `0.1.5-rc.2`）各跑十三项门禁，`search-real` 用真实发行版夹具驱动真实工具，只剩既有的两项基线失败（`typecheck` 与需要在线服务的 `host-api`）。152 例单测，含"每个渲染器与宿主套件自己的格式化函数逐字节对比"的平价检查。五个版本的浏览器真实会话覆盖工具行为，另外**八个版本全部做了前端验证**（入口按钮、对话框、路径检查、创建并打开、模式选择器、含 v0.7.0 与 8 个版本标签的帮助面板），并以会话日志为证据覆盖工具集、搜索结果、目录替换、shell 回退与后台任务生命周期。

## 0.6.0 — 2026-09-19

- **WSL 会话补上了 `grep` 与 `glob`**：宿主的搜索套件跑的是打包的 Windows ripgrep，而模型给的路径全是 Linux 路径，于是生成的世界干脆丢掉了 `tool-fs-search`，让模型自己在 shell 里搜——这正是面板已知问题里的最后一条。现在世界挂上发行版内的同类实现，并保留宿主套件的契约：同样的工具名、参数 schema、条数上限（250 条命中 / 100 个路径）、输出 schema、`Line N:` 分组、命中数表头、超限尾部提示、搜索卡片与超限结果落盘；渲染直接调用 `@deepseek-ai/dsh-tool-fs-search` 自己导出的格式化函数，只有该包未导出的两处投影（卡片元数据与 glob 分页）在本插件里复刻，并有单测与它逐字节对比。`grep` 在发行版内跑 GNU grep（`-rnIEH -Z`、POSIX ERE、像 ripgrep 默认那样跳过隐藏项与 `node_modules`、不读 `.gitignore`），`glob` 用 GNU `find` 列文件并在插件内按 gitignore 风格匹配、按 ripgrep 的"最旧优先"修改时间排序。模型给的每个值都作为独立 argv 传给固定脚本，任何输入都不会被 shell 解析。
- **技能目录现在能感知"改写"，而不只是"新增"**：模型的目录消息只在注册表修订号变化时重建，而旧的探测只比对目录列表——所以改写一个已有的 `SKILL.md`（比如描述）它看不见，模型会一直用旧文案直到下个会话。轻量档现在额外给每个技能文件记修改时间与大小，并且从 10 秒改成 3 秒一次；完整重新发现——唯一能发现"此前不存在的技能目录"的一遍——挪到自己的 30 秒节奏。于是检测既更快，也比被它取代的"10 秒一遍"更省。
- **`lib/` 现在确定性重建**：`tsdown` 的产物是提交进仓库的，而 `clean: false` 加两个配置共用一个输出目录，导致早先构建留下的分包块每次都活下来。本地构建工具现在先清空目录，并补声明了三个运行时同伴依赖（`@deepseek-ai/dsh-tool-fs-search`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/schemastery`）——这同时是它们保持 external、不把 DSH 工具栈再打包一份进本插件的原因。
- **验证**：八个已声明版本（`0.1.0-rc.7` … `0.1.5-rc.2`）各跑十三项门禁，新增 `search-real`——用真实发行版上的固定夹具驱动真实工具（记录框定、include 与 `{}` 展开、上限与尾部提示、落盘、卡片、错误码、argv 安全性、显式点名的点文件、读不了的根、名字含换行的根、`/mnt` 路径、超时中断与输出溢出、glob 排序与剪枝）——只剩既有的两项基线失败（`typecheck` 与需要在线服务的 `host-api`）。单测新增 `tests/wsl-search.test.ts`（33 例）与两例刷新用例；`skills-real` 现在也证明"改写技能文件"能通过共享自己的修改时间让目录失效。五个版本的浏览器真实会话端到端确认。
- **按"最坏输入"复查新代码抓到的四个缺陷**：隐藏文件守卫连"显式点名的文件"一起排掉（`grep path=.env` 返回 0 条）、`find` 的退出码被丢弃（`glob path=/nope-missing` 看起来像空目录）、glob 头部按行结尾（根目录名里带换行时被截断）、Windows 路径没做翻译（`grep path='D:\proj'` 失败，而 `read` 能读）。另外把落盘 schema 收紧成了闭合对象——那会让每次"超限且有落盘后端"的调用在返回时校验失败；技能变更探测也加了在飞行中守卫，一次慢 pass 不会再叠出多层 `wsl.exe` 调用。
- **宿主 `bash` 包装命令的行为写进了工具描述**：持久工具把每条命令包成 `eval -- $'…'`，所以命令末尾的 `&` 会把整条包装命令后台化——调用立刻返回、exit code 0、没有输出；而这只 shell 是整轮会话一个进程，`cd` 会带到下一次调用。宿主默认描述两条都没提，DSH 自己的极简模式还建议 `sleep 10 &`；世界现在覆盖描述，写明这两点与安全写法。
- **`0.1.0-rc.7` 不再拿到一个坏掉的 shell**：那个版本的 `dsh-subprocess-local` 没有 Windows 进程检查器，宿主的 PTY 持久 shell 在 Windows 上根本起不来（宿主自己也有这个缺口：它那个版本的极简模式挂 `persistent-bash` 时没有 Windows 守卫）。世界改为在启动时探测底座，答案是否就保留一次性的 `bash` 行——一个能用的无状态 shell——而不是每次都报错。已在真实会话里验证。
- **WSL 会话重新有了可跟踪的后台任务**：用持久 shell 取代一次性 bash 工具时，也把唯一会启动注册表任务的东西一起去掉了，于是 `job_list` 永远回答"没有后台任务"，而传给 `bash` 的 `run_in_background: true` 被静默忽略（参数 schema 允许额外属性，没人报错）——正是运营方会话里暴露出来的缺陷。世界现在挂 `bash_background`（`src/host/wsl-jobs.ts`），一个架在宿主 `ctx.jobs.start` 与本插件 `ctx.shell.start` 之上的薄生产者：工具返回 job id，`job_list`/`job_output`/`job_kill` 照常工作。真实会话已验证：`started background job bash-1` → `job_list` 显示 `running` → `job_output` 增量读到 `tick 1`、`tick 2`，再读 `tick 3` → `[status: completed, exit code: 0]`，并附带运行时推送的完成通告。

## 0.5.0 — 2026-09-19

- **文件工具现在跟随 Linux 符号链接**：`\\wsl.localhost` 共享只列得出链接条目、描述不了它——对链接的 `lstat`、`stat`、`readFile` 全部失败，而 `resolve()` 会回一个词法身份——于是链接路径被当成不存在的文件，链接进来的项目**根本读写不了**。现在只要这份共享描述不了该路径，`resolve`/`lstat` 就向发行版问一次（`wsl.exe … readlink -f`，与技能扫描同一个实现）并从真实路径继续。链接不会被普通文件替换；向悬空链接写入会创建它的目标并保留链接。
- **访问模式重新约束 WSL 会话**：变体在预设的 isolate realm 里挂自己的 `fs` 提供者，宿主的 `fs-sandbox` 包装层不在调用路径上，所以 `workspace-write` 拦不住工作区外写入（修复前实测：Linux 路径与 `D:\...` 路径都能写）。现在 `writeText`/`editText` 完全按 `@deepseek-ai/dsh-fs-sandbox` 的方式围栏：`ctx.sandboxPolicy`（工具层按次传入的优先，否则服务自解析）、同一份 `writableRoots` 白名单加发行版 `/tmp`、同样的 `FS_SANDBOX_DENIED`，并提供工具读取的 `sandboxMode` 以便声明升级。围栏在链接解析之后执行，判定的是真实路径——链接指向工作区外就按工作区外拒绝。
- **技能目录实时刷新**：此前对 UNC 固定 `watch: false`，会话中途加入的技能只能等下个会话。现在 provider 为每个服务过的扫描根维护一个变更探测，每 10 秒重查已发布的目录形状（技能根 + 条目名与类型，**不重读技能文件**），有变化就调 `control.invalidate()`，目录中间件会在会话下一个回合重新收集。
- **`bash` 换成有状态的 WSL shell**——逐模式矩阵反复暴露的那个缺口（每条 `bash` 都是新进程）。DSH 的 PTY 注册表支持替换后端，而 `@deepseek-ai/dsh-terminal-bash` 是配置驱动的，于是世界把它挂进自己的 `persistent-shell` 组（注册表是 agent 级服务），用 `backendType: wsl` 指向本插件的中继脚本（`src/host/wsl-relay.ts` → `lib/wsl-relay.js`），由宿主自己的 node 运行。中继解析发行版（会话 UNC cwd → `DSH_WSL_DISTRO` → 宿主默认）与可选用户名（`DSH_WSL_USER`），然后把自己的 stdio——也就是那个 PTY——交给 `wsl.exe -d … --cd … -e bash -lc 'cd … && exec bash -i'`：登录环境、交互式、且保留会话目录。`@deepseek-ai/dsh-tool-bash-persistent` 注册的工具名就是 **`bash`**，所以它取代了一次性的 `dsh-tool-bash` 行（同时挂载会让整个预设挂载失败——DSH 自己的极简模式正是用"只给持久 shell"来避开这个冲突）。世界还隔离并提供自己的 no-op `sandbox` 能力：PTY 后端在启动前会调 `ctx.sandbox`，而宿主的 Windows 运行器读不了 `\\wsl.localhost\…` 工作区根的安全描述符（`GetNamedSecurityInfoW failed (Win32 1)`），所以 WSL 会话声明 `enforcement: 'partial'`，把策略留在真正有意义的地方——文件工具里。
- **验证**：八个已声明版本（`0.1.0-rc.7` … `0.1.5-rc.2`）跑十二项门禁——新增 `fs-real`（真实后端上的链接解析、经链接与链接链读取、悬空链接创建、链接保留、出工作区链接的围栏、发行版 `/tmp` 允许）与 `relay-real`（真实 WSL 上的有状态 shell、发行版与用户名解析、干净退出）——仅剩既有的两项基线失败（`typecheck` 与需要在线服务的 `host-api`）。单测：`tests/fs-policy.test.ts`（7 例围栏）+ 技能 provider 的刷新用例，叠加在原有套件之上。

## 0.4.5 — 2026-09-19

- **链进 WSL 工作区的项目现在能被发现了**：`\\wsl.localhost` 9P 共享会把 Linux 符号链接当作条目列出来，却解析不了它的目标，于是技能扫描（本来就会在能解析链接的底层上跟随目录链接）直接跳过所有链接进来的项目，连带跳过它下面的嵌套项目（即 [#10](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/10) 描述的那种布局）。现在只要共享报出一个它跟随不了的链接，插件就回头问发行版本身（`wsl.exe -d <发行版> -- readlink -f <Linux 路径>`），拿到真实路径后从那里继续走。这条回退是有意加了上限的：每次查找最多 32 条链接、最多 4 个调用并发、单次超时 10 秒，原有的深度 / 已访问目录 / 技能目录预算不变。由于继续扫描的位置是解析后的真实路径，同一项目既被直接访问又被链接访问时只会走一次，指回工作区根目录的链接环也会被已访问集合吸收，不会打转。
- **这条回退不覆盖什么**：`read/write/edit` 仍然走 `WslFileSystem` 的路径解析，它不跟随 Linux 链接，因此直接读写链接路径会报路径不存在——请用真实路径。帮助面板的「已知问题」现在如实写这一点，而不再是"可以用 readlink 补，但尚未实现"。
- **为什么一条链接一个 `wsl.exe`**（实测记录）：`wsl.exe` 会**丢掉**命令之后的参数（`sh -c 'echo $#' sh a b c` 返回 0），而且它的命令行解析会把含双引号的参数截断，所以批量的 `sh` 循环没法可靠地透过它工作。直接 `readlink -f a b c` 也不行：GNU `readlink` 遇到第一个解析不了的路径就停下（仍以非零退出），批次里后面的链接会被无声地饿死。把每个路径作为进程参数交给一次短调用，就完全绕开了引号问题——带空格、引号、反斜杠的路径都能解析——代价是每条链接一个进程（热态约 35 ms；本机上 6 条链接端到端 179 ms；没有链接的工作区则完全不会启动发行版进程）。
- **验证**：八个已声明版本（`0.1.0-rc.7` … `0.1.5-rc.2`）跑通与 0.4.4 相同的 8/10 项检查（仅剩既有的 `typecheck` 基线与一项需要在线服务的检查）。真实 9P 检查现在会构造"只能靠符号链接进入"的 fixture，断言链接进来的项目、它下面的嵌套项目以及 `get()` 取正文；同一次运行里把回退能力摘掉再走一遍，两者都找不到，即修复前的行为在原位复现。在真实 WSL fixture 上（`/home/mille/symprobe/ws`：链到工作区外的目录、链接链、指向文件的链接、悬空链接、指回根目录的环）技能目录从 2 个变成 5 个，`get()` 也都能通过解析后的定位读回正文。

## 0.4.4 — 2026-09-19

- **在 WSL 变体基础上改出来的自定义模式完全用不了**：本插件靠 id 前缀（`wsl-`）识别自己的产物，于是「把生成的 `wsl-standard` / `wsl-cordis` 复制改名再改」得到的用户预设会被当成普通源预设，被**再追加一个世界组**。DSH 拒绝含两个 `wsl-world` 行的组合，选中该模式时直接失败：`无法切换到「WSL · <名称>」：duplicate loader entry id: wsl-world`；在"先挂载组、后校验行 id"的版本上，同一处重复会晚一步表现为 `tool "str replace editor" is already registered in this scope`（即 [#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24) 报告的现象）。现在生成器会**替换**它找到的世界组（按挂载的 `shell-wsl` / `fs-wsl` provider id 识别，改过组名也能认出），每个变体最终只挂一个世界，且指向本机安装的 provider。源里重复出现的顶层行 id 也只保留第一处——DSH 遇到重复 id 是**整个预设**不可用，而不是只丢那一行。
- **`tool-str-replace-editor` 行与旧的 `str-replace-editor` 行一样被替换**（[#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24)）：较新的名单用这个 id，而它注册的工具名与注入世界组里那个编辑器行相同，所以源里的那行会被丢弃，变体注入的、走 WSL 文件系统的编辑器保留。
- **变体显示名不再多出一层引号**：变体的 `preset.yml` 原先逐字复制源里的 `name:` 标量，于是 `name: 'Data mode'` 到了模式选择器里变成 `WSL · ''Data mode''`；现在会先去掉一层 YAML 引号再写出。
- **未采纳 [#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24) 的做法**：禁用 `tool-cordis` 行来规避 inspect provider 重复注册。`disabled` 的行根本不会 apply，结果是 WSL 创造模式**直接丢掉** `cordis_inspect_list` / `cordis_inspect_query`（已与 0.4.3 对照：0.4.3 里两个工具都在，且能返回 host 与 client 两侧的 provider）；PR 描述里"模型仍能在工具目录看到、只是不能用"与实际不符。其报告中的重复注册需要该行被 apply 两次，而这在"由复制预设引起"的情形下已由上面的行 id 去重解决。
- **帮助面板整理**：面板最前面是一句问候语与仓库链接，新增「本次更新」一节，已知问题只保留仍然成立的条目——历史上的「0.4.3 已修复」说明与按版本讲旧 API 的段落已删除。兼容性 chips 保持原样：它们是本构建声明的清单，不是历史。
- **逐模式矩阵（真模型）**：在 `0.1.0-rc.7`、`0.1.1-rc.2`、`0.1.3-alpha.2`、`0.1.5-rc.2` 上，四个 WSL 变体（标准 / PTC / 极简 / 创造）各自跑一遍：用文件工具写文件、用 bash 执行 `uname -r; pwd; whoami` 并把输出重定向落盘、再读回文件。每个模式都在 `/home/mille/<工作区>/notes/` 里留下了 `MODE-<模式>-OK` 与 WSL2 内核输出，零 loader 报错；随后单独一次 bash 调用又回到工作区目录，即文档所写的「按次 shell」（PTY 组仍然不注入）。`0.1.2-rc.1` 与 `0.1.5-rc.1` 只做了四模式切换与真实回合，没有文件/bash 断言。
- **验证**：八个已声明版本（`0.1.0-rc.7` … `0.1.5-rc.2`）跑通与 0.4.3 相同的 8/10 项检查（仅剩既有的 `typecheck` 基线与一项需要在线服务的检查）；17 个已安装运行时里全部 shipped 预设共 136 次变换，除本次修复外结果不变；68 个"复制变体"场景全部收敛为单一新世界组。另做浏览器 + 真模型验证：复制变体模式本身、创造模式（检查工具完整）以及 `0.1.0-rc.7` 的标准流程。

## 0.4.3 — 2026-09-11

- **persona 文本位置变更**（[#22](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/22)）：DSH 把 persona 面向模型的字段从 `text` 改为内联 `suffix` + 折叠 `prefix`，而变体生成器只识别 `text: >-`，于是 WSL 环境说明从未追加（会话仍在发行版内运行，但模型不知道自己的 cwd 是 Linux 路径）。现在按 `suffix` → `text` → `prefix` 依次补写（内联标量会先折成块标量，句子落在原 `text` 块的位置），带 `complete: true` 的 persona 依旧不动。
- **帮助面板**：对话框新增「?」按钮，就地展示本构建声明的 DSH 版本（直接从 `package.json` 经宿主路由读取，不会与清单脱节）、插件的用法与特性，以及无法修复的已知限制。
- **UNC 工作区终于能收到技能目录**：宿主技能提供者用 `fs.watch` 监视工作区，对 `\\wsl.localhost\...` 会抛 `EISDIR`，该次观测被判为不完整，而 `dsh-tool-skill` 在快照不完整时会丢弃**整条**目录消息，于是 WSL 会话的模型一个技能都看不到。现在生成预设时把 `skill-filesystem` 行的 `watch` 固定为 `false`（若该行已有 `config:` 就并入，源里自己声明了 `watch` 则不动），目录改为在会话启动时扫描一次。代价是不再实时刷新：会话运行中途加入的技能要等下一个会话（技能正文仍实时读取）。
- **`verify-lib` 加固**：其注释/字符串剥离器会把注释里的孤立单引号与后面的引号配对、吞掉剩余 bundle，使所有 `node:*` 导入看起来都被 tree-shake 掉；现在引号规则遇换行即终止，与 JavaScript 字符串一致。

## 0.4.2 — 2026-09-10

- **在 `0.1.2-rc.1` 工作区里「创建并打开」**：会话启动器改为在对话框写入时解析——本插件 apply 早于发布 `uiWorkspace` 的 UI 域注册服务，apply 时缓存的值整页都是 `undefined`，于是「创建并打开」只建了工作区、没开会话，而对话框仍报成功。两种 API 都不存在的版本现在会在写入前直接失败，不再留下孤立工作区。
- **技能正文完整性**：`findFrontmatterEnd` 返回的就是正文首字符下标，此前的偏移会把首字符吃掉；同时**带 UTF-8 BOM 的 `SKILL.md` 不再被丢弃**（BOM 会在围栏检查前剥离）。
- **绑定收敛于迟到输入**：agent preset 名单与已注册的 `/mnt/<drive>` 工作区集都是绑定的输入且都异步到达，现在各自到达后重跑一遍，而不是等一个可能永远不来的会话存储事件。
- **兼容性清单修正**：`0.1.3-alpha.1` 并未发布（`npm view` 为 404），替换为已发布的 `0.1.3-alpha.2`。
- **可复现发布**：新增 `.gitattributes`（`* text=auto eol=lf`、`lib/** -text`）。`core.autocrlf=true` 会在检出时把文本文件改写成 CRLF，而 `lib/` 是提交并原样发布的，导致同一提交在不同机器上产出不同的 npm 包。
- **闭环测试**：`tests/client-lifecycle.test.mjs` 用发布出去的 `lib/client.js` 跑通新旧两种服务形态（`connection.api.agentPresets` + `workspaces.startSession` 与 `remote.agentPresets` + `uiWorkspace`），并断言「创建并打开」的正常、迟到注册与无启动器三种情况。

## 0.4.1 — 2026-09-03

- **DSH `0.1.2-rc.1` 兼容**：运行时按特性检测自动选用新旧 API——`uiWorkspace.startSession()`（`0.1.2-rc.1+`）/ `workspaces.startSession()`（`0.1.1-rc.2` 及更早）、`summary.projectionValues?.agentPreset`（`0.1.2-rc.1+`）/ `summary.agentPreset`（`0.1.1-rc.2` 及更早）；兼容性清单加入 `0.1.2-rc.1`。
- **修复 `0.1.2-rc.1+` 上的 `without inject` 崩溃**：agent preset 名单改经 `ctx.get('remote.agentPresets')` 读取（拓扑无关的服务查找），不再走 `remote` 聚合上的 `agentPresets` 属性——Cordis 的 associate 代理会拒绝未在 `inject` 声明的点号属性。`inject` 仍只保留两代 DSH 共有的服务（`slots`、`locale`、`sessions`、`workspaces`）。

## 0.4.0 — 2026-08-29

- **查询缓存**：已完成的技能目录查询按扫描根缓存 10 秒，避免在慢速 9P 共享上反复重扫；`get()` 仍实时读正文，新技能在 TTL 窗口内出现。
- **符号链接项目**：0.4.0 时显式识别目录符号链接并安全剪枝（不崩、不循环），并实测出"光靠共享本身跟不了"——Windows 侧无法解析 Linux 符号链接（`readlink` → `EISDIR`，`stat`/`readdir` → `ENOENT`）；0.4.5 改为交给发行版解析，链进来的项目因此可被发现（见该版本说明）。另加"名称 + 正文"指纹去重，保证能解析链接的平台上别名技能不会被发布两次。
- **块标量 frontmatter**：`description:` / `whenToUse:` 写成 YAML 块标量（`|`、`>`）现在能解析，此前这类技能会被静默丢弃。
- **兼容性清单**：`dsh.compatibility.dshReleases` 逐版本声明兼容性，并附可复现的一次性 Profile 安装/启动/卸载证据；`engines` 声明 Node.js 下限。
- **门禁脚本**：`scripts/check-rank-parity.mjs` 在项目等级常量与宿主 `dsh-skill-filesystem` 漂移时让发布失败。

## 0.3.2 — 2026-08-29

- **WSL 会话注入嵌套项目的技能目录**（[#10](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/10)）：注册工作区之下的嵌套项目里的 `.dsh/skills` / `.agents/skills` 会带宿主的项目等级与来源一并发布，模型看到的目录与会话 cwd 就在项目里时一致；发现过程有深度与预算上限，会剪掉 `node_modules` 与点目录，且不改动非 WSL 会话。
- **扫描根对齐宿主**：从项目子目录发起查询先就近解析 `.git` 祖先，深层 cwd 也能看到所属项目的技能，且不会泄漏该祖先之上的技能。
- **加固**：技能根预算按次强制，`skills.registerProvider` 调用加了保护，宿主 `skills` 服务形状不同时不再拖垮插件加载。
- **清理**：移除历史预构建 `lib/` chunk 中残留的死 vendor 代码（含内联的 schemastery 副本），并补充嵌套技能目录的回归测试与 TESTING.md 章节。
