# Changelog

All notable changes to this package. Newest first.

The canonical, in-progress record of what was changed and why lives in [doc/progress.md](../../doc/progress.md);
this file is the package-facing release history.

## 0.22.3（2026-10-24：修「更新按钮从未成功过」—— 缓存缺席时真的去拉 Releases 清单）

### 修复：`POST update` 在缓存未命中时必败，且报错误导（真缺陷，用户原话「从头到尾我自己点击这个按钮都没有成功更新过一次」）

- **根因**：控制面 `POST update` 把缓存清单传给 `installFromReleases`（缓存为空传
  `null`），路由注释承诺「缓存没有清单就传 null，installFromReleases 会自己拉」——
  但实现把 `null` 当**空数组**直接定位资产：`assetForVersion([])` 恒为 null ⇒ 报
  「Releases 上找不到 X 的 tarball」。而路由在每次点击后**立刻清空缓存** ⇒ 第一次
  失败之后，**之后每一次点击都必然走这条必败路径**——报错把「我们没去查」伪装成了
  「Releases 上没有」。
- **修法**：新增 `ensureReleasesForInstall()`，把「清单从哪来」收进一处：传入清单为空
  ⇒ 现拉一次；传入清单非空但点名版本不在其中（过期缓存，例如开屏检查早于新版本发布）
  ⇒ 再现拉一次；现拉仍无才如实报「找不到」。`fetchReleases` 可注入，护栏全部离线。
- **护栏**：`test/update.test.mjs` 新增 6 项（缺席现拉 / 拉取失败如实报错**不伪装成
  版本不存在** / 过期缓存补拉 / 缓存命中零网络 / 真缺版本由调用方如实报 / 源码级接线
  防回退），文件 25/25。
- **本机真机链路验证**：修复后以 `releases:null, version:'0.22.2'` 实跑（用户失败的那
  条路径）⇒ 自拉清单 → 定位资产 → 下载 958,806 B → `dsh plugin --profile web add`
  安装成功，profile 内版本核为 0.22.2。

## 0.22.2（2026-10-24：设置页中英双语（GitHub Issue #2）+「本地工具调用」总闸收口升号）

### 新增：独立设置页中英双语（GitHub Issue #2「No english translation」）

`/__webcode/settings-page` 全量双语：标签、提示、按钮、下拉项、脚本状态与报错文案。结构是
**zh 正本 + en 覆盖层**——静态文本中英成对 `<span>` 就地相邻写在同一模板行（翻译与原文
永不分离，没有第二份字典可漂移）；`<option>` / placeholder / title 三个解析规则不允许
成对 span 的位置走 `data-en` 属性覆盖；脚本动态文案收进唯一 `MSG` 字典（护栏钉住 zh/en
条目数相等）。语言按 `localStorage('webcode-settings-lang')` → `navigator.language` 判定，
标题行「English/中文」按钮切换（重载式，无半个翻译态）；无 JS 默认中文，与旧版行为一致。
**边界**：服务端现算读数与教学文本（协议真源）不翻译；原生面板 client.cjs 未双语化。

### 收编：「本地工具调用」总闸（0.22.1 续，2026-10-24 当轮未升号，本版一并发布）

- **默认纯网页会话**（`toolsEnabled: false`）：关闸时系统提示 / 全局指令 / 站点指令
  **一并不注入**、自发 `mcp_action` 围栏**零重放**；开闸才恢复工具教学，且开启路径必过
  确认弹窗（如实写明「注入对站点可见、自动化面变宽」）。
- 两处入口：原生面板 dock 药丸「本地工具 · 开/关」+ 设置首卡开关；全局一枚，无站点级覆盖。
- 品牌图标重绘为自绘悬索桥（24 网格全直线 + 一条二次贝塞尔主缆；与 client.cjs 共用路径数据）。
- 护栏：`tools-switch.test.mjs` 10 条 + 变异体 M1/M2/M3+M3b 反向验证全红；
  `settings-page-i18n.test.mjs` 8 条；品牌图标判据改钉新路径。

## 0.22.1（2026-10-21：webcode 模式与官方 `standard` 的漂移闸门 —— 一条漏项 + 一条假红）

### 修复 ①：`tool-plugin-market` 未被交代 —— 与官方 `standard` 静默漂移

官方 `@deepseek-ai/dsh-web-app`（DSH **0.2.1-rc.2**）的 `presets/standard.patch.yml`
第 126 行新增了一行：

```yaml
- id: tool-plugin-market
  name: '@deepseek-ai/dsh-plugin-market/tools'
  disabled: true
```

本插件的「wecode 模式」以「与 shipped standard 做减法」为存在理由，而它的
**漂移闸门**（`test/webcode-preset.test.mjs` ②c）要求 standard 的**每一行**都被交代：
要么在本模式里，要么在三份差集清单之一。`tool-plugin-market` 两边都没有 ⇒ 闸门变红。

**判读与处置**：`tool-plugin-market` 与清单里已有的 `tool-plugin-manager` **同族**——
在 standard 里就 `disabled: true` ⇒ **不进提示词、不注册工具** ⇒ 本模式省略它
**不改变能力面**。因此按 `DISABLED_IN_STANDARD_ROWS` 的口径补进清单（同 `tool-plugin-manager`），
而不是把它加进本模式。

> ⚠ **这条闸门在 CI 上是 skip 的**：CI runner 没装 DSH，装机目录不存在 ⇒
> `officialStandardBlocks()` 返回 `null` ⇒ ②c/②e 整体跳过。
> ⇒ **CI 绿不等于没漂移**，只有开发者本机跑全量才会看到。这与 0.21.3 是**同一形状**
> （那次上游删了 `tool-subagent-codex` / `tool-subagent-claude-code` / `tool-ralph`）。
> 本轮的机制没有变强，但**记录**里多了一次同形状的实证。

### 修复 ②：漂移闸门自己把**注释**当成了行（假红）

补清单时，差集表里照抄了官方 standard 的原文形状（`- id: tool-plugin-market`），
②d 立刻红：

```
✖ ②d 三份差集清单里的每一项都真的不在本模式里
  AssertionError: 差集清单声明 tool-plugin-market 不在本模式里，但它出现在 cordis.patch.yml 中
```

而那是**假红**：本模式确实没有这一行，注释只是引用它。
根因是 `hasRow()` 直接拿整份 `cordis.patch.yml` 做正则匹配 ⇒ **注释里出现一次行 id
就被算成「本模式里有这一行」**。

**修法是改判据，不是改措辞**：判据现在只读 **YAML 行**（整行注释剔除），
`hasRow()` / ② / ②b / ②d 三处统一走它。理由与本仓库纪律一致——
**判据自己有洞时，改措辞去迁就判据 = 把「判据不可信」这件事藏起来**。

### 反向验证（先证明护栏能红）

四个变异体（各自只动一处，逐个确认**变异真的落盘**后才跑）：

| 变异体 | 期望 | 实测 |
| --- | --- | --- |
| M1 清单里拿掉 `tool-plugin-market` | ②c 红 | ✔ 红 |
| M2 本模式真加一行 `tool-plugin-market` | ②b/②d 红 | ✔ 红 |
| M3 只在**注释**里出现该行 | **全绿**（假红已消除） | ✔ 绿 |
| M4 差集清单整体清空 | ②c 红 | ✔ 红 |

> ⚠ M2 的第一版**没有真的落盘**（锚点 `- id: present` 的缩进比正则允许的深），
> 于是它「绿」了——但那是**证明了零件事的绿**。因此脚本现在对每个变异体
> 都先断言「文件确实变了」，变了才跑测试。**「假绿比假红更贵」**。

### 读数

| 项 | 值 |
| --- | --- |
| `test/webcode-preset.test.mjs` | **12/12** |
| 全量单测（`test/*.test.mjs`，逐文件） | **125/125**（此前 123/125） |
| 闸门 | `ci-local --fast` **12/12 PASS** |

### 已发布

| 渠道 | 读数 |
| --- | --- |
| GitHub Release | [v0.22.1](https://github.com/RSLN-creator/dsh-web-bridge/releases/tag/v0.22.1) —— 资产 `dsh-webcode-bridge-0.22.1.tgz`（941,971 B） |
| npm | `dsh-webcode-bridge@0.22.1`，`dist-tags.latest` = **0.22.1** |
| CI | run `39593226054` **success**（4 个 job：ubuntu/windows × Node 22/24） |

> 两个产物的**归档字节**不同（npm 会重打包）且都不等于本地 tarball 的 sha256 ——
> **这是正常的**（tar 归档含 mtime 与压缩顺序）。判据是**包内每个文件的内容逐字相同**：
> npm 上的 0.22.1 解包后与工作树 **54/54 逐字相同**。

## 0.22.0（2026-10-21：删除「任务板」与「并发会话」两块 —— 一次纯减法）

### 移除：任务板（含花名册）整条链

用户 2026-10-21 指令：「现状插件我不想要任务版和并发会话了」。

- **`lib/` 删 6 个模块**：`task-ledger.js`（669）、`task-graph.js`（184）、`task-plan.js`（427）、
  `task-split.js`（238）、`team-state.js`（240）、`roster.js`（595）—— 共 2,353 行。
  `roster.js` 一起删的理由：它只被 `lib/index.js` 的一处注入消费，而那个注入只喂
  `/status` 的花名册字段，花名册唯一的界面消费者就是任务板面板。
- **控制面删 7 个动作**：`GET task-ledger`、`POST task-{create,update,delete,comment,comment-resolve,implement}`，
  连同它们的存储根解析器 `taskRootOf` 与 `resolveWorkspaceRoot` 注入。
- **`/status` 删花名册分区**：`subAgents` / `team` / `members` / `tasks` / `graph` / `plan` /
  `planError` / `taskError` / `teamSource` / `tasksSource`。⚠ 是**字段缺席**而不是「字段为空」——
  字段为空会被读成「确实没有」，字段缺席是显式的 `undefined`，不会被误读。
- **界面删 1,136 行**：`useRoster` / `taskStatusOf` / `TaskBoardPanel` / `NewTaskModalDialog` /
  `TaskDetailNotionView` / `TaskBoardPanelIcon` / `TaskBoardMain`，以及左栏 `sidebar.panellist`
  行与中央 `main` 座位的成对注册。

### 移除：并发会话的界面层（并发能力本身一行未动）

**这是本轮最容易被误读的一点**，用户原话已澄清：

> 「功能需要能够用户单对话时候还能并发，但是左侧面板自己设计的并发会话去除」

| | 处置 |
| --- | --- |
| **真并发**：`lib/relay.js` 的每账号车道、`maxConcurrentLanes`、`lib/accounts.js` 的 per-slot profile | **一行未动**（provider 通路，与界面无关） |
| **并发的界面**：左栏行 / 中央面板 / 右栏页签 / 列顶栏 / 列沙箱 | **全部删除** |

- **`lib/` 删 3 个模块**：`column-context.js`（127）、`column-fs.js`（142）、
  `comparison-view.js`（522）—— 共 791 行。`comparison-view.js` 是并发面板 0.17 时代的静态
  HTML 前身，在 0.19.55 被「每列一条真官方会话」取代后**界面上再无任何入口**
  （`client.cjs` 全文 0 处指向 `/__webcode/comparison`，只能手敲 URL）。
- **`client.cjs` 删 1,906 行**：`ColumnHeader` / `ConcurrentColumn` / `ConcurrentColumns` /
  `ConcurrentPanel` / `ConcurrentPanelIcon` / `HwbBoundary` / `sessionViewsFor` / 面板守卫族 /
  `OFFICIAL_CSS` 与全套 `oc()` 类名，以及三处座位注册（左栏行、中央 `main`、右栏页签）
  与列子槽声明。
- **控制面删 `POST chat`**：它的**唯一消费者是任务板的「按批注派发」**（`client.cjs:1401`），
  随任务板一起失去调用方。同批移除的还有它用到的 `saveColumnReply` 与 `columnContext`
  拼装（列身份 → 提示词的引导）。

### 修复：三处「留着就撒谎」的接线（删的过程中发现）

1. **`lib/index.js` 的 `inject` 从 5 项收窄为 1 项**（`['llm']`）。
   `agents` / `sessions` / `sessionProjections` / `subagents` 是 0.15.0 专为 `roster.js`
   逐个核实注册点后声明的；花名册删掉后**全包零读取点**。留着不是无害的：cordis 会等到
   声明齐备才调用 `apply()`，任何一个缺席的 profile 都会停在 `pending (waiting for service: …)`。
   **不用的 inject = 给插件多上一道可能挂住的门。**
2. **`client.cjs` 的 `inject` 从 8 项收窄为 3 项**（`['slots','sidebarRightTabs','sidebarRight']`）。
   移除 `sessions` / `layout` / `uiWorkspace` / `jobs` / `configForms` —— 同一纪律，同一理由。
3. **`web-control.js` 删掉 `POST status` 别名**。它存在的唯一理由是客户端要带会话身份
   （`api('status', { sessionId })`）去轮询会话级花名册；花名册删除后客户端只剩无 body 的
   `api('status')`（GET），`test/client-server-contract.test.mjs` 会把多注册的 POST 判成死路由。

### 删除的测试（9 个文件 + 24 条用例）

`task-ledger` / `task-graph` / `task-plan` / `roster` / `wiring-roster` / `column-context` /
`column-fs` / `team-compare` / `comparison-view` —— 被测模块本身已删，判据的对象不存在了。
另从 `client-render` 删 22 条、`control-routes` 删 2 条。**133 → 124 个测试文件。**

⚠ **删测试时保住的知识没有被丢掉**（写在各文件原位注释里）：`control-routes` 保留
「每个动作都必须能被真实 HTTP 命中」这条判据（它对**当前**动作表继续生效），并注明
「写路径的根必须单一取法」这条判据**若将来再加任何落盘族要原样重建，不要另立第二套取法**。

### 读数（改前 → 改后）

| 项 | 改前 | 改后 |
| --- | --- | --- |
| `lib/` 模块数 | 53 | **44**（含 `sites/` 共 46） |
| `lib/` 总行数 | 39,973 | **33,324** |
| `client.cjs` | 8,063 行 | **5,010 行** |
| 测试文件 | 133 | **124** |

### 本轮没做的事（如实登记）

- `lib/tool-parser.js` 仍是「未接线、只被单测引用」的状态（它自己的文件头已如实写明）。
  它**不是本轮的目标**，因此未动。
- 未真机验证。本轮是纯删除，判据是「全量单测 + 三条闸门 + 索引一致」，**不冒充真机通过**。
- 未打包、未装入 profile、未发布。

## 0.21.3（2026-10-20：会话界面补上「联网搜索」开关）

### 修复：用户报「缺少点击联网搜索的按钮/功能」（以 DeepSeek 为例）

- **先核事实**：搜索开关 0.19.94 就已实现（`planSearchToggle` + 点开后回读 `aria-pressed`），
  但入口只在**设置页 → deepseek 分区**，且默认 `auto` = 不动网页；会话界面里没有入口，
  而网页版那颗按钮就在输入框旁 ⇒ 用户感知为「缺功能」。契约本身未过期
  （真机读数：`.tmp/live-2026-10-19/search-toggle-report.md` §5b/§5c）。
- **新增 `POST composer-search`**：按「这个会话最近一次真实发送落在哪个站点」读写
  `searchModeBySite[siteId]`；没发过言 ⇒ `siteId:null`（**不猜**默认站点）；
  回 `calibrated` 标明该站点是否真有 `searchTogglePattern` 契约。写入只动站点档，
  不顺手改全局 `searchMode`。
- **新增 `searchModeOf` 导出**（web-control.js）：站点档 → 全局档 → auto，
  index.js 与 composer-search 共用同一份回落链（不再各写一份）。
- **会话输入框底下新增一枚「联网搜索 · 自动/已开启/已关闭」开关**（dock 条目
  `webcode-search`，order 21）：三态循环；站点未知或未校准 ⇒ 禁用并如实说明原因，
  不给「点了会生效」的假承诺。
- **测试**：`client-render` 77/77（dock 桩改为按 id 收全两枚条目 + 3 条新用例）；
  `settings-transport` 21/21（新增 ⑪：未知站点不猜 / 只写站点档 / 非法值归 auto /
  未校准如实标注）；`provider-surface` 对外面逐字不变。
- **边界**：其余九站仍未校准搜索控件，界面对它们如实显示「未校准」并禁用；
  默认值仍是零位移的 `auto`。

## 0.21.2（2026-10-20 凌晨：修 CI 红 —— zai-cn 不继承「需要真机读数」的声明位；本机 web/headless 安装版；GitHub/npm 双发布）

### 修复：CI 单测红（zai-cn 违反「没有真机读数不许声明站点知识」）

- **现象**：GitHub CI 在 0.21.1 上失败两个文件——
  · `test/answer-selector.test.mjs` 判据 6：`zai-cn` 声明了 `answerSelector` 却不在真机名单 → 违反「没有读数的声明不许写」；
  · `test/think-effort.test.mjs` ①/①c/①d：`zai-cn` 继承了 `efforts/effortControl` 但 think-effort 没有它的声明（null）→ 等级集合/同源/载荷三条红。
- **根因**：`ZAI_CN = {...ZAI}` 的「整份继承」把**需要真机证据**的三类声明位（`answerSelector` / `efforts` / `effortControl`）也带进了尚未登录、零真机读数的中国站。本地没跑这两个文件所以漏测（CI 抓到了——这正是 CI 的价值，如实入账）。
- **修法**：从继承里显式剔除这三类，只复用不需要读数的通用字段（completionPaths/decoder/input/sendButton/staticOrigins 等）；注释写明「不是永不做，是没证据前不做」。中国站登录能取读数后再逐条补并同步护栏名单。
- 复跑：answer-selector **8/0**、think-effort **28/0**、provider-surface 8/0、model-labels 12/0、captcha-gate 7/0、timeout-order 7/0、driver-scope 7/0、client-render 74/0、control-routes 19/0、settings-transport 20/0、login-state 16/0、lint PASS。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm（发布动作在收尾流程里统一做）。

## 0.21.1（2026-10-20 凌晨：about:blank 真根因修复 + z.ai 海外站 origin 修回；本机 web/headless 安装版；未推 GitHub/npm）

### 修复：登录/展示窗口 about:blank 的**第二个**隐藏根因（用户 2026-10-20 报障）

- 0.21.0 已让 goto 失败可诊断（navError），但真机验证发现**白页时 navError 仍为 null**：因为 `launch()` 只开新页**不导航**，fresh 驱动打开窗口时 page 就是`about:blank`；旧代码把它当「remembered 会话地址」⇒ `target = about:blank` ⇒ goto 成功、永远白页、且没有任何错误。
- 修法：`openWindow` 里把 `about:blank` / `chrome://newtab/` / 空串一律当「没有 remembered」，回落 `cfg.site`。
- **真机验证（worktree 0.21.1）**：fresh 驱动对 qwen `openWindow` → `url: https://chat.qwen.ai/`、`navError: null`（PASS）；对不可达站点 grok → `navError: page.goto: net::ERR_CONNECTION_TIMED_OUT at https://grok.com/`（说明失败现在**可归因**，不再白屏无读数）。
- 护栏：`timeout-order.test.mjs` 新增「about:blank 不可当 remembered」接线判据（6→7 条）。

### 修复：z.ai **海外站 origin 仍是 zchat**（0.19.99 漏改回来）

- 0.21.0 拆出 `zai-cn` 中国站后，运行中 `login-sites` 实测 `zai` 的 origin 仍是 `https://zchat.z.ai` ⇒ 两站指向同一域名，违反用户「国内国外是两个站点」。
- 修法：`ZAI.origin = 'https://chat.z.ai'`（海外站）；`ZAI_CN.origin = 'https://zchat.z.ai'`（中国站）保持不变。重启后 `login-sites` 应显示两条不同 origin。
- 测试：provider-surface / model-labels / captcha-gate / timeout-order / driver-scope / client-render 等全部复跑绿。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。
- 中国站登录仍未建立（用户指示忽略）；GLM 在 0.20.0 实测真实一轮能拿到回复（无验证码）。

## 0.21.0（2026-10-20 凌晨：z.ai 国内/海外两站拆分 + 登录/展示窗口 about:blank 修复；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：z.ai **国内站 / 海外站拆成两个独立站点**（用户 2026-10-20 明令）

- 用户原话：「国内国外网站是两个！你要区分！！」——0.19.99 把 `ZAI.origin` 改成 zchat 是错的，本版起：
  - `zai` = **海外站** `https://chat.z.ai`，profile `sites/zai`；
  - **`zai-cn` = 中国站** `https://zchat.z.ai`，独立 profile `sites/zai-cn`、独立登录态；复用海外站前端契约（同一应用的中国镜像），只覆盖身份三元组 id/name/origin/shortKey。
- 模型选择器/目录现在同时出现 `z.ai/glm-5.3` 与 `zchat.z.ai/glm-5.3` 等条目（目录 17→20 行，`provider-surface` 的钉死清单/组名/显示名**有意**同步更新并在测试注释注明理由）；`client.cjs` 的 SITE_NAMES 同步加 `zai-cn: 'Z.ai 中国站'`，并修正 provider-surface 里只认无引号键的解析器（带连字符站点键必须支持）。
- 别名：`zai-cn` / `zchat.z.ai` → `zai-cn:glm-5.3`。**中国站登录按用户指示忽略**（未登录只报 NEED_LOGIN，不影响海外站）。
- 测试：provider-surface **8/0**、model-labels 12/0、client-render 74/0、control-routes 19/0、settings-transport 20/0、model-picker 19/0、login-state 16/0、client-server-contract 2/0 全绿。

### 修复：登录/展示窗口 `about:blank` 一直不加载（用户 2026-10-20 报障）

- 用户原话：「about:blank，这个什么你每次登录这个就是这个网址一直没有加载」。
- 根因：`openLogin` / `openWindow` 里两处 `page.goto` 都带**空 catch**（失败=静默），任何一次导航失败窗口就永远停在 about:blank，且没有任何读数可归因。
- 修法：新增 `navigateForDisplay(target)` 三档降级——`waitUntil:'commit'`（快速提交）→ `'domcontentloaded'`（容忍慢站）→ `page.evaluate(location.href=target)`（SPA/跳转兜底）；每档失败 `warn` 并把最后错误记入 `lastWindowNavError`，`windowState()` 新增 `navError` 字段（`GET/POST /window` 会回带）；openLogin 的 3 处 goto 与 openWindow 的 goto 全部改走它。
- 测试：timeout-order **6/0**（新增接线判据：navigateForDisplay 存在、≥4 处调用、windowState 回带 navError）；driver-scope 7/0、captcha-gate 7/0、capture-stall-rescue 20/0、lint PASS。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。
- 中国站登录仍未建立（用户指示忽略）；GLM 真实一轮已能拿到回复（0.20.0 实测，无验证码）。

## 0.20.0（2026-10-20 凌晨：验证码命中自动转有头窗口；z.ai 两站拆分改为下一版；本机 web/headless 安装版；未推 GitHub/npm）

### 修复：验证码命中后自动把**同一现场**转成有头窗口（用户明令）

- 用户原话（2026-10-20）：「就算你开了有头，你没有触发我也完全看不见！！！在这个窗口里无滑块」。
- 根因：另开一个干净浏览器（或手动 `chrome --user-data-dir`）里**没有挑战现场**，所以永远看不到滑块；能过验证的只有「触发验证的那个 page」。
- 修法：`browser-driver.js` 新增 `autoShowCaptchaWindow(reason)`——验证码抛出点（超时点 + 发送后点）在 reject/throw 前调度它：等 1.5s 让轮次收尾，若 `busy` 仍为真则让路（不硬抢窗口），否则 `openWindow({})` 把**命中验证的同一个浏览器**转成有头（openWindow 会 goto 当前 page URL = 挑战页），用户即可在窗口里拖滑块；验证过后同一浏览器继续服务后续轮次。
- 测试：`captcha-gate.test.mjs` **7/0**（新增接线判据：两个抛点都调度 helper、helper 调 openWindow 且 busy 让路）；driver-scope 7/0、capture-stall-rescue 20/0、timeout-order 5/0、captcha-gate-scan 7/0；lint PASS。

### 记录：z.ai 国内/海外两站拆分（用户 2026-10-20 明令；**本版未发布**）

- 用户纠正：「国内国外网站是两个！你要区分！！」——正确模型是两个独立站点：`zai`（海外 chat.z.ai，profile sites/zai）+ **`zai-cn`（中国站 zchat.z.ai，独立 profile sites/zai-cn、独立登录态）**，各自出现在模型选择器（`z.ai/…` 与 `zchat.z.ai/…`）。
- 0.19.99 曾把 `zai.origin` 改成 zchat，**不符合「两个站」的要求**，本版已还原为 chat.z.ai；两站拆分的代码骨架在本轮做过但未过完模型目录/快照回归（provider-surface/model-labels 的钉死清单要随新增站点更新），故**回滚、不发布**，下一版连同测试快照一起上。
- 中国站登录态用户明确「暂时没法登录，先忽略」：`zai-cn` 未登录只会报 NEED_LOGIN，不影响海外站。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。

## 0.19.99（2026-10-20 凌晨：z.ai 切到中国站 `zchat.z.ai`；本机 web/headless 安装版；未推 GitHub/npm）

### 修复：z.ai 的 origin 由 `chat.z.ai` 改为中国站 `zchat.z.ai`（用户明令）

- 真机证据（0.19.96 E2E）：国内访问 `chat.z.ai` 会被地区弹窗「请切换至中国站点 zchat.z.ai」拦截，且弹窗在 effort 控件之前就把页面挡住 ⇒ 继续用 `chat.z.ai` 等于让 z.ai 永远不可用。
- 改动：`providers.js` 的 `ZAI.origin = 'https://zchat.z.ai'`；**`id` 仍为 `zai`**，模型 id / 会话槽 / 账户槽 / 已登录 profile 全部沿用，不新增也不需要二次登录（有头窗口里已在 zchat 站登录/验证）。
- 用户名/站点键不变（选择器里仍显示 `z.ai`）；若 zchat 的登录 cookie 与 chat.z.ai 不同域，首次可能需要在右侧面板的 z.ai 行打开窗口登录一次（本轮已为用户打开有头窗口）。
- 测试：provider-surface 8/0、model-labels 12/0、session-stay 6/0、mirror 8/0、site-verify-verdict 33/0、zai-answer-selector 7/0、glm-conversation 18/0、settings-transport 20/0、model-picker 19/0；lint PASS。
- **未做真机复验**（用户需先在 zchat 窗口完成登录/验证；之后跑 zai 真机轮）。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。

## 0.19.98（2026-10-20 凌晨：超时点 DOM 兜底 —— 页面已有回复不再被当成超时丢掉；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：超时点 DOM 兜底（partial 交付，不再整轮丢内容）

- 背景：2026-10-19 GLM 任务模式 / chat_agent 真机 E2E 到总超时那一刻，页面**已有 3 字回复**（`selectorHit:true`），但没有任何收束判据成立 ⇒ 旧实现按 `web turn timed out` 失败、内容整轮丢掉。
- 判据：新增纯函数 `metrics.shouldRescueTimeoutScene(scene)` —— **仅当** `selectorHit===true && replyChars>0` 才允许救（选择器没命中=可能读到上一轮回复；无回复文本=可能还在 prefill/验证闸门，均不救）。
- 接线：`browser-driver.js` 超时回调在 captcha 归因之后、`lastEndReason='timeout'` 之前，按该判据读 `answerSelector` 最后一节点 innerText → `cleanAnswerDomText` → 非空则 resolve `{text, partial:true, complete:false, reason:'dom-rescue-timeout'}`，走 `noteEndReason` 同一条 partial 落账路径（不新增第二条收尾通路）。
- 读数：`capture-stall-rescue.test.mjs` **20/0**（新增判据真值表 + 接线顺序/落账源码判据）；`metrics.test` 13/0、`driver-scope` 7/0、`tool-loop`、`send-confirmed 5/0`。反向变异：把判据改成恒 false ⇒ **19/1 红**（判据用例命中），还原 20/0 绿。

### 工具：E2E 探针超时对齐产品口径

- `.tmp/live-2026-10-19/e2e-mode-effort.mjs` 的 `requestTimeoutMs` 由 180s 改为 **480s**（= 产品 relay 240×2），避免与驱动 `answerTimeoutMs`(180s) 同值赛跑造成「假超时」；GLM 任务模式定性复跑用此脚本（待用户过滑块）。

### 边界

- 本轮**无真机发送**（GLM 处于访问验证状态、zai 地区弹窗未解，按纪律停手）。
- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。

## 0.19.97（2026-10-19 夜第五轮：超时现场 DOM 诊断 + GLM 任务模式 E2E 复核分析；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：turn 超时现场加采 DOM 诊断（只读读数，不改行为）

- 背景：2026-10-19 GLM 任务模式 E2E 出现 `{captureAlive:true, replyChars:3, selectorHit:true}` 却在超时点才失败——页面已有回复但「流结束」判据没成立；仅凭这三个数分不清「agent 模式回复容器不同」「停止/结束标记不同」「探测链没建立」。
- 修法：`browser-driver.js` 的超时现场页内采样在原有三字段外新增 `dom`：`bodyTextTail`（末 600 字）、`visibleButtons`（文本/aria/class/disabled）、`answerNode`（tag/class 链/textHead）、`visibleCounts`（think/stop/loading/generat/agent 可见数）。**全部包在 try/catch**：诊断失败不改变原超时路径与错误码；仍写 `lastTimeoutScene` 供 `GET diagnostics` 事后取。
- 护栏：`driver-scope` 7/0（新增页内代码无自由标识符）；`tool-loop` 12/0；lint-comments 0 error/0 warn。

### 复核分析：GLM 任务模式 E2E 超时的两种可能（尚未定论，不假装修好）

- 我方 E2E 脚本把 `requestTimeoutMs` 设成 `180_000`，与驱动的 `answerTimeoutMs`（默认 180s，思考-only 硬上限）**同值** ⇒ 总超时与「稳态/硬上限收束」在同一时刻赛跑，谁先到由事件循环决定；真机日志正是「180s 超时、页面已有 3 字」。
- 产品路径的 relay 请求超时是 `requestTimeoutMs×2`（240s×2=480s），**比硬上限宽**；因此不能据本次 E2E 断言「任务模式回复捕获坏了」——需要把 E2E 的 requestTimeout 调到 480s 后复跑（待用户过完 GLM 滑块验证后执行）。
- 该条仍按「未定论」处理：等复跑读数决定是「agent 模式流结束信号不同」（真问题 #85）还是「探测超时配置太紧」（探针缺陷）。

### 边界

- 本轮**无真机发送**（GLM 处于访问验证状态，按纪律停手；zai 地区弹窗未解）。
- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。

## 0.19.96（2026-10-19 夜第四轮：任务模式设置页 UI + GLM 模式真机校准 + zai #34 disabled 态修复；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：任务模式设置页 UI（用户第①项的「最后一跳」）

- `lib/client.cjs` 设置页站点分区新增：**glm「任务模式」下拉**（默认（不改变网页）/ 任务 / 研究报告 / PPT制作 / 数据分析，选项逐字来自真机盘点）与 **glm/deepseek「深度思考（本站覆盖）」三态**（跟随全局 / 开 / 关；「跟随全局」=删键，不写伪值）。与既有全局「深度思考」并存、互不顶掉；保存走 `api('settings')` + settingsRevision 重拉。
- 护栏：`test/client-render.test.mjs` **74/0**（原 72 + 2 条）；反向变异：载荷字段名写错 ⇒ 73/1、跟随全局写成 on ⇒ 73/1，还原 74/0。

### 修复：GLM 任务模式「选中态回读」判据（真机校准后，原判据必失败）

- 真机校准（零发送）：选择器逐字命中；**点击模式后工具行淡出隐藏**，`div.model-select-item.selected` 文本在 DOM 但 visible=false；可见证据在 `div.selected-model-info`（chip）；条目全程无 aria-selected/aria-checked ⇒ 旧判据（只看可见 item 的 aria/class）恒 false、必抛 `SITE_MODE_UNAVAILABLE`。
- 修法：`providers.js` GLM `modePicker` 增 `trigger: '.selected-model-info'`（展开工具行）与 `selectedInfo`；`browser-driver.js` 新增纯函数 `siteModeVerdict({selectedItemTexts, selectedInfoText, mode})`（**不过滤可见性**的 selected item 文本 **或** chip 文本命中 ⇒ confirmed，并标 basis），应用段幂等/展开/点击/轮询回读/失败带真机现场。修掉上一版一处换行粘连。
- 读数：settings-transport **20/0**、model-picker 19/0；变异：退回只看可见 item ⇒ 19/1、删 selected-item 证据 ⇒ 19/1，还原 20/0。
- **未做**：「更多」浮层内 7 条隐藏模式（无逐个真机读数，不猜）；真实 fresh 轮端到端应用未跑（零发送校准已就位，下一次真机 send 会验证）。

### 修复：z.ai 推理等级 #34 —— 「控件存在但禁用」不再误报致命 THINK_EFFORT_UI_CHANGED

- 真机证据（登录态会话页只读）：`div#bits-c802[aria-label="当前场景无法取消"]` 内裸 div class 含 `opacity-50 cursor-not-allowed`，文本仍是「深度思考 最高」；控件**在**（不是契约过期），当前场景**不可操作**。
- 修法（`lib/think-effort.js`）：`FIND_EFFORT_CONTROL` 内新增 `disabledBasis(el)`（class/aria-disabled/native disabled/计算样式/祖先 2 层），新增导出页函数 `FIND_DISABLED_EFFORT_CONTROL`；applyEffort 在幂等快路之后、fail 之前返回**非致命** `{applied:false, reason:'control-disabled', state, readback, note}`——不点、不抛、不假装成功。
- 读数：think-effort **28/0**（原 27 + ④k 真机形状假 DOM）；变异「把禁用当可点」⇒ 27/1 红，还原 28/0。
- **未做**：该 note 目前冒不到界面（驱动调用点只处理 applied:true，补 warn 留下轮）；未做真实发送端到端。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。
- ⚠ 真机校准曾把 live `glm` 页面切到「任务」模式且未找到清除 UI 路径 ⇒ **右栏 GLM 可能仍停在「任务」模式**；请在有头窗口/网页里点 chip 手动切回所需模式（或新开会话）。
- 遗留：任务模式「更多」浮层、驱动侧 control-disabled 的 warn 推送、GLM/zai 真实 fresh 轮端到端验证。

## 0.19.95（2026-10-19 夜第三轮：全站多调用并行 + 任务模式设置层 + 三站模式补采；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：全站「多调用并行」教学（0.19.94 只对 deepseek，本轮提升为公共段）

- `lib/agent-preset.js`：强约束句（「无依赖、相互独立的多个工具调用，必须合并在同一条回复里一起发起；只有存在依赖才分多轮」）从 deepseek 条件展开**提升为公共教学段**，全站生效且 lib 中只出现一次；使用准则旧句「能用一次调用覆盖就不用多次」全站替换为不冲突措辞（保留反重复原意）。
- 基线：`test/prompt-variants.test.mjs` 的 ZERO_DRIFT_BASELINE **刻意更新**（测试头与段落注明理由，只有本次教学相关两行变化，其余逐字保留 0.14.7 原文）；`test/deepseek-prompt-slim.test.mjs` ④/④b 改为全站生效断言+去重计数。
- 读数：deepseek-prompt-slim 10/0、prompt-variants 15/0、prompt-compact 5/0、tool-loop 12/0、fence-nested-call 19/0、fence-prose 12/0、present-preset 4/0、glm-session-replay 24/0、parse 22/0。反向变异：把公共新句换成占位符 ⇒ prompt-variants 14/1、deepseek-prompt-slim 8/2 红；还原全绿。

### 新增：任务模式设置层（per-site 深度思考 + glm 任务/研究报告模式）

- **per-site 深度思考**：`thinkModeBySite` 站点级覆盖（回落链：站点→全局→auto，唯一解析点 `web-control.js` 的 `thinkModeOf`；index.js 先 siteId 后解析，守住 TDZ 教训）；GET/POST settings 归一化；无会话轮与 turnOpts 两通道透传。
- **glm 模式**：`providers.js` GLM 段新增 `modePicker`（`.model-select-container` / `.model-select-item`，选择器逐字来自 2026-10-19 live 直读盘点：可见条目 任务/研究报告/PPT制作/数据分析/更多）；驱动新增纯判定 `planSiteMode({siteMode,fresh})`（**默认空=零位移；resume 不动网页；只在 fresh 轮 apply**），发送前按文本精确匹配点击并轮询回读选中态；找不到/回读未确认 ⇒ 抛 `SITE_MODE_UNAVAILABLE`（已登记 error-codes）。
- 读数：settings-transport **17/0**（原 11 + 新增 6）、error-codes 10/0、think-effort 27/0；反向变异：回落链写坏 16/1、默认空当强制点 16/1、删回读确认 16/1，均还原 17/0。
- **未完成（如实）**：① 设置页 UI 未接（数据层 GET/POST 已通，可用 `POST /__webcode/settings` 传 `thinkModeBySite` / `siteModeBySite`）；② GLM 模式点击后的「选中态」回读口径**无真机读数支撑**，首次真机应用可能抛可读 `SITE_MODE_UNAVAILABLE`，需下轮校准；③ GLM「更多」浮层内模式未做（无读数不猜）。

### 新增：kimi / qwen / zai 模式入口补采（零发送只读，取证非功能）

- kimi：侧栏 6 项（我的 Kimi/插件/定时任务/灵感库/PPT/更多）+ composer 2 按钮 + 快捷能力行 6 项（PPT/深度研究/文档/构建应用/表格/设计），均可见 enabled；无 aria-pressed 开关。
- qwen：`.qwen-thinking-selector` 文本「自动」（think-effort 声明选择器命中成立）、`div.mode-select-open`（选择模式）、`div.wms-trigger`（Select Model，Qwen3.7-Plus）；联网/搜索开关与思考三档菜单未展开 ⇒ UNVERIFIED。
- zai：`modelSelectorButton` 文本 GLM-5.3-Flash，root enabled / 会话页 disabled；「深度思考」pill **只在会话页**存在（root 无 pill）。证据：`.tmp/live-2026-10-19/mode-inventory.md` §5。

### zai 推理等级（#34）：本轮**未修**，取证 UNVERIFIED

- 两条零发送只读通路（桥 CDP 新标签页 / sites/zai 落盘 profile 临时副本）读到的**都是未登录落地页**（有「登录」按钮、无档位控件、`[class*=think]`=0）；落地页本就不渲染该控件 ⇒ **(a) 当前状态无该档 vs (b) 契约过期无法区分**。
- 反证：`sites/zai/webcode-login-state.json` 1 小时前仍 loggedIn/probe-ok；契约依据来自 2026-09-27 **登录态对话页**（`深度思考/最高`）。
- 处置：**不改任何选择器**（无依据改了会点错控件/每轮误报），#34 保持 open；下一步建议先用 `POST window {accountKey:'zai', action:'open'}` 恢复/确认登录态，再取登录态对话页只读 DOM 后修契约。

### 边界

- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；宿主侧需重启 `dsh web` 生效。
- 任务模式设置页 UI、GLM 模式真机回读校准、zai 等级、非 deepseek 站点的搜索开关都仍是下一轮。

## 0.19.94（2026-10-19 夜第二轮：搜索开关 + 多调用并行教学；本机 web/headless 安装版；未推 GitHub/npm）

### 新增：per-site「搜索开关」`searchMode`（auto | on | off）

- 设置：`searchMode`（全局）与 `searchModeBySite`（站点级覆盖，回落链同 `sendGapMsBySite`）；非法值一律归 `auto`。
- 驱动：`lib/browser-driver.js` 导出纯函数 `planSearchToggle({pressed, mode})`；执行段按计划——
  `auto`=**零位移**（不点、不改网页；旧实现「按开就点掉」的强制关被取消，这是默认值变化）、
  `off`=旧行为（按开则点掉）、`on`=未开则点开并**轮询回读 aria-pressed=true**（10×150ms）；
  回读超时抛 `SEARCH_TOGGLE_UNCONFIRMED`（新错误码，已登记 `WEBCODE_CODES`），绝不静默当成功。
- 无 `searchTogglePattern` 契约的站点：不点、不抛（只 onThink 提示未校准）——本设置目前只在 **deepseek** 真机校准，设置页也只在 deepseek 分区显示。
- 真机只读证据（`GET /__webcode/diagnostics`，零发送）：deepseek 会话页 composer 控件 `[智能搜索 pressed=false]`、`[深度思考 pressed=true]`，`requestMetadata.search_enabled=false` ⇒ 控件真实存在、初始未开；`auto` 的「不点」语义有真机旁证。on/off 的点击执行由单测+3 组反向变异覆盖（本机 live profile 被运行中的桥占用，未做发消息验证）。

### 新增：多调用并行教学（仅 deepseek 分支）

- `lib/agent-preset.js` 补强约束式教学：「无依赖、相互独立的多个工具调用，必须合并在同一条回复里一起发起；只有存在依赖才分多轮」；
  并把 deepseek 的「能用一次调用覆盖就不用多次」改为「同一信息不要重复获取，但独立调用要合并」。
- **只对 deepseek 生效**：default/glm 文本被 `prompt-variants` 的 ZERO_DRIFT_BASELINE 逐字钉住，其余站点本轮零位移；要全站生效需单独一轮改基线+全站回归。

### 修复（本轮集成时发现）

- **`lib/index.js` executor 里 `searchModeOf(..., siteId)` 写在 `siteId` 声明之前 ⇒ TDZ（每次发送 ReferenceError）**：由 Lead 在集成时经 `prompt-compact` / `tool-loop` 端到端测试抓到并修复（先 siteId、后 searchMode，注释写明「顺序即判据」）。这直接说明「单测绿 ≠ 接线对」，故本条如实入账。

### 边界（如实）

- 任务模式 / 网页搜索（非 deepseek 站点）/ 智能体模式 / z.ai 推理等级：**本轮未实现**；站点「模式入口」盘点证据在 `.tmp/live-2026-10-19/mode-inventory.md`，作为下一版契约取证。
- 本版本只装到本机 web/headless profile；未推 GitHub、未发 npm；tag 仍停在 `v0.19.92`。

## 0.19.93（2026-10-19 真机轮，本机 web profile 安装版；未推 GitHub / npm）

本轮顺序：**先写风控调研，再做真机验证与修复**。调研见
[ doc/research/2026-10-19-risk-control-and-real-machine-protocol.md ]（本仓库文档，含同类插件/反检测框架对照与
「学真实浏览器/持久登录、不学指纹伪造/打码/代理池」的取舍 + 本轮真机硬约束）。

### 修复（全部带护栏 + 反向变异）

- **#59** doubao `loginProbe.ok` 与 `accountProbe.avatar` 同源（真机 doubao `login.basis=probe-ok` 验证）。
- **#65 + #82** kimi `loginProbe.ok` 精确子集 + `judgeLoggedIn` 的 **ok 特征水合窗口**（真机 kimi `login.basis` 由 UNVERIFIED→**PASS(probe-ok)**）。
- **#60** `loginState` 三态被登录进度同名变量覆盖：进度变量改名 `loginPhase`，三态贯通 **index / web-control / bin / client.cjs**（uncertain→「待确认」），新增 `normalizeLoginState` 白名单归一。
- **#62** `site-verify` 把站点侧限流/资源类错误归 **BLOCKED**（真机 kimi `resource_exhausted` 实证）。
- **#57** `strictModelType` 接消费者（**部分**：全量「契约字段必须有消费者」闸门留后续）。
- **#71 同族** `site-verify` 的会话判据接入 `sameSessionVerdict`（槽锚第二判据；qwen 真机 PASS `basis=slot-anchor`）。
- **F8** `turn.settled` 假 UNVERIFIED：`diagnostics()` 补收束读数 + 工具侧收束后重取（deepseek 真机 UNVERIFIED→**PASS(finished)**）。
- **新 #79** doubao 既有会话页不渲染「对话/工作」分段控件 ⇒ resume 轮 `no-options`（F15：`segmented-control-absent-on-resume` 出声跳过；真机第 3 轮不再报错）。
- **新 #81** zai 导览/地区遮罩拦截发送（F16：发送前遮罩检测、导览 Escape 重试、新码 `WEB_UI_OVERLAY_BLOCKED`；真机可读归因）。
- **F18** `site-verify` 把新码 `WEB_UI_OVERLAY_BLOCKED` 归 BLOCKED。
- **撤回 F9**：GLM `bad` 收窄假设被 live 真机复核否定（原读数是残缺 profile 副本的游客页），代码保持原值。

### 真机四态（`test-mock/site-verify.mjs`，每站每代码变体一次 = 3 次发送、零重试）

| 站 | 结果 | 说明 |
| --- | --- | --- |
| qwen | **PASS 14/0/0/0** | 首次真机全绿（已登录；回填往返 slot-anchor；收束 PASS） |
| glm | **PASS 14/0/0/0** | 3 轮投递/流一致/工具往返全 PASS |
| deepseek | **PASS 14/0/0/0** | F8 真机判据：turn.settled 由 UNVERIFIED→PASS |
| doubao | **UNVERIFIED** | F15 修复后不再 FAIL；仅 **#80 流式增量缺失**（`dom-rescue-capture-stall`）2 项 UNVERIFIED |
| kimi | **BLOCKED** | 站点忙（`resource_exhausted`，外部条件）；F14 登录判定已真机验证 |
| zai | **BLOCKED** | 地区弹窗遮挡（外部 UI 条件，F16 可读归因，0 发送）；#64 仍欠复跑 |

变体 1 = 修复后冻结代码；变体 2 = 仅 F14/F15/F16 定向修复，补跑 kimi/doubao/zai。两变体边界见
`doc/progress.md` 2026-10-19 节与 `doc/verify.md`。全程有头、串行、站间 ≥20s、零重试、**无验证码/滑块**。

### 新增登记

`doc/long-term-issues.md` **#79–#84**：doubao resume 分段 no-options（已修）/ doubao 流式增量捕获（未修，根因未定位）/
zai `SEND_NOT_CONFIRMED` 与导览遮罩（F16 保守处置）/ kimi 登录水合时机（已修）/ z.ai 国内站与地区弹窗（未改 origin）/
kimi 站点忙弹窗可关闭但桥无受控恢复（未实现，用户现场情报）。

### 边界

- **本版本只装到本机 web profile**；未推 GitHub、未发 npm；tag 仍停在 `v0.19.92`。
- 全量单测：133 文件 **132 exit 0**（唯一红 `webcode-preset` ②e 为既有 #56 官方 standard 漂移，非本轮引入）。
- kimi/zai/doubao 的非 PASS 结果均为**站点侧**或**未修缺陷**，未写成通过；详见台账。

## 0.19.92（2026-10-17 发版轮）发布轮 + 本机 GitHub 传输层修复（**版本号一次从 0.19.76 跨到 0.19.92**）

### 这次发版装的是什么

0.19.77–0.19.91 这十五轮改动**一直没换版本号**（本文件里那些小节的标题自己写着「版本号不变」），
`package.json` 停在 `0.19.76`。本版本把它们**一次性收口**到 `0.19.92`，tag `v0.19.92`
与 `package.json` 的 version 逐字一致（`release.yml` 有硬校验）。

覆盖面（明细见 [doc/progress.md](../../doc/progress.md) 各节）：

| 轮次 | 主题 |
| --- | --- |
| 0.19.77 | 除 DeepSeek 外全部站点的「三项适配」真机审计 + 三条真缺陷修复 |
| 0.19.78 | Phase 2 真实对话验证：五站四态 + 三条真缺陷修复 |
| 0.19.79 | 图片能力真机验收 + 「人类操作记录」回路 + zai `no-options` 排查 |
| 0.19.80 | 设置页不再手写第二份协议（红线 3）+ 站点级工具调用适配审计 |
| 0.19.81 | zai 第二轮 `no-options` 真因（按钮被禁用）+ 挖出红线 1 的洞 |
| 0.19.82 | 红线 1 的洞 + 验收工具**自己**的两条缺陷（#68 / #69） |
| 0.19.83 | doubao 图片式验证不再伪装成「超时」（#63）+ 词表收成唯一真源 |
| 0.19.84 | 取证日志被测试污染（#73） |
| 0.19.85 | 第 ④ 项「解析覆盖」逐站闭环（含穷尽性证明） |
| 0.19.86 | 默认模型 `deepseek-web` 的带图能力两条路径给相反答案（#74） |
| 0.19.87 | #63 真机复验失败的**真因**：判据从未跑过（#75）——`page.evaluate` 只接受一个 arg |
| 0.19.88–0.19.91 | doubao「读不到回复」真因（#76）+ 修复真正部署到 live 副本 + 逐站真机验证已声明的 `answerSelector` |

### 本机 GitHub 传输层修复（**不改产品代码**）

本机 `git` 的两条传输在受限环境里都是死的，而**报错都指向错误的方向**：

| 现象 | 真因（实测） | 修法（全局 git 配置） |
| --- | --- | --- |
| 走 SSH：`ssh.exe: fatal error - NtCreateDirectoryObject(\BaseNamedObjects\msys-2.0S5-…): 0xC0000022` | Git 自带的是 **MSYS ssh**，它要建命名对象而被沙箱挡住；网络与密钥其实都好（`ssh -T git@github.com` 认证成功） | `core.sshCommand = C:/Windows/System32/OpenSSH/ssh.exe` |
| 走 HTTPS：`schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS (0x8009030e)` | 默认 `schannel` 后端在受限凭据上下文里取不到凭证；**与证书、代理、网络都无关**（同一时刻系统 `curl.exe` 报的是**一模一样**的错） | `http.sslBackend = openssl` |
| `github.com:443` 时通时不通（`Failed to connect … after 21085 ms` / `Connection was reset`） | git / curl 的多次直连超时或重置；而同一时刻对**同一个 IP** 的独立 HTTPS 探针又能连续成功 ⇒ 本机到 GitHub 的 443 通路是**间歇性**的，不是永久封锁。`api.github.com` 全程正常（`gh` CLI 可用） | 固定「一直可用」的那条腿：SSH；HTTPS 侧改用 openssl 后端 |

修复后实测（**命令行无任何 `-c` 覆盖**）：`git ls-remote origin` ✔、
`git ls-remote https://github.com/RSLN-creator/dsh-web-bridge.git` ✔、
`git push --dry-run origin main` ✔（`6dcd816..917abaa  main -> main`）。

### 边界（如实）

- 本次只发 **GitHub Release**（tag 触发 `release.yml` 产出 tgz 资产）；registry 上的 `latest`
  仍是 `0.19.76` —— 本项目的发布通道按设计**绝不 publish 到任何 registry**。
- `github.com:443` 的偶发重置是**本机网络**层面的现象，本轮**没有**改 DNS / hosts / 代理设置；
  上表最后一行只是把可用通道固定下来。
- 全局配置已备份（`gitconfig.bak-*`），回退只需 `git config --global --unset` 那两行。

## 0.19.87（2026-10-16 第六轮，**版本号不变**）★★ 找到 #63 真机复验失败的**真因**：判据从未跑过（#75）

### ★★ `page.evaluate` 只接受**一个** arg，而 #63 的修复传了 4 个

Playwright 契约：`evaluate(pageFunction, arg)` —— **pageFunction 之外只许再有一个 arg**。
传多了它**同步抛** `Too many arguments`（离线实证，用仓库自带 chromium）。

**危险在于它抛得早、又紧接着 `.catch`**：表达式抛 ⇒ 异常被吞成 `null` ⇒
调用方看到「没命中」，与「页面本来没有验证码」**完全一样**，而真相是**那段判据从未执行**。

⇒ 这正解释了 0.19.83 的真机复验：报错仍是 `web turn timed out`、
**连 `probe` 诊断行都没打出来** —— 那是「代码没跑」的指纹。

⚠ **最刺眼的一点**：当时**源码级护栏全绿**（只断言「源码里有 `page.frames?.()` 这个字符串」）。
**护栏只证明「字符串在场」，不证明「跑得起来」** —— 这次付了**真机**代价。

**全仓普查**：`evaluate` 传 ≥3 顶层实参的调用 = **恰好 1 处，就是它**。
⚠ 普查首版把阈值写成「>1」⇒ 把 **32 处正确**写法报成缺陷（**假读数**）；正确阈值是 **3**
（pageFunction 自己占第一个位置）。接收者正则首版也漏掉了 `f.evaluate` —— 而那正是缺陷点。

**修法**：参数收进**一个对象**。**护栏双判据** `test/evaluate-argc.test.mjs`：
① 源码级（顶层实参不得 ≥3）；② **行为级**（真的起浏览器，钉住「传 3 个必抛」+「单对象正常」
+「#63 的 doubao 真机原文在正确传参下必须命中」——最后一条证明**修完之后那段判据真的跑得起来**）。

### 另一个产物：逐 frame 扫描的编排抽成纯函数 `lib/captcha-gate-scan.js`

抽它的**唯一理由**就是 #75：编排原写在超时回调里、依赖真实 `page` ⇒ 只能写源码级护栏。
抽出来后用**假 frame** 离线驱动：`test/captcha-gate-scan.test.mjs` **7/7**
（命中即停 / probe 汇总 / `frameError` 如实记 / 跨域抛错不挡真命中 / 空列表不抛）。
**反向变异全部变红**：静默吞 frameError ⇒ 5/7；命中不即停 ⇒ 5/7；未命中不汇总 probe ⇒ 3/7。

### 诚实边界

**doubao 真机仍未复验** —— 本修让那段代码**第一次真的跑得起来**，
但「跑起来能否命中 doubao 的验证码」**仍需真机读数**，**不许写成通过**。**本轮零真机发送**。

## 0.19.86（2026-10-16 第五轮，**版本号不变**）修掉 #74：默认模型的「带图能力」两条路径给相反答案

### ★ 别名 `deepseek-web` 的目录条目漏了 `acceptsImages`

同一个模型有**两条取数路径**（都真实被消费）：

| 路径 | 取数 | 谁消费 |
| --- | --- | --- |
| **目录路径** | `listAllModels()` 的条目 | `web-control.js` 的 `GET models`（`:968`）与 `GET context-windows`（`:999`）原样返回 |
| **解析路径** | `resolveWebModel(id)` | `index.js:1270` 的 `inputModalities` —— **护栏** |

修前实测：`listAllModels()` 17 条里 **1 条两路径不一致** ——
`deepseek-web: catalog=false resolve=true`（`providers.js:963` 手写的别名条目
**漏了 `acceptsImages`**；而 `deepseek-web` 正是 **`DEFAULT_MODEL_ID`**）。

**为什么危险**：`inputModalities` 是**护栏**，宿主**只在它明确不含 `'image'` 时**
把图片换成文字占位 ⇒ 声明错方向时 **harness 截图在到达桥之前就被剥离**，
症状是「网页端说没图」，而**桥这边看不到任何异常**。

**修法**：别名条目与真条目**同源取值**（照抄 `DEEPSEEK.models[0]`），不再手写第二份
⇒ 将来加字段不会再漏。**实测**：不一致 **1 → 0**。
**护栏** `test/provider-surface.test.mjs` 判据 8；**反向变异**：再删该字段 ⇒ **7/8 红**。

**边界**：本判据只查「两条路径同值」，**不查**「该值是否符合真机事实」
⇒ 本修是「消除自相矛盾」，**不是**「证明图片能力」。

### 第 ① 项离线侧读数

`acceptsImages` 声明分布：deepseek / glm / kimi(k3,k2.8) / doubao / zai 全 `true`；
**chatgpt / qwen / grok / claude / gemini 的 auto 未声明**；
⚠ **全仓零个模型被显式声明为 `false`** ⇒「不能带图」在声明层是**缺席**表达。
⇒ 方向安全（不会误放图片）。真机四态沿用上一轮：zai **PASS** / doubao **UNVERIFIED** /
kimi **BLOCKED** / glm **BLOCKED**（其 `attachPreview` 修复仍未在真页面验证）。**本轮零真机发送**。

## 0.19.85（2026-10-16 第四轮，**版本号不变**）第 ④ 项「解析覆盖」补上逐站闭环（含穷尽性证明）

### 一、缺口：没有任何测试**逐站**断言「教什么就解析什么」

第 ④ 项三件事里「**解析覆盖**」此前分在两侧、中间那条缝没人管：
教学侧（`tool-transport`）只断言「文本里有没有那段话」，解析侧（`official-tool-calls` 等）
只用**手写夹具** ⇒ 没有一条把「教学产出的形状」与「回读」接起来。

新增 `test/teach-parse-closure.test.mjs`：遍历 `providers.js` 的**全部站点**，
断言「教学文本含该形状标志物」+「按该形状回帖解析出恰好 1 条」。**10 站全 PASS**。

### 二、★ 护栏的**确切**增量（四个反向变异，其中一个否掉了我的说法）

| 变异 | 结果 |
| --- | --- |
| **M1** glm 教学改成标签形状 | 本护栏 2/4 红；⚠ **既有护栏也红**（四道一起）|
| **M2** 删判据②的 `mcp_action` | **全部吞掉**（解析器另有宽容路径）⇒ **变异无效，不算证据** |
| **M3** 官方骨架失去 invoke 闭 token | **本护栏也吸掉** ⇒ 在该形态上不敏感 |
| **M4** 往 `SITES` **注入第 11 个假站点** | ★ 本护栏**自动覆盖**（日志出现 `zfakeprobe=tag`），而既有护栏**全绿、根本没看它** |

⇒ **诚实结论**：本护栏**不是**「既有护栏漏掉的那条缝」的独家守卫。
真实价值两条：① **穷尽性**（遍历 `SITES`，新增站点不会静默跳过；M4 已证）；
② **机读的逐站四态表**（正是用户第 ④ 项要的「给出每站四态」那一格）。
⚠ **不把 M1 的红读成独家覆盖**：那是四道护栏一起红。

### 三、方法论：本轮我自己的判据错了两次（读数对、构造错）

1. 首版断言 deepseek 解析 **1** 条，实际 **2** 条 ⇒ 查证发现**是我的构造错**：
   官方骨架有**两行**占位符，我用 `.replace()` 只换了第一处。
   `official-tool-calls.test.mjs:101` 早已钉住「两条相同占位示例按签名去重合成 1 条」是**按设计**。
   ⇒ **与既有设计冲突时，先查设计，别先改产品**。
2. M2/M3 没红时，我先记成「护栏不敏感」；读解析器实际输出后才发现是**变异被宽容路径吸收**
   ⇒ **无效变异与漏检是两件事，不能混记**。

### 四、诚实边界

- 本闭环是**离线**判据（教学 ↔ 解析自洽），**不等于真机适配**；
  真机 5 站里只有 glm 有形状读数，zai/qwen/doubao 仍零条；
- **本轮零真机发送**；第 ① 项沿用上一轮四态；#63 真机复验仍**未通过**；kimi #70 未修。

## 0.19.84（2026-10-16 第三轮，**版本号不变**）修掉**取证日志被测试污染**（#73）+ 第 ④ 项拿到**真机读数**

### 一、★ 一片「假读数」差点让第 ④ 项改错代码（#73）

做第 ④ 项（不同厂家工具调用适配）时，先从真机取证日志 `~/.dsh/logs/` 现算各站形状，
读出「deepseek **321 条 UNPARSED**」，其中一条形状占 **88 条**。

**动手前先查了来源**（这一步是本轮最有价值的动作）——决定性判据：

| 判据 | 读数 |
| --- | --- |
| 占位值 | `D:/x.js`、`old_string:"a"`、`new_string:"b"`（不像真实用户数据）|
| 逐字重复 | 201 条只落在 **4 个片段**上 |
| 会话名 | **9 个 `auto-*`**（已 grep 在 `test/auto-continue*.test.mjs` 逐条对上）|
| 节奏 | 同会话内间隔**工整 20s / 30s**（探针节奏）|
| **真机会话** | **0 条** |

⇒ **那是测试写进真实日志的合成样本**。照它改 = 为「真实模型从不这么吐」的形态增加
解析宽容度 ⇒ 真实链路什么也没修好。**假读数比没有读数更贵。**

**根因**：守卫只认 `NODE_TEST_CONTEXT`，而它**只在 `node --test` 下设置**；
本机因 `spawnSync` EPERM 而**文档化要求逐文件跑** ⇒ 那种跑法**不设该变量**。
**实测**：跑一次 `node test/auto-continue.test.mjs` ⇒ 真实日志 `session=auto-cont-1`
条目 **34 → 36（+2）**；全量 **3644 条里 2065 条非真机**，真机（`session-<uuid>`）仅 **1579 条**。

**修法**：三条拒写判据——① 原 env；② ★ **调用栈里出现本仓库 `test/`/`test-mock/` 的 .mjs**
（**与跑法无关**）；③ 显式 `opts.dir` 照常放行。
**验证**：同命令污染 delta **+2 → 0**；护栏新增两条；反向变异 ⇒ **7/9 → 9/9**。

### 二、★ 第 ④ 项的真机读数（**只用 `session-<uuid>` 真机会话**，现算解析）

⚠ 首版按 `auto-` 前缀排除，把 `fence-tail`/`headless-tail` 一类**漏成了真机**（表里出现假读数）。

| 站点 | 正文 | 思维链 | 含痕 | PARSED | UNPARSED |
| --- | --- | --- | --- | --- | --- |
| **deepseek** | 818 | **649** | 782 | **755（96.5%）** | 27 |
| **glm** | 65 | **45** | 49 | **43（87.8%）** | 6 |
| kimi | 1 | 1 | 0 | 0 | 0 |
| qwen/doubao/zai | **0** | — | — | — | — |

**原生形态**：deepseek = 官方 `▁` token（711 条，占痕量 91%）；glm = ```json 代码块（24 条）
⇒ **这两站的适配在工作，且与 `transportNoteFor` 的教学立场一致**（覆盖率 96.5% / 87.8%）。

**剩余 8 站**：**不是「缺真机读数」，而是「取证通道里没有它们的记录」** ——
`appendReplyLog` 的调用点**只在 `lib/index.js`**（产品适配器路径），
而 `site-verify-multiturn` 走 **driver 直连** ⇒ 不经过那条路径
（glm 最后真机条目停在 2026-09-28、kimi 09-27、zai 零条）。
⇒ 已给取舍脚本加**可选**留档（`SV_CAPTURE_REPLIES=1`，**只写 `.tmp/`，绝不碰 `~/.dsh/logs/`**），
并加源码级护栏钉住该边界（反向变异 ⇒ 8/9 红）。

**deepseek/glm 的 UNPARSED 逐条归因**：模型自身漂移 / 断流截断（属既有 #25）/ 误报候选
——**三类都不是「厂家适配缺失」**。

### 三、台账纪律：补记真机额度（记账失误已追平）

`GATE.md` 的「已用次数」此前**没跟着本轮真机跑更新**（glm/zai 仍写 `0`，实际已各用 **3/3**）。
已按**本次会话日志**追平，并加 §3.2 额度小结。**纪律**：每次真机发送后必须立刻回写。

### 四、诚实边界

- **第 ④ 项仍未完成**：只有 deepseek / glm 两站读数；zai/qwen/doubao 仍零条（原因见 §二）；
- **日志里 2065 条污染条目仍在**：本修只阻止新增，**不删旧数据**（属用户数据，须报备）；
- **第 ① 项沿用上一轮读数**；#63 真机复验仍**未通过**；kimi #70 未修；
- **本轮零真机发送**（glm/zai 额度已尽；kimi/doubao 剩下的额度留给用户点头的时机）。

## 0.19.83（2026-10-16 第二轮，**版本号不变**）doubao 图片式验证不再伪装成「超时」+ 词表收成唯一真源

### 一、★ 修 #63：`WEB_CAPTCHA_REQUIRED` 取代误导性的 `web turn timed out`

真机（doubao，两轮独立复现，逐字一致）：
`turn timeout scene {"captureAlive":true,"replyChars":0,"selectorHit":false}` ⇒
`web turn timed out after 110000ms`。**报错方向是误导的**：用户会去查解码器/网络，
而真实下一步是「**需要人去过验证**」。

**根因三条**（第三条是上一轮漏掉的）：① doubao 未声明 `captchaSelector`；
② `detectChallenge` 的 body 词表没有图片选择式（「请选择所有…图片 / 并拖拽到下方」）；
③ ★ **`detectChallenge` 只在导航时跑**，而发送后那次采样**以站点声明为前提**
⇒ 两条路都不通。**检查点选错了位置**——doubao 的验证在**发送之后**才弹出。

**修法**：在**整轮超时点**加归因，两条独立判据任一命中即抛 `WEB_CAPTCHA_REQUIRED`：
① 站点声明位命中**可见**节点；② **可见文案指纹**（**不依赖站点声明**，这是 doubao 的缺口）。
⚠ 文案指纹**只归因、不代替站点声明** ⇒ **没有**顺手给 doubao 加 `captchaSelector`
（未取得真实选择器读数，按纪律不猜）。

### 二、★ 词表收成**唯一真源**：新增 `lib/challenge-heuristics.js`

原词表**内联在 `page.evaluate` 字符串里**（测试读不到 ⇒ 护栏只能查字符串），
且合并前**已有两份近似清单**——这正是 #63 的成因结构：同一事实两处各写一份。
新模块把三类事实分开：「页面自己说了什么」（词表，**唯一真源**）／
「哪个选择器属于哪个站点」（仍收口 `providers.js`）／「归因还是拦截」（驱动）。
驱动以 `source` **传参**在页面侧重建，与 `attach-scope.js` 的 `ATTACH_PICK_SRC` 同一条纪律。

⚠ 三条正则**不得带 `g`/`y`**：页面上下文里同一对象会被多次 `test()`，
带 `g` 会因 `lastIndex` **递进而交替返回真假**（本仓库记过同型缺陷）——有护栏钉住。

### 三、本轮被自己的护栏拦下四次（都是真缺陷，不是误报）

1. `driver-scope` 的扫描器**不收集箭头函数形参** ⇒ `PAT.body` 被报成自由标识符。
   修法是**改代码形状**（两个 source 用独立裸形参传入），**不是放宽扫描器**。
2. 新护栏 ④b 首版**自相矛盾**（既禁又要求同一个字符串）⇒ 当场假红。
3. ④/④b **两次把注释里的「证据引用」当违规**——驱动的注释逐字引用真机原文来解释为什么修。
   修法：判据先剥 `//` 行与 `/* */` 块注释，**判代码不判注释**。
4. #63 的 ④b 锚点**用错**：「超时前 6000 字符内找码」⇒ **删掉整块仍 PASS**
   （窗口落到发送后闸门）。已改用只属于超时块的字符串。
   **与 #68 同型：护栏若只匹配「名字出现过」，它判的是文本，不是实现。**

### 四、#71：无地址形状站点的「同一会话」第二判据

`sessionId` **或** 驱动 0.19.52 起的落地地址锚 `landedUrl` 任一逐字相同即成立；
两者都拿不到**仍记 UNVERIFIED**。⚠ **不用「同一个 key 复用」当证据**（key 是 DSH 侧槽名）。

### 五、★ 真机复验：**没有命中**（如实记，本轮最重要的负面读数）

重跑 doubao（1 次发送，有头、串行）读数仍是：

```
[log] send confirmed (composer cleared / navigated) url=https://www.doubao.com/chat/local_…
[warn] turn timeout scene: {"captureAlive":true,"replyChars":0,"selectorHit":false}
FAIL  send — (no code): web turn timed out after 110000ms（捕获链在，页面无回复文本）
```

⇒ **不是** `WEB_CAPTCHA_REQUIRED`。**纪律：不许把「代码改了」写成「缺陷修好了」。**
#63 的状态 = **离线护栏全绿 + 真机复验未通过**，不是一个干净的成功。

**从负面读数得到两条新线索（都已落地，但同样尚无真机验证）**：

1. ★ **`page.evaluate` 只跑主框架**，而风控/人机验证**普遍渲染在独立 iframe 里**
   ⇒ 主框架 `body.innerText` 一个字都看不到，词表再全也抓不到。
   已加固为**逐 frame 查**（`page.frames()`；跨域 frame 抛错则跳过并记入 probe），
   护栏 + 反向变异：改成只查主框架 ⇒ `captcha-gate` **5/6** 红。
2. **没命中时必须能说清「为什么没命中」**：只记「没命中」会把「本来没有验证码」与
   「有验证码但我不认」印成同一件事。已加 `probe` 诊断（**只落关键词与长度，
   不落整页正文**——这类页面可能含账号信息）。
   ⇒ 下一个动作：读 `captcha probe at timeout (no hit)` 那一行的 `frames`。

只读探针 `test-mock/doubao-frames-probe.mjs` 已建成（零发送）：静止态 **2 个 frame**
（主框架 + `drive-iframe`）、**无**验证 ⇒「弹验证时」的 frame 结构**仍未取到**。

### 六、验证与边界

- 护栏：`challenge-heuristics` **7/7**、`captcha-gate` **6/6**、`driver-scope` **7/7**、
  `regression` **54/54**、`site-verify-verdict` **22/22**。
- **反向变异全部变红**：删超时归因块 ⇒ 4/6；删 doubao 词条 ⇒ 4/7；加 `g` ⇒ 4/7；
  只查主框架 ⇒ 5/6；detectChallenge 改回内联（title 一条）⇒ 53/54。
- **#63 真机复验未通过**（见 §五）⇒ **不许写成「doubao 已验证」**。
- kimi #70 未修（长度相关仍是假设）；第 ①/④ 项未推进。
- 本轮真机发送追加 1 次（doubao）；该站累计 2 次，**未超 ≤3 次额度**。

## 0.19.82（2026-10-16 第一轮，**版本号不变**）修掉红线 1 的洞 + 修掉验收工具**自己**的两条缺陷

**用户指令（逐字）**：「每站 1 发、间隔 60s，同一会话最低 3 次来回，回复间隔 30s」，
以及上一轮那句仍未过期的「**！！不要每次就几个字还很短！！被风控了！！！**」。

### 一、★ 红线 1「绝不静默降级模型」的洞（#67，**已修**）

`lib/model-picker.js` 的 `readbackConfirms` 修前有**两个**会放行「比目标更短的回读」的入口
（登记时只看见一个）：双向包含 `a.includes(base) || base.includes(a)`，以及
「被点中项比目标短时拿它当基准」。⇒ 用户选 `zai:glm-5.3-flash`、切换没生效、
页面仍停在 `GLM-5.3` 时，判据说「已确认」⇒ **界面完全看不出来**。

**修法**：判据收成**单方向** `a.includes(w)`（回读必须覆盖目标名），并删掉第二个入口。
单方向足够，因为装饰（`GLM-5.3-Flash ▾`）与营销后缀（`GLM-5.3-Flash NEW …`）
**只会让回读更长**。⚠ 误判方向是刻意选的：判 false 只降级成 `strict:false` + warn
（**切换本身仍发生**），判 true 则是静默降级——两者代价不对称，宁少放行。

**验证**：`test/model-picker.test.mjs` **14/14**（`todo` 转正式断言，文件里不再有 todo）；
反向变异（把 `|| w.includes(a)` 加回去）⇒ **13/14**，护栏当场红。

### 二、★ 验收工具读的是**空目录**（#68，**已修**）

`test-mock/site-verify.mjs` 手写的槽目录 `<root>/<siteId>` 与产品的
`accounts.slotProfileDir()`（非 primary 站点 = `<root>/sites/<siteId>`）**分叉**，
差集恰好是**全部非 deepseek 站点**（deepseek 是唯一 primary，恰好相同）
⇒ 验收工具指向不存在的空目录，每个非 deepseek 站点只会得到 `BLOCKED 未登录`——
**与「profile 真的没登录」完全不可区分**。

**修法**：改用产品那一个真源，不再手写第二份公式。

**验证**：护栏 ⑥ 断言「工具算出来的路径 == 产品算出来的路径」+ 钉住
「`mountAtRelayRoot` 只允许 deepseek」；反向变异 ⇒ ⑥ 红（16/17）。
⚠ 顺带抓到**护栏自身的假绿**：首版 ⑥ 只 `assert.match(src, /slotProfileDir\s*\(/)`，
匹配到的是 import 行与注释 ⇒ 实现换回手写后仍 PASS（已改成剥注释 + 行首锚定）。

### 三、★ 验收工具三轮发送**零间隔**（#69，**已修**）

`.tmp/real-lane/PHASE2-RUNBOOK.md` §3 写着「本脚本内部三轮间隔由 `sendGapMs` 控制」，
而修前的 `site-verify.mjs` **一处等待都没有**（可 grep 证伪）⇒ 三轮**背靠背连发**。
`sendGapMs` 是产品里**唯一**一档发送节流 ⇒ 验收脚本用比产品严苛的节奏打站点
⇒ **自造风控** ⇒ 四态记成 `BLOCKED`，**外部原因与工具自己的违规读数上无法区分**。

**修法**：新增 `test-mock/site-verify-pacing.mjs`，**复用**产品两个真源
（`accounts.sendGapForSlot` + `metrics.computeSendGap`），每轮发送前过闸门，
并另设**独立于设置的 ≥20s 地板**（设置 0 时也不许零等待）。

**验证**：`test/site-verify-pacing.test.mjs` **9/9**；反向变异（删三处闸门调用）⇒ ④ 红（8/9）。

### 四、新增用户第 ③ 项的真机执行器（`site-verify-multiturn.mjs`）

既有的 `site-verify.mjs` 第 3 轮是 `fresh:true`（**新开会话**）⇒ 证明不了「同一会话」。
新脚本**全程同一个会话**跑 **≥3 轮来回**，间隔按用户节奏（回复间隔 30s，取较大者），
且第 3 轮断言回复里出现一个**只存在于本机临时文件**的随机标记
⇒ 这才分得开「真的读了工具结果」与「模型瞎编」。`--dry-run` 已跑通（零发送）。

### 五、第 ③ 项真机执行：四站串行跑完（Lead 主裁判，60s 节奏）

新增执行器 `test-mock/site-verify-multiturn.mjs`（**全程同一个会话**、**≥3 轮来回**、
每轮前过间隔闸门、`fresh:true` 只用在第 1 轮）。**live profile、有头、串行**：

| 站点 | 四态 | PASS/FAIL/BLOCKED/UNVERIFIED | 结论 |
| --- | --- | --- | --- |
| **glm** | UNVERIFIED | **13/0/0/1** | ★ **同一会话 3 轮 + 工具闭环成立**（三轮 `sessionId` 逐字相同；第 3 轮回复里出现本机临时文件的随机标记）|
| **zai** | UNVERIFIED | **11/0/0/3** | ★ **修正 ② 真机生效**：三轮全程 `selectModel`，**零 `MODEL_UNAVAILABLE`**（上一轮同位置必现 `no-options`）|
| **kimi** | **FAIL** | 2/1/0/1 | **新缺陷 #70**：`SEND_NOT_CONFIRMED`（长正文发不出去）|
| **doubao** | **FAIL** | 2/1/0/1 | 与上一轮 **#63 同形**（图片式人机验证未被识别）|

**共 12 次真实发送**（glm 3 + zai 3 + kimi 2 + doubao 1），失败处**停手不凑数**，
每站第 2/3 轮实等 60s（日志 `[gap]` 行佐证）。
⚠ 两个闭环成立站仍记 **UNVERIFIED 而非 PASS**（各有 `turn.settled` 等未验项）——
**UNVERIFIED 不许写成通过**。

### 六、kimi 的 `SEND_NOT_CONFIRMED`（#70）：三条只读取证赶走两个诱惑

驱动日志逐字 `send path: sel=.send-button-container ready=true composerChars=1549`。
两条「看起来很像结论」的假设都被**读数**否掉：

- 「一定没登录」⇒ 截图昵称 `TYZ0712`、composer 可编辑 ⇒ **排除**（不是 #53 那种三态问题）；
- 「发送键契约过期」⇒ `locator.click({trial:true})` **通过全部可点性检查**
  （attached/visible/**stable**/receives-events/enabled 全绿，`pointer-events:auto`、无 `disabled` 类）
  ⇒ **排除**（连「动画 SVG 让 stable 失败」这个最像的机制也不成立）；
- 弹层吃点击 ⇒ 可见 dialog/modal/mask **为空** ⇒ 排除。

⇒ 剩余方向是**长度相关**（既有成功读数那轮 65 字，本轮 **1549 字**），
但**配对实验未做** ⇒ 只作假设，**不写成结论**。诊断器 `test-mock/kimi-send-diagnose.mjs`
（**只读**：只有 `trial:true`、不写输入框、不按 Enter）复现了全部读数。

### 七、诚实边界

- 第 ③ 项 **2 站成立 / 2 站 FAIL**（kimi #70 未修、doubao #63 未修）；
- `session.same` 的第二判据（#71，落地地址锚）**已修并离线验证**，但 zai 那两次
  UNVERIFIED **保留原读数**（额度已尽，未用它重跑）⇒ 不改写成通过；
- 第 ① 项图片真机验收、第 ④ 项站点级适配（#66）本轮**未推进**；
- 全量测试：**2 条既有失败**（`webcode-preset` ②e、`aux-delta-compact`）
  **在 pristine HEAD 上同样失败**（已 stash 核对）⇒ **本轮零回归**。

## 0.19.76（2026-10-15 第四轮，**版本号不变**）设置页不再手写协议文本（红线 3）

**用户指令（逐字）**：**「另外你的思维工具调用不同厂家模型适配没做啊！」**

### 一、真缺陷：设置页教了**桥自己不教、也解析不了**的协议形状（已修）

为回答「不同厂家适配没做」而做的审计：把 `transportNoteFor()`（**真源实际发送的教学**）
与 `settings-page.js` 的手写卡片**逐字对照**，发现两处不一致：

| 站点 | 设置页原先教的 | 真源实际发的 | 后果 |
| --- | --- | --- | --- |
| **Doubao** | `<seed:tool_call>{"name":…}</seed:tool_call>` | `<tool_call>{"mcp_action":"call",…}</tool_call>` | 教了**真源从不教**的壳，且缺 `mcp_action` |
| **Kimi** | `<tool_call>{"name":…,"arguments":…}</tool_call>` | 同上（带 `mcp_action`） | 缺 `mcp_action` |
| **Qwen** | `# Tools` + `<tool_call>{"name":…}` | 同上（带 `mcp_action`） | 缺 `mcp_action`、多教 `# Tools` |

`agent-preset.js:2553` 的**唯一解析判据**是 `tagged || obj.mcp_action === 'call'`
⇒ 设置页教的裸 JSON 形状（无 `mcp_action`）**解析不了**，用户照抄会得到「调用了但没执行」。

**决定性读数**：`seed:tool_call` 在**解析器**里（`agent-preset.js:2712`，宽容外壳）、
在**设置页**里（4 处，教用户）、而**教学侧零出现**（`transportNoteFor` 从不发它）。

**修法**（落实红线 3「工具协议只有一处定义」）：`renderSettingsPage(models, teaching)`
新增第二参数，由 `index.js` 在挂载路由时**运行时从真源取**
（`transportNoteFor` / `trainNoteFor`）；旧手写段整段删除；缺省时**如实显示「未提供」**，
**不回落**旧文本（回落＝把刚删的第二份请回来）。

**护栏**：`test/settings-transport.test.mjs` ⑦（正向上屏 / 反向消失 / 缺省不回落三支）。
**反向变异已验证**：换回手写 `seed:tool_call` ⇒ **10 → 9 pass / 1 fail**；还原 10/10。

### 二、用户第 ④ 项的答案：变体粒度确实不足（登记 #66，**未修，缺读数**）

现算读数：`prompt-variants` 只有 **3 条**变体（`official`←deepseek / `glm`←glm /
**`default`←其余 8 站**），而厂家有 5+ 个 ⇒ 除 GLM/DeepSeek 外**8 站教学逐字相同**
（已核对 `transportNoteFor` 对 kimi/doubao/zai/qwen/chatgpt 输出**逐字一致**）。

GLM 的先例证明站点差异是真实的（网页内置执行器会抢走 `<tool_call>` 标签）。
**已知但无读数**：doubao 网页原生标签是 `seed:tool_call`；gemini 走 `dom` 解码器。

**为何未修**：按纪律「没有真机读数就不声明」—— 同一晚 Lead 在 GLM 发图上连猜三次全错
（第二次把图传成用户头像），**给 8 站凭空加变体等于把同一个错误放大 8 倍**。

### 三、判据

```
node test/settings-transport.test.mjs → 10/10 pass exit 0（新增 ⑦）
node test/composer-write.test.mjs     → 25/25    node test/think-effort.test.mjs  → 27/27
node test/model-picker.test.mjs       → 8/8      node test/prompt-variants.test.mjs → 15/15
node test/provider-surface.test.mjs   → 7/7（对外面逐字不变）  node test/login-state.test.mjs → 6/6
七个仓库闸门                          → 全 PASS
```

### 四、本轮**未做**

- **未**给 doubao/kimi/qwen 新增专属变体（缺真机读数）。
- **未**做用户第 ③ 项（4 站同一会话多轮 + 本地工具完整闭环）。
- 设置页修复**未在浏览器里目视核对**（要等 DSH 重启后新代码生效）。

## 0.19.76（2026-10-15 第三轮，**版本号不变**）图片能力验收 + GLM 附件确认选择器修复

**用户指令**：① 图片能力真机验收 ② 修 zai 切模型 ③ 4 站完整闭环 ④ 各厂家「思维链+工具调用」适配。
以及一条**方法论纠正**：**「你一直自己改改不好！」** —— 用户提出「你操作、我记录」的取证方式。

### 一、GLM `attachPreview` 修复（真机轨迹取证）

**缺陷**：GLM 上传成功后的预览节点是 **`img.limitSize`**，而声明的
`[class*='file'], [class*='attachment'], [data-file]` **一个都不匹配它**；
`[class*='file']` 还会命中那 **3 个隐藏 `<input type=file>`**（class 含 "file"）
⇒ 计数非 0 但全不可见 ⇒ `ATTACH_NOT_CONFIRMED` ⇒ 按既有设计**静默降级纯文本**
⇒ 模型回「我没有收到任何图片」。

**修法**：`attachPreview: "img.limitSize, [class*='file'], …"`（只加不换）。
⚠ **该修复尚未在真页面验证**（GLM 深链会触发阿里云滑块，见下）——如实记为待验。

**同轮撤回一次错误修复**：我曾把 `attachSelector` 改成 `:not(.img-input)`，
结果**把图传成了用户的账号头像**（用户截图抓到「修改头像」对话框）。
真机记录证明通用回退本来就能选中正确的 `.img-input` ⇒ 已撤回。

### 二、图片能力真机验收（判据 = 模型是否真的读出图中字符）

| 站点 | 四态 | 依据 |
| --- | --- | --- |
| **zai** | **PASS** | 上传 `ok=true` + 模型读出 **`HELLO`**（13.5s）|
| doubao | UNVERIFIED | 上传 `ok=true` 但 150s 超时无回复 |
| kimi | BLOCKED | 站点限流 `resource_exhausted` |
| glm | BLOCKED | 深链撞阿里云滑块（`TraceID 3ccdc160…`）|

**只有 zai 拿到完整闭环证据**（上传 + 识别 + 回流）。**没有把「上传成功」当成「图片可用」。**

### 三、zai 第二轮切模型 `no-options`：排除七条候选，**未修**

已排除：` NEW` 徽章匹配失败 / 菜单打不开 / 菜单已开再点会关 / 生产函数自身缺陷 /
「会话页」URL 形态 / 覆盖层遮挡 / actionability（**JS 直接 click 也开不了**）。
已复现的确切条件：**只在「实发一条消息之后的活 SPA 状态」上出现**（两次独立复现）。
⇒ 范围已缩小，**未修**（按纪律「改了没验 = 没做」，不写已修）。

### 四、判据

```
node test/composer-write.test.mjs   → 25/25 pass exit 0
node test/think-effort.test.mjs     → 27/27 pass exit 0
node test/model-picker.test.mjs     → 8/8 pass exit 0
node test/provider-surface.test.mjs → 7/7 pass exit 0（对外面逐字不变）
node test/model-labels.test.mjs     → 12/12 pass exit 0
七个仓库闸门                        → 全 PASS
```

### 五、本轮**未做**

- 用户第 ③ 项（4 站同一会话多轮 + 本地工具完整闭环）：**未做**。
- 用户第 ④ 项（各厂家「思维链 + 工具调用」站点级适配）：**未做**（用户已点名，如实留待下一轮）。
- GLM 图片需用户过一次滑块后重测。

## 0.19.76（2026-10-14 第二轮，**版本号不变**）Phase 2 真实对话验证：五站四态 + contenteditable 回读判据修复

**本轮起因**：用户在点破 qwen 登录真因后**亲手过了「访问验证」+ 输入邮箱验证码**（登录成功），
并要求把 Phase 2 跑完。据此在**五个站点**上跑完真实对话验证（有头、串行、每站一次 = 3 次发送
= 每变体上限），四态如实归档。

### 一、五站四态（**没有把 UNVERIFIED 写成通过**）

| 站点 | 四态 | PASS | FAIL | BLOCKED | UNVERIFIED |
| --- | --- | --- | --- | --- | --- |
| **qwen** | UNVERIFIED | **9** | 0 | 0 | 2 |
| **glm** | UNVERIFIED | **10** | 0 | 0 | 1 |
| **zai** | FAIL | **9** | 1 | 0 | 1 |
| **kimi** | FAIL | 2 | 1 | 0 | 8 |
| **doubao** | FAIL | 2 | 1 | 0 | 8 |

**最硬的正面读数**：glm 的 **`tool.resultRoundTrip` PASS** —— 第二轮收到 127 字且
**会话 id 一致**（`6acf9978581cc707568d90bc`）⇒「调用 → 本地执行 → 回填**同一**网页会话
→ 第二轮收束」这条链路**真实成立**。

### 二、修复：contenteditable 回读判据（doubao / kimi **全线不可用**量级）

**现象**：`PROMPT_TRUNCATED: 网页输入框只接收了 20/17 字符（判据=content-mismatch）`
—— 回读（20）**比发送（17）还长**，自相矛盾。

**真机读数**（doubao/tiptap，只写不发送）：

| 写入 17 字 `[本地工具传输协议]\n请回复：收到` | 结果 |
| --- | --- |
| `innerText` | **18**（`\n` → `\n\n`） |
| `textContent` | **16**（换行被吞） |
| **块感知读取（本版新增）** | **17 ✓** |

**根因**：tiptap/ProseMirror 把**每个换行渲染成一个块级 `<p>`**：

| 写入 | DOM |
| --- | --- |
| `a\nb` | `<p>a</p><p>b</p>` |
| `a\n\nb` | `<p>a</p><p class="is-empty"><br></p><p>b</p>` |

⇒ `innerText` 在块间补 `\n\n`（换行数翻倍）、`textContent` 不补任何块间分隔（换行被吞）。
**两个浏览器内建读数都不等于写入内容** ⇒ 恒判 `content-mismatch`
⇒ **每一轮含换行的提示词都被拦住**——而 DSH 的真实轮次**必然含换行**。

**修法：修读取，不放宽判据**。新增纯函数 `EDITOR_BLOCK_TEXT`（`lib/browser-driver.js`，**v4**）：

1. 有块级子节点时**只按顶层子节点切分**，每段取行内文本，用**单个 `\n`** 连接；
2. 行内 `<br>` 记一个 `\n`（用户的硬换行）；
3. **只含换行的段视为空串**（空块里的 `<br>` 是 ProseMirror 的**占位断行**）；
4. 去掉**首尾**空段、**保留中间空段**（那是真空行）。

`readComposer` 对 contenteditable 改用它。**`composerEchoVerdict` 的白名单一个字都没动**
⇒ 真正的截断**依然**不等、照旧被拦。

**真机验证**：doubao **7/7**、kimi **7/7** 逐字精确（含空行、`\t`、17 字原案）。
**效果**：doubao 的 `PROMPT_TRUNCATED` **消失**，失败下沉到下一层。

**变异反向验证**：去掉「空块记空串」⇒ **25 → 23 pass / 2 fail**；
`join('\n')` 改 `join('')` ⇒ **25 → 21 pass / 4 fail**；还原 25/25。
新增 6 条护栏在 `test/composer-write.test.mjs`（最小 DOM 桩，不引 jsdom）。

### 三、修复：qwen 登录判定的两个缺陷

1. **水合竞态**：qwen 先渲染「游客壳」（登录/注册可见）、~4s 才水合成已登录；
   而 `gotoFreshChat` 等的是 `SEL.input`，**游客壳自带 composer ⇒ 0.25s 就返回**
   ⇒ `judgeLoggedIn` 撞上瞬时游客壳 ⇒ `probe-bad` **立即 return false**。
   修法：`bad` 命中后给**有界 6s 窗口**（仅在有可见 composer 时），窗口内消失则继续走 ok 判定。
   窗口取 6s 的依据是两轮真机采样（水合落在 3.8s～7.8s 之间）。
2. **`loginProbe.ok` 选错**：真机头像是 `img.user-img`，声明的 `.user-avatar` 一族命中 **0**
   ⇒ 已登录也只得 `probe-fallback`（与 doubao #59 同型）。已按真机读数**追加** `img.user-img`。

**修后**：`loggedIn=true`、`loginBasis=probe-ok`、`loginVerified=true`。

### 四、判据

```
node test/composer-write.test.mjs  → 25/25 pass exit 0
node test/think-effort.test.mjs    → 27/27 pass exit 0
node test/login-state.test.mjs     → 6/6 pass exit 0
node test/provider-surface.test.mjs→ 7/7 pass exit 0（对外面逐字不变）
node wiki/tools/gen-index.mjs --check → exit 0
```

### 五、本轮**未**做（诚实边界）

- **kimi 的 8 项 UNVERIFIED 拿不到读数**：被站点侧限流挡住
  （`resource_exhausted：和Kimi聊天的人太多了`）。
- **doubao 的 8 项 UNVERIFIED 拿不到读数**：被站点**图片式人机验证**挡住，
  而桥**没识别**出那是验证 ⇒ 报「超时」而非 `WEB_CAPTCHA_REQUIRED`（已登记 #63）。
- **zai 的 `MODEL_UNAVAILABLE（no-options）`** 只定位到抛点，**未修**（已登记 #64）。
- 全部 Phase 2 读数来自**拷贝的 profile**（线上桥进程持有各站账号锁）
  ⇒「线上那个 profile 也验过了」**不成立**。

## 0.19.76（2026-10-14 轮，**版本号不变**）思考档位下发：三个站点的真机缺陷与修复

**本轮起因**：用户要求「除了 deepseek 之外的所有站点……每个网站模型适配提示词，模型适配思维链，
工具调用方法……**然后才能让他进行真实操作**」。据此做**零发送**的真机审计，
结果四个已登录站点里**三个**的「思维链适配」在真机上根本走不通——
若先发真消息，这些缺陷会被误记成「站点风控」或「网页不稳定」。**本轮真机发送次数 = 0。**

### 一、glm：档位下发整轮失败（三层根因）

**现象**：`applyEffort(page, {siteId:'glm', target:'快速'})` 抛
`THINK_EFFORT_UI_CHANGED: 站点 glm 的思考档位子菜单入口「思考强度」不可点`。
⚠ `极致` 因走**幂等快路**恰好通过 ⇒ **该缺陷长期躲过真机复跑**（这正是它活到今天的原因）。

**三层根因**（每层都由真机 trace 取出）：
1. 入口「思考强度」在**尚未打开的**模型弹层里——弹层未开时 `getByText` 命中 0，
   旧实现在此直接 `fail`。真机读数：先点一次 `.think-mode-trigger` 后，
   同一 `getByText` 立刻 `count=1 / visible=true`。
2. GLM 档位条目是**裸 `div.think-mode-item`**（无 `role`、无 `data-state`）⇒
   不在 `CLICK_EFFORT_OPTION` 的 `CLICKABLE` 白名单里 ⇒ 候选池 `poolSize=0`。
3. 补上 `optionSelector` 后**仍失败**：`.think-mode-item` 同时命中**左列模型行**
   （`GLM-Flashnew5.3-Flash，回复速度快`）与**右列档位条目**（`快速`）
   ⇒ `filter({hasText})` 匹配 2 个元素 ⇒ Playwright 严格模式 `isVisible()` **抛错**，
   被 `catch` 吞成 `false` ⇒ 误报 `UNAVAILABLE`。
   另：重试时只重开模型弹层、**没有重新展开子菜单** ⇒ 下一档必然失败。

**修法**：① glm 段补 `optionSelector: '.think-mode-item'`；② `CLICK_EFFORT_OPTION` 支持
`{want, selector}`，**限池后**才启用**前缀**判据（GLM 真实条目是 `深度处理复杂任务` 这类前缀形态，
旧的 `endsWith` 恒 false）；③ `click-option` 改为 **locator 优先 + 页面侧兜底**
（kimi 的 testid 走 locator，护栏钉住）；④ 重试时若声明 `itemLabel` 则**重新展开子菜单**。

### 二、doubao：回读判据与真机不符（含一次**自己改法被证伪**）

**现象**：`probe-think-effort-live.mjs doubao` → `FAIL（0/2）`，两档均 `no-readback`。
**真机读数**：两条目 `aria-checked` / `data-state` **皆为 null**（全页 TRUE 计数 = 0）。

⚠ **第一版修法（新增 `menuitem-svg` 口径：读带对勾 svg 的那条）经实测证伪**：
豆包的 radix 菜单**点完条目后立刻关闭**，回读那一刻 `role="menuitem"` 条目**已不在 DOM**
（trace：`afterClick_*` 各采样点 menuitem 数 = 0）。
⇒「读弹层选中项」这一类口径对豆包**结构性不可用**：**不是判据不对，是读的时机窗口不存在。**

**修法**：改走 `effort-text` —— 真机 trace 显示点完后触发钮文本
**从 `豆包 快速` 变成 `豆包 专家`**；该段已声明 `triggerText:'豆包'`，
`effortFromTriggerLabel` 剥掉锚点即得档位，**无需新增页面侧函数**（第一版新增的函数已整体撤掉，不留死代码）。

### 三、kimi：选路判据判错（顺带修复）

档位是**二级菜单**，但 `useSubmenu()` 判它「不需要」⇒ `THINK_EFFORT_UNAVAILABLE`。
与 glm 同一条链，修复后真机复跑 **2/2 PASS**（`标准` / `进阶`）。

### 四、判据与反向验证

```
node test/think-effort.test.mjs      → 27/27 pass exit 0
node test/provider-surface.test.mjs  → 7/7 pass exit 0（对外面逐字不变）
node test/model-labels.test.mjs      → 12/12 pass exit 0
真机零发送回归：glm 1/3 → 3/3 PASS；doubao 0/2 → 2/2 PASS；kimi 2/2 PASS；zai 3/3 PASS（连跑三次稳定）
```

| 反向变异 | 读数 |
| --- | --- |
| doubao 回读口径退回旧 `aria-checked` | **27 → 26 pass / 1 fail（exit 1）** ✔ |
| 删掉 glm 的 `optionSelector`（**真机复跑**） | **3/3 → 1/3**，错误与修复前逐字同形 ✔ |

**⚠ 一次自己造成的次生事故（如实记）**：第一版反向验证用 `Set-Content` 写回**破坏了文件编码**
（中文全丢、1087 行变 778 行）。已 `git checkout` 恢复，改用
`[System.IO.File]::WriteAllText(path, text, UTF8Encoding($false))` 重做。
> **教训**：变异测试前先备份；写回必须指定**无 BOM UTF-8**；否则「还原」本身变成一次事故。

### 五、顺带发现（**未修**，已登记 `doc/long-term-issues.md`）

- **doubao `loginProbe.ok` 与同文件 `accountProbe.avatar` 口径不一致**：真机头像 class 是
  `rounded-full object-cover select-none mr-8 h-36 w-36`（**不含 "avatar"**）⇒
  `loginProbe.ok` 命中 **0** ⇒ **已登录的 doubao 被判 `probe-fallback`（uncertain）**，
  尽管头像与昵称 `aaa` 都在（用户当场指出，Lead 有头复核确认）。
- **`#53` 的三态是死代码**：`browser-driver.js:1548` 算出的 `loginState`
  被 `:1586` 的简写 `loginState,` 覆盖（后写者胜）⇒ **0.19.75 的三态从未到达面板**。
  活体反证：10 行 `loginState` **恒为 `idle`**，而三态只可能返回那三个值之一。
- **qwen 登录**：不是站点弹验证码，是**登录循环 300s 墙钟 + 从不检测验证码**
  （`browser-driver.js:4655-4679`）= `long-term-issues` **#33** 现场；且今天那个浏览器是
  `--headless` ⇒ 用户想登也没有可见窗口。

### 本轮**未**做（诚实边界）

**真实对话验证一次都没跑**：三个站点 cot 前置原先未绿（**现已修复，但修复后仍未做端到端真实发送**），
qwen 未登录 ⇒ 按用户纪律**没有任何站点满足放行条件**。长运行稳定性、多轮工具循环收敛、
会话重启恢复、视觉上传：全部【待验】。

## 0.19.76

**补发 0.19.75 资产里缺的那半个 captcha 修复；并修掉三处「工具/闸门自身」的缺陷。**

### 为什么换号重发（如实）

0.19.75 已经发布（npm `latest` + GitHub Release 资产都在），但**它的资产不含本轮修的
captcha 可见性修复**——逐字节核实下载回来的 tgz：新判据 `window.getComputedStyle` **不在**、
旧的 `offsetWidth || offsetHeight || getClientRects` 判据**在**。
⇒ 装 0.19.75 的人会碰到「正常轮次被误报需要人机验证」。
npm 不允许覆盖同一版本号 ⇒ 只能换号重发。

### 一、captcha 闸门在**零尺寸容器**上开火（产品缺陷）

判据 `el.offsetWidth || el.offsetHeight || el.getClientRects?.().length` 的最后那个兜底，
对**零尺寸但仍在布局里**的节点恒为真。z.ai 的 `#chat-captcha-element` 正是这种：
常驻 DOM、`offsetWidth/Height = 0`，而 `getClientRects()` 仍返回 1 个 0×0 矩形。

真机实测（发送中密集采样 20 拍，逐拍读数）：`0x0 rects=1` —— **20/20 拍**都被判「可见」，
而真正该判的是那个 332×329 的 `#aliyunCaptcha-window-popup.window-show`。
⇒ **正常轮次被判 `WEB_CAPTCHA_REQUIRED`**（`site-verify` 那次报 `visible:1`，
而那一个「可见节点」是 0×0 的空壳）。

修后判据：必须有**真实像素**（宽高都 > 0）且非 `display:none` / `visibility:hidden`。
两向复算：只有空壳 → 旧 `visible=1`（误报）/ 新 `visible=0`（正确）；
真弹滑块 → 旧 `visible=2` / 新 `visible=1`（**仍然报，没漏**）。

### 二、模型回读把成功判成失败（产品缺陷，`readback-mismatch` 假红）

原判据 `applied.includes(chosen.name)`，拿**已点击那一项的整行文本**当右侧。
z.ai 的选项行逐字带营销后缀 `GLM-5.3-Flash NEW 轻量旗舰模型，高质量极速响应`，
而触发按钮回读只有 `GLM-5.3-Flash` ⇒ 恒 false。
真机日志逐字：`模型回读不一致：目标 GLM-5.3-Flash，回读 GLM-5.3-Flash`（两者看起来一样）。

代价不只是难听：`selectModelGeneric` 把 `confirmed:false` 降级成 `strict:false`
⇒ 该站点「严格模式」**静默失效**，而用户以为桥在校验。

修法：抽出纯函数 `readbackConfirms(applied, chosenName, wanted)`；基准取 `wanted`，
或「是 wanted 前缀」的被点中项；再互相包含（容忍 `… ▾` 装饰与回读更完整两种形态）。

### 三、验收工具自己的假红（`test-mock/site-verify.mjs`）

z.ai 与 GLM 声明**同名 id**（都是 `glm-5.3-flash`）。只传裸 id 时
`resolveWebModel('glm-5.3-flash')` 解析成 **glm 站点**，驱动抛 `MODEL_SITE_MISMATCH`
⇒ 工具报 FAIL，而**真实链路根本没被验到**。改成传站点限定形式 `zai:glm-5.3-flash`
（产品路径本就用这个形状）。

### 四、闸门/工具自身的三处缺陷（三次把 CI 打红，本次全部修复）

1. **`gen-progress-index.mjs --check` 对行尾敏感**：`core.autocrlf=true` 且仓库无
   `.gitattributes` ⇒ CI 干净 clone 出的是 CRLF 而本机是 LF；重建块恒为 LF ⇒ 与 CRLF 块
   永不相等 ⇒ **本机绿、CI 红**。改为比较前归一化、写回时用文件原行尾。
   另修一句**自相矛盾的读数**：旧文案用 `parseSections(cur)` 数「块里的小节」，
   而该函数跳过整个索引块 ⇒ 对块本身恒为 0 ⇒ 打成「文档 99 节，索引 0 节」。
2. **新 EOL 闸门收了「依赖本机留档」的检查**：`doc-rewrite-audit check` 需要 `.tmp/` 快照，
   而 `.tmp/` 被 gitignore ⇒ 干净 clone 上必然失败 ⇒ 闸门在 CI **恒红**。
   已把范围纪律写进脚本头：**只收能在干净 clone 上独立跑通的检查**。
3. **`captcha` 修法初版用了裸 `getComputedStyle`**：该代码物理上位于 `runTurn` 函数体内，
   在 Node 作用域里没有绑定（正是 `nav is not defined` 那一族）⇒ 既有护栏
   `driver-scope.test.mjs` 当场抓住。已改 `window.getComputedStyle`。

### 判据

- 新增/改动的护栏：`test/model-picker.test.mjs` **8/8**（新增回读确认 4 组、含 4 条反向）、
  `test/zai-decoder.test.mjs` **7/7**、`test/driver-scope.test.mjs` **7/7**、
  `test/captcha-gate.test.mjs` **5/5**。
- **变异全被抓**：回读判据退回旧写法 ⇒ 新用例红；decoder 退回 `openai-sse` ⇒ ⑦ 红；
  去掉 caption 归一化 ⇒ EOL 闸门在 CRLF 形态下红。三次都逐字节还原后复跑为绿。
- `node scripts/ci-local.mjs --fast` **12/12 PASS**；CI（含 CodeQL）**success**。
- 全量单测 **126/127**：唯一失败 `webcode-preset.test.mjs` ②e 是**机器状态依赖**
  （读本机安装的官方 `standard.patch.yml`），在改动前的 `2368b4a` 上同样红，CI 干净跑器上不红。

### 本轮**未**做（诚实边界）

- **z.ai 未完成端到端真机验收**：`site-verify --site zai` 走通了「登录判定 PASS ×3」，
  但发送被**真弹的阿里云滑块**拦住（`BLOCKED send.text`）——z.ai 当前持续弹验证，
  wire 上零帧。`stream.*` / `tool.*` 诸项因此仍 `UNVERIFIED`。**代码不能绕站点风控。**
- 长运行稳定性、多轮工具循环收敛、非 Windows 平台均未验。

## 0.19.75

**先让验收工具不能假绿，再谈站点覆盖；并给「内容丢在哪一层」装上逐层取证。**

复审复核 0.19.74 后指出：看门狗与调用恢复的方向对，但**站点验收工具自身**有三个会
制造假结论的问题，且 Issue #1 的**内容交付**那一半并未解决。本轮按此收口。

### 一、验收工具：三个「自己制造假结论」的缺陷

1. **最终状态把 `UNVERIFIED` 汇总成 `PASS`。** 旧收尾只问「有没有一项 FAIL」，
   没有就 PASS ⇒ 一个站点**一条工具调用都没产出**也能被盖章通过。
   现在判定完全交给纯函数 `verdictOf()`：优先级 `FAIL > BLOCKED > UNVERIFIED > PASS`，
   且**缺必要检查项也算不确定**（防止「没跑那一段」被读成「那一段没问题」）。
2. **从未真正做工具结果往返。** 旧实现只「发送 → 解析调用」，注释却写成
   「工具结果回填后能收束」（描述超出实现）。现在真的执行 `read`、把结果作为
   `mcp_action:"result"` 回填进**同一个**网页会话、再解析第二轮，产生
   `tool.resultRoundTrip`；并且**校验两轮会话 id 一致**（否则那是两次独立提问，不是往返）。
3. **全文回读掩盖流式交付失败。** 旧写法在没有增量时只要全文非空就判「流有正文」，
   **恰好掩盖 Issue #1 的症状**。现在 `stream.fullText` / `stream.deltaReceived` /
   `stream.consistency` 三项分开判（`streamDeliveryVerdict()`）。

另外：`--profile` 的语义歧义已明确为 `--profile-root`（收**根目录**，脚本在其下按
站点取子目录；旧名保留但会打印按根目录解释的提示）；`login.basis=probe-fallback`
（声明了登录特征却一个都没命中 = 猜测）不再记 `PASS`。

### 二、交付链逐层取证（`lib/delivery-trace.js`）

复审明确指出：`lastEventAt` 只在**适配器收到流事件**时推进，而 `domReplyChars` 只是
诊断读数——**不会**把页面 DOM 里的正文送回流里。所以看门狗计时修好，也不会把卡在
DOM 里的那 2172 字补回来：**「不再误杀」与「内容是否完整交付」是两件事。**

新增逐层取证：`capture → decoder → driver → relay → adapter`，每层记
**事件数 / 字符数 / 首末时刻 / 内容哈希**（默认**不留原文**；`WEBCODE_DELIVERY_TRACE_SAMPLE=N`
才保留每层前 N 字符）。`compareStages()` 给出**首个分叉层**，并区分两件下一步完全
不同的事：

- 某层**零事件** ⇒ 丢在「上游→本层」这一段（查上游的发送/绑定）；
- 某层**有事件但更短** ⇒ 丢在**本层**（查本层的解析/过滤）。

`capture` 是**原始上线帧**（含字段名与转义），与正文长度本就不可比，因此只判
「有没有解出正文」而不比长度——否则每一轮正常生成都会被报成分叉。

### 三、#53 三态（窄口径）

登录状态改为 **已验证登录 / 已验证未登录 / 不确定**。`probe-fallback` 与 `stale`
落进 `uncertain`；`probe-ok`/`probe-bad` 是核验结果；**`input-fallback` 保持原语义**
（DeepSeek 没有 `loginProbe`，靠输入框回退是设计如此——若连这一档也降为不确定，
主站点会长期显示「不确定」）。不确定时**不**改写 `loggedIn`：那会把只是站点改版
导致特征失效的**已登录用户**判成未登录，是用误报换谎报。

### 判据与变异

新增 `test/site-verify-verdict.test.mjs`（16 项，含「零工具调用不得判 PASS」
「只有全文+零增量不得算流式通过」「缺项不得判 PASS」）、
`test/delivery-trace.test.mjs`（12 项）、`test/delivery-trace-wiring.test.mjs`（5 项，
接线级）、`test/login-state.test.mjs`（6 项）。
**变异 V1–V5 全部被抓**，且各被对应护栏抓住（V1 收尾退回旧形状 ⇒ ①；
V2 未知状态自成一桶 ⇒ ④；V3 缺项不折算 ⇒ ②；V4 probe-fallback 退回已验证 ⇒ ①；
V5 适配器只数 delta ⇒ ⑤）。

### 本轮**未**做（诚实边界）

- **Issue #1 的原始内容缺失仍未闭环**：本轮只交付了取证工具，**没有**真机复现
  「页面 2172 字未回传」并定位到具体层——那需要真实 DeepSeek 长会话，且要登录态。
- 真机长轮次（>120s 单轮）、首字节耗时分布、非 Windows 平台、多站点真机覆盖均未做。
- 站点验收工具修好了，但**尚未用它跑过任何真实站点**（那需要用户的登录态）。

## 0.19.74

**从「继续证明锁协议」转向「修用户看得见的故障」：两个看门狗既有缺陷 + 工具调用静默丢失 + 每站点真实验收入口。**

### 适配器看门狗：两个独立缺陷（既有，非本轮引入）

回应 GitHub Issue #1 报错原文的自相矛盾——「判定相位=网页还没开口且驱动不在忙」，
同一句里却并列「最近驱动活动时间 2s 前」与「页面已有 2172 字回复未回传」。

1. **相位被冻结整轮 ⇒ 首字节宽限在生产路径上从未生效。**
   `nextWithIdle()` 第一次执行发生在 executor 微任务**之前**（`relay.submit` 只入队），
   而驱动的 `busy = true` 写在 `runTurn` **内部**（更晚）。旧实现把第一次算出的相位
   一次性冻结，于是唯一那次读到的 `busy` 恒为 `false`，`idleFirstByteMultiplier`
   这一档永远轮不到；而报错现场是在开火那一刻**新采**的。既有护栏 ① 用静态桩
   （驱动从未被调用就报 `busy:true`）复现不出这个时序。
2. **`idleDeadline` 锚在提交时刻 ⇒ 总时长上限冒充静默上限。**
   函数文档承诺「自上次收到任何 delta/think/image 起 N 秒」，实现却是整轮固定。
   实测（窗口压到 1300ms、每 150ms 一个 delta）：**1315ms 处判死**，
   而那时已收到 `chunk0…chunk7`。真机尺度 = 单轮超过 120s 即腰斩，**哪怕正文一直在流**。

修法：相位与窗口**每一拍重算**；deadline 起点取 `max(投放时刻, 上次事件时刻)`；
文案改为「桥侧已 Ns 没有从网页收到任何新事件」（旧文案替网页侧下了结论，
而看门狗量的是适配器流），相位文案改为「驱动**自报**不在忙」。
护栏 ⑧/⑨/⑨b（反向安全线：事件真停了仍须在一个窗口内判死）/⑩，变异 M1–M3 全部被抓。

### 工具调用：部分丢失不再静默（#25 残余）

两个恢复点的门是 `withheld > 0 && !calls.length`，只在**一条调用都没解析出来**时跑。
于是一轮 3 条调用、解析出 2 条、丢 1 条时，既不复恢复也不发 `TOOL_CALL_UNPARSED`
提示——**静默丢步骤**。修后实测 3/3 派发。

修法是**减法**而不是追加：`recoverUnparsedCalls` 读 DSML `<invoke>` 骨架，对主解析
**已经拿到**的调用也会返回一份，直接追加会**重复派发**同一个工具。新增纯函数
`callsLostFromParse`，按「工具名 + 规范化参数」可消耗配对，只留下真丢的。
红线不放宽：仍只走 `read`/`glob`/`grep` 只读白名单。

### 每站点真实验收入口（`test-mock/site-verify.mjs`）

`real-verify.mjs` 把站点写死成 DeepSeek，于是 README 的「Kimi / Qwen / Doubao / Z.ai
未完成长运行验证」一直没有可重复的验收方式。新增入口把站点变成参数：

```powershell
node test-mock/site-verify.mjs --list
node test-mock/site-verify.mjs --site kimi --site qwen
```

四态口径（`PASS` / `FAIL` / `BLOCKED` / `UNVERIFIED`）——只有真实页面加真实请求
支持的才是 `PASS`；未登录、验证码、限流一律记 `BLOCKED` 并注明**不是产品结论**。

### 安装文档：不要用仓库地址安装（GitHub Issue #3）

插件包在 `package/dsh-webcode-bridge` 子目录，**仓库根没有 `package.json`**。
把 `dsh plugin` 指向仓库根会找不到清单，于是被当成普通依赖安装并打
`declares no dsh.bundle` 警告、界面显示「已安装，未生效」。这条警告意味着
**装错了目录**，不是本包缺元数据（它本来就声明了 `dsh.bundle.patch`）。
两份 README 都补上了说明与正确命令，并写明本插件面向 `web` profile。

### 已登记但**未修**（诚实边界）

- **#53**：登录判定对已声明特征的站点会「靠输入框兜底」谎报已登录。真机读数
  （全新空 profile、kimi）：声明的 `bad` 特征命中 0、页面「登录」可见数 0、
  输入框可见 1 ⇒ `loggedIn=true`，而真实发送路径回 `NEED_LOGIN`。修法需要
  「已登录 kimi」的对照读数；贸然收紧会**把已登录用户全判成未登录**，故本轮只把
  它变成可复核读数（验收脚本记 `FAIL`）并登记。
- 真机长轮次（单轮 >120s）复现、首字节耗时分布采集、非 Windows 平台验证均未做。

## 0.19.73

**守卫回收协议：存活判定必须排在移走之前（复审指出的三进程窗口）。**

`recoverStaleGuard()` 原顺序是**先 `rename` 移走守卫、后查存活**。移走期间 `guardDir`
路径**不存在**，第三个进程 C 可趁空档 `mkdir` 成功并自认持有者——而原持有者 A
**仍活着、仍在临界区**。

**确定性三进程注入读数**：`guardPathExistsAfterRename: false` →
`C_acquiredInWindow: true` → `B_putBackSucceeded: false` ⇒ **A 与 C 同时持有**，
且 A 的守卫被永久遗弃在 aside 路径。

**这不是「年龄兜底」那一类风险**：B 的判断本身**正确**（A 活着 ⇒ 拒绝回收），
破坏互斥的是「先移走」这个动作 ⇒ **只加一次存活检查不够**。

**修法**：

1. **先**读标记 + 判存活（完全不碰路径），明确存活 ⇒ 当场拒绝（路径始终存在）；
2. 只有「确认已死」或「判据无结论且远超放宽阈值」才 `rename` 认领；
3. 认领后**复核身份**（`pid` + `startedAt`）防 ABA，不一致就放回并报告竞争；
4. 放不回也**不继续**（宁可放弃回收，也不冒双持风险）。

**为什么不用原子替换**：实测**文件** `rename` 覆盖已存在目标原子成功，但**非空目录**
抛 `EPERM`（目录无法原子替换）；守卫是目录（`mkdir` 是唯一的原子「不存在才创建」原语）
⇒ 只能靠「先验证后动手」。

**防线**：⑨b2（inode 不得变化 + C 不得进入 + 不得遗留 aside）、⑨c2（ABA 复核）、
⑨c3（已死仍可回收）；`bridge-lock.test.mjs` **38/38**；**变异 M15–M17 全部被抓**。
⚠ 诚实记录：⑨c2 首版是**装饰性的**（M16 逃逸），已重写为用 `isAlive` 回调在
「预检后、认领前」确定性替换守卫，才真正命中窗口。

## 0.19.72

**过渡互斥的回收判据：活着的持有者不再被年龄回收（复审指出的真漏洞）+ 释放判据加固。**

### 一、守卫回收不能只看年龄（真实缺陷，已故障注入复现）

0.19.71 新增的过渡互斥（`webcode-bridge.lock.guard.d`）在回收陈旧守卫时**只看
`mtime` 超过 15 秒**。设计理由「临界区只有毫秒级同步文件操作」是**概率论证，
不是不变量** —— 进程可以被暂停（调试器断点、系统休眠、GC 停顿、文件系统异常）
超过 15 秒而**仍然活着**。

**故障注入读数**（把守卫 mtime 改老、`owner.json` 保留一个确定存活的 pid）：
`acquiredGuardWhileAAlive: true` ⇒ B 抢走了活持有者的守卫，两个进程都认为自己持有。
这与 0.19.71 在**判据层刚修完**的「活进程不得被锁龄放行」是**同一个错误**，
在守卫层又犯了一遍。

**修法（与锁文件判据同构的三态）**：

| 持有者存活判定 | 行为 |
| --- | --- |
| 明确**存活** | **绝不回收**，无论多老 —— 它可能只是被暂停了 |
| 明确**死亡** | 回收（唯一能证明安全的情形） |
| 标记缺失 / 损坏 / 判据无结论 | 仅用**显著放宽**的年龄兜底，且**留痕写明这是猜测** |

### 二、释放判据不只认 pid

原实现只比 `owner.pid`，而 pid 会被复用 ⇒ 旧持有者在「守卫已被回收、新持有者已重建」
之后恢复执行，可能**误删新持有者的守卫**（等于把别人踢出临界区）。现在判据是
**pid + `startedAt`**；标记**缺失/损坏**时按「可能是自己写坏的」删除并留痕 ——
**一个不可删除的空守卫会挡住所有人**，那比误删更糟。

### 三、防线与未验边界

护栏 ⑨b–⑨g（活持有者不回收 / 已死可回收 / 判据失效只用放宽兜底 / 标记损坏可删 /
同 pid 不同化身不得删 / 回收重建后旧持有者不得删新守卫），`bridge-lock.test.mjs`
**35/35**；**变异测试 M11–M14 全部被抓**（退回只认 mtime / 把活当死 / 去掉身份校验 /
去掉放宽兜底）。

⚠ **仍不是数学意义上的无条件互斥**：放宽兜底仍是**猜测** —— 极端情况下
（标记损坏 **且** 持有者恰好暂停超过 `4×stale`）双持仍可能。该路径已尽量收窄
（需标记缺失/损坏/判据失效三者之一）并强制留痕。非 Windows 未实测；
真实「两个桥互相杀浏览器」的端到端复现未做。

## 0.19.71

**账号锁：明确存活的持有者不再被锁龄放行（判据层修复）；126 字符问题归因实证为「换行规范化误报」。**

### 一、账号锁判据层修复（真实缺陷）

`decideBridgeLock()` 有个与注释不符的分支：存活判据已明确返回「活着」之后，代码**仍继续**
检查锁龄，于是「真跑了 12 小时以上的桥」会被第二个实例抢走账号锁——两个进程同时驱动同一
账号、互相杀浏览器、两条用户消息交错污染同一网页会话（即文件头记载的失败形态）。

修法（两侧都必须守住）：

1. **明确存活 ⇒ 一律拒绝**，锁龄**不再**构成放行理由；
2. 时间上限**降级为**「仅当存活判据缺席时的有界兜底」——保住 0.19.22 修掉的
   「自己的死锁把自己挡 12 小时」不复发；
3. PID 复用（原持有者已消失、该 pid 被无关活进程占用）的出口显式标为
   **`recoverable: true`** —— 不永久锁死，也不无声放行。

护栏：`test/bridge-lock.test.mjs` 新增 ①e2–①e6（含「存活判据抛异常 **≠** 持有者已死」
与「无判据 + 过期仍有界放行」），**25/25**；**变异测试 M1–M4 全部被抓**。

⚠ **同轮已完全修复**：**接管动作本身**的原子性也已落地（下节）。

### 二、账号锁接管路径原子化（同轮完成）

上面的判据层修复只解决了「明确存活不得被锁龄放行」；**接管动作本身**仍是
「读 → 判 → 非独占覆写」，实测在陈旧锁场景下会**多持有者**：

| 场景 | 修复前 | 修复后 |
| --- | --- | --- |
| 盘上有陈旧锁，2 进程 × 12 轮 | **20/20 轮多持** | **0 多持**（每轮恰好 1 个赢家） |
| 盘上有陈旧锁，4 进程 × 8 轮 | **8/8 轮多持**（每轮 4 个进程全 `ok`） | **0 多持** |
| 无锁文件，2 进程 × 12 轮 | 0 多持 | 0 多持（无退化） |

**修法**：新增**过渡互斥**（`webcode-bridge.lock.guard.d`，`mkdir` 原子原语），
**所有对锁文件的改动（建立 / 回收 / 释放）都在该临界区内**，且临界区内**重读并重新裁决**
——外面那次判据只作快速路径，不作权威判据。拿不到守卫时**绝不改动锁文件**
（acquire 明确拒绝，release 放弃本次释放）。守卫本身只删自己的（按持有者标记核验）。

**为什么不是「换成 mkdir 锁」或「rename aside」**——三个候选都被同一个交错打穿过：
`rename` 是**按路径**而非**按身份**操作，会移走别人刚建立的**活锁**（ABA）；
天真的「`mkdir` + `rmdir` 回收」只修首次获取、回收仍删掉新持有者。
**「原型 0/N 多持」不构成安全证明**，只说明**该交错**未触发。

**防线**：新增 ①e2–①e6、⑤b、**⑧/⑧b（真多进程竞争，赢家保持存活）**、⑨（守卫回收）

- 只删自己的 ⇒ `bridge-lock.test.mjs` **29/29**；**变异测试 M1–M7 全部被抓**，
  其中「摘掉过渡互斥」直接让 ⑧ 变红 ⇒ 该护栏真实承载修复。

**已知边界（未验，不声称已覆盖）**：守卫有效性依赖「临界区远短于守卫超时（15s）」，
若日后再往临界区里加网络/浏览器等待会破坏该前提（实现处注释已写明）；非 Windows 未实测；
真实「两个桥互相杀浏览器」的端到端复现未做（已证明的是**协议不再允许**该状态）。

### 三、`PROMPT_TRUNCATED` 误报：归因实证 + 判据改内容等价（同轮修复）

原登记（2026-10-06）推测「写入通道丢了一段固定前缀/后缀」。**该推测已证伪**：
真机 Chromium 实测表明，`textarea.value` 会按 HTML 规范把 `\r\n` 规范化为 `\n`，
而桥用**长度**代理**内容完整性** ⇒ 差 126 就报截断。

实测（CRLF 252 对 / LF 对照 / 规范化后内容 / 尾部保留 / 生产判据）五项断言全部成立；
旁证是与事故同日的 4 个落盘提示词文件 `CRLF` 计数**恰为 126**，其区段正是注入的
`AGENTS.md`（本机恰好 126 行）。**正文零丢失。**

**判据已修**：新增纯函数 `composerEchoVerdict`，把「比长度」改成比**内容等价**，
规范化白名单刻意极窄（只允许 `\r\n`→`\n`、**行尾**空白、NFC），
**不允许**泛化 trim / 折叠中间空白（那会掩盖真正损坏）。真正的截断
（中间丢字 / 尾部截断 / **等长内容被替换**）**依然**被拦住并给出可归因 `reason`。

**防线**：`composer-write.test.mjs` 新增 ①–⑦ 七条（含「等长内容被替换必须失败」
这条专门证明只比长度不够）；**变异测试 M8–M10 全部被抓**，其中
**M8「退回只比长度」让 ①（126 误报本身）与③④一起变红**。

### 四、如实记录：本版未做真机网页验收

上述修复与归因实证都在**本机离线**完成（纯函数/多进程测试 + 真实 Chromium 的离线页面）。
**没有**跑真实网页账号轮次，因此「两个桥实际互相杀浏览器」的端到端复现、
以及 `inline` 档在真实 DeepSeek 长会话上的表现，均**未验证**。

## 0.19.69

**并列会话的左右悬浮按钮改用官方 UI + 鼠标 / 触控板横向滚动连续移动列（2026-10-08 第二轮）。**

### 用户指令（两条，逐字）

①「将并列会话那里的左右悬浮按钮优化为统一官方UI」；②「增加鼠标右滑可以移动列（这个移动不需要
强制跳下个会话）」。两条选型经用户确认：① 口径 **A** —— 「直接用官方组件 primitives.Button（官方
胶囊 + 官方 IconChevronLeftOutline14 / IconChevronRightOutline14 图标）」；② 「鼠标滚轮 / 触控板
横向滚动来平移」。

### 一、左右悬浮按钮 = 官方 primitives.Button（不再自绘）

- 旧实现是**自绘**的圆形毛玻璃 `‹` `›` 钮（`.hwb-concurrent-pan` 自带 `border-radius:50%`、
  `place-items:center`、`font-size:16px` 的字形，只借了官方 `specific-menu` 底色）。
- 现在按钮本体是 **`primitives.Button`**（`variant:'ghost'` + `size:'sm'`），图标是官方
  `IconChevronLeftOutline14` / `IconChevronRightOutline14`（两代名字都试，见 `iconOf`）。
  胶囊几何、hover / active、焦点环、disabled 全部由**官方 Button 自己的 CSS** 提供。
- 本插件只剩「把它浮起来」那一层（`position:absolute` + 官方 token 的浮层底色 / 毛玻璃 / 阴影）：
  官方没有「浮在内容上的横向平移」控件，位置只能自己给。
  ⚠ 特异性是**刻意**的：本类 1 个类名，而官方 `.ghost:hover:not(:disabled)` 是 2 个 ⇒ 悬停 / 按下时
  官方规则胜出；静止态官方 `.button{background:transparent}` 与本类同为 1 个类、我们的样式后注入 ⇒
  静止时用浮层底色（否则透明按钮压在会话内容上看不见）。

### 二、鼠标 / 触控板横向滚动 = 连续移动列（不吸附回整列）

- **平移真源从「整数列下标」换成「单一像素偏移」**（`firstCol` → `offsetPx`）：用户要的连续移动
  不可能正好落在整列上，也**不该被吸附回整列**——那正是用户说的「强制跳下个会话」。
- 非 passive 原生 `wheel` 监听挂在观察窗节点上（React 的 `onWheel` 在根容器是 passive，
  `preventDefault` 会失效）；**只接管横向意图**（`deltaX` 占主导，或 `Shift+滚轮`），
  **纵向滚轮原样放行**——列里装的是官方会话体，纵向滚动归它自己。
- `‹` `›` 两颗按钮**仍按整列宽 + 列间距步进**（用户 0.19.29 验收过的对齐语义），只是落点也由
  `offsetPx` 表达，不再有第二份状态。
- 监听器用**同一个具名函数**注册 / 注销（0.19.68 记过匿名注册 / 具名注销会泄漏）；effect 依赖是
  `hasViewport` 而不是 `[]`（`cols.length===0` 时走另一条渲染分支、节点不存在——0.19.68 抓过的
  「首帧 ref 为 null ⇒ 监听器永远没接上」同一族缺陷）。

### 验证

`team-compare` **32/32**（新增两条判据：官方 Button + 官方 chevron / 横向滚轮连续移动；并改写列宽与
平移那一组，删掉钉旧自绘 UI 的三条）、`client-render` 71/71、`client-server-contract` 2/2、
全量 121 测试文件逐文件 exit 0。**两条新判据都做了反向变异**：把滚轮改回「先加 dx 再取整到整列」
⇒ 新判据红；把左按钮改回自绘 `<button>` ⇒ 判据红（首版只判一颗，被「另一颗仍命中」蒙混过关，
已收紧成两颗都判）。漂移闸门新增 1 段（`primitives-button-and-chevrons`，共 20 段）。

真机 `probe-concurrent-live` 新增读数与判据：平移钮挂官方 Button 哈希类名、内含官方 `<svg>` 图标、
页面无 `‹ ›` 字形、横向滚轮后 `translateX` 连续变化且**不是列步长整数倍**（即没有被吸附回整列）。
## 0.19.68

**并发会话按官方口径重构（2026-10-08 轮，版本号不变）：删三套自造机制 + 列顶栏改官方 header 高保真复刻 + 列宽可拖拽。**

### 用户指令（五条，逐字）

①「请你看好官方怎么管理工作区的！！文件夹内一个会话一行！！不是每个会话一个文件夹！删除多出来web的dwb+并行会话文件夹+『并发会话』在插件栏目怎么会出现什么『并发会话.3列』？？？不要这个」；②「并发会话的『开工』那一行去除！！！」；③「将并列会话里面非官方UI都去除！！特别指的是：…⇥ 引用 /  官方视图 / ⧉ 开工 / ✕ / 标准模式 / 智能体团队…应该改为和官方一致顶部…只应该在比官方多隔开3列！！」；⑤「现状是中间想要做到官方那样的会话调整宽度不行！」。

### 删掉的三套自造机制（不留死代码）

- **每组一个 git worktree 专属工作区**（10-06/10-07 引入）：删客户端 `provisionGroupWorkspace`、服务端两条路由（`POST concurrent-workspace` / `GET concurrent-worktrees`）、整个 `lib/concurrent-workspace.js` 模块、`inject` 里的 `'workspaces'`。新列改绑**当前工作区**（`currentWorkspaceId()`），与官方「新会话」完全同路径 ⇒ 官方左栏自然「一个文件夹内一条会话一行」。
- **左栏「并发会话 · N 列」历史组目录 + 组留痕存储族**：删 `syncGroupRows`/`groupRows` 与 `readConcurrentGroups`/`createConcurrentGroup`/`appendToConcurrentGroup`/`writeConcurrentGroup`/`newConcurrentGroupId` 等 11 个名字。找回过去的会话不需要插件另立目录——官方左栏的会话清单就是它们的家。
- **跨列引用「⇥ 引用」**（10-07 引入）：删 `sessionMentionOf`/`appendToSessionDraft`/`conversationFaceOf` 等 6 个工具 + `inject` 的 `'conversation'` + 三处 `hwbConversation`。**能力没丢**：`@[标题](dsh-session:<base64url(JSON(id))>)` 是宿主原生语法，手打进任意输入框即生效（宿主 `sessionReferenceResolver` 在 `agent/pre-step` 把目标会话上下文带进来）。

### 列顶栏 = 官方 header 的高保真复刻（`ColumnHeader`）

两道官方闸（授权 `renderer:332` + 声明唯一 `slots:193`，`conversation.session.header` 已被官方 `registerHeader` 声明）在 **0.2.1-alpha.1** 复核仍成立 ⇒ 不能 renderSlot 官方那一份（官方侧栏自己也不渲染，只渲染 `conversation.content`）。复刻四条口径：① 结构逐字照官方（9 个结构位 + `role`/`aria`）；② 样式**不自己写**——挂官方 CSS module 的哈希类名，前缀**运行时发现**（查 `<style data-plugin-css>` 反解），官方改哈希自动跟上；③ 数据与文案照官方投影键 + zh 词典，拿不到就不渲染那块；④ 能点的都是真功能（「用 X 打开」走官方 `open-in-app` 路由、「下载 Session 日志」走官方 `api/session.export`、「更多操作」用官方 `primitives.Menu`）。「对话 / 轨迹」页签读官方 `conversation.view` 注册表、照抄「开发者工具关闭时隐藏 trajectory」（`inject` 新增 `'configForms'`），点击真的切官方视图（经 `hwbView` owner prop → `views` 局部槽 → 官方 `renderSlot('conversation.view', { only })`，组件身份按 viewId 缓存防重挂）。「标准模式」刻意只读——官方那块本来就是只读标签（`AgentPresetLabel` 渲染 `<span>`）。

### 列宽可拖拽（用户第 5 条）

列间 16px 间隔升级为拖拽把手（官方 `WidthHandle` 同款 pointer capture + rAF 节流 + 拖动期只写 CSS 变量 + 抬手才 commit 并持久化 + **双击复位**）。真机顺带抓出**两个既有布局缺陷**：① ResizeObserver 的 effect 依赖是 `[]`，而首帧 `ready=false` 走早返回 ⇒ 观察器**从未接上**、`viewportW` 停在 `window.innerWidth`（面板 1320 读成 1600、列宽错算）；修 = 依赖改 `[ready]`。② 拖拽上限 `min(952, 可视宽)` 是单会话语义，并发列**可比可视区宽**（放不下由整列平移接手）⇒ 默认顶死拖不动；修 = 上限分两层（默认受可视宽约束、**用户拖出来的偏好**只受绝对上限 952 约束）。顺带修 `pointerup` 匿名注册/具名注销不匹配导致的监听器泄漏。

### 磁盘清理（不可逆，先取证后动手）

37 个 `dsh-webcode-bridge-hwb-*` + 4 个 `A0-Robocup-hwb-*` worktree（逐个 `git status` clean、分支 `ahead=0` 确认无独有提交）+ 51 个孤儿会话目录，全部删除；三处 `-Depth 2` 复核残留 0。未碰宿主 `workspace.json`（实读发现「并发会话组 N」从来不在宿主账本里）。

### 官方一侧本轮真的升级了（0.2.0-rc.2 → 0.2.1-alpha.1）

npm `dsh` 在本会话中升级（`ui-conversation` 18478→23304 行），旧行号引用全部失效——本轮新注释一律给 0.2.1-alpha.1 新行号并逐条重读。桌面 asar 仍是 0.2.0-rc.2、web profile 已 0.2.1，**两版 CSS module 哈希前缀不同** ⇒ 正是「哈希运行时发现」要解决的问题。顺带修 `webcode-preset` 差集闸门红出的官方 `standard` 新增两行 `time-context`/`tool-schedule`（判定**原样保留**：无读数支持删除）。

### 验证

`team-compare` 31/31（⑦⑧ 重写 + 复刻/拖拽两条新判据，删掉钉旧 UI 的三条）、`client-render` 71/71、`webcode-preset` 12/12、`client-server-contract` 2/2、全量 121 文件逐文件 exit 0；漂移闸门 19 段一致（删 1 段随跨列引用、加 7 段复刻锚点）；`probe-concurrent-live` 真机全部判据成立（banned 文本为空、左栏恰好一条、顶栏挂上官方哈希类名、9 结构位齐、更多操作三项可用、移除列生效、官方视图放行 `allow:leave`、拖宽 756→846 且持久化、双击复位）。版本号保持 **0.19.68**。

## 0.19.68（2026-10-07 轮）

> **本节的「修二：跨列官方会话引用」与（同版本 10-06 节的）「每组一个 git worktree 专属工作区」
> 已被 2026-10-08 轮按用户指令整体删除**（见本节上方那条）。保留本节是为了留住两条仍然有效的结论：
> ① 守卫放行时序的根因与修法（`allowLeave` 必须在 `panelInfo` 订阅回调里消费——现在仍成立，
> 「在官方视图打开」还靠它）；② 官方 session header 两道闸的真机定案（仍然成立，
> 10-08 轮的 `ColumnHeader` 正是据此选择「复刻」而非「渲染官方那一份」）。

**并发会话对齐官方单会话（2026-10-07 轮，版本号不变）：修通「↗ 官方视图」+ 跨列官方会话引用「⇥ 引用」+ 官方 header 两道闸定案。**

### 用户指令

「研究并行界面不够单会话官方界面的地方并自行尝试修改」；确认口径：研究对象 = 本插件注入 DSH 的「并发会话」视图与决议，参照系 = DSH 官方单会话界面，跨会话需求 = 「输入应该可以方便引用到别的会话里面/让别的会话方便清楚知道另一个会话结论/借鉴/并行」。

### 修一：`↗ 官方视图` knownGap 修通（long-term-issues #47④）

放行标记 `probe.allowLeave` 由按钮 onClick 置位，而守卫只在 pointerdown 捕获期消费它——onClick 在 pointerdown 之后，那个消费点永远读到 false；做拉回决策的 `layout.panelInfo` 订阅回调又不看标记 ⇒ 每次交回官方视图都被拉回。修法：订阅回调在决策前同样消费放行标记（`lib/client.cjs` `createPanelGuard`）。真机修后读数：`panelGone:true`、`officialHeaders:1`、`pulls:0`、`lastDecision:"allow:leave"`。

### 修二：跨列官方会话引用（列头新按钮「⇥ 引用」）

走**宿主原生机制**，不抄全文：

- **mention 形状**（照抄 `dsh-session-reference`）：`@[label](dsh-session:<base64url(JSON(sessionId))>)`，label 转义照官方 `escapeLabel`；
- **写入通道**（照抄 `dsh-client-ui-conversation` 的 ConversationController，服务名逐字 `"conversation"`）：`conversation.input.shell(sessionId)` → `snapshot.draft` 读 + `setDraft(text)` 写（官方切会话搬草稿用的就是这一对）；**追加**不覆盖，提交阶段（adjudicating/submitting）拒写，失败降级剪贴板；
- 宿主 `sessionReferenceResolver` 在发送时校验 mention 并把源会话上下文带进目标会话——「让别的会话知道另一个会话结论」由宿主在 `agent/pre-step` 完成。

真机验收：点第 1 列「⇥ 引用」，第 2/3 列输入框出现 `@[…](dsh-session:InNlc3Npb24t…)`，base64url 解码 = 目标会话 id 逐字一致（canonical）。

### 定案：官方 session header 在第三方面板内不可达（#47 结论更新）

本轮先推翻 0.19.66（发现 chips 实际注册在 `conversation.session.header.actions`），随即在真机复判：在列条目 children 里声明该槽撞两道官方闸——授权（`renderSlot` 只认本条目 children，`renderer:331-338`）与声明唯一（`slots:191-194`；该槽已被官方 `registerHeader` 声明，`ui-conversation:18254-18257`）⇒ 真机逐字 `slot "conversation.session.header" is already declared (by an entry in "conversation.header" (mf))`，**列条目注册整体失败、列体全空**。撤销声明与渲染，自绘 chips 定案保留；「完整官方 header」由修通后的「↗ 官方视图」承担。判据反钉于 `test/team-compare.test.mjs` ⑪ 段。

### 顺带的安装取证

`dsh plugin --profile web add <同版本 tgz>` **不覆盖**已装的同版本包（装后文件 SHA256 与工作树不一致）。验收正解：`remove` + `add`，装后核对 `lib/client.cjs` SHA256 与工作树一致再重启。

### 判据与验证

`test/team-compare.test.mjs` 34/34（inject 判据加 `conversation` + ⑪ 段三条新判据）；`test/client-render.test.mjs` 71/71；全量 121 测试文件逐文件 exit 0（`NODE_TEST_CONTEXT=1`）；漂移闸门 13 段一致（新增照抄台账 `session-mention-encoding`、`conversation-input-hub`）；真机 `probe-concurrent-live` 全部判据成立（3 列各 1 官方 composer、slotErrorCount=0）。

## 0.19.68（2026-10-06 收口）

**并发会话「一组一行」收口（long-term-issues #40 结论反转 + 落地）。**

### 用户选了什么

> 「做不到你就自己新建不行吗？？」

⇒ 自己给每一组**新建一个真工作区**。选型由用户拍板为 **git worktree**：`git worktree add -b hwb/concurrent/<组id> <repo>-hwb-<组id>` —— 同项目的**完整副本 + 自己的分支**，列里的 agent 看得见全部代码，而各组真隔离（这正是用户早先提的「git 分支 + 最后旋转」）。

### #40 的原判只对了一半（本条登记已更正）

原判是「官方清单条目是 shell 私有代码（`SessionNodeItem`），插件只能装饰既有行、不能分组」。2026-10-06 实读官方 0.2.0-rc.2 后更正：**缺的不是分组渲染器，而是「一组一个真目录」这个前置**——

- 官方左栏**本来就**按工作区一行、折叠时不投影会话行（`dsh-client-ui-workspace/lib/client.js:661` `groupBy:"workspace"`；`:492/:502` `sessions: expanded ? … : []`）；
- 会话归属是**宿主硬判据**：`SessionHeader.cwd` 经 `realpath` 必须**逐字等于** `workspace.path`（`dsh-workspace/lib/index.js:122`；README:172 *a session from another directory cannot be moved in*）⇒ 打不了虚拟分组标签；
- `workspaces.create(path)` 要求目录**已存在**（`dsh-workspace/lib/index.js:406-409` `realpath` + `stat`）⇒ 先建目录是**前置**，不是优化。

### 两半接线

| 半边 | 做法 | 出处 |
| --- | --- | --- |
| 建目录 | `POST concurrent-workspace` → 绝对路径（客户端没有 fs/子进程） | `lib/concurrent-workspace.js`（新增）、`lib/web-control.js` |
| 注册工作区 | `ctx.workspaces.create({path})` + `rename`（线上契约只收 `path`） | `lib/client.cjs`（`provisionGroupWorkspace`） |

三个挂载点（主面板 / 历史组 / 右栏页签）都把 `hwbWorkspaces` 传下去；历史组重开时服务端回 `reused` ⇒ 复用已建的 worktree，不重复改标题。

### 如实写明的边界

- **降级不是崩溃**：不是 git 仓库 / 服务面缺席 ⇒ 回落旧行为（绑当前工作区）并把原因**上屏**，绝不假装建好；
- **不代做破坏性 git 操作**：不自动 `merge`、不自动 `worktree remove`，只回 `recipe` 三条可复制命令（合并是主线列/用户的「最后旋转」）；
- 用户在 `groupBy` 选「单列表」时**没有**分组行——那是用户偏好，不是缺陷；
- worktree 建在**仓库外的兄弟目录**（进仓库内会让 `git status` 变脏、被别的列误检）。

### 判据

`test/team-compare.test.mjs` 两条新用例（⑩ 段），并做过**反向验证**：删掉绑定优先级 / 删掉建目录调用 / 跳过真注册，三种变异都让判据变红，还原后复绿。契约闸门的「死路由」判据还当场点出只读诊断与客户端动作同名的问题，故改名 `GET concurrent-worktrees`（而不是放宽判据）。

### 真机验收（本版**已装上 web profile 并用真实 `dsh` cli 重启**）

| 步骤 | 读数 |
| --- | --- |
| `pnpm pack` → `scripts/verify-pack.mjs` | tarball **920,092 B**；与工作树**逐字相同 62/62** |
| `dsh plugin --profile web add <tgz>` | 声明/磁盘/工作树三处一致（三个文件 SHA256 逐个相同） |
| 真实 `dsh --profile web` 重启 | `GET /__webcode/status` ⇒ `build.version = **0.19.68**` |
| `probe-concurrent-live.mjs` | ✔ 3 列 / 每列官方 composer / `slotErrorCount=0` / 守卫 `pulls=2` |
| **`probe-workspace-group.mjs`（新增，#40 判据）** | ✔ 官方左栏**每组恰好一行**（`并发会话组 1/2`，都在「工作区」标题之下）；每个 worktree 各含 **3 条会话** |
| **`probe-jobs-chip-live.mjs`（新增，④）** | ✔ chip = `1 个后台任务运行中`；独立旁证：真有 `node -e setTimeout(…)` 进程（模型真起的后台任务，非假数据） |

> **④ 为什么要「造 live job」**：jobs chip 的数据面是官方 `ctx.jobs`，**空态下「写死不渲染」与「从未订阅成功」长得一模一样**。本轮走真链路（官方 composer → 适配器 → 模型 `mcp_action` → harness → 官方 jobs → chip），无一处是探针塞的假数据。

### 真机验收中抓到的第二个缺陷（**本轮已修**）

真机读数（`~/.dsh/storages/workspace.json`）显示 6 个组里 **4 个没被改名**（停在 worktree 目录名）：

```
title="并发会话组 1" / "并发会话组 2"                        ← 恰好对
title="dsh-webcode-bridge-hwb-muw1enzkiovq" 等 4 条          ← 没改名
```

**根因**：官方 `rename` 有**重名闸**（`dsh-api-workspace-controller/lib/index.js:239-241`，同名抛 `workspace/name-conflict`），
而我的编号取的是 `readConcurrentGroups().length`——组留痕写在后面的 `.then` 里，探针又每次都是**全新浏览器**（localStorage 空）
⇒ 该数恒为 0 ⇒ 每组都取「并发会话组 1」⇒ 撞名被拒、被 `catch` 静默吞掉。**前两组「看起来对」只是因为它们恰好是 1 和 2。**

**修法**：编号改为从**官方工作区清单现读**（`wsFace.list().items` 里 `并发会话组 N` 的最大 N + 1），并在撞名时递增重试。
判据钉住三点（**先去注释 + 先归一 CRLF** 再匹配——本判据连踩两次：没剥注释、剥了又被 `\r` 挡住 `$`）；反向变异 ⇒ 1 红，还原复绿。
**真机复验**：在**已有 6 个旧组残留**的 store 上再建一组 ⇒ 拿到 **`并发会话组 3`**（从 max=2 推出），`sessions=4`。

### 顺带复核（不改产品行为）

- #44「检查更新恒报已是最新」**已解决并复核**：tag `v0.19.67` 与 `package.json` 一致、Release 带 tarball 资产，`updateDecision` 以 0.19.61 为 current 的实跑读数 = `outdated → 0.19.67`；
- 修掉 `doc/long-term-issues.md` 表里一处**既有格式缺陷**：#40 那一行被复制了一份粘在 #39 行尾（缺换行），一并清掉；
- 真机造 job 时撞到一条**既有缺陷**（**本轮未修，已登记 #48**）：用户设置 `promptTransport: inline` 时，44,202 字符提示词灌进 deepseek 输入框，**两次尝试都恰好丢 126 字符**（常量，非长度上限）⇒ `PROMPT_TRUNCATED`、该轮失败；切到 `attach`（本插件默认值）后同一轮立刻成功。`SITE_COMPOSER_HARD_LIMIT` 目前**只有 kimi**，deepseek 无底线兜底。两个修法都动到用户设置的语义，留给用户拍板；验收时临时切换的设置**已逐字还原**。

## 0.19.67

**右栏页签版并发视图 + 官方 header 三块 chip 全部接线（用户选 A：自己复刻）。**

### 右栏页签（用户：「右侧 tab 功能一并抄上」）

按官方两段式（在产范例 dsh-client-ui-sidebar-documentpreview:6811/6822）：ctx.sidebarRightTabs.register({id,kind,title,keepMounted}) + ctx.slots.inject(sidebar.right.pane.tab) 并声明自有的 session 作用域子槽 ⇒ renderer 照发 SessionProvider/renderSlot，每列仍是显式绑定的真会话。真机读数：window.__hwbRail = {registered:true}（探针在打开主面板之前断言）。限制如实写在代码注释里：右栏常态 300px~45%、中列保底 400px ⇒ 3-4 列并排只在全屏下实用。

### 三块 chip（照抄官方数据面 + 官方文案）

| chip | 数据面 | 真机 |
| --- | --- | --- |
| 模式 | 会话清单投影 byId[id].projectionValues.agentPreset（ui-agent-preset:357）+ 官方 i18n 文案 | 可见「标准模式」 |
| 后台任务 | ctx.jobs 服务 + 槽 inject（照 ui-jobs:596-621），并调 watchRows(sessionId)；口径 running/stopping | 已接线（无任务时不显示） |
| 子智能体/团队 | root 标准绑定 useSessions：subagentCatalog / agentTeam 投影 | 可见「智能体团队 1 成员」 |

### 其它

- 官方漂移闸门扩到 11 段并接提交前钩子；
- 清掉 3 处悬空引用（此前注释引用的 doc/research §11 在仓库里不存在，已改成真实出处 dsh-client-ui-slots/lib/index.js:313）；
- 残留：「↗ 官方视图」仍 knownGap（探针如实记录，不放假绿灯）。

## 0.19.66

**照抄官方 header：模式 chip 落地（官方投影路径 + 官方文案）；官方漂移闸门 11 段并接提交前钩子；
左栏并发会话目录找回历史组；列头第一段按官方规格。**

### 用户选了什么

> 「自己复刻三块 chip」

⇒ 三块 chip（标准模式 / 后台任务 / 子智能体·团队）**自己复刻**。复刻口径写进代码注释：
**数据面抄官方、文案抄官方、拿不到就不画**（绝不摆假壳）。

### 落地

| 项 | 结果 |
| --- | --- |
| **模式 chip** | 走官方**会话清单投影**：`state.byId[sessionId].projectionValues.agentPreset`（照 `ui-agent-preset:357`）；文案逐字来自官方 i18n（`:309` 「标准模式」／`:311` 「PTC 模式」）。真机：每列列头出现「标准模式」，探针新增硬判据 |
| 后台任务 / 子智能体 | 代码走 `props.useJobs` / `props.useSubagents`，**宿主未发 ⇒ 不渲染**（如实留空，注释写明原因与后续路径） |
| **官方漂移闸门** | `scripts/check-official-drift.mjs` 扩到 **11 段**（新增 `agent-preset-labels`、`jobs-count-label`）；接 **提交前钩子**（`scripts/hooks/pre-commit` + `install-git-hooks.mjs`），官方一侧变了会在提交前点名；钩子在 `node` 不在 PATH 时**放行**（否则会拦死所有提交） |
| **列 vs 官方对照探针** | `test-mock/probe-column-parity.mjs`：共用 `[data-conversation-scroll]` + 官方 composer + 同族会话体；差异 = 官方 4 处 header vs 列 0 |
| **左栏并发会话目录** | 多组留痕 + 每组一条 `sidebar.panellist` 行 + 同名 `main` key；真机验证：点「并发会话 · N 列」能**找回**那一组 |
| 列头第一段 | 标题常显 + 官方 44px / 13px 规格 |
| 面板级页签 | **移除**（视图交回官方默认：会话自己记住的那一个） |

### 未完成（如实记，见 `doc/long-term-issues.md` #47）

后台任务与子智能体两块 chip 的数据面（官方 hook 未发到第三方座位）；右侧 tab（官方右栏被
`activePanelId === null` 闸门收走，受支持路径下第三方无法在自有面板里渲染官方右栏内容）。

## 0.19.64

**「还是会切回到官方工作区」的真根因：守卫从未生效（`CONCURRENT_PANEL_ID` 跨作用域）＋
守卫看不见门户浮层里的那一下选择；另加每列「⧉ 开工」独立工作区指令。**

### 用户报了什么（原话）

> 「1.还是会切回到官方工作区，2.请你确保解决同一会话/工作区内的并行多任务和沙箱管理？
>  3.我现在有想法就是能够通过git分支和最后旋转来进行并列多会话？如何官方流程？
>  4.你确保修复好后不用动桌面端，打包推送git+npm发布，版本号0.19.x」

### 真根因（真页面探针复现 + 页面内取证读数）

1. **守卫从来没生效过**：`createPanelGuard(props.layout, CONCURRENT_PANEL_ID, …)` 写在
   `ConcurrentPanel`（定义在 `apply()` **之外**）里，而 `CONCURRENT_PANEL_ID` 声明在 `apply()`
   **内部** ⇒ 每次挂载都是 `ReferenceError: CONCURRENT_PANEL_ID is not defined`，被 effect 的
   try/catch 吞成一句 warn（0.19.63 把 `warn` 修好之后才看得见这句）。与 `warn` 是同一族
   **跨作用域接线断裂**，到 0.19.64 已经咬过两次。
2. **原判据看不见门户（portal）**：官方 hero 工作区胶囊的浮层开在门户里，DOM **不在面板子树内**
   （探针真机读数 `insidePanel=false`）⇒ 挂在面板根上的 `pointerdown` 永远收不到「选工作区」
   那一下，`uiWorkspace.openWorkspace → replaceMain('reveal') → selectPanel(null)` 得逞。

### 修法

- 面板 id 与子槽名提到**工厂作用域**（并写明「为什么不能声明在 `apply()` 里」）；
- 守卫改为在 **document 捕获** `pointerdown`，按落点分三类：**左栏**（放行——用户 2026-10-04
  确认「左栏点会话可以跳」）/ **面板内**（原语义，`PANEL_GUARD_MS`）/ **门户链**（面板外、
  左栏外，但刚点过面板内，`POPUP_CHAIN_MS = 15s`）⇒ 后两类都回拉；
- 左栏子树**不钉官方哈希类名**：入口自打 `data-hwb-nav-entry`，从它往上取**第一个不含面板根**的
  祖先——那就是左栏那一列；
- 新增真机取证通道 `window.__hwbPanelGuard`（`available / pulls / lastClick / lastDecision`）：
  守卫的决定能在页面里直接读。这是被第一版「源码看着对、真机拉不回来」逼出来的。

### 每列「⧉ 开工」：独立工作区（git 分支 + 收尾轮转）

用户第 3 点的想法落地成**一键可复制**的指令（worktree + 分支 + `merge --no-ff` 收尾），
并**把官方口径写进指令文本**：沙箱按**会话**解析工作区根（`SessionHeader.cwd`）、官方 Agent Team
是 **one shared checkout** 且**不带 worktree**（`dsh-experimental-agent-team` README 原文）。
剪贴板不可用时文本摊在面板里，不静默失败。详见
[`doc/research/2026-10-05-dsh-multi-session-and-client-hot-swap.md`](../../doc/research/2026-10-05-dsh-multi-session-and-client-hot-swap.md) §10。

### 验证

- 真页面探针（**默认判据**，含「在工作区浮层里选一项、且刻意等 2.5s 超过 900ms 窗口」）：
  面板 `1320×1000`、两页签、3 列、每列 1 个官方 composer、无 `[data-hwb-boundary]` /
  `[data-slot-error]` / `pageerror`、列内点击不跳走、`__hwbPanelGuard.pulls ≥ 1`；
- `team-compare` **28/28**（新增两条：**标识符作用域扫描**——面板路径用到、却只在 `apply()` 里
  声明的名字即红；门户守卫形态）、`client-render` **71/71**；
- 桌面端**原地热换**客户端产物即时生效：图 rev `f1dbb1e962bd`，按该 rev 取回 493,990 B =
  磁盘 493,915 + 75 B trailer，**逐字节相同**；**未重启桌面端、未改 profile 声明**。

## 0.19.63

**并发会话真机排障：两个「跨作用域接线断裂」——`layout` 没进 inject、`warn` 声明在 `apply()` 里；
并首次开出真页面探针（GUI 不再靠离线回放猜）。**

### 用户报了什么（原话）

> 「继续研究现在的多会话如何真实实现？现在还是不行！然后你不能够自己查看cli安装和问题排查吗？
> 你的会话是在桌面端啊！」

### 先查安装，再查代码（本轮最要紧的一条读数）

桌面 profile 的声明钉的是 `dsh-webcode-bridge-0.19.61.tgz`，装的也是 0.19.61
（`lib/client.cjs` 464,929 B / `CBF6A6…`），运行中宿主 `GET :8932/__webcode/status`
实读 `build.version = "0.19.61"` ⇒ **0.19.62 的修复从未装上桌面端**，用户看到的空白是旧代码。

### 真页面探针（此前认为做不到，其实做得到）

`dsh web` 启动横幅里**就打印带 token 的 URL**（官方 `dsh-web-app` 的 `announceReady`：
`console.log("dsh web: " + connection.authenticatedUrl(url))`）。此前「token 只在进程内存里 ⇒
开不了真 GUI 页面」的判断只对了一半：token 确实只在内存里，但**我们自己启动那个进程就能拿到它**。
新增 `test-mock/probe-concurrent-live.mjs`：开真页面 → 点左栏「并发会话」→ 建 3 列真会话 →
**同时收错误读数与几何读数**（`pageerror`/console/`[data-slot-error]`/`[data-hwb-boundary]` +
根节点与父链的 bounding box/computed style，用来区分「抛错空盒」与「塌高」两类空白）。

### 两个真根因（都在渲染/提交期抛错 ⇒ 被官方 SlotErrorBoundary 兜成空 div）

1. **`ctx.layout` 没进 inject**：`main` 条目的组件函数里写 `layout: ctx.layout`，
   而 inject 列表是 `['slots','sidebarRightTabs','sidebarRight','sessions']`。
   cordis 的 reflect 代理对**未声明 inject 的服务读取直接抛错**
   （`cannot get property "layout" without inject`），不是给 undefined ⇒ 组件一渲染就抛。
   官方 `dsh-client-ui-sidebar` 同样把 `"layout"` 列进 inject。
2. **`warn` 声明在 `apply()` 里面**：`ConcurrentPanel` / `createPanelGuard` 定义在
   `apply()` **之外**，而挂载 effect 的 catch 分支调用 `warn(...)` ⇒
   `ReferenceError: warn is not defined`。0.19.62 的「守卫退出渲染路径」把 catch 写进
   effect 之后才让它必然被触发。

两条都是**跨作用域接线断裂**，而当轮三处源码正则护栏**全绿**——正则判据看不见作用域。

### 修法

- `inject` 补 `'layout'`；`ctx.layout` 改经 `layoutFaceOf()` 读（服务缺席降级为「没有守卫」）。
- `warn` 提到**工厂作用域**（全文件唯一一处），并写清「为什么不能声明在 `apply()` 里」。
- `HwbBoundary` **上提到条目注册处**：0.19.62 那两层边界都在 `ConcurrentPanel` 内部，
  而本次崩溃发生在**构造 `ConcurrentPanel` 元素之前**——上提之后「面板级空白」也能变成
  可读错误文本（本轮正是它把 `warn is not defined` 打出来的）。

### 护栏

- `test/team-compare.test.mjs` **25/25**：inject 字面量 + **通用判据**「源码里出现的每个
  `ctx.<服务>` 读取都必须出现在 inject 列表里（cordis 自带成员除外）」+ 「`warn` 必须声明在
  `function apply(ctx)` 之前且全文件只有一处」+ 注册处不得再直接读 `ctx.layout` + 条目级边界挂点。
- `test/client-render.test.mjs` **71/71**。

### 真机读数（web profile，`probe-concurrent-live.mjs`）

面板 `1320×1000`、两页签「并发对话 / 并发轨迹」、3 列、**每列 1 个官方 composer**、
无 `[data-hwb-boundary]` / `[data-slot-error]`、列内点击后主区**不跳走**；
截图与 JSON 落在 `test-mock/out/concurrent-live-*.{png,json}`。

### 桌面端怎么生效（不改应用、不重启进程）

官方 `@deepseek-ai/dsh-client-hmr`（`dsh-web-app` 补丁里**无条件挂载**）每 500 ms
stat 轮询插件的客户端产物，变化即经 `/plugins/events`（**免 token**）推 `rebuilt` 帧，
打开着的页面随之拆除旧 fiber 并重新 import。因此**原地覆盖**（`writeFileSync`，绝不删除/改名）
`~/.dsh/profiles/desktop/node_modules/dsh-webcode-bridge/lib/client.cjs` 即可热换；
外部可用「宿主自己发布的 rev + 按该 rev 取回的字节」逐字节核对。

⚠ **反例（本轮踩到并记下）**：先用「写临时文件 + `Move-Item -Force` 替换」的写法，
删除+改名在轮询器眼里出现残缺窗口，`clientModules` 当场拒绝为新内容重建响应
（该 rev 及后续所有 rev 一律 404）。**回滚与恢复**：把旧字节原地写回即恢复
（已验证 200 且与磁盘逐字节相同）。结论：**热换只许原地覆盖**。

⚠ **`build.version` 不会变**：只换客户端产物时宿主里跑的仍是 0.19.61 的服务端代码，
`/__webcode/status` 继续读 `0.19.61`——它**不是**「没生效」的判据；判据是
`/plugins/events` 的 rev 与 `/plugins/??dsh-webcode-bridge/client.js&rev=<新 rev>` 的字节。

### 尚未收口（如实记）

桌面 profile 的**声明仍钉 0.19.61**（磁盘已是 0.19.63）。要恢复「声明 == 磁盘」，需要
**完全退出桌面端**后走官方通道安装一次（`doc/long-term-issues.md` 已登记，含现成命令）；
在那之前，任何一次 pnpm 通道都可能把客户端静默换回旧版。

## 0.19.62

**并发会话防跳走：列内点击不再把中央区换回单个会话（建列绑工作区 + 面板意图守卫）。**

### 用户报了什么（原话）

> 「现状是一点击选择范围就会跳成单独那里对话，没有实现我的设想想！」

### 真因（实读官方 0.2.0-rc.2 bundle 取证，非推测）

官方导航链条：`uiWorkspace.openSession(id)` / `openWorkspace(...)` →
`replaceMain(target, signal, "reveal")` → **`ctx.layout.selectPanel(null)`**。
官方布局契约里 `main` keyed 面板与 `conversation`（单个会话）**互斥**，`selectPanel(null)`
= 整个中央区换回单个会话——并发面板由此消失。

列内的触发点是官方 `conversation.content` 自带的「工作区选择」：`sessions.create({})`
造出的会话没绑工作区 ⇒ 列里渲染「虚线选择工作区」composer 卡 ⇒ 一点就走
`selectWorkspace → openWorkspace → selectPanel(null)` 那条链。

### 修法（两半配套）

1. **建列绑工作区（治本）**：`createColumns` 先取 `currentWorkspaceId()`
   （当前会话所在工作区 → 最近更新的工作区 → 不绑），传给
   `sessions.create({ workspaceId })`——与官方 `reuseOrCreateBlank` 同款参数。
   绑上之后 composer 直接可用，「选择工作区」这一步根本不出现。
2. **面板意图守卫（兜底）**：列内还有其它官方导航入口（hero 工作区胶囊、crumb、
   分支按钮），都是官方组件内部行为，无法逐个替换。守卫在面板根记
   `pointerdown` 时间戳并订阅 `ctx.layout.panelInfo`：**紧跟面板内点击**发生的
   `selectPanel(null)` 判定为列内官方交互触发，立刻 `selectPanel(面板id)` 拉回；
   面板外导航（如点左栏清单打开某条会话）没有面板内点击，照常放行。
   `ctx.layout` 服务缺席（旧宿主/测试桩）时守卫整体降级关闭。

### 真机第一轮回执：面板全空（同版收口）

重启后回执「并发界面打开是一片空白」。取证：官方渲染器对每个条目套 `SlotErrorBoundary`，
崩溃条目渲染成**空 div** ⇒「连页签都没有的全空面板」= 并发 main 条目渲染抛错。首版守卫
在渲染期同步 `createPanelGuard(...)`，其中 `panelInfo.subscribe()` 若同步抛，`guardRef.current`
停在 null ⇒ 下一行读 `.onPointerDown` 即 TypeError——正是这个形状。修法：守卫彻底退出
渲染路径（挂载 effect 内 `addEventListener` + 订阅，逐层 try/catch 降级），并给
`sessions.create({ workspaceId })` 加「被拒回落不绑」重试。判据改钉新形态并双向反向变异。

### 真机第二轮回执：仍然空白 → 结构免疫 + 「从未重启」取证（同版收口）

机器取证：正在运行的宿主进程全部启动于两轮安装**之前**，桥控制面实读
`build.version = "0.19.60"`——用户的「重启」没有重启宿主进程，页面刷新拿到的仍是
0.19.60 的客户端 bundle（由内存中的宿主下发）。空白面板的结构性问题是：官方
SlotErrorBoundary 把崩溃条目渲染成**空 div**，任何一层渲染抛错在真机上都表现为
「全空」。本轮加插件自建 `HwbBoundary`（惰性 class 边界，`React.Component` 缺席
回退透传），包住列区整体与每列正文两层：崩溃显示「渲染失败 + 错误 message」的
可读文本，其余列照常——空白从此变成可贴的诊断。`client-render` 新增 1 条判据
并反向验证（拆挂点 ⇒ 红，还原 ⇒ 71/71 绿）。

### 护栏

`test/team-compare.test.mjs` 新增 2 条（建列绑工作区的取值回落链、守卫三件套 +
挂接 + 卸载注销 + 渲染路径零守卫），并做反向变异验证（改实现 → 红；还原 → 绿）。

## 0.19.61

**六条用户反馈收口：站点标题改网站名 + 头像被矢量盖住的真根因 + 目录居中 + 模型目录随账号 + 版本/更新。**

### 用户报了什么（原话，逐字）

> 「1.右侧 tab 菜单，设置界面，这里头像是网站矢量，头像是网站矢量，都没渲染…
> 2.右侧 tab 菜单显示的是用户名而不是网站名…
> 3.现在模型选择了后，又跟账号无关了，模型选择的选项是独立的了
> 4.右侧 tab，展开时候，窗口够大就没问题，但是缩小窗口就能看到，顶部置顶了，已经够大时候就已经是偏上了
> 5.显示版本和更新都有问题，更新点击，并没有真正更新」

### ① 头像「都没渲染」的真根因：画的顺序错了（不是抓不到）

用户确认「**只有头像没出来，昵称是对的**」。而服务端实测 `deepseek` / `glm` / `kimi` /
`doubao` / `z.ai` **都有真实 `avatarUrl`**（全部 URL 实测 200 + 有效 JPEG/PNG）⇒ 不是抓取问题。

真因是**层叠顺序**：三处头像挂点里，`SiteGlyph` 矢量标记画在 `<img>` **之后**（DOM 顺序 =
谁盖住谁），而两者都铺满身份盒 ⇒ **矢量永远压住头像**。昵称能显示是因为它在另一个文本节点上。

另外两处独立缺陷同时修掉：
- `SiteGlyph` 的画布是 `size + 8`（26px 盒里画 34×34），而身份盒**没有 `overflow:hidden`**
  ⇒ 矢量从四角溢出来——这就是用户看到的「边边角角」；
- 设置页的 `.hwb-avatar-img` **缺 `position:absolute`**（目录页那条有、这里没有）⇒ img 与
  矢量是并排的两个 flex 子项，挤在 28px 圆框里 ⇒ 看起来就是「头像没渲染」；
- 下拉行的 `acctIcon` 旧实现**只 return 那个 `<img>`**，矢量标记压根没进 DOM ⇒
  注释承诺的「失败时露出下面的标记」没有落点，加载失败就是空白。

现在三处**同一口径**：标记先画当底图、头像绝对定位盖在上面、容器 `overflow:hidden` 裁掉
画布余量、`onError` 藏掉 img 即露出标记（回落链终于真的成立）。

### ② 站点卡片标题 = 网站原名（昵称回到下拉行）

旧实现 `title = primary.accountName || siteName(sid)`——「抓到昵称就显昵称」，于是同一列里
`glm` 显 `RSYHN`、`kimi` 显 `TYZ0712`，而 deepseek / z.ai 因昵称读不到（长期问题 #43）显站点名：
**同一列两种语义混排**。现改为恒取 `siteName(sid)`；真实昵称**只在下拉的账户行里**（没有丢）。

### ③ 站点目录居中：`justify-content:center` → `margin:auto`

旧规则逐字抄官方 GuideBody（`justify-content:center` + `min-height:100%` + `:after{flex:0 10%}`）。
那三件在官方 guide 里成立（内容一两行、永远装得下），但站点目录是**十行胶囊**：窄/矮窗口下
内容高于容器 ⇒ 竖直居中把**顶部推出容器**，而宿主 `.P3OORG_tabBody` 是 `overflow:hidden`
⇒ 顶部被裁且**无法滚动到达**（`scrollTop` 不能为负）。

`margin:auto` 是唯一两边都成立的写法：有富余时上下等分（= 居中）、空间不足时归零
（= 从顶部开始、可滚动）。**真机反向变异验证**（`test-mock/probe-catalog-center.mjs`，
真实 Chromium 量 `getBoundingClientRect`）：旧 CSS 在 420px 高窗口下 `gapTop = −106px`
（顶部被推出 106px，不可达）；新 CSS `gapTop = 0`、可滚 212px；1200px 窗口下上下留白
284/284 精确居中。

### ④ 模型目录随账号动态化（用户报「模型选择跟账号无关」）

`index.js` 顶层曾是 `const WEB_MODELS = listAllModels();`（**不传 accounts**），进程启动时
求值一次就冻结。而 `listModels` 里 0.19.46 加的那句读 `settings.accounts` 只用来**改显示名**，
**没用来决定有哪些行** ⇒ 非默认槽的模型行永远不出现。真机读数：`/__webcode/models` 回
**17 条、`@2` 行 0 条**，而 `listAllModels([{siteId:'deepseek',slot:'2'}])` 实测 **18 条**。

改为 `webModelsFor(accounts)` 按**当次**账号现算（`GET models` 与 `GET context-windows`
同一条取法），并在 `configManager.set()`（`POST settings` / `account-add` / `account-remove`
的唯一落盘路径）后广播 **`llm/adapters-updated`**——宿主选择器是事件驱动的，不广播则
「数据对了、界面还是旧的」。实测账户 2/3 出现后目录 17 → 21 条且默认槽行一条不丢。

### ⑤ 版本显示与更新

- **`GET update-status` 的缓存里存的是判据、且缺 `current`**：启动 15 秒后那次检查在模块顶层
  跑，拿不到 `config.version` ⇒ 缓存恒为 `{status:'unknown', current:''}`，而读取端
  `{...current, ...updateCache.value}` 让空串**覆盖**真实版本 ⇒ 10 分钟内版本位渲染成空的
  「v」、按钮永远不是「更新到 vX」。改为**只缓存原始事实**（releases 清单 / error），
  判据在用它的那一刻用真实 `current` 现算。
- **`profile` 名推导错**（用户报「更新装不上」的直接原因）：旧实现用
  `path.basename(config.profileDir)`——那是**桥自己的浏览器数据目录**
  （`webcode-edge-profile-desktop`），不是 DSH profile 名。真机实测该端点实回
  `profile: "webcode-edge-profile-desktop"` ⇒ `dsh plugin --profile webcode-edge-profile-desktop add …`
  指向不存在的 profile，**必然失败**。改为四级回落：`DSH_PROFILE`（宿主权威，
  本机实测 `desktop`）→ `DSH_PROFILE_DIR` 末段 → **本插件安装路径里的
  `profiles/<name>/node_modules/…`** → `'web'`。实测三种环境形状分别得到 `desktop` /
  `headless` / `web`。
- 文案里仍写「registry 上是 v…」，而 0.19.56 起数据源已是 **GitHub Releases**——改成 Releases。
- ⚠ **「真正更新」还需一个仓库侧动作**（不是代码缺陷，如实记）：最新 tag 是 **v0.19.55**，
  而 `package.json` 已是 0.19.60 ⇒ `latest < current` ⇒ 更新检查**恒报「已是最新」**。
  要让更新真的能装到东西，需要 `git tag v0.19.61 && git push origin v0.19.61`（release.yml 才会
  产出 tarball 挂到 Release）。本轮**未打 tag、未同步 GitHub**。

### 验证与闸门

- 全量单测 **121/121 文件逐文件 exit 0**；`client-render` **70/70**（新增 2 条 0.19.61 用例：
  标题恒为网站名、头像必须在矢量之后 + 三处挂点同口径；另 1 条为居中规则）；
  `accounts-integration` **17/17**（新增「目录随账号动态化」护栏）、`control-routes` **19/19**
  （profile 判据改为钉 `DSH_PROFILE` 优先并**反向禁止**按 `profileDir` 末段取名——旧断言钉的
  正是缺陷本身）、`regression` **54/54**。
- 真机探针 `test-mock/probe-catalog-center.mjs`：**ALL PASS**（含反向变异证明旧 CSS 必红）。
- 闸门：`lint-comments` 253 文件 0 error / 0 warn、`check-ledger` PASS、`check-repo-hygiene` PASS、
  `check-commit-msg` PASS、`gen-index --check` PASS（52 模块）。

### 未做 / 边界

- **未新增 favicon 抓取**：这是我在诊断阶段提的假设，但真因是层叠（已修），且「没有真机读数
  不许编能力」是本仓库纪律 ⇒ 不交付猜测的网络能力。
- `deepseek` / `z.ai` 的**昵称**仍读不到（长期问题 **#43**，本轮未动）：本轮只修了「头像被盖住」，
  没有为昵称编选择器。
- 装机与重启由用户自己做（重启会终止正在使用的会话）；本轮未打包、未装机。

## 0.19.60

**站点显示名统一为网站原名 + 账户身份取证的活页面通道。**

### 用户报了什么（原话，逐字）

> 「你好，请你查看本插件：
> 现在设置界面：各个站点，不同账户没能使用已抓取真实账户头像和账户名
> 2.右侧网页界面，单个账户时候：例如豆包，居然不是显示原来网站名称？」

### ① 站点显示名 = 网站原名（右栏与设置页从此同一个名字）

站点显示名此前**有两份口径**且互不相同：服务端 `providers.js` 的 `name`
（`DeepSeek 网页版` / `智谱清言 (GLM)` / `通义千问 (Qwen)` / `豆包`）与客户端 `client.cjs`
的 `SITE_NAMES`（`DeepSeek` / `智谱清言` / `通义千问` / `豆包`）。于是同一个站点在右栏标签页
标题、工具条、目录卡片与设置页 tab 上是**两个名字**——这正是用户看到的「豆包不显示原来网站名称」。

现在十站统一为**网站自己的名字**：`DeepSeek / GLM / ChatGPT / Kimi / Qwen / Doubao / Grok /
Claude / Gemini / Z.ai`（去掉「网页版」「(GLM)」「(月之暗面)」这类注解后缀）。
范围含 `lib/providers.js`、`lib/sites/deepseek.js`、`lib/client.cjs`、`lib/settings-page.js`
与客户端两处任务表单下拉。**只改展示**：站点 id、模型 id、`shortKey` 与分组键
（`glm → chatglm`、`zai → z.ai`）一个字都没动。

**新护栏**：`test/provider-surface.test.mjs` 判据 7 同时钉「服务端 `name` 等于网站原名」
与「客户端 `SITE_NAMES` 与服务端 `name` 逐字一致」——两份口径各写一遍时，任何只渲染一侧的
单测都不会红，这一条专门补上那个盲区。

### ② 账户真实昵称/头像：造出**活页面取证通道**（选择器待真机读数再声明）

真机 `/status` 读数（2026-10-03）：12 个槽里只有 **glm**（唯一声明了 `accountProbe` 的站点）
与 **kimi** 有真实昵称；deepseek / doubao / zai 的 `accountName` 为空 ⇒ 界面必然回落成
「站点名 + 站点矢量图」。接线本身是通的，断的是**抓**这一步。

本版新增取证能力（**默认路径零开销**）：
- `lib/account-candidates.js`（新模块）：页面侧候选扫描的**唯一**实现（昵称候选 / 头像候选 /
  登录态线索 + 可复用选择器提示 + 语义分数），桥的活页面与离线探针共用同一份；
- `browser-driver.readAccountIdentity({ debug: true })`：仅在显式 debug 时多扫一遍页面，
  常规轮询（右栏每 8s 一次）行为与开销一字未变；
- `POST /__webcode/account-identity { siteId, slot, debug: true }`：把候选原样透出。
  ⚠ 候选是**取证读数**，不是最终选择器——本项目纪律是选择器必须来自真机读数。

**重启后的真机取证结果**（新增 `test-mock/probe-account-identity-live.mjs`，6 个已登录槽串行取证）：
据此给 **Doubao**（昵称 = 头像右侧那行 + 账号头像 CDN）与 **Kimi**（`span.user-name` /
`img.user-avatar`）声明了 `accountProbe`，GLM 复核通过。**DeepSeek 与 Z.ai 刻意不声明**——
真机读数里 deepseek 的昵称候选 **0 条**（账号区不可见；头像照样读得到，因为读取不判可见性）、
z.ai 的最优头像候选 `rect.x = -12`（侧栏在视口外）且是 Svelte 哈希类名：两站**读不到昵称节点本身**，
凭印象补 CSS 等于把猜测写进真源，已登记为长期问题 **#43**（含两条出路）。

### ③ 离线探针不再静默半拷贝

新增 `test-mock/probe-account-identity.mjs`（在 profile **副本**上取证）。真机踩到并已堵住的坑：
运行中的浏览器**独占** `<profile>/Default/Network/Cookies`，`copyFileSync` 抛 EBUSY 后被
`copyProfile` 的 catch 咽掉 ⇒ 副本**没有登录态**，页面渲染成游客态，而读数看起来「完全正常」。
现在显式核对「源有 cookie 库而副本没有」⇒ 硬失败并指出两条出路（关掉浏览器再跑 / 改用活页面通道）。

### ④ 发布工具 `scripts/install-profiles.mjs` 两个真 bug

装箱时踩到并修掉：`--profiles <名字>` 的取值被当成显式 tarball 路径（⇒ 直接报「找不到
tarball」）；以及只传一个不在默认名单里的 profile 时，筛选结果是空数组、一个都没装却照样
打印「✔ 已装入」并退 0。修后 `desktop`（GUI 实际运行的 profile）也装到 0.19.60。

### 验证

受影响护栏全绿：`provider-surface` 7/7、`account-identity-cache` 9/9、`client-render`、
`control-routes`、`settings-transport`、`client-server-contract`、`accounts`、
`accounts-integration`、`model-labels`、`hooks-order`；反向变异两条均已确认精确变红。
闸门 `gen-index --check` / `lint-comments` / `check-ledger` / `check-repo-hygiene` /
`check-plugin-contract` / `check-long-term-issues` 全部 PASS。

**已知边界（如实记）**：本机受限模式下 Chromium 起不来（Mojo 命名管道被拦），因此探针的
真机读数依赖不受限的终端；而**运行中的 profile 一律拿不到登录态副本**，与长期问题 #32 同族——
活页面通道是本机唯一可靠的取证路径。

## 0.19.59

**设置界面四改 + 会话纪律写进 AGENTS.md（含一处产品姿态变化：网页自动化改为恒开、不可关）。**

### 用户要的是什么（原话，逐字）

> 「1.本插件记录到agents.md中：每次会话结束都需要记录用户原话，每次会话开始都需要提问让用户选择
> 正确意图才开始操作，确保没有理解错误
> 2.设置界面左侧：『账号与余额 / 通用设置 / 模型 / 内置插件 / Agent 预设 / 通知 / Jet Hub /
> 网页桥接 / 壁纸引擎』栏目中，没有设置好网页桥接的图标，仍然是默认齿轮
> 3.同步右侧面板中各个网站的账户图像展示方法没有同步：豆包和kimi--一个账户的那种展示不错，但是
> deepseek和z..ai两个账户的就不行？展示，可以以第一个按照原来的那样，，234你自己想怎样适配
> 4.设置界面，给『连接 / 网页服务 / 中继已连接 / 主线落点 / deepseek:deepseek · deepseek/deepseek /
> 去配置 DeepSeek / 启用网页自动化 / 已启用（本机永久保存）』都不显示，就是去除那一框，内部都是
> 默认全开启
> 5.请你给每个设置界面的网站分页，每个账户除了『更换账户 检测 导入本机登录态 独立窗口』外增加
> 一个按钮：『删除』作用是：删除这个账户数据
> 提问确保理解再开始」

本轮按新规矩执行：**先读代码、再提问确认意图、用户选定后才动手**。

### 改了什么

- **① 会话纪律**（`AGENTS.md` 新增 §0）：会话开始先提问确认意图；会话结束跑
  `node scripts/user-voice-log.mjs` 重新生成 `doc/user-voice-log.md`（该文件是脚本生成）。
- **② 「网页桥接」导航图标**：**不修**。取证结论——`settings.section` 的注册契约只有
  `id/order/label`（没有 `icon`），导航字形由官方壳 `dsh-client-ui-settings-general` 的
  `navIcon(id)` 硬编码，未知 id 一律回落齿轮。插件侧结构上做不到，已登记
  `doc/long-term-issues.md` #42（不 hack 官方包、不占用 shipped id）。
- **③ 右栏站点卡片的账户图像**：多账户站点改为「**第 1 个账户当主身份**（真实昵称 + 真实头像）
  + 其余账户在头像右下角**叠层**（最多 3 颗，超出折成 `+N`）」；单账户站点行为不变。
  顺带修掉一个真缺陷：旧实现把头像与站点矢量标记**并排**画（抓到头像时两个图标同时出现），
  现在头像绝对定位**盖住**标记，`onError` 才真的「露出下面的标记」。
- **④ 删掉设置页「连接」整卡**：网页服务读数 / 主线落点 + 去配置 X / 启用网页自动化勾选框
  一并消失。授权改为**恒开**（`relay.js`: `consent` 是常量 true，不再读落盘
  `accepted:false`，`setConsent(false)` 也关不掉）——**这是一处产品姿态变化**：
  用户不再有「一键停用网页自动化」的把手，要停只能停插件或退出进程。
- **⑤ 每个账户行新增「删除」**：服务端 `POST account-remove` 一条动作——先关该账号自己的
  浏览器（`accountForget` → 驱动 `close()`，非默认槽从 `drivers` 摘掉）→ 删数据 →
  摘槽位并清掉两处悬空引用（`sendGapMsBySlot[accountKey]`、正指向该槽的
  `defaultModelBySite[siteId]`）。**默认槽只清文件、绝不 rm -rf 目录**（deepseek 的默认槽
  就是 profileDir 根，其余站点的默认槽目录里还住着账户 2/3）。界面为两步点击确认
  （不用阻塞式 `window.confirm`）。

### 验证

- 全量 **121 个测试文件逐文件 exit 0**；受影响面 `control-routes` 18/18（新增 account-remove
  用例）、`client-render` 67/67（新增 2 条 0.19.59 用例 + 「连接卡」三条改反向）、
  `settings-transport` 9/9（分区锚点改到「速度与等待」）、`hooks-order` 2/2、`run-m1` PASS。
- 台账 / 注释 / 卫生三闸门 PASS。

## 0.19.58

**品牌图标按用户微调稿定稿：黑色 + 旋转 90° + 浅蓝副影 + 白底圆角（修 0.19.57 做错的部分）。**

### 用户要的是什么（原话，逐字）

> 「我让你看："…iconoir_bridge-3d.png"按照我的更改那样改你眼瞎吗？？？？？？改好了！！！
> 我是经过微调的！{…appicon-forge 配置…}」

0.19.57 拿到了图却自选了蓝青渐变配色、也没做旋转——这次按用户的 appicon-forge 稿逐项还原：
`iconColor` 黑 `#000000`、`iconRotation` **90°**、`iconShadow` 浅蓝 `#65b3fc`（offset −2,+3）、
白底圆角 77 + `#D1D1D1` 描边、`iconSize 149` 的占画比。形状仍是 Iconoir `bridge-3d`
官方逐字几何（Luca Burgio，MIT）。

### 改了什么

- **界面内品牌标记**（左栏行 / 右栏 tab / guide 图标 / 设置页品牌位）：官方几何 +
  内层 `<g rotate(90 12 12)>`；彩色位 = 黑本体 + 浅蓝副影，线稿位 = `currentColor`
  **不描影**（与官方图标并排时描影显脏）。
- **`icon.svg`**（插件管理页）：白底圆角 77 + `#D1D1D1` 描边 + 黑色图标 + 旋转 90° +
  浅蓝副影，保留 Iconoir 作者与 MIT 声明。
- **核对**：用包的 playwright 真渲染三版成 PNG，与用户给的图肉眼比对（落点算术吻合）。
- **护栏升级**：除官方几何外，追加用户定稿的旋转/配色/底板判据（0.19.57 的蓝青渐变
  不得复活），三条新判据逐条反向验证。

## 0.19.57

**品牌形象换成 Iconoir `bridge-3d`（用户指定；作者 Luca Burgio，MIT 许可）——三处同一官方几何，逐字路径不自改。**

### 用户要的是什么（原话，逐字）

> 「名称bridge-3d作者Luca Burgio许可MIT集合下，https://github.com/iconoir-icons/iconoir，
> 请你查看本地图片……进行使用替换本地全部形象，彩色随官方色调你来绘制，
> 主要注意每个需要图标大小/颜色需要参考对应官方图标」

### 改了什么

- **几何 = 官方逐字**：Iconoir `bridge-3d` regular（viewBox 24 / stroke 1.5 / round 端点）
  的 5 条元素原样入画——两条短竖线、两个实心端点、S 形桥体。0.19.39/0.19.56 两轮
  自绘形状被用户判为「绘制错误」的教训收口为一条护栏：品牌几何必须官方逐字，
  自改坐标即红（反向验证已做）。
- **颜色按位置分**（0.19.56 立的规则延续）：官方图标行内（左栏行 / 右栏 tab / guide）
  用 `currentColor` 正确线稿；「能彩色的地方」（设置页品牌位 + `icon.svg` 插件管理页）
  用桥的品牌蓝青——右上蓝 `#7CB7FF`（网页侧）、左下青 `#45D9E7`（本地侧），桥体
  沿流向渐变。Iconoir 是单色图标集（无官方彩色变体），彩色取**本插件**的官方色调。
- **功能图标不动**：任务板 / 并发会话 / 站点品牌矢量表达的是各自语义，不是插件形象。

## 0.19.56

**更新源切到 GitHub Releases（修「检查更新永远说已是最新」）+ 启动自动检查一次 + 更新按钮与 GitHub 链接同圆框 + 品牌图标统一为 icon.svg 同构。**

### 用户要的是什么（原话，逐字）

> 「设置界面的更新：为什么没法做到真正更新？--点击检查更新后不能自动拉取更新安装？
> 已经现在设置默认启动时候检查一次更新吧，然后是按钮和右边的github一样圆框」
> 「现在"插件"界面那个带有颜色的矢量图不错，本插件形象，但是别的地方的都是绘制错误了，
> 请你：能彩色地方彩色，用这里的图，不能的地方改为正确黑色矢量图」

### ① 更新为什么一直是假的（根因）

检查与安装都对着 npm registry，但本项目**从不 publish**（release.yml 有守卫强制，
唯一发布渠道是 GitHub Releases）。registry 最新 0.19.51 ≤ 本地 0.19.55，
`updateDecision` 永远回答 `current`——按钮没坏，是**更新源指错了地方**。

### ② 改了什么

- **`lib/update.js` 判据层换数据源**：`fetchPackument` → `fetchReleases`
  （`GET /repos/RSLN-creator/dsh-web-bridge/releases`，匿名只读）；`pickLatest`
  改吃 Releases 数组（draft / prerelease 不进候选、按版本号比不按数组顺序）；
  新增 `assetForVersion`（按 release.yml 命名规则定位 `<包名>-<版本>.tgz`）与
  `isSafeDownloadUrl`（下载域白名单——资产 URL 来自外部 API，不直接信任）；
  `runInstall` → `installLocalTarball`（装**下载到本地的 tgz**）+ `installFromReleases`
  一条龙。「有新版但 tarball 资产缺失」如实报 `unknown`，不冒充「已是最新」。
- **启动自动检查一次**：进程起来 15 秒后打一次 Releases API，结果写进设置页
  同一个缓存——打开设置页第一眼就是已检查的结论（测试进程与
  `WEBCODE_UPDATE_CHECK=off` 跳过）。
- **更新按钮同圆框**：`.hwb-update-btn` 数值逐项对齐右侧 GitHub 链接
  （12px 字号 / 2px 8px 内边距 / 12px 圆角 / .5px 边框）。「有新版换主色底」
  是唯一剩余差异。
- **品牌图标统一**：`DwbMark` 重绘为 icon.svg 的**同构线稿**（拱 + 两端方墩 +
  中间虚线，currentColor，官方图标行内使用）；新增 `DwbMarkColor`（icon.svg
  原始配色）只用于设置页品牌位——「能彩色的地方彩色，不能的地方正确黑色矢量图」。

### ③ 边界

安装坚持「先下载 tgz 到本地再 `dsh plugin --profile <p> add <路径>`」——
`dsh plugin add <https://…>` 没有契约背书，本地 tarball 是 README 写明的安装形态。
装完仍然**只提醒重启、绝不代重启**（重启会终止在跑的会话）。

## 0.19.55

**并发会话改造：每一列都是一条真官方会话（不再自绘），入口搬到左栏「并发会话」。**

### 用户要的是什么（原话，逐字）

> 「并发必须能够保留真实会话！能够查看！」
> 「然后是中间区域，将原本在会话中的『并发』删除，改为对齐新会话的『对话』和『轨迹』
> --变为『并发对话』和『并发轨迹』」
> 「我要一摸一样，确保每一列都有完整的官方会话所有能力」

### 改了什么

**① 每列 = 一条真会话。** 旧实现的每一列是**自绘的假会话**（消息只活在 React state 里、
回复靠 `/__webcode/chat` 把网页正文抄回来、对话框是复刻的）。现在每一列：

- `ctx.sessions.create()` 在 Host 上造一条**真会话**，`retain()` 拿到引用；
- 官方座位 `SessionProvider` 显式绑定该引用（只认真引用——官方 `ui-session` 的
  `bindingSource` 会校验世代）；
- 渲染官方 `conversation.content` **factory**（`variant:'embedded'`），于是官方的消息列表、
  思考块、工具调用、附件、**官方 composer（模型选择 / 权限 / Plan / 发送）**全部就位。
  「完整官方能力」不是复刻出来的，是**本来那一份**。
- 引用成对释放：移出列、面板卸载都 `release()`；**移出列不删会话**（用户要「保留真实会话」）。

**② 位置：左栏「并发会话」行 + 中央 `main` 面板。** 会话内那个「并发」页签**已删除**——
这不是取舍，是硬约束：`renderFactorySlot` 会检查渲染祖先，在 `conversation.content` 的子树里
（会话内视图所在的位置）再渲染同名 factory 会当场抛 `recursive render of factory`，
所以官方会话体**只能**渲染在官方会话之外。新位置走官方 `sidebar.panellist`（按钮由 shell 画，
渲染顺序天然在「新会话」下方），两半同名成对（list id = main key）。

**③ 两个页签：并发对话 / 并发轨迹**，分别把官方 `conversation.session` 的视图钉死为
`chat` / `trajectory`（经 factory 的 `views` 局部槽覆盖）。**默认 3 列**
（用户点名的「3 个重叠标签页」），可加到 4 列。

**④ 「3 个重叠标签页」的入口图标**：左栏那一行代表的是**会话组**而不是一条会话，
所以图标画成三个错位叠放的圆角矩形（画法与既有 panel 图标同刻度、`currentColor`）。

**⑤ 组可恢复**：组（哪几条会话属于这一组）存浏览器本地，重开面板看到的是同一组会话，
不会每次打开都新建一批。存的只是**本浏览器的面板布局**——会话本身早已在 Host 上，
所以这份存储丢了也只是「重新建组」，不丢任何对话内容。

### 保留（用户已验收的部分，判据逐字未动）

列的可见边界（官方那套「平时隐形 + hover 一点光」）、列宽上下限（全部由官方常量推出）、
三列**宽度同步**、放不下时左右切换（整列平移、永远不对齐到半列）、按钮位置与官方胶囊底色。

### 删除（对象没了，判据与样式一并删）

自绘 composer 的整套刻度（`hwb-col-composer-*`）、自绘消息与引用条（`hwb-quote-bar` /
`hwb-chat-*`）、旧视图根节点（`hwb-compare-*`）、以及 `conversation.view` 上那个
`webcode-compare-view` 注册本身。`test/team-compare.test.mjs` 的判据同步改写为钉**真会话那条链**
（create / retain / release / SessionProvider / factory / 两个页签 / 位置），
并保留反向断言防止自绘层与「会话内页签」复活。

## 0.19.54

**错误码不再被 harness 吞掉：官方的自动重试与超限自动压缩修复终于对本插件生效。**

### 修的是什么（真实缺陷）

DSH 的 `HarnessError.code` 是**唯一**的机器路由判据
（`dsh-llm/lib/types/error.d.ts:13` 逐字：*route on this, never by parsing `message`*），
而它的归一化**只认自己那一份类身份**：

```js
// dsh-llm/lib/types/adapter-failure.js:104-107
function harnessErrorCode(error) {
    return error instanceof HarnessError ? error.code : 'UNKNOWN';
}
```

本插件此前给普通 `Error` 挂 `.code`（**24 处**），于是**全部退化成 `UNKNOWN`**。

**284 份真实会话全量实测**（`node scripts/scan-error-codes.mjs`）：

```
终止失败 171 条；code=UNKNOWN 137 条（80.1%）
  137 条全部归因本插件   ← 137/137，存活率 0.0%
  官方 provider 丢码 0 条
```

**同码对照**：`CONTEXT_WINDOW_EXCEEDED` 两边都在用——官方适配器的原样保留，
本插件的落盘成 `UNKNOWN`。同一个码名、同一个语义、同一个 harness，只差异常类型。

**后果（三件事，全都静默不报错）**：

1. 官方自动重试（`dsh-llm-retry`）**从未生效**——`retryableCodes` 恒不命中；
2. `CONTEXT_WINDOW_EXCEEDED` 的**超限自动压缩修复从未触发**
   （`compaction-basic:862` 的判据恒不命中，等于把官方的上下文自动修复关掉）；
3. UI 一律显示 `UNKNOWN` 徽章（`dsh-client-ui-chat/lib/client.js:1302-1305`）。

### 怎么修的

新增 `lib/error-codes.js` 作为**错误码真源**：

- `webcodeError(message, code, extra)` / `withWebcodeCode(err, code)` —— **不可分割地**
  同时写 `code` 与一个**自洽的** `failure` 快照（`Object.freeze({message, code})`）。
  官方 `ownFailureSnapshot` 的采信条件正是 `failure.code === error.code`；
  两者不一致仍会退化成 `UNKNOWN`（已实测），故把它做成一步。
- **为什么不用官方推荐的 `new LlmError(...)`**：`@deepseek-ai/dsh-llm` **不在本仓库
  工作区**（实测 `ERR_MODULE_NOT_FOUND`），静态 import 会让全部测试文件加载失败；
  且桌面版把宿主打进 `app.asar`，插件解析到的 `dsh-llm` 与宿主内部那份**可能不是同一
  模块实例** ⇒ `instanceof` 跨副本不成立。官方实现自己就为这件事留了口子
  （`adapter-failure.js:17-21` 逐字 *Cross-package copies preserve own data but not
  class identity*），自洽快照走的就是这条路——**零新依赖、同步可用**。
- **24 处抛点全部改走真源**，另把两处「空回复」的无码错误对齐成官方
  **`EMPTY_RESPONSE`**（`dsh-llm` 的 `EMPTY_RESPONSE_CODE`，其文档逐字：
  *The attempt produced nothing durable, so retry policy treats it as safe to repeat*）
  ——它**在官方默认可重试集里**，于是「网页偶发空回复」现在会走官方退避重试，
  而不是当场判死整轮。
- **`providerRetryPolicy` 从 `undefined` 改为显式策略**（`WEBCODE_RETRY_POLICY`）：
  `maxRetries: 1`、`initialDelayMs: 2000`。返回 `undefined` 会用官方 **HTTP** 默认
  （5 次 / 500ms 起），而本插件**重试一次 = 再驱动一次浏览器**，代价完全不同。

### 刻意不做（避免「顺手对齐」引入行为变更）

- **`CONTEXT_WINDOW_EXCEEDED` 绝不进 `retryableCodes`**：`dsh-base/cordis.patch.yml` 里
  `llm-retry`（:91）注册在 `compaction-basic`（:341）**之前**，waterfall 按注册顺序调用
  ⇒ `llm-retry` 一旦命中就**不再 `next()`**（`dsh-llm-retry/lib/index.js:160`）。
  把它放进可重试集，超限请求会被**原样重发** N 次，而**官方的压缩修复永远不会跑**。
- **`RATE_LIMITED` 不改名成官方 `RATE_LIMIT`**：官方退避 500ms 起，而本站限流滑窗以
  **十秒**计；改名后每次重试都是一次真实浏览器投递。
- **`NEED_LOGIN` 不映射到 `AUTH`**：官方 UI 会把 `AUTH` 替换成「API 密钥无效」，
  而这里该做的是「打开网页登录一次」——映射会把用户引向错误的排查方向。
- **自建重试未删**（`RATE_LIMITED` 的 10s 下限退避、`PROMPT_TRUNCATED` 的一次性压缩重试）：
  需先观察官方 `llm-retry` 真的接住，再决定删哪一段。

### 验证

| 验证 | 读数 |
| --- | --- |
| 端到端（真实 `adapter.stream()` → 真实官方 `normalizeLlmFailure`） | `{"code":"EMPTY_RESPONSE"}`（修前 `UNKNOWN`）✅ |
| 反向变异：删掉 `failure` 快照 | 护栏 **3 条红** ✅ |
| 反向变异：把 `CONTEXT_WINDOW_EXCEEDED` 混进 `retryableCodes` | 护栏 **1 条红** ✅ |
| 全量单测 | **121 个测试文件逐文件 exit 0**（含新增 `test/error-codes.test.mjs` 10/10）✅ |
| 闸门 | `lint-comments` / `check-ledger` / `repo-hygiene` / `long-term-issues` / `plugin-contract` / `gen-index --check` 全 PASS；`ci-local --fast` **10/10** ✅ |

⚠ **基线数字不会因此改变**（仍是 137/137）：历史会话的 `UNKNOWN` 是**既成事实**，
不会被追溯修复。本版声称的是「**机制已接通**」，不是「丢码率已下降」——
后者要等新会话产生后再跑 `scripts/scan-error-codes.mjs` 才能声称。

**能力面不变**：只改错误分类与重试策略，不改任何站点协议、模型目录或提示词。
完整机制与官方对位见
[`doc/research/2026-10-02-dsh-official-error-and-repair.md`](../../doc/research/2026-10-02-dsh-official-error-and-repair.md)。

## 0.19.53

**模型选择器收成唯一一组：所有站点的模型放一起，思考等级保持按站点声明不变。**

用户原话：「帮我插件的网站选择模型他们放一起，不用就是按站点隔开，然后能不能做到
不要空路由，就是是一个真路由，然后放在一个组下面，然后就是保持那个思考等级不变，
先用思考等级划分不变」。

### ① 单一真 provider `webcode`

`providerIdsForRegistration()` 从 11 个（10 站点 + 兼容空壳）改为**只返回 `webcode`**。
依据（实读宿主）：一个 provider 就是一组——
`dsh-api-session-controller/lib/types/catalog.js:38`
`{ id: provider.id, name: provider.name, models: entries }`。
0.19.28 的「一个网站一层」与「全部放一组」在协议上只能二选一，本次按用户要求选后者。

### ② 真路由，不再是空壳（含两条被推翻的旧认知）

旧实现让 `webcode` 当「兼容空壳」：`listModels → []`，靠目录侧
`group.models.length > 0` 的过滤不生成组。现在它是唯一真路由，**必须返回模型**。

同轮取证推翻了本项目两条既有注释：

- 宿主里**没有** `routeServed` 这个函数（`@deepseek-ai` 全树 grep **0 命中**）。
  发消息前的真判据是 `requireModel` → `modelAvailable`
  （`dsh-api-session-controller/lib/index.js:900` + `lib/types/catalog.js:67`），
  要求 `listProviders().some(id)` **且** `listModels().some(model)`。
- 因此「注册了但目录为空」只保证不抛 `NO_ADAPTER`，**不能**保证能发消息。
  旧注释「旧会话照旧可跑、可解析」据此修正为只对前者成立。

### ③ 「组内按思考等级分区」在协议上不成立（决定性取证）

宿主模型行只有 `{id, name, description?, inputModalities?}`
（`dsh-llm/lib/types/types.d.ts:304` 的 `LlmModelInfo`），**没有**
`group` / `category` / `family` / `tag` 任何一项（宿主全树无消费方）。
「思考等级」是模型行自带的 `reasoning`，UI 渲染成**另一条独立 pane**
（`dsh-client-ui-model-selection/lib/client.js:899`、:1031），不是子组。

⇒ 思考等级**保持现状、一行未改**：每模型各自带 `efforts`，逐站点声明不变。
efort id 仍是网页菜单的逐字文本（「快速」「专家」「极致」…）——宿主
`ReasoningEffortId()` 是**恒等函数**（`dsh-brand/lib/index.js:20-22`），无枚举白名单；
唯一校验是非空 string + 组内唯一（`dsh-llm/lib/index.js:2134`）。

### ④ 行名改回带站点键（合并后的正确性修复）

`modelGroupEntryName` 从裸名改为 `站点键/模型名`：`chatglm/GLM-5.3` vs `z.ai/GLM-5.3`。

这不是美观问题：**glm 与 z.ai 有同名模型 `glm-5.3`**。分组消失后，裸名会让同组里
出现两行逐字相同的 `GLM-5.3`——用户既分不清也选不对。这与 `modelDisplayName`
当初把站点键塞进名字里的原因是同一个。

### ⑤ 站点 provider 不再注册（用户明确「不要旧兼容路由」）

`webcode-<siteId>` 这 10 个 id 从注册表移除；`providerIdForSite` / `siteIdForProvider`
保留为**纯函数**，仍认得这些字面值（用于诊断与报错）。

**代价如实记录**：这些值曾写进每个会话的 `subagentModelSelectionPolicy`
（真机读数 `~/.dsh/storages/session_projcache/`：kimi 94 / zai 90 / glm 67 /
doubao 60 / deepseek 32 次…，其余各 30）。不注册 ⇒ 那批存量失效。这是用户明确
接受的（「我不需要旧，我只要新版的」），配套动作是把 web profile 的 16 条
`allowedModels` 改写为 `provider: webcode`，让源头不再写回旧值。

### ⑥ 注册幂等

参考 `dsh-codearts-auth` 的 `registerAdapterIdempotent`：cordis 重启插件 fiber 时，
新 fiber 的 apply 与旧 fiber 的异步 dispose 会在 dsh-llm 的 directory 上赛跑，撞出
`an adapter for provider "x" is already declared`。重复时保留现有路由并跳过本次提交
（同一份代码，语义等价）；**非重复类失败照常抛出**——吞掉真实配置错误比重启失败更糟。
判据只匹配「already declared / already registered」这类精确语义，不用泛词。

### ⑦ 桌面端装机 + 独立数据副本

- **web**：`dsh plugin --profile web add`（正常完成）。
- **desktop**：该 profile 由 Electron 独占，CLI 报
  `profile "desktop" is managed exclusively by the Electron application`，
  故改为直接部署文件并同步四处声明（`package.json` / `.modules.yaml` / 已装目录）。
- **数据目录**：`~/.dsh/webcode-edge-profile`（16380 文件 / 1.864 GB）复制为
  `~/.dsh/webcode-edge-profile-desktop`，桌面端 `profileDir` 指向它，中继端口改
  **8932**（避让 web 的 8931）。
  为什么必须独立：一个 profileDir 就是一个账号（桥锁粒度）。共用会导致桥锁互斥，
  且驱动按 profileDir 匹配命令行杀「对方浏览器」——两边互相杀，表现为随机超时。
  副本里那份陈旧桥锁（指向已不存在的 PID）已清除，登录态与会话槽一并带过来。

### 验收

| 检查 | 结果 |
| --- | --- |
| 注册 provider id | `["webcode"]`（唯一） |
| `listModels('webcode')` | 16 行，含全部 10 个站点 |
| glm / z.ai 行名 | `chatglm/GLM-5.3` vs `z.ai/GLM-5.3`（可分辨） |
| 思考等级 | glm `快速/深度/极致`（默认 极致）、kimi `标准/进阶`（默认 标准）——**不变** |
| 桌面端 import | OK（48 模块，`node --check` 全过） |
| 全量单测 | 120 个测试文件逐文件 exit 0 |
| 闸门 | `gen-index --check` / `lint-comments`(246 文件 0/0) / `check-ledger`(0.19.53,120) / `check-repo-hygiene` 全 PASS |

**提交前抽查补刀（0.19.53 收尾时发现并当场修）**：裸跑形态（本机唯一可行跑法）下
`regression.test.mjs` 的「整段重放」用例稳定红——prompt store 的 `NODE_TEST_CONTEXT`
隔离守卫在裸跑不生效，重放读回了测试自己写进真实 `~/.dsh/webcode/sessions/` 的首轮
正本（增量轮不落盘 ⇒ 缺「第二句」）。修法：该测试文件显式 `WEBCODE_PROMPT_STORE_DIR='off'`
（其断言目标本就是内存 rebuild 路径），并删除真实目录里的测试残留。
**顺带暴露一个生产缺陷候选**（真实轮重放读回首轮正本会丢后续增量，红线二形状）——
本轮不修，已登记 `doc/long-term-issues.md` #38。

**能力面不变**：改的是分组形态与行名；路由、协议、思考等级、驱动器零改动。

## 0.19.51

**修 DSH 0.2.0-rc.2 上升级后插件「整套消失」——根因是 peer 范围的形状，不是代码。**

### ① 阻断级：插件在 0.2.0-rc.2 上被整包跳过

DSH 升到 0.2.0-rc.2 后插件**根本没有被加载**。`dsh --profile web --dump-config` 的 stderr：

```
dsh: skipping profile bundle "dsh-webcode-bridge": Error: Plugin dsh-webcode-bridge@0.19.50 is
incompatible with dsh 0.2.0-rc.2: peerDependencies
{"@deepseek-ai/dsh-client-ui-sidebar-right":"^0.1.5-alpha.1"}. ...
```

同一次 dump 里 `id: webcode-bridge` 与 `id: preset-webcode` **各 0 行**（正常有若干行）。

**根因**：`dsh-app-boot` 的 `evaluatePluginCompatibility` 对每个 `@deepseek-ai/dsh*` peer
做 `semver.satisfies(runtimeVersion, range, { includePrerelease: true })`。对 `0.x`，
`^0.1.5-alpha.1` ⇒ `>=0.1.5-alpha.1 <0.2.0`——**只覆盖一个 minor 代**。它在上一个宿主
（0.1.7-alpha.2）上恰好为真，所以当时整套测试与真机验收全绿；跨到 0.2.0-rc.2 就恰好为假。
而失败形态是**整包从配置里静默消失 + 一行 warn**：插件没有被加载，于是它的**所有单测照常通过**。

**两处新采的读数（都推翻了此前的理解）**：

- **`peerDependenciesMeta.optional: true` 不豁免这道闸门。** `evaluatePluginCompatibility`
  完全不读该字段（实测：给可选 peer 写窄范围照样被判不兼容）。「可选」是**installer 语义**，
  不是 **host 兼容语义**。
- **`engines.dsh` 不参与这道拒绝。** 全量检索 dsh 各包的 `lib/*.js`，`engines.dsh` 零命中；
  改 `engines.dsh` 修不了这件事，只有 `peerDependencies` 管用。

**修**：`^0.1.5-alpha.1` → **`>=0.1.5-alpha.1 <1.0.0`**。下界不动（仍覆盖 0.1.5/0.1.6/0.1.7），
上界写 `<1.0.0` 表示「0.x 全代」。依据是客户端**按能力探测**而非按版本号工作：实测 0.2.0-rc.2 里
我们探测的每个 `*Outline14/16` 图标名都不存在、而每个 `*Regular` 回落**都在**，
`sidebarRightTabs` / `sidebarRight` 两个服务与八个槽位也都仍在。

### ② `wecode模式` 的 `order` 与官方 `preset-cordis` 撞号

官方四个预设实测 `standard=1 / ptc=2 / minimal=3 / cordis=4`（直接读
`dsh-web-app/presets/*.patch.yml`），而本模式也写 `order: 4`。`order` 的语义是
**「Roster order」**，同号两行的排序官方**没有定义** ⇒ 花名册位置不确定。改为 **5**。

### ③ `wecode模式` 与 shipped standard 的漂移：补上机制，而不只是补一句话

0.2.0-rc.2 给 standard 加了 `workflow-ptc` / `tool-subagent-codex` /
`tool-subagent-claude-code` / `tool-plugin-manager`，而本模式的注释只写「从 standard 逐字复制」、
**没写版本**，也没有任何断言去读**当前装机的** standard ⇒ 这四处漂移**无人判定**。

**判据**：新增 `②c` / `②e` 两条护栏，读**装机版** `standard.patch.yml` 的 `plugins:` 段，
要求**官方每一行都被交代**——要么在本模式里，要么在「有意删 / 随之删 / 本就 disabled」
三份清单的任何一份里。反过来 `②e` 把那三份清单**钉回官方事实**（`DROPPED_ROWS` 必须是官方
**启用**项、`DISABLED_IN_STANDARD_ROWS` 必须官方**确实 disabled**、名字都必须在官方存在）。

**四条反向验证**（改坏必须变红，实测都变红）：从 disabled 清单移除 `tool-ralph` ⇒ 1 红；
把 `tool-ralph` 谎称「启用被删」⇒ 1 红；谎报 `tool-fs` 已删 ⇒ 3 红；`order` 改回 4 ⇒ 1 红。

**处置**：逐项给出判定而不是照抄——`tool-workflow` 与 `tool-subagent-fork` 按本机读数
（23,636 次调用命中 0 次）**有意删**；`workflow-ptc` **随 `tool-workflow` 一起删**（实测
`dsh-workflow` 定义的是服务缝 `ctx.workflowEngine`，而 `workflow-ptc` 只是它的执行提供者，
消费者只有 `tool-workflow` / `tool-ralph` / 它自己；前两者本模式都不启用 ⇒ 无消费者，
**且它不注册模型可见工具**，故这一删不改变能力面）；四项在 standard 里本就 `disabled: true`
（不进提示词、不注册工具）⇒ 不复制。**能力面不变**。

### ④ 契约闸门：6 条 → 7 条判据（宿主兼容）

新增**判据 7**，两条臂：

- **形状臂（离线，CI 也有效）**：`@deepseek-ai/dsh*` 的 peer 范围**不得**是对 `0.x` 的
  `^`/`~`。要求写成显式 `>=<下界> <<上界>`，把「我不支持哪一版」从**默认**变成**作者的决定**。
- **事实臂（本机装了 dsh 时跑）**：用**该 dsh 自带的 semver** 复算
  `satisfies(运行时版本, 声明范围)`——即**直接跑宿主那道判据**；装了 dsh 却算不过 ⇒ 红。
  没装 dsh ⇒ 标 SKIP 并**打印原因**（不静默假绿）。

**反向验证**：把 peer 改回 `^0.1.5-alpha.1` ⇒ **两条臂同时变红**、退出码 1。

### ⑤ 顺带修一条一直红着的 CI 断言（`run-m1.js`）

`test/run-m1.js` 断言 `listModels('webcode').length >= 2`，写于 v0.5.1（`git log -S` 取证）。
0.19.42 起模型选择器**按站点分组**，`webcode` 退化为**兼容空壳**、`listModels` **刻意返回
空数组** ⇒ 这条断言从那天起就是假的，而 `pnpm test`（CI 跑的正是它）因此**在任何平台恒定失败**。
同一件事 `test/regression.test.mjs:29` 早已按新口径钉住，只有本文件漏改。

**修**：改为按设计钉住两件真事——空壳必须为空、站点 provider 必须公布模型。
**M1 首次 PASS**（此前 `M1 RESULT: FAIL (1)`）。

## 0.19.50

**对齐 DSH 0.1.7/0.2.0 插件规范 + `lib/sites/` 站点解耦起步 + 结构清晰化。能力不变。**

三件事：把长期欠账如实登记、按当前规范逐条核对声明层、把散落的站点知识收口。
贯穿全程的不变量是**能力不变**——十个站点照常收发、工具协议、面板、任务板、镜像全部不动。
为此**先立护栏再改代码**，每条新护栏都做了**反向验证**（改坏实现必须变红）。

### ① 规范面：比预期窄，且已对齐的部分比台账多

权威出处是 `@deepseek-ai/dsh-package-manifest` 的五个接口。实测：**实装 `0.1.7-alpha.2` 与
npm `latest` `0.2.0-rc.2` 的 `types.d.ts` 内容逐字相同**——0.1.7 → 0.2.0 **没有新增清单字段**。

已对齐、复核后确认无需改动：`dsh.client.inject` 已删除；`client.cjs` 的 Cordis `inject`
已无 `settingsScope`；**六个客户端槽位全部 `active: true`**（`cordis_inspect_query` 实读）。

### ② 声明层改动

- **`engines.dsh`：`>=0.1.0-rc.6` → `>=0.1.6-alpha.2`。** 旧值 `git log -S` 取证写于
  **v0.5.1 的初始快照**，此后从未重看——那时 `slots` / `primitives` / `sidebarRight*` 都还不存在。
  新值依据：`doc/verify.md` 有真机验收记录在 0.1.6-alpha.2 上完成，且客户端 bundle
  **刻意横跨 0.1.6 / 0.1.7 两代图标导出名**。
  **刻意不取 `>=0.1.7-alpha.2`**：代码刻意支持 0.1.6，写成 0.1.7 会是一句代码并不支持的声明。
- **新增显示元数据**：`package.json` 的 `icon`、`locale/en.json` + `locale/zh.json`，
  以及 `exports` 的 `./locale/*.json`、`./icon.svg`（规范要求经 ESM resolver 可达）。
- **`peerDependencies` 核查后不改**：`^0.1.5-alpha.1` 满足实装的 `sidebar-right@0.1.7-alpha.2`，
  且**这个宽度是必要的**（要同时支持两代）。「看着旧」不等于「错」。

### ③ 契约闸门：4 条 → 6 条判据（其中一条官方不查）

- **判据 5**：`dsh.client.inject` 的每个值**必须是包名**（规范逐字：「Informational
  package-name dependencies, **not** Cordis service injection」）。
  **官方闸门只查空值与重复**，所以这个错误会静默存活——本仓库真实发生过一次
  （0.19.1 把服务名记为「合规」，直到 0.1.7 升级才暴露）。
- **判据 6**：`icon` / `locale/` **声明了就必须合法**（≤256 KiB、在 manifest 目录内、
  `locale/en.json` 存在、`exports` 可达、形状是 `{ meta: { title, description } }`）。
- 两条判据都做了反向验证：填回服务名 ⇒ 红；坏 icon + 删 exports + 坏 locale 形状 ⇒ 红。

### ④ 站点解耦：`lib/sites/<siteId>.js`，本轮只搬 DeepSeek

`lib/sites/index.js` 是注册表（`migratedSiteIds()` 让迁移进度**可断言**），
`lib/sites/deepseek.js` 承接站点声明；`providers.js` 保留**组装职责**
（`withEffort` 摊平 + 冻结），那一处因此缩成一行。

**为什么是「一个文件一份声明」而不是「按站点复制代码」**：复制在本仓库**产生过漂移而不是隔离**
（三份逐字相同的 `ANSWER_SELECTOR`，「修一处、忘两处」），且会让公共缺陷的修复成本乘以站点数
（0.19.16 一次修了 4 个共用基类的解码器族）。

其余 9 站**刻意保持内联**——迁移是渐进的，工作量以 DeepSeek 为准。
行为不变由新增的 `test/provider-surface.test.mjs` 钉住（站点表 / provider id / 模型目录 /
分组名 / 可逆性 / 17 行快照），搬家前后 6/6 绿。

### ⑤ 两处「规则抽成具名纯函数」（这才是真正缺的那一半）

- `browser-driver.js` 的 **`answerSelectorFor(siteId)`**：`声明 || 兜底` 这条规则此前只活在
  一个**内联表达式**里，而 `createBrowserDriver` 需要真浏览器才跑得起来 ⇒ **规则写反了
  没有任何单测会红**。现由 `test/answer-selector.test.mjs` 5 条钉住。
- `index.js` 的 **`isInjectedDefaultSlot`**：两处 `siteId === 'deepseek'` **看着像站点特例**，
  实际是**测试注入契约**（删掉会让全部既有测试在无头环境里真的去拉一个 Edge）。具名之后不会被误改。

### ⑥ 结构清晰化

- `doc/CODE-STRUCTURE.md` **全量重算**（原写 30 模块 / 15,175 行 / 109 测试；实测 **48 / 34,570 / 118**）；
- `doc/ROADMAP.md` 补写「站点差异外移」一节——原文件两处被引用为「第 2 节」但**没有对应内容**（死引用）；
- `gen-index.mjs` 由**扁平扫描**改为**递归**：引入 `lib/sites/` 后，扁平 `readdirSync`
  不报错、**只是看不见**那些文件 ⇒ 索引凭空少一层。索引随之 48 → **50 个模块**。

### ⑦ 诚实交代（本轮没做的）

- `lib/sites/` 其余 **9 站**未迁移；
- **未跑真机验收**（需要已登录浏览器），`engines.dsh` 的新下界只由既有 verify.md 记录支撑；
- **zai / doubao 的思考等级真机回读**仍未取得 ⇒ 新登记为长期问题 **#34**；
- 全量单测 118 文件 **115 通过 / 3 失败**，三条已逐条归因、**无一是本轮回归**：
  `prompt-store` 是调用方式产物（需 `NODE_TEST_CONTEXT`，设上后 11/11 通过）；
  `regression`（53/1）与 `aux-delta-compact`（4/1）是**既有常红**，
  已用 `git stash push -u` 在**干净树**上复跑确认**逐字同样红**。
  这两条是**同一条行为**（`WEB_SESSION_LOST` → 整段重放），落在「绝不静默丢上下文」红线区，
  已登记为新长期问题 **#37**。

## 0.19.49

**修：思考等级「回读读不到档位」——5 个站点里 4 个各自有独立的回读缺陷；外加一个带槽的兼容别名真 bug。**

用户原话（2026-09-28）：
> 本轮运行失败THINK_EFFORT_UI_CHANGED: 思考等级「标准」没有生效（站点 kimi）— 回读：读不到档位
> （判定 no-readback）。本轮已中止，避免把「用户选了高档、网页仍是低档」当成成功。
> 其余都是类似原因

**「其余都是类似原因」这句话是对的。** 取证方法与逐站点真因见
[`doc/research/2026-09-28-think-effort-readback.md`](../../doc/research/2026-09-28-think-effort-readback.md)。

### ① kimi：把**模型名**当成了锚点（用户报障的那一条）

真机读数（只读 CDP，线上已登录页）：触发控件 `div.current-model[data-testid="model-select-trigger"]`
的文本是 **`K3 标准`** —— `K3` 是**当前模型名**、`标准` 才是思考档；模型菜单里可选的模型是
`K3` / `K2.8 Preview` / **`快速`** 三条。

0.19.48 声明的 `triggerText: '快速'` 因此是**把模型名当锚点**：只有当用户恰好选「快速」这个模型时
触发文本才是 `快速 进阶`（锚点命中），换成 `K3` 后是 `K3 标准` ⇒ 锚点不在文本里 ⇒
`FIND_EFFORT_CONTROL` 的 token 匹配池为空 ⇒ 回读 `null` ⇒ **每一轮都抛 `THINK_EFFORT_UI_CHANGED`**。

修法：删掉 `triggerText`（不再靠文本定位），改用 `triggerSelectors` 的 testid；新增
**`readbackSelector: '.current-effort'`** —— 真机实测该节点的文本**恰好是档位**（`标准`），
不含模型名。同时去掉 `openVia`（它与触发控件是同一个节点，旧计划会点两次、第二次把刚打开的菜单关掉）。

### ② glm：模型名与档位**无缝拼接**，词级判据切不出来

真机读数：`.think-mode-trigger` 的文本是 **`GLM-Flash极致`**（对比另一模型下是 `GLM-5.3 极致`，**有**空格）。
`'GLM-Flash极致'.split(' ')` 切不出 `极致` ⇒ 词级兜底失效 ⇒ 恒 `unknown`。
修法：`confirmEffort` 新增**后缀判据**（文本恰好等于 / 以某档位结尾，长者优先），并补 `triggerSelectors` + `readbackSelector`。

### ③ qwen：触发文本**就是**档位 id，锚点语义自相矛盾

真机读数：`.qwen-thinking-selector` 文本是 `自动`（切档后 `思考`）。0.19.48 声明
`triggerText: '自动'`，而 `自动` **同时是一个档位 id** ⇒ 剥离函数返回 null、词级兜底又被
「声明了锚点」关掉 ⇒ 恒 `unknown`。修法：删 `triggerText`，改选择器定位 + `readbackSelector`。

### ④ zai / doubao：本轮**未发现**缺陷

zai 的 `triggerText: '深度思考'` 是**真的**固定前缀（pill 文本 `深度思考 最高`），锚点在文本里；
用真机形状在当前代码上跑 `applyEffort`，低/高/最高三档全部 `applied=true` 且回读正确。
doubao 走 `readback: 'aria-checked'`（弹层里被 checked 的那条），本就不依赖触发文本。

### ⑤ 顺带修掉一个真 bug：带槽的**兼容别名**解析不了

`resolveWebModel` 的别名查表发生在**槽解析之前**、用的是整串原文。于是单账户写 `zai:auto` 正常，
**一旦启用第二个账户**（id 变成 `zai@work:auto`）就报「不支持的网页模型」—— 而这两者本该等价。
历史 settings 与 `subagent-model-selection.allowedModels` 里写的都是不带槽的形式，
用户在面板上给模型挑一个账户后就撞上它。修法：把「站点:模型」这一段单独再查一次别名表，
命中后**按原本的槽**解析（槽信息绝不因别名而丢失，否则「账户2」会静默写回默认槽的登录态）。

### ⑥ 判据变更（有意的行为放宽，代价写清）

`confirmEffort` 取「当前档位」新增两种真机形态：**整段即档位**（qwen `自动`）与
**无分隔符拼接**（glm `GLM-Flash极致`）。放宽的只是「怎么取出档位」，**没有**放宽
「取出后算不算命中」——「有没有读到别的档位」仍只按词判（`低 ⊂ 最高` 不会被误判），
且**绝不**按「文本里含目标片段」判定（`进阶的快速响应` 仍诚实报 `unknown`）。
理由：旧行为在这两种形态下返回 `unknown`，而调用方**无法补救**（整轮中止）；
`mismatch` 至少会触发一次真实改档。

### 护栏

- `test/think-effort.test.mjs` 27 项（含新增的形态②③与「词中不算命中」）
- `test/think-effort-realtext.test.mjs` **新增 5 项**：以**真机触发文本**为数据（逐条标出处与日期），
  钉住「锚点必须在文本里」「回读节点必须读得出档位」「真机文本喂判据不许 unknown」
  「kimi 锚点不许再写成模型名」「readbackSelector 接线」
- `test/model-picker.test.mjs` / `test/accounts-integration.test.mjs`：跟上 0.19.43 的
  `auto` 真删（补「历史 id 仍可解析且**槽信息不丢**」的断言）

### 验收现况（2026-09-29 收口复跑，如实记）

**绿的**：`ci-local --fast` **10/10 PASS**；受影响测试集逐个跑全绿
（`think-effort` 27/27、`think-effort-realtext` 5/5、`model-picker` 7/7、
`accounts-integration` 16/16、`multi-site-decoder` 28/28、`model-labels` 12/12，
另 6 个文件 4–17 项不等）。

**未取的**：**线上完整真实轮次仍未跑**。交付时没有任何浏览器在跑 ——
五个站点的 `DevToolsActivePort` 都在而端口全部连接被拒，只读 CDP 这条路当时也走不通。
**在拿到真实轮次读数之前，请不要把「kimi 不再抛 `THINK_EFFORT_UI_CHANGED`」当成已验证**；
手测清单见 `doc/research/2026-09-28-think-effort-readback.md` §9。
「验证需要人的环节没有提醒人手动过」这条已登记为长期问题 **#33**。

## 0.19.48

**加：思考等级（推理等级）真正下发到网页 —— DSH 模型选择器里多出「推理等级」一栏；豆包模型档位取证。**

用户原话（2026-09-28）：
> 1.现在的模型网页端，除了 deepseek 是只有深度思考开关没有思考等级开关，其他网站都有思考等级的分级你没有选择，写好 dsh 这里能主动选择，然后是 2.豆包不是只有对话和工作两个模式，左下角有模型和思考等级选择！请你都解决了！

### ① 真因：DSH 的推理等级通道**桥根本没接**

DSH 宿主本来就支持「每模型一套思考等级」：provider 适配器的 `resolveModel()` 返回
`reasoning: { efforts, defaultEffort? }`，模型选择器就会多出「推理等级」一栏，选中值随
`GenerateOptions.reasoningEffort` 回到适配器。桥此前**一个字都没声明** —— 于是所有站点在选择器里
都是「没有等级可选」。**这不是网页端没有，是桥没接。**

现在（`lib/think-effort.js` 是唯一真源）：

| 站点 | 选择器里出现的档位 | 网页上是什么 |
| --- | --- | --- |
| Kimi | 标准 / 进阶 | 模型菜单里的「思考强度」子菜单 |
| 智谱清言 | 快速 / 深度 / 极致 | 模型弹层里的「思考强度」子菜单 |
| Z.ai | 低 / 高 / 最高 | 输入框旁「深度思考 最高」那枚 pill 的档位菜单 |
| 通义千问 | 快速 / 思考 / 自动 | `qwen-thinking-selector` 的下拉 |
| 豆包 | 快速 / 专家 | 模型下拉里的档位徽章（用户说的「左下角模型和思考等级」） |

**刻意不声明 `defaultEffort`**：声明它等于桥替用户定档，而且宿主会把它 materialize 进每一轮请求。
不声明时选择器显示「Default」，桥**一个字都不动网页**（与升级前逐字相同）；只有用户显式选了某一档，
`reasoningEffort` 才出现并下发。

**未取证的站点一律不声明**（chatgpt / gemini / grok / claude 本机网络不可达；deepseek 只有「深度思考」
开关没有分级 —— 用户原话即此）。宁可下拉里少一栏，也不声明一个点不到的控件：那会让该站点每轮都报错。

### ② 绝不静默降级

`applyEffort()` 只有两种收场：设好并**回读确认**，或抛错（`THINK_EFFORT_UI_CHANGED` /
`THINK_EFFORT_UNAVAILABLE` / `THINK_EFFORT_UNKNOWN`，各带可选项与当前读数）。
没有「点了但没生效就照常发送」——用户选了「深度」而网页停在「快速」，那是一次静默降级，界面上看不出来。

### ③ 豆包：模型 + 档位两个控件都取证了

原先代码注释写着「豆包没有下拉式模型选择器」——**这是错的**。真机读数
（`test-mock/out/think-control-doubao-*.json`）证明它有两个独立控件：常驻的「对话 / 工作」分段控件，
以及 `[data-testid="chat_input_action_model"]` 模型下拉（两条：「豆包 快速」/「豆包 2.1 Turbo专家」）。
用户说的「左下角有模型和思考等级选择」正是后者，现在它的档位（快速 / 专家）进了「推理等级」栏，
模型模式仍由 `modelPicker.segmented` 契约承担。

### ④ 真机取证（`test-mock/probe-think-effort-live.mjs`）

不发任何消息，把真 Playwright 页面适配成 `applyEffort` 认的形状直接跑：

* **z.ai**：低 / 高 / 最高 三档全部下发成功（连跑两轮 3/3 PASS），回读值为「深度思考 低 / 高 / 最高」；
* **Kimi**：`进阶` 命中（幂等快路）；`标准` 那一档因该 profile 已掉登录（页面明写「登录以同步历史会话」）
  无法取证 —— **如实记，不假装验过**。

### ⑤ 取证路上修掉四个只看真机才现形的缺陷

1. **页面函数不得引用模块作用域**：`FIND_EFFORT_CONTROL` 通过 `page.evaluate` 序列化后在浏览器里
   单独执行，调模块级辅助函数 ⇒ 真机 `ReferenceError`（而 Node 单测全绿）。现在页面函数自包含。
2. **`page.evaluate(fn, cfg)` 的参数转发**：cfg 写成第三个参数会被 Playwright 静默丢掉 ⇒ 每站都报
   「找不到控件」。测试里为此单独立了一条护栏。
3. **菜单里有一条与触发控件同名的常驻行**（z.ai 的「深度思考」总开关）：菜单一开，同 token 候选从 1 个
   变 2 个，回读读到菜单行 ⇒ 三次下发全失败，而 pill 明明显示着档位。现在按「不在浮层里 + 有开合状态属性 +
   词的构成」三层判据定位触发控件。
4. **`menuOpen` 误判**：原先「页面上存在 `data-state=checked` 的元素」就算「菜单已开着」，于是 kimi 的
   模型下拉触发钮让驱动跳过「点开触发」，档位菜单永远不开。现在要求那个 checked 元素**有浮层祖先**。

### ⑥ 顺带修正的模型表（同一次真机读数）

* **Kimi**：菜单里 **K3 集群已消失**（站点提示「集群功能已移动至+号面板中」）⇒ 模型表真删 `k3-cluster`，
  新增 `k2.8-preview`；历史 id `kimi:k3-cluster` / `k3-cluster` 仍解析（收敛到 K3，旧会话不断链）。

### ⑦ 护栏与闸门

* 新增 `test/think-effort.test.mjs` **25 项**：声明层（哪些站点有读数、id 逐字、控件契约完整）、
  计划层、判定层三态、执行层（用**假 DOM 跑真页面函数**，含「点可点元素而不是内层 span」「常驻 checked
  不算菜单已开」两条真机教训的回放）、接线层（`resolveModel` 的 reasoning、驱动的 `applyEffort` 调用、
  两条发送路径都带等级）。
* `node scripts/lint-comments.mjs` 0 error 0 warn；`check-ledger` PASS（0.19.48 / 115 个测试文件）；
  `check-repo-hygiene` PASS；`gen-index --check` PASS（48 个模块）。

**如实交代**：本轮**只跑了受影响集**（新增 25/25、`model-labels` 12/12、`multi-site-decoder` 28/28），
未跑全量；Kimi 的 `标准` 档、以及 chatgpt / gemini / grok / claude 四站的思考等级**没有真机读数**，
缺口登记在 `doc/long-term-issues.md`。

## 0.19.47

**修：选了账户2 打开界面仍是账户1；模型行显示真实用户名；剥掉走错通道的 `</think>`。**

用户原话（2026-09-28）：
> 现在右侧选择了账户2打开界面仍是用户1：rsyhn的登录账户，deepseek
> 现在为什么不能做到新增账户后在模型列表就更新为：deepseek-rsyhn(这是真实用户名)，deepseek-177...这样子？？？
> 右侧tab展开站点的：DeepSeek 网页版改为真实用户名！然后是设置界面也需要同步有改为：图像+用户名
> 其余所有已登录网站的用户名和头像一样触发缓存更新字段！

### ① 账户2 打开界面仍是账户1 —— 镜像只按站点挂载

`mirrorFor(siteId)` 的 Map key 只有 `siteId`，cookie 来源写死 `driverFor(sid)` ——
传的是**裸 siteId**，按 `accounts.formatAccountKey` 的语义**恒等于默认槽**。账户2 的标签页
因此命中默认槽那个 mirror 实例、回填账户1 的 cookies；**槽在 URL 里没有任何位置 ⇒
服务端结构上无从区分**。客户端 `siteBase(sid)` 也只按 siteId 拼源，两端一起把两个号混成一条。

修法是给**槽一个独立源**（沿用本项目「每个站点独立源」的既有立场）：

| | 源 |
| --- | --- |
| 默认槽 | `http://<siteId>.localhost:8931/`（deepseek 仍走中继根）—— **逐字不变** |
| 非默认槽 | `http://<slot>--<siteId>.localhost:8931/` |

服务端按 **accountKey** 取驱动、mirror 实例 key 带槽；客户端用**活状态** `accountSlot`
（只传初值会让标签内切号不生效）。护栏 `test/mirror-slot-isolation.test.mjs` **6 项**，
已反向变异确认红灯基线。

### ② 模型行显示真实用户名（`deepseek-rsyhn` 而不是 `(账户2)`）

按槽读落盘的身份缓存，把真实用户名拼进显示名；**读不到就逐字退回 `(账户N)`**（不造假）；
单账户站点不加后缀（0.14.6 逐字不变）。**模型 id 一个字都没动** ⇒ 历史会话、别名表、
`subAgentSite` 全部不受影响。

### ③ 右侧 tab / 设置页显示真实用户名+头像，且覆盖所有已登录站点

两处真缺陷（均为本仓库记过的「后端算好了、中间层丢掉」同型）：`refreshIdentities` 的
返回值被**直接丢弃**（面板渲染的 `rows` 从没拿到 name/avatarUrl，且服务端那次刷新不写缓存）；
刷新**只挂在下拉展开上**（用户从不点开下拉的单账号站、以及账户2 都覆盖不到）。
现按 accountKey 合并进 `rows`（服务端回 null 时**不覆盖**）、并挂到既有 8s 轮询上
（只挑已登录且尚无昵称的槽）。

### ④ 剥掉走错通道的 `</think>`（真机长跑探针顺带发现）

GLM 把思考的**闭合标签**写进了 text 通道，正文出现 `` ``` … ```</think>两条输出分别是… ``。
`GlmDecoder.emitText` 现在剥掉 `</think>` / `</thinking>` 闭合标签本身；刻意**不碰**开标签与
其它标签。护栏 `test/glm-think-tag-leak.test.mjs` **4 项**，已反向变异确认红灯基线。

### ⑤ 顺带修好两条常年假红

`control-routes` 与 `multi-site-decoder` 长期各 1 条红——它们断言 `glm:auto` / `kimi:auto` /
`zai:auto` 必须在目录里，而这三个条目已在 **0.19.43 按用户指令真删**。已改成**双向断言**：
目录里没有 auto（chatgpt/qwen 因无 modelPicker 契约保留），历史别名仍解析得开。
修后 **17/17** 与 **28/28**。两条红的既有性已用 `git stash` 对照证实。

---

## 0.19.46

**修：右侧 tab 的账号昵称/头像「每次新开就没了」—— 身份读数落盘。**

用户原话（2026-09-28）：
> 1.请你查看现在右侧tab获取的deepseek账户名和图像不会缓存？每次新开？
> 然后是展开后需要显示的是图像+账号名；而不是图像+Deepseek网页版，然后设置界面DeepSeek
> 的账户与登录里面一样，其余网站你也搞好

### 根因（两处，缺一不可）

1. **`accountIdentity` 原先只是内存里的一个 `let`**（`lib/browser-driver.js`）。桥重启 /
   懒驱动被回收 / profile 重建 ⇒ 名字与头像归零，面板回到 `displayName` ——
   用户看到的就是「图像 + Deepseek网页版」。
2. **`readAccountIdentity()` 在没有活页时直接返回全 null**。而重启后浏览器尚未 launch
   ⇒ `page` 恰为 null ⇒ **缓存即使存在也被这一格抹掉**。这正是「每次新开就没了」。

对照：`loggedIn` **早就落盘了**（`webcode-login-state.json`，其注释逐字写着
「进程内存里的 loggedIn 重启即归零，没有这份缓存，面板每次重启都把所有站点打回『待检查』」）。
账号身份是同一类装饰性读数，却漏了同一层。

### 修法

新增 `webcode-account-identity.json`（与登录态缓存同目录、同 `0o600` 纪律）：

- 驱动构造时读回缓存（坏 JSON / 缺 `name` 与 `avatarUrl` 两者 ⇒ 当没有，绝不半信）；
- `readAccountIdentity()` 读到就落盘（落盘失败不影响本次读数）；
- **没有活页时回落缓存**，`basis` 如实标成 `'cache'`，**不冒充** `'site-probe'`；
- 仍**不造假**：没有缓存又没有活页 ⇒ 依然是全 null，绝不拿槽名冒充昵称。

### 护栏

新增 `test/account-identity-cache.test.mjs` **7 项**，**已反向变异确认红灯基线**
（退回「无活页即全 null」⇒ ②④ 变红）。

### 附：「profile 被占用，直接复制新开不行吗？」

**行，而且项目本来就这么设计 —— 用账户槽。** 已查实 GLM 的锁持有者 pid **确实活着**
（`process.kill(pid,0)` 回 EPERM ⇒ 按设计一律按「活着」处理），是**合法持锁，不该删**。

`POST account-add` 给 `glm` 的下一个空槽是 **`glm#2`**，其 profileDir 是
**`sites/glm/2`** —— 另一个目录 ⇒ 另一把锁 ⇒ **与在跑的桥不冲突，可真并发**。
效果等同「复制一份新开」，但由代码保证「一个槽一个目录一把锁」，不会出现两实例共用
同一目录而互相杀浏览器 / 交错网页消息。

⚠ 同一登录态在两处并发仍会撞同一网页会话；要真并发请在新槽里**登录另一个 GLM 账号**。
风控纪律照 `CONTRIBUTING.md` §1.3（串行、每变体 ≤3 次、间隔 ≥20s）。

---

## 0.19.45

**修：「空回复」报错的两处取证缺陷 —— 收束原因假陈述 + 只截流首段。**

用户原话（2026-09-28）：
> 本轮运行失败 empty response from web AI（收束原因 finished） | 流首段: data: {"id":"6ab9fff5a0610b1da4035fd6","conversation_id":"6ab9fe16a0610b1da4035b49","assistant_id":"65940acff94777010aa6b796","parts":[],"created_at":"2026-09-28 13:49:41","status":"init","last_error…

> 你看下：现在是桥的问题吗？1.正文回复明显不对？2.调用一半我不返回了却提醒这样！

### 先回答「是不是桥的问题」：这一轮不是桥把回复弄丢了，是网页侧返回了空流

**主证据**（DSH 会话存档 `session-ee3a8650` 的 `session.v4.jsonl.zstd`，逐条解帧）：turn 1 正常
`completed`；**turn 2 的前 9 个 assistant 步骤全部正常带 tool-call**（05:44:09 → 05:49:11），
**第 10 步**才返回空流。同时 `/__webcode/status` 的 `relay.lastError` 显示 `durationMs: 8620`、
`firstTokenMs: 7674`、`responseMs: 946` —— 网页侧开了流（`status:"init"`）但 `parts:[]` 随后结束。

⇒ GLM 在失败前**连续正常工作了约 6 分钟 / 9 个工具步骤**。不是桥丢内容。

**但这不代表桥没问题**：报错文本本身有两处真缺陷，它们正是让「这到底是不是桥的问题」
**无法回答**的原因。

### 缺陷 A：收束原因是一句假陈述

旧实现兜底那格**无条件写 `'finished'`**：

```js
noteEndReason(lastFinished?.settled_by || (site.decoder === 'dom' ? 'dom-capture' : 'finished'));
```

它完全不看解码器给的是 `{complete:true}` 还是 `{complete:false, reason:'incomplete'}`。
而 GLM 的解码器只在 `status === 'finish'` 帧才置 `done` —— 那一轮只有 `status:"init"`
就断了，解码器给的是 `incomplete`。**报错于是把「网页没说完」写成了「网页正常收束」**，
与本项目记过的同族缺陷（旧读数冒充本轮、`mid-stream` 被印成「已开流后的静默」）逐字同构。

**修法**：判据与 `result.complete` **同源**——跑完整才写 `finished`，没收完整就如实带出
解码器的 reason（`partial:incomplete` / `invalid_stream` / `rate_limited`…）。

### 缺陷 B：流首段按构造必然误导

`rawHead` 取的是流**最前面** 400 字符。而 `status:"init"` + `parts:[]` 是 GLM **每一轮**的
正常开帧 —— 首段按构造永远是它，与「这一轮为什么空」毫无关系。真因（限流原话 / 审核提示 /
`last_error`）都在**后面的帧**里，而报错只截首段前 200 字符。

**修法**：新增 `rawTail`（保留**最后一帧**原文），随 `rawHead` 一起进现场；首尾重合时只印
一段。两处使用点都改：`emptyWebResponseError` 与 `web capture ended incomplete`。

**修前 / 修后对照**：

```
修前: empty response from web AI（收束原因 finished） | 流首段: …"parts":[],"status":"init"…
修后: empty response from web AI（收束原因 partial:incomplete） | 流首段: … | 流尾段: …"message":"当前访问人数过多，请稍后重试"…
```

### 护栏

新增 `test/zero-progress-scene.test.mjs` **5 项**（原因自洽 / 首尾段齐备 / 不编造尾段 /
不重复印 / 有内容时不报错），**已反向变异确认红灯基线**（去掉尾段 ⇒ ② 变红）。

### 关于「GLM 以前能跑几个小时」——现有数据不支持，如实交代

查了全部工作区的 DSH 会话存档：GLM 驱动过的步骤共 **80** 个（占全部 30,464 步的 **0.26%**），
出现于 47 个会话，**最长连续运行 0.79 小时（47 分钟）/ 8 步**。会话存档里**找不到任何
「GLM 连续跑数小时」的运行**。

`doc/progress.md` 里唯一的长跑记录是 **DeepSeek** 的 70 分钟真机长跑，而那份表的第 1 轮 GLM
被明确标注为「GLM 误路由」——是事故，不是成功案例。

**为什么无法验证「0.19.40 之前」**：回复取证日志的 `site=` 与 `v=` 字段是 **0.19.30** 才加的；
更早的日志（`webcode-bridge-replies.log.1`，4276 条）没有站点字段，**无法归因到 GLM**；
且 0.19.30–0.19.39 十版在 git 里是一次性合并提交，版本级不可分辨。⇒ 这是**未取证**，
不是被推翻：现有材料既不能证实也不能否定这个记忆。

---

## 0.19.44

**修：发送间隔等待被算进了无进展看门狗窗口 —— GLM「等近两分钟才开始思考」并撞
`WEB_NO_PROGRESS` 的根因。**

用户原话（2026-09-28）：「请你查看下现在的 chatglm 怎么回事：1.超长时间刚开始加载--40 前面版本
我记得都是马上就接着思考而不是现在等近两分钟！才开始有 2. 本轮运行失败 WEB_NO_PROGRESS:
网页侧超过 120s 没有任何新内容（页面在，上一轮收束原因（120s 前） finished，判定相位=网页还没
开口且驱动不在忙（按常规窗口未宽限）） — 本轮已中止，可重试」+「这个是不是叠加1的问题引起的？」

**答：是，第 2 条就是第 1 条引起的。**

### 根因

`relay.submit()` 一调用，适配器的看门狗计时器**当场开跑**；而 `relay.submit` 只是**入队**。
从那一刻到消息真正落进网页 composer 之间，桥还要做几件**与网页生成无关**的事：

| 段 | 真机读数 / 上限 | 依据 |
| --- | --- | --- |
| 发送间隔等待 | **30,000ms**（用户设 `sendGapMs: 30000` + `end-to-start`；旧版本是 10000 + `send-to-send`） | `~/.dsh/.../webcode-settings.json` 与其 `.bak-0140` |
| 驱动懒创建 + `ensure()` 冷启动 | 数十秒量级 | GLM 非 deepseek 默认槽，创建点在 `attempt()` 里，**在间隔等待之后** |
| 附件上传 | **真机 85k 实测 53s** | `uploadTextAttachment` 注释里的既有读数（GLM 走附件） |
| 限流退避 / relay 排队 | 退避 ≤ 3 次、排队 ≤ `queueTimeoutMs` | 各自的上限 |

真机一轮 GLM 实测 **97,993ms**（`webcode-send-state.json` 的 `send`/`end` 差），
而看门狗只给 120s——30s 间隔 + 附件上传已吃掉大半，「网页还没开口」就撞线。

**决定性实测**（注入式脚本驱动，窗口压到毫秒级，首字节延迟固定 800ms，只改间隔）：

```
修前: gap=0→OK(992ms)  gap=1000→OK(1819ms)  gap=2000→FAIL(2607ms)  gap≥3000→FAIL(2609ms)
      且 gap≥3000 时**驱动调用次数只有 1**（这一轮根本没送到驱动）——看门狗是在「等间隔」期间判死的
修后: gap=0/1000/2000/3000/5000/8000/20000 → **全部 OK**，驱动调用 2 次
```

### 为什么这是口径不一致，而不是「窗口太小」

relay 自己的账本就写着「发送前等待发生在网页生成之前，**不计入** `durationMs`」
（`lib/relay.js` 的 `sendWaitMs` 注释）。同一个量在一处被排除、在另一处被计入，才是真缺陷。
**修法是让两处同口径，不是放宽窗口**——顺带一提：`idleFirstByteMultiplier` 只作用于
「首字节之前 + 驱动忙」那一格，本次恰恰落在「驱动不忙」，调它对这个 case **完全无效**。

### 改法

executor 与适配器共享同一个 `meta` 对象，新增两个字段（**唯一真源**，只在 executor 写）：

- `meta.delivering`（活标记）——进 executor 即为真，`markPreDeliver()` 时置假。
  **必须有它**：30s 间隔等待进行到第 5 秒时 `preDeliverMs` 还没法定值，看门狗若只看那个
  字段就会以为「从未投放」而在常规窗口开火（本修复第一版正是如此，实测 gap≥3000ms 仍失败）。
- `meta.preDeliverMs`（累计时长）——投放前累计耗时，每次 `attempt()` 之前重算，
  于是限流退避与压缩重试后 deadline 会相应后移。

看门狗据此算**有效 deadline**：

```
投放中   → submitAtReal + preDeliverMs + windowMs     （窗口本身逐字不变）
投放前   → Infinity（消息还没发出去，「网页不吐字节」尚无证据）
从未投放 → submitAtReal + windowMs                    （既有安全线，逐字保留）
```

**投放前不是无限豁免**：`delivering` 只在 executor 的同步流程里为真，对应的每段等待各有上限，
且新增 `PRE_DELIVER_CEILING_MS`（= `requestTimeoutMs`）硬界作双保险 ⇒ 不会「永不失败」。

### 护栏与反向验证

新增 `test/pre-deliver-window.test.mjs`（6 项）：

- ① 间隔 0→20000ms（窗口的 8 倍）、首字节在窗口内 ⇒ **每档都必须成功**，且驱动调用 2 次；
- ①b 间隔确实生效（墙钟差异 > 3s）——防「间隔根本没生效」的假绿；
- ② 首字节超窗口 ⇒ 仍判死（**窗口不得被放大**）；
- ②b 扣等待 ≠ 免判死（间隔 3000 + 首字节 4000 ⇒ 仍判死）；
- ③ 驱动不忙 ⇒ 仍按常规窗口快报（**既有安全线不得削弱**）；
- ④ 从未投放（驱动挂住）⇒ 必须按窗口判死，不得永不失败。

**反向变异确认红灯基线**：把 `isPreDelivering` 变异成恒 `false`、`preDeliverMs` 变异成恒 `0`
（= 退回修复前行为）后复跑，① 与 ①b **变红**、②③④ 保持绿 ⇒ 该护栏确实钉住本缺陷，
不是空转。

受影响集全绿（新增 6/6、`watchdog-first-byte` 9/9、`idle-window` 18/18、`timeout-order` 5/5、
`capture-stall-rescue` 18/18、`stall-settle` 15/15、`session-continuity` 14/14、`tool-loop` 14/14、
`wait-stats` 40/40、`settings-transport` 8/8、`prompt-transport` 12/12、`glm-attach-limit` 5/5、
`captcha-gate` 5/5、`model-labels` 12/12）。`regression` 53/1 与 `control-routes` 16/1 各有 1 条红，
**已用 `git show HEAD:` 换回旧版 `lib/index.js` 复跑确认两条在 HEAD 上逐字同样红 ⇒ 与本轮改动无关**。

### 仍未修 / 未取证（如实登记，见 `doc/long-term-issues.md` §24-bis）

- **GLM 走附件本身的几十秒还在**（`SITE_ATTACH_INLINE_LIMIT.glm = 8_000`）。本轮只把这段
  从「看门狗判死」里摘出去，**没有**改变它的耗时。用户若仍觉得慢，下一步该做的是
  §24 第 3 条那条一直挂着的**配对实验**：同一 prompt，`promptTransportBySite.glm` 的
  `attach` / `inline` 各跑一次比首字节。
- **GLM 首字节的直接读数至今无人量过**（与 §24 第 1 条同一个空白）。

---

## 0.19.43

**模型选择器：组名加 `webcode-` 前缀；四个有模型选择器契约的站点删掉自造的 `auto` 档。**

用户三点要求（2026-09-28）：

1. **可见组名改为 `webcode-xxx`** —— 一眼看出这组模型来自网页桥，也顺带避开与官方
   provider 分组重名。GLM 仍是 `chatglm`、z.ai 仍是 `z.ai`，只是加了前缀：
   `webcode-deepseek` / `webcode-chatglm` / `webcode-chatgpt` / `webcode-kimi` /
   `webcode-qwen` / `webcode-doubao` / `webcode-grok` / `webcode-claude` /
   `webcode-gemini` / `webcode-z.ai`。
2. **DeepSeek 去掉「（深度思考）」后缀** —— 能力注记由模型元数据 `thinking` 表达，
   不该占用显示名。行名现在是 `DeepSeek`。
3. **删掉「网站名当模型名」的那些条目** —— `智谱清言` / `豆包` / `Kimi` / `Z.ai`
   其实是每个站点那个 `auto`（「不切换网页模型」）档，是桥自己发明的，不是网页给的档位。

### 删 `auto` 的判据与边界（这一条不能只看一半）

用户原话：「这个除了网站给的自动，一般都是没有这个模型档位吧？……这里不是已经让选择了吗？
提供网站提供的模型列表就行」「如果你不知道的话，就先空着不用改」。

于是按「桥有没有该站点的 `modelPicker` 契约」划线——**契约在，说明桥知道网页真实档位**：

| 站点 | 有 modelPicker | 处置 | 现在公布的档位 |
| --- | --- | --- | --- |
| glm | 有 | **删 auto** | GLM-5.3 / GLM-5.3-Flash |
| zai | 有 | **删 auto** | GLM-5.3-Flash / GLM-5.3 / GLM-5.2 |
| kimi | 有 | **删 auto** | K3 / K3 集群 / 快速 |
| doubao | 有（segmented） | **删 auto** | 对话 / 工作 |
| chatgpt / qwen / grok / claude / gemini | **没有** | **保持不动** | 仍是那一条 auto |

后 5 个站点如果也删，`models.length === 0` 会让 DSH 目录构建器**整组不生成**——
下拉里会凭空少掉 5 个组。

**兼容性（与 `webcode` 空壳同一条纪律）**：模型表里删掉，**历史 id 仍必须解析得开**。
真机 `~/.dsh/settings.yaml` 的 `subagent-model-selection.allowedModels` 里就写着
`glm:auto` / `kimi:auto` / `doubao:auto`；只删模型表不改别名，这些白名单会当场变成
不可解析值。因此 `ALIASES` 同时覆盖裸站点名与已限定的历史 id，各自收敛到本站点档位：
`glm:auto` → `glm:glm-5.3`、`kimi:auto` → `kimi:k3`、`doubao:auto` → `doubao:chat`、
`zai:auto` → `zai:glm-5.3`。

### 取证与护栏

- `node .tmp/verify-groups-43.mjs` 模拟 DSH 目录构建器：**11 provider / 10 个可见组**，
  空壳不生成组，10 个组名全部带 `webcode-` 前缀，组内行名是裸名。
- `test/model-labels.test.mjs` **12/12**（组名对照表改成带前缀，新增一条
  「组名一律带 `webcode-` 前缀且不重复前缀」的护栏）。
- `test/regression.test.mjs` **53 pass / 1 fail** —— 那 1 条是既有失败
  （「网页会话丢失时用整段首轮提示词重放」，与本轮改动无关，0.19.42 阶段同读数同断言，
  已用 `git stash` 反证）。该文件里 auto 相关的两处断言已按本轮语义重写。

---

## 0.19.42

**模型选择器按站点分组：一个网站一层。**

此前全部站点挤在一个 provider 里，下拉只有「Harness Web Bridge」一组，10 个站点的
模型平铺在一起。现在每个站点各占一组，组名是站点短键/域：

```
deepseek · chatglm · chatgpt · kimi · qwen · doubao · grok · claude · gemini · z.ai
```

GLM 与 Z.ai 各自成组（同名的 `glm-5.3` 不再看起来像同一个网站的重复项），
组内行名改为**裸模型名**（`GLM-5.3` / `GLM-5.3-Flash`），模型 id 不变。

**兼容性**：仍然注册一个 `webcode` provider 作为兼容空壳，但它的模型列表为空
（目录侧按 `models.length > 0` 过滤，因此不会多出一个空组）。**没有它，所有旧会话、
默认模型与 20 条子代理白名单都会在发消息前报 `session/model-unavailable`** ——
DSH 每次发消息都会校验存储的 provider 是否仍被服务。

已取证：`node .tmp/verify-groups.mjs` 模拟 DSH 目录构建器 → 11 个 provider 注册、
10 个组可见、空壳不生成组且旧值仍解析。护栏：`test/model-labels.test.mjs` 新增 3 条。

**版本号**：分组改造落地时用户明确要求「只做修改 git 不要更新版本号」（2026-09-28），
因此它先以未升版形态提交（`e365eef`）；0.19.42 是**把它打包安装**时才补的版本号——
用户随即要求「打包安装新版本 42，现有刚刚改完的站点模型选择」。两个提交的内容一致，
差别只在 `package.json` 的 `version` 与本小节标题。

---

## 0.19.41

**Kimi / Z.ai 真机取证：用户拿到的两个报错都不是真因。Kimi 投递链修复（六处），Z.ai 改为诚实报错。**

用户报障：`Kimi / Qwen / 豆包 / Z.ai —— 现在都不行`，并贴了两条报错。真机取证
（完整证据 [doc/research/2026-09-27-kimi-zai-glm-real-machine.md](../../doc/research/2026-09-27-kimi-zai-glm-real-machine.md)）
发现**两条报错都在指向错的方向**。

### Kimi —— `PROMPT_TRUNCATED: 20158/38807（网页端长度上限）` 归因错了

- **20158 不是网页的上限，是桥自己的分块写入丢的**。真机逐档实测：**单次**
  `insertText` 从 8,000 到 200,000 字符**全部逐字回读**（最长 167ms）；而**分块**
  20,000+18,400 只回读到 **20,002**（尾部 18,398 字符静默丢失）。块间间隔
  0/100/…/4000ms **七档结果完全一致** ⇒ 等待无效。
  **修**：富文本输入框改**一次性**写入（`composerWritePlan` 只对表单控件生效）。
- **附件投递其实可用，但确认判据恒为 null**：kimi 的附件卡只渲染**去扩展名的 stem**
  （`<p class="file-card-info-name">webcode-context</p>`），而判据找的是完整文件名 ⇒
  `ATTACH_NOT_CONFIRMED` → 回落 inline → 撞上上一条。
  **修**：判断加第二把尺子（stem 回退，仍受作用域/正文排除/长度三闸约束）。
  **真机复验 173ms 命中、`matched:"stem"`**（此前是等满 90s 超时）。
  刻意**不**打开类名候选：kimi 页面上 `[class*='file-card']` 有 42 个可见节点。
- **带附件时发送键处于禁用窗口**：附件上传完成前控件是
  `div.send-button-container **disabled**`，那一刻按 Enter 网页**完全不响应**——
  旧实现的「点按钮 → 回车 → 再点按钮」三条路全落在窗口内。
  **修**：kimi 补 `sendButton` 声明；驱动发送前**等宿主控件的 disabled 消失**
  （判据取页面事实，不再用 `isEnabled()` 预判——控件是 div，那个判断恒真）。
- **会话槽从来没落过 id**（`WEB_SESSION_LOST: 会话槽为空`，即「每轮新开对话」）。
  真机拿到**可导航**证据后补 kimi 的 `/chat/<uuid>` 形状。
- **网页明说「还在生成」，驱动却按 2.5s 稳态把轮次收束了**（真机现场
  `settling turn with 0 chars answer / 114 chars thinking`）。kimi 的流里每帧都带
  `message.status`，是权威信号，旧实现在注释里说「做锚点」却什么都没做。
  **修**：解码器透出 `generating`，终态时**真的**置 `done`；驱动新增第四条判据
  （网页说还在生成就推迟收束，仍受既有硬上限约束）。
- **服务端原话被丢掉**：错误帧的 base64 解出来是
  「和Kimi聊天的人太多了，订阅会员可进入优先队列」，而旧实现只报
  `invalid_stream | 流首段: {"heartbeat":{}}`。**修**：解出 `code：原话` 进 reason。

**修完之后的最终定性**：投递链逐格变绿（附件 173ms 命中 / `ready=true` / `send confirmed` /
38,807 字符逐字写入），随后服务端回 `resource_exhausted` —— **kimi 账号级限流**。
这不是桥的缺陷，但以前**一个字都看不出来**。

### Z.ai —— `页面已有 7 字回复未回传` 是**假读数**；真机跑不通，且原因是风控闸门

- 那 7 个字是**输入框容器**的 innerText：旧 `answerSelector` 尾部的
  `[class*="message"]` 命中了 `div.messageInputContainer`（「深度思考\n最高」=7 字），
  而驱动读的是 `querySelectorAll(sel).pop()`。三处独立读数吻合。
  **修**：`answerSelector` 改为语义特征 `div.chat-assistant, #response-content-container`。
- **真因**：站点风控闸门拦在请求之前——`features.enable_captcha=true` ⇒ 前端
  `await HN()`（阿里云滑块）不返回 ⇒ `POST /api/chat/completions` **永不执行** ⇒
  wire 上零帧。四轮真机复现全部 0 帧；换 UA 也不过闸。
  **绕滑块属破解站点风控，本项目不做** ⇒ 处置是**诚实化**：新增站点声明位
  `captchaSelector`，驱动在发送确认后一次采样，命中即抛 `WEB_CAPTCHA_REQUIRED`
  （文案说明「消息未被受理 / wire 零帧」并要求手动过验证后重试）。
- 顺带推翻「z.ai 用 GLM 的 `parts` 帧」这条假设（站点 bundle 里 `parts`/`choices`/
  `reasoning_content` 各 0 次）；真实帧形状已记档，**decoder 刻意不改**（取不到真机帧，
  换 decoder 是「猜着解不出」）。

### GLM —— 真机原生调用形态有 3 种，旧解析器对其中 1 种给出**错参数**

真机派发出去的调用是 `read {"md":"limit=150\n<tool_call>glob\n…"}`（DSH 回
`missing required property "file_path"`）。模型原文是 `<tool_call>` + 裸工具名 +
`key=value` 行、**没有 `<arg_key>`**，而旧兜底正则把键取成 **`md`**（`README.md` 的行尾）。
**修**：新增 `parseNativeKeyValueLines`（键必须由工具 schema 声明，否则整块拒绝并留诊断）
与「原生 key=value」分支；**删掉**产生 `{"md":…}` 的那条兜底正则。
**错参数比丢调用更坏**——它让模型以为格式已对，只微调格式反复重试。

### 全量门禁

109 个测试文件 / 109 通过 / 0 失败 / exit 0（冻结工作树后逐文件串行实跑，1866s）。
本轮新增 4 个护栏：`composer-single-write` 4 项、`captcha-gate` 5 项、
`kimi-decoder` 5 项、`zai-answer-selector` 6 项。

### 未取证（不猜）

① kimi 分块丢内容的**内部机制**未证明（已确证「只在连续写入、间隔无效、单次可靠」）；
② **z.ai 真机帧未取得** ⇒「按真机帧写 decoder」未交付（取不到，不是没做，未编造夹具）；
③ z.ai 人肉过一次验证后是否仍每次触发：未取证；
④ **Qwen / 豆包本轮未取证**——用户报障提到它们，但本轮只拿到 kimi 与 z.ai 两条失败现场。

## 0.19.40

**交付前审计：修两条真缺陷 + 摘掉一条环境假红。全量单测首次跑通（1162/1162）。**

- **`lint:comments` 原本是红的**（闸门默认 `--max-warnings=0`）。CS005 在注释散文里把 CSS
  自定义属性取值 `var(--x)` 判成「像代码」——因为 `var(` 的后一个字符是 `(`，命中了
  JS 关键字判据。而 `var(` 永远不是合法 JS，这是**闸门自身的假红**。按「假红会让闸门被
  绕过，比没有闸门更坏」的纪律**修判据**：把 `var` 从那条共用正则里拆出来单列，
  `var x = 1;` 仍被抓住，只是不再把 `var(` 当代码。已验证收窄后仍能抓住真死代码。
- **本文件此前停在 0.19.33**，缺 0.19.34–0.19.39 六节——而它随包分发，用户拿到的包里
  最近六轮变更记录是空白。已补齐。
- **一条环境相关的假红**：`reply-log.test.mjs` 断言「生产日志里不得出现本用例写入」，
  而那份日志同时是桥的原始回复留痕，正文里出现该字样完全合法（本机实测确有一条被 dump 的
  回复逐字引用了该测试源码）。`git show HEAD:` 对照确认**与本轮改动无关**。返回 `null`
  已是「没有落盘」的完整证据，故删掉那条判不了「谁写的」的内容断言。修后全量 1162/1162。

## 0.19.39

**dwb 品牌矢量 + 设置页顶部更新栏（真装，但只提醒重启）。**

用户两条原话：①「参考 dsh-store 的设置界面顶部『插件市场 / dsh-market / v1.65.1 / 更新插件市场 /
本次全部忽略』设计好本插件的更新和**只做提醒重启**操作，替换现在空白的单独 github 按钮」；
②「为本项目 dwb 设计合适的矢量图标，替代所有本项目默认的图标」。

- 新增 `lib/update.js`：判据与进程调用分离。版本解析 / 比较 / 挑最新版全是纯函数
  （`parseVersion` / `compareVersions` / `pickLatest` / `updateDecision`），**不引 semver 包**
  以保住零运行时依赖；只有 `runInstall` 碰 `child_process`。
- **只认正式版**：按 `versions` 键自己挑，不直接用 `dist-tags.latest`（它可能指向预发布）。
- **三态而不是两态**：`current` / `outdated` / **`unknown`**——查不到时**不**说「已是最新」。
- 控制面拆两个动作：`GET update-status`（只读，10 分钟内存缓存）与 `POST update`
  （唯一有副作用的，走 `csrfSafe`，并**校验版本号形状**——spec 会被拼进命令行）。
- **「只做提醒重启」的落点**：`runInstall` 真装，装完只回 `needsRestart`，**绝不代重启**。
  重启会终止用户正在使用的会话；装完不重启只是没生效，代重启是当场毁掉用户手上的东西。
- `DwbMark` 品牌标记（拱 + 桥面 + 顶点，viewBox 16 / stroke 1.3 / `currentColor`），
  落在右栏 guide 大图标、右栏标签页 chip（走 `sidebar.right.pane.tab.title` 自定义节点）、
  设置界面介绍区。**设置切换标签页的导航项无法替换**——官方 `settings.section` 只收
  `id`/`order`/`label`，图标由 shell 画。

## 0.19.38

**设置标签栏：与分隔线的距离，以及滚轮到边不再带走页面。**

- 距离按官方读数重定：官方同类「文字 + 底线」分隔栏是文字底边到线 9px，而本实现是浅色胶囊，
  贴线的是**色块**而非文字 ⇒ 容器 `padding-bottom` 4px → **10px**（色块底到线 10px、
  文字底到线 15px）。修的不是「把 9 调大」，是按色块重新定距。
- 滚轮到边：旧判据 `scrollLeft` 前后不等才拦默认，而到边时浏览器把越界值钳回最大值 ⇒
  判据恒假 ⇒ 同一次滚轮继续冒泡去滚整个设置页。改为按「这条栏真的有可滚内容」拦默认，
  另加 `overscroll-behavior-x:contain`。

## 0.19.37

**账户真实昵称/头像四跳补齐 + 站点健康度三色圆点。**

- 真根因是**接线缺陷**而非选择器不准：驱动早已透出 `accountName`/`avatarUrl`，
  但 `index.js` 的 `siteStatusRow()` 两个分支都没转发、客户端的字段挑选表也没挑进来 ⇒
  界面拿到的永远是 `undefined`。四跳逐跳补上；未初始化的槽**如实给 null**，不拿槽名冒充昵称。
- 头像改真实 `img`（抓不到回落站点矢量标记，跨域拒热链时 `onError` 藏掉）；
  昵称取 `accountName || displayName`。
- 「已登录」占位框改为标题行右上角**一个圆点**，判据是跨该站点全部账户的聚合：
  全绿 = 每个账户都可用，全红 = 没有一个可用，黄 = 部分可用（`--dsw-alias-state-warn-primary`，
  取自官方 token 白名单）。颜色是唯一视觉载体，因此 `aria-label` 把含义说全。

## 0.19.36

**打开提示词文件的报码短路 + 报错/长文本换行保护。**

- 两处「查看」此前调 `api()`，而它在 `ok:false` 时直接抛 ⇒ 紧跟其后的报码映射表**永远走不到**，
  整段 JSON（含完整 Windows 路径）成了界面文案。改走 `apiSoft` + 短文案。
- 换行保护：Windows 路径 / JSON / URL 都是**没有可断点**的长串。给 `.hwb-hint` 等 13 处
  加 `max-width:100%` + `overflow-wrap:anywhere`（逐个点补而非通配，通配会命中图标与按钮）。

## 0.19.35

**等待面板行序 + 豆包/清言真矢量 + 去图标悬浮说明。**

- 删掉与标题重复的「本次会话等待发送」行；行序改成同类相邻（两个占比 → 两个轮次 →
  限流/间隔 → 平均值 → 累计总量压最底）。
- 豆包换 `doubao.svg`（3 条路径，含两条 `fill-opacity=.5` 浅色层），智谱清言换 `qingyan.svg`
  （2 条路径——`chatglm.svg` 是底层模型的标，`qingyan` 才是这个应用的标）；`SiteGlyph`
  新增多路径分支。两者档位 `official` → `vector`。
- 删掉图标上的 `title`。**删的是显示不是记录**：`SITE_ICON_TIER[].why` 逐条保留，
  改由护栏在源码层核验。

## 0.19.34

**等待面板：在途读数上标题，删「正在等待发送」行。**

- 标题右侧改读新增的 `projectedValue` = 账本累计 + 在途增量，逐秒前进。
  旧形态钉的是已落账的累计（在途期间不动），另起一行显示在途——一次等待期间面板里
  站着两个数。
- 加法收进单一函数 `projectedWaitMs(session, live, now)`：此前药丸与明细各写一遍，
  迟早会有一处漏掉在途增量。
- 死代码清理：`web-control.js` 的 `liveElapsedMs` 与载荷字段 `liveValue` 一并删除。

## 0.19.33

**设置改了，派生读数不再「跟不上」；并修掉一条会让 GLM 整轮没回复的作用域缺陷。**

用户原话：「现在设置界面分站点的投递选择更改后提示词更新跟不上，请你修复」；
以及「本轮运行失败 sleep is not defined？你看下为什么长期桥运行 glm/glm5.3 会出现这个问题」。

### 设置修订号（settingsRevision）

- 根因：设置面的派生读数（提示词模板 / 增量再教学 / 投递形态生效值）由服务端现算，
  而客户端的拉取时机此前只有「挂载时一次」——站点页保存后**没有任何东西会重拉**，
  于是「已保存」与「看到的仍是旧的」同时成立。
- 服务端在 configManager 的 set() 里自增一个单调递增的修订号（唯一落盘点，
  POST settings 与 account-add 两条写路径都覆盖），并随 GET settings / attach-status /
  prompt-variants 一起回；POST settings 的响应**当场**带回新号。
- 客户端把号当 effect 依赖，任何一次设置写入后派生读数立刻重拉，不必等下一次轮询。
- 站点页保存投递形态后额外重读一次 attach-status?siteId=…（「当前生效：」那一行是
  用户核对「本站点覆盖到底生效没有」的唯一入口）。
- 独立设置页保存后除模板外还刷新读数行，并新增逐站点生效读数。
- 顺带修掉一条同源的旧缺陷：GlobalPrompt 保存全局指令后只调 onSaved?.()，而
  PromptSection 自 0.16.38 拆卡起就不接这个回调了——「改了全局指令、模板不更新」一直存在。

### sleep is not defined（跨作用域引用，同型第二例）

- runTurn 的续聊重试分支写了 `await sleep(1500)`，而 sleep 只存在于 DOM_CAPTURE
  **注入模板串**里（喂给 page.evaluate 在浏览器执行的代码），Node 侧没有这个绑定 ⇒
  必然抛 ReferenceError，被 catch 当成普通失败。
- 触发条件与既有记录的 nav 那一例逐字相同（第一次导航就没 ready，页面冷加载慢的常见
  现场），所以用户看到的是「GLM 这一轮整个没回复」——这就是长期跑 glm 会撞上的原因。
- 修法：模块级新增 Node 侧等待原语 delay(ms)（刻意不叫 sleep，避免与浏览器侧同名混淆）。
- **护栏为什么没抓到它**：driver-scope.test.mjs 的绑定表建在原始源码上，模板串里那句
  `const sleep = …` 被当成了真实模块级绑定——正是该文件头注写明的「只会漏报」方向。
  已改为在抹掉模板串字面文本的源码上收集绑定，并新增 sleep 逐字回归钉与扫描器自检。

### 护栏

- test/site-prompt-transport.test.mjs 新增 ⑥a/⑥b/⑥c：真 HTTP 断言写入后修订号前进且
  三条读数路由号一致；源码断言每个设置写入点都应用号（按调用点逐个查，不是数个数）。
- test/driver-scope.test.mjs 新增 ⑤（sleep 回归钉）与 ⑥（扫描器自检：先断言旧写法确实
  漏报，再断言修法能抓住）。两条新护栏都做过红灯验证。

---

## 0.19.32

**提示词投递形态支持站点级覆盖：全局一个，每个站点可单独设。**

用户原话：「提示词投递：给每个模型站点都做到和『发送间隔（全局）』一样的逻辑：全局设置一个，
但是针对每个单独网站设置能够单独设置」。

- 回落链与 sendGapMsBySlot 逐字同构：**站点档 → 全局档 → 插件 config → attach**。
- 新设置键 promptTransportBySite（字典）。「跟随全局」用**删键**表达，落盘的字典里只许有
  inline / attach 两个合法值，因此「没配」与「配成跟随全局」始终可区分。
- 原生面板与独立设置页**两处**都加了入口：站点页三态（跟随全局 / 附件投递 / 纯文本）
  单选即时保存，全局页那一档原样保留。
- attach-status 现在按站点回答（?siteId=），并新增 POST attach-status 别名——客户端带
  body 就是 POST，只注册 GET 会撞 405 + 空 body（0.15.3 修过的同族缺陷）。
- 读数分两格：promptTransport（合并后的生效值）与 promptTransportSite（覆盖存在性，
  null = 跟随全局）。
- 护栏 test/site-prompt-transport.test.mjs 五条：真驱动合并、现读纪律、两个构造点都接线、
  真 HTTP 往返与归一化、面板入口存在且不新造设置键。

---

## 0.19.10

**图片「有，但加载不出来」的真因：附件证据窗口太短，把慢上传读成了失败。**

用户会话 `62f74e98`（附图 1086×1425 / 942,941 字节）里，桥自己的回复日志留下了这句：

```
⚠ 图片未能附加到网页（ATTACH_NOT_CONFIRMED），本轮将以纯文本发送
```

于是那一轮静默退化成纯文本 —— 用户看到的是「网页端有图片但加载不出来」，而模型
只能去翻 `read_image` 的元数据，最后如实回答「我看不到像素内容」。

真机复现（同一张图、同一条路径）：一次 `imageTransport.ok=true`、端到端 **37 秒**，
模型正确描述出画面。也就是说 **15s 的等待窗口不是「上传失败」，是「缩略图还没渲染」**：
DeepSeek 客户端要等它自己的上传/压缩流程走完才给出 `img[src^='blob:']` 证据，图越大越久。

修法：`uploadImages` 的证据窗口 15s → **90s**，`uploadTextAttachment` 20s → **90s**
（同一口径；85k 字符真实投递实测 53s）。证据一出现就返回，所以这只影响失败时的等待，
不让正常轮次变慢；上传入口缺失仍在 `setInputFiles` 之前立刻抛 `ATTACH_UNAVAILABLE`。

护栏 `attach-callsite` ⑥c：两个窗口都不得短于 60s、也不得超过 120s（与整轮 240s 预算打架）。

## 0.19.9

**有图又超长的轮次：正文没走附件，整段灌进了输入框。**

用户原话：「deepseek 明明在附件投递模式下，看的还是完整上下文」。

真机复现（一轮里同时有一张图和 81,004 字符正文）：页面上的用户消息
`userMsgChars=81139`、`mentionsFullHistory=true` —— 正文整段进了输入框，而
`attachTransport` 还停在**上一轮**的读数（于是面板显示「当前生效：附件投递」，
用户看到的是全文 —— 最坏的那种不一致）。

根因是调用点的一个守卫：`if (!attachEvidence)`。`attachEvidence` 在**带图轮**已被
`uploadImages` 置位，于是「既有图、正文又超长」的轮次**整块跳过**了投递判定。图片与
文本附件是网页输入区里两件独立的事，一个已经挂上不代表另一个不用挂。改法：判据换成
`plan.mode`（正文是否需要附件），不再看「有没有用过附件」。

顺带修掉一个**假阳性陷阱**：`waitForAttachment` 的类名候选判据（`img[src^='blob:']`）
分不出附件是谁的——带图轮里它会被图片命中，把「.md 没落地」误判成「已确认」。文本
附件的证据现在只认文件名（`text:<name>`，`allowCandidates:false`）；图片那条保留候选
兜底（既有行为，不动）。

> 后端 `lib/browser-driver.js` 是随 `dsh web` 进程加载的。**这两处都要重启才生效**，
> 上一轮已经踩过一次：0.19.8 交付时用户当时没重启，我复跑真机时读到的仍是旧行为。

## 0.19.8

**图片轮发不出去：发送只调用、不确认。**

真机取证（DeepSeek 统一 UI）：不带图的一轮 1.6s 正常回答；**带图**的一轮，附件上传
证据命中（`imageTransport.ok=true`、`img[src^='blob:']`），但整轮 240s 超时；22 秒后
直连浏览器（CDP）探活发现**页面仍停在站点首页、正文还躺在输入框里**——程序化 Enter
没有提交。同一页面手工按 Enter 立刻发送成功，模型正确读出图里的字符。

根因是发送没有确认：**「没发出去」与「发出去了但网页不回」在读数上长得一模一样**，
而旧实现调用完就 `startWipWatch()` 等回复。

修法：发送后按**页面事实**确认——输入框被清空，或地址栏从站点根切到 `/a/chat/s/<id>`；
未确认就换下一条路重试（契约按钮 → 聚焦输入框末位回车），三条都不成立则抛
`SEND_NOT_CONFIRMED`，不再伪装成超时。护栏见 `test/send-confirmed.test.mjs`。

## 0.19.7

**界面三条：输入框探出卡片、顶栏缺项目链接、控件风格不统一。**

1. **输入框超出卡片框**（用户反馈）：设置页的 `.hwb-model-select` / `.hwb-prompt-input`
   与任务板弹窗的 `.hwb-input` / `.hwb-select` 都缺 `box-sizing:border-box`，于是
   `width:100%` / `max-width:340px` 按**内容盒**计算，加上左右 padding 与边框就比容器宽
   二十来像素；`.hwb-card` 没有 `overflow:hidden`，看起来就是控件从卡片边框里探出来。
   已补齐 `box-sizing:border-box` + `max-width:100%` + `min-width:0`，并加护栏
   （`test/client-render.test.mjs` 的 0.19.7 用例，删掉任一条声明立刻变红）。
2. **顶部 GitHub 链接**：设置界面标题行右端（原生面板 `.hwb-settings-head`，独立设置页
   `.header-row`）各加一枚项目主页链接。
3. **统一控件风格**：模型下拉与文本框统一为「填满可用宽度、上限 340px」（旧版没有宽度、
   只有 `min-width:220px`，下拉比标签列还窄且一行里长短不一）；任务板弹窗控件补上盒模型
   与圆角刻度。

## 0.19.6

**两件事：界面文案按「官方解释长短」收紧、插件市场整页崩溃修复。**

### 一、设置界面与右栏：只留状态与动作，解释不进界面

用户反馈（2026-09-24）逐条落地：等待占比不再输出「（覆盖 n/N 轮）」；agent preset
展示名改为 **`wecode模式`**，description 与 persona 压成简短声明；设置页删掉
「正在运行（子代理 / Team）」整卡、限流退避说明、「附件探针」与「最近一次实际投递」、
真机事故叙述、会话隔离的机制句、重复的全局指令说明；「首轮提示词（只读）」卡重排美化
且信息面不变。判据落在**渲染出的文本**上，不看源码注释（`test/settings-transport.test.mjs` ⑧
与 `test/client-render.test.mjs` 的新契约用例）。

一条要紧的口径：**探针是开发者自用的读数通路，不是用户功能**。它的服务端动作
（`POST /__webcode/attach-probe`）保留，但按钮与读数已从两处用户界面（原生设置面板、
独立设置页 `/__webcode/settings-page`）全部撤掉。

### 二、插件市场（dshmarket）整页崩溃：图标导出改名

市场页报 `Minified React error #130`。根因不是市场本身逻辑，而是 **DSH 0.1.7-alpha.2
把 primitives 的图标导出整体改了名**（`IconXxxOutline14` / `…16` → `…Regular` / `…Medium`），
而 dshmarket 1.55.0 的 bundle 直接解构旧名 → `undefined` 被当组件调用 → 整棵 React 树崩。
修法：把 web profile 的 `dshmarket` 升到 **1.59.0**——它的 bundle 带 `ICON_ALIASES`
两代名字回落（0.1.7+ 的 `…Regular` 与 0.1.7 之前的 `…16/14` 依次取值）。

## 0.16.9

**三件事：Harness 侧 markdown 逐字保真、站点禁令可核对、`pnpm test` 死锁解除。**

### 一、`## 标题` 变 `##标题`、表格塌成一行（真因与修法）

用户原话：「好像零点九几的时候，harness 显示的 Markdown 是没问题的，但现在渲染到 harness
就会格式错乱」「#后面没有空格？代码块包裹没有换行？导致没有闭合？」

真因不是渲染，是**字节在桥里被吃掉了**：流式正文外发处的 `tagDebris` 判据把**纯空白**和
**ASCII 竖线 `|`** 都当成「标签残渣」，命中后静默推进游标、一个字节都不发；而
`PROSE_TAIL_CHARS = 8` 的滞后让「本片放行区间恰好是一个空格或换行」成为必然。于是
`## 标题` 丢空格、空行消失、围栏不闭合、`| 列 A | 列 B |` 塌成 ` 列 A  列 B `。

回归窗口自 **0.14.2**（那次为修「回复夹杂错误调用」加了这个判据），0.9.x 没有这条分支——
所以用户「零点九几是好的」这个观察是准确的。

修法：判据拆成 `tagOnly`（只由标签字符组成）**且** `hasTagChar`（至少含一个真标签字符），
纯空白因此照常外发，ASCII 竖线从「标签族」里剔除（它是 markdown 表格分隔符）。
护栏 `test/markdown-whitespace.test.mjs`（8/8）**逐字符驱动**——只有逐字符喂进去，释放边界
才会落在每个字符上；既有的 `markdown-block-integrity` 抓不住它，因为它只断言「块内容 ≡
Σ增量」，而本缺陷里**两条通道一起**少同一个字节。

### 二、DeepSeek 禁令现在**看得见**

0.16.7 加了站点禁令，但它只存在于代码里：`site-no-attach` 分支不写读数、`status()` 不投影
这个布尔量，`GET attach-status` 反而还在承诺「超过 60000 字符时改走附件」（对该站点已不成立）。
用户因此**无法核对修复是否生效**。本轮补齐 `SITE_NO_ATTACH` 读数、`status()` 的
`attachForbidden` + `siteId` 投影，以及面板上的「本站点永不使用附件投递」。护栏 ⑩（11/11）。

### 三、`pnpm test` 曾连**启动**都做不到

lockfile 把**可选** peerDep 记成普通依赖且 `specifier: '*'` 对 `version: 0.1.5-alpha.1`
自相矛盾，pnpm 的前置检查（`install --frozen-lockfile`）必然失败——**CI 下它会先删
`node_modules` 再报错**，实测真删过一次。修法是把 specifier 收紧为 `^0.1.5-alpha.1`。
顺带修了 `control-routes` 夹具在并行负载下读端口为 `null` 的既有 flake（用 HEAD 版本同样会红，
已确认与本轮改动无关）。

## 0.16.31

**三件事：DeepSeek 附件投递解禁、发送间隔新增「距上次回复完成」口径、等待药丸不再跳动。**

### 一、DeepSeek 附件投递解禁（`ATTACH_FORBIDDEN_SITES` 清空）

0.16.7 的静态禁令（「DeepSeek 收得下附件但读不到它」）建立在一次真机事故上：附件传上去了、
页面也出现了，但那一轮**零回复**。0.16.31 用只上传、**绝不发送**的探针
（`POST /__webcode/attach-probe`）重测，读数推翻了其中一半前提：

```json
{ "ok": true, "evidence": "text:webcode-probe.md", "ms": 105,
  "domSnippet": "<div class=\"e70accd6\">webcode-probe.md</div>", "cleaned": false }
```

即**收得下、渲染得出**在今天的页面上成立（当年 `ATTACH_NOT_CONFIRMED` 的直接原因是文件名
证据还没实现，类名清单零命中）。而另一半（模型读不读）现在有**运行期自愈**兜底：
`DYNAMIC_ATTACH_BLOCKS` 对「附件轮整轮零回复」的两个签名自动降级并记住，当轮即回落
inline。

取舍写在明处：静态禁令用**永久走不了附件**换**一轮风险**，不划算，因此清空静态表；
若自愈再次记下 `ATTACH_ZERO_REPLY`，`/__webcode/status` 的 `attachBlocked` 会如实报出，
那时再按读数据决定是否恢复。**本轮没有真机验证「模型确实读到了附件内容」**——
探针不发送，回答不了这个问题，如实记在台账里。

顺带修了附件 chip 的清理路径：`cleanupAttachment` 原先只沿祖先链找删除控件（真机上 chip
的最深节点是文本 div，控件不一定在祖先里），现在先按语义标签找、再按**位置**找（与文件名
同容器、位于其右侧的可点控件），且多附件时**不按位置猜**——点错会删掉别人的附件。

### 二、发送间隔：新增 `end-to-start`（距上次回复完成）

用户原话：「我需要的是 web 思考后调用时间后不立即回复而是间隔多少秒回复……为了隔开和
他发消息我立马回复的规避点！」。这与既有的 `send-to-send`（距上次发出）**防的不是一件事**：

- `send-to-send`（**仍是默认**）：防站点的滑窗限流，按请求到达计——上一轮跑得久时本轮
  **无需再等**。改默认值等于静默改掉所有既有用户的行为，因此只新增选项。
- `end-to-start`：防对话节奏贴得太紧——上一轮跑了多久**不影响**本轮，**答完那一刻起**
  重新数满间隔。

落盘格式从裸数字升成 `{ send, end }`（**旧格式照样读**，升级不丢基准）；设置页两个面
（HTML 页 + 右栏面板）都有口径下拉；读数新增 `gapBasis`，面板文案随口径切换为
「距上次发出」/「距上次回复完成」。护栏 `test/send-gap-basis.test.mjs` 12 条。

### 三、等待药丸的跳动（真缺陷）

用户报「底下框的时间会跳动」。根因不是取整，是**在途读数被提前清空**：
`clearLiveWait` 原在等待 sleep 的 `finally` 里，于是等待一结束读数就消失，而账本要等整轮
生成跑完才吸收——中间那几十秒药丸掉回**上一轮**的旧值，收束时再跳上去。现在正常路径
保留在途读数（`endsAt` 已把它冻结在满值），只有 **abort** 才清（那时本轮不会有 metrics，
留着会让药丸停在一个不动的假数上）。

## 0.16.30

**官方 token 的「双竖线」漂移让整族解析归零**——这是用户报「版本更新后自动化流程被打断、
每轮都错」的根因。真机回复里模型把包裹竖线写成**两枚** U+FF5C：

```
<｜｜tool▁calls▁begin｜>
<｜｜tool▁call▁begin｜>pwsh<｜｜tool▁sep｜>{"command":"pwd"}<｜｜tool▁call▁end｜>
<｜｜tool▁calls▁end｜>
```

而 0.16.18–0.16.29 的解析正则全是 `'<' + 单枚竖线 + '\s*'`，`\s*` **吃不下第二枚竖线**，
于是四条链路一起失守：`findProtocolStart` → `-1`（认不出边界）、`normalizeOfficialToolCalls`
→ 原文逐字不变（改不动）、`parseAgentReply` → `calls=0`（每轮 `TOOL_CALL_UNPARSED`）、
`partialProtocolAt` → `-1`（半截标记不扣留、漏成正文）。

**「改了什么」的答案**：协议从 DSML 族换成官方 token 族时，**DSML 族一直有的竖线宽容没跟过来**。
`DSML_MARK_SRC` 写的是 `DSML_BAR_CLS + '{1,3}'`（容 1~3 枚），官方族从 0.16.18 引入起只有一枚。
日志计数坐实两族都在写双竖线：全日志 9,803 处 —— DSML 时代 9,764 处（当年被容下、照常执行）、
官方 token 族 25 处（本次现场，一条都不认）。

修法是新增 `OFFICIAL_BAR_CLS = OFFICIAL_BAR + '{1,3}'`（与 `DSML_BAR_CLS` 同纪律、同字符、
同 1~3 上界），替换官方族**全部五条**正则的包裹竖线，并给 `partialProtocolAt` 的精确前缀表
补上双竖线族（前缀表是精确字符串，归一化对"标记不完整"一格都不改，所以归一化救不了它）。
上界 3 不是无界：锚点与改写要在流式途中反复调用，无界量词会把 `<｜｜｜｜｜…` 噪声吞进协议判定。
**只放宽竖线，单竖线既有行为逐字不变。**

再教学提示点名真实病灶（「包裹竖线是一枚，不要写成两枚」）——与 0.16.20/0.16.25 同一条纪律：
提示要指出模型实际写错的那一处，否则模型自查「name 在、JSON 合法」后无处可改、只能原样重发。

验证：新增 `test/official-double-bar.test.mjs` 11 条，对已发布的 0.16.28 代码实跑 **8 红 3 绿**
（红的正是双竖线全族，绿的正是"不许回归"三条），修复后 11/11 绿。真机回复原文重放：日志里
双竖线官方 token 共 **6** 轮、live 运行时 **6/6 全记 `calls=0`**，修复后 **6/6 全部解析成功**、
共产出 **13** 条可执行调用。

## 0.16.29

**再教学提示不再铺进会话正文**（用户指令：「关于 TOOL_CALL_UNPARSED: 直接隐藏」）。三处出口
（`TOOL_CALL_UNPARSED` / `TOOL_UNKNOWN` / `THINKING_ONLY_NO_ANSWER`）的提示仍逐字作为**补发的
用户消息**发给模型，界面上只留一句 `AUTO_CONTINUED: 已自动补发提醒，网页已重新发起 N 条调用。`。
例外：补发**根本没发生**时（`autoContinueRounds=0` 或无会话键）提示照旧当正文交回——
隐藏的前提是它已经送达模型。

**落盘从副本升格为投递源。** 用户要的是「如果新开会话-web 端，就一样把这个当上下文通过文件
发送」。0.16.28 只落了盘（写而不读），本轮新增 `readPromptFile` + `readSessionPrompt`，
整段重建改为 `readSessionPrompt(sessionKey) ?? rebuild()` —— **优先读磁盘正本**，
读不到才回落内存序列化。发出去什么、重建用什么，从此同一份字节。

顺带修掉 0.16.28 的一个真缺陷：`writePromptFile` 用 `[..., ''].filter(Boolean).join('\n')` 拼头部，
那个空串被 `filter` 删掉，头部末行与正文首行**粘成一行**（真机实测：读回只剩第二行之后，
正文首行被吞）。现改为显式 `+ '\n\n'`，读回端兼容旧粘行格式。

**节流窗文案更正**：`SESSION_REBUILD_THROTTLE_MS` 自 0.16.6 起就是 **60 秒**（注释里的「30 秒」
是引 0.16.6 提交里用户对更早版本的描述）。真机佐证：reply-log 的 `19.962s 前…还剩约 40.038s` = 60.0s。

## 0.16.7

> **⚠ 本节结论已于 0.16.31 推翻（原文保留作历史记录）**：`ATTACH_FORBIDDEN_SITES`
> 已清空，DeepSeek 恢复附件投递。推翻的是**前提**：0.16.7 当时判定「网页收下了」所依赖的
> 类名选择器清单在真机上**零命中**，于是把「确认失败」误读成「模型不读」（文件名证据 0.16.3
> 才实现，探针 0.16.4 才有）。0.16.31 的探针实测 `ok:true` 并实拍到 chip DOM。
> **未被推翻的是风险本身**：模型究竟读不读附件，探针回答不了；现在由运行期自愈兜底
> （附件轮零回复 → 当轮降级 inline 并记住）。详见 §0.16.31 一。

**DeepSeek 站点不再走附件投递。** 真机实测（2026-09-18）：71994 字符走附件时，附件确实
传上去了、页面上也出现了，但这一轮**没有任何回复**——`domChars:0`、页面退回
`chat.deepseek.com` 根地址，navTrace 里连 `landed:after-submit` 都没有；同一账号改回
**纯文本投递**就正常（用户原话：「deepseek以附件投递会出问题！不能回复！前面时候改为
输入框还行！」）。

也就是说「网页收得下附件」与「网页模型会读这个附件」是两件事，而 DeepSeek 的答案是「不读」。
因此新增站点契约 `ATTACH_FORBIDDEN_SITES`（当前含 `deepseek`），并在投递判定里把它排在
**阈值之前**：该站点无论多长都只走输入框，宁可慢，也不要「网页收下了、什么都不回」。
判据是**站点声明**而不是调用方的开关——这条知识属于站点契约，写在别处必然漂移。

反向要求同样成立：GLM 的输入框装不下长文，必须留在附件路径上。**本表只排除，
不改变其它站点的既有行为。** 护栏：`test/prompt-transport.test.mjs` ⑨（禁令生效）/
⑨b（不误伤 GLM）。

## 0.16.6

**会话重建节流不再中断会话。** 同一个会话键在 60 秒内已经整段重建过一次时，第二次不再重放
四十万字符——但 0.16.4 的实现是抛 `WEB_SESSION_REBUILD_THROTTLED` 把这一轮**判死**，
界面上就是一条红色「本轮运行失败」、会话当场断掉（用户原话：「改为只提示已经切换会话而不是
打扰直接中断会话」）。现在改为交回一条提示正文：

```
SESSION_SWITCHED: 网页会话已切换——本轮不再重放首轮上下文（上一次整段重建在 30s 前、
重放了 127895 字符，节流窗还剩约 30s）。会话没有中断：直接发送下一条消息即可继续——
窗口过去后这一轮会整段重建，窗口内则继续显示本条提示。
```

节流那一轮的正文一个字节都没进网页会话，所以发送游标**不前进**（下一轮会把这一轮的内容
一并带上，而不是只发一个没有前文的增量），会话槽也**不重置**（留给下一轮与驱动的 URL 自愈）。
提示过几次可在 `/__webcode/status` 的 `driver.sessionSwitchNotices` 核对。护栏：
`test/session-continuity.test.mjs` ⑥（这一轮成功且正文是提示 / 重放仍只发 1 次 / 下一轮仍整段重建）。

> 0.15.0–0.16.5 的变更未逐条回填本文件，见仓库根 `README.md` 的「当前能力 / 验证和边界」
> 与 `doc/progress.md` 的逐轮台账。

## 0.14.5

**修掉两个由真机会话日志定位的缺陷，并把右栏按官方审美重排。**

### 超长提示词写不进输入框（`PROMPT_WRITE_STALLED`）

真机证据（`session-c710ef6e` 的 turn 2 终局，逐字）：

```
locator.fill: Timeout 30000ms exceeded
  - locator resolved to <textarea … placeholder="给 DeepSeek 发送消息 ">
  - fill("# 可用本地工具…(+807789)
```

一次性把 80 万字符交给 `fill()` 时，Playwright 在网页侧整段卡住，30s 后超时；卡住期间
**没有任何中间态可读**。现在按 `composerWritePlan` 分块写入、块间回读长度，连续两块不增长即
抛 `PROMPT_WRITE_STALLED`，错误里带**已写/总长度**与元素现场（标签、id、placeholder、可见性）。
决策抽成纯函数（`composerWritePlan` / `stallStep`），`test/composer-write.test.mjs` 12 项钉住边界。

### `<tool_result>` 外壳漏进正文

同一会话的 `assistant/message` seq=587 里，网页模型把工具结果连同外壳吐了回来：

```
block 9  text len=198  <tool_result>\n{"mcp_action":"result","name":"edit",…
block 11 text len=200  </tool_result>\n{"mcp_action":"result","name":"write",…
```

协议边界锚点只认 `tool_call`，于是边界落在**外壳之后**的 JSON 上，`<tool_result>\n`（13 字符）被当正文发出。
现在 `tool_result|tool_results` 进锚点，但**故意不进 transport 判定**——工具结果不是工具调用，
绝不能被当成待执行的调用。`test/protocol-leak.test.mjs` +2 项钉住这条区分。

### 右栏重排（对齐官方右栏的实测尺寸）

- **顶层工具条**：当前站点名 + 8px 状态点 + 右侧四颗 **28px 图标按钮**（刷新 / 独立窗口 /
  新面板 / 浮动），尺寸与官方 `ExpandButton` 实测值一致（`width/height:28px`、`border-radius:28px`、
  hover `--dsw-alias-interactive-bg-hover`）。文字全部进 `title`/`aria-label`。
- **站点选择**（0.16.37 定稿）：右栏「Web Bridge」标签页 = **站点目录**，每行是官方「新建终端」
  同款**胶囊**——左侧主区（图标 + 站点名，多账户时多一行「N 个账户可选」）是一颗官方
  `Button variant:'ghost'`，右端一颗 44px 宽的 chevron `Button` 作为官方 `Menu` 的锚点；
  点开列出该站点的各账户，选中即用那个槽开标签。尺寸**逐项**取自官方 `TerminalGuide.module.css`
  （`border-radius:24px` / `min-height:56px` / `padding:14px 20px` / 触发器 `width:44px` /
  标题 `15px`、说明 `13px`）。点主区 = 为该站点**新开一个独立标签**（`multiple: true`），
  于是官方标签条上就是「Web Bridge / DeepSeek / 智谱清言 …」一行并列，点回 Web Bridge 即回目录。
  面板内**不再**有任何自建站点导航条，工具条上的站点下拉按钮与 `SiteMenu` 组件也已删除。
- **图标**：十个站点**全部**有真实品牌矢量。DeepSeek 用官方 primitives 的 `FISH_LOGO_PATH`；
  八个（ChatGPT / Claude / Gemini / Grok / Qwen / Kimi / 豆包）用 simple-icons（CC0-1.0）；
  GLM 与 Z.ai 用 `@lobehub/icons-static-svg` 取回的矢量——simple-icons 实测没有它们的条目
  （`zhipu` / `chatglm` / `zai` / `z-ai` / `zhipuai` / `bigmodel` / `zcode` / `glm` 八个 slug 全 404）。
  来源分档记在 `SITE_ICON_TIER`（`official` / `vector` / `missing`），逐条挂在图标 `title` 里，
  **不把社区图集说成官方发布**。
- 空态/错误态统一成官方 guide 卡片形态（`.5px` 边框、`bg-layer-1`、标题+说明+主按钮三层）。

### 会话日志归因工具（长期资产）

`test-mock/parse-session-log.mjs`：DSH 的会话落盘是**多帧拼接**的 zstd，`zstdDecompressSync`
只解第一帧（1.3 MB 的文件解出 220 字节），这个坑上一轮会话连踩三次。工具按 zstd magic 切帧逐帧解压，
输出事件直方图、轮次结局、失败项、工具调用与用户输入。错误码与归因见 `doc/bridge-failure-ledger.md`。

## 0.14.4

原生「设置 > 网页桥接」管理登录与启用开关。默认沿用 `~/.dsh/webcode-edge-profile`。
模型分组 Harness Web Bridge 暴露全部内容服务站点（`site:model` 限定 id）；显示名为
「站点短键/模型 id」（如 `z.ai/glm-5.3`、`deepseek/deepseek`），**一眼能看出是哪个
网站**；兼容别名 `deepseek-web` 不出现在下拉里（历史会话仍可解析）。

## 0.14.4

**修掉「打开右侧网页之后掉登录」，并把等待时间变成可核对的两个数。**

（本节以下为 0.14.4 的原始记录，保留不动。）

### 掉登录（豆包真机报障）

根因在 `Set-Cookie` 的两个消费方向被混成字符串替换（`lib/cookies.js` 是本次抽出的纯模块）：

1. **删除指令被当成「设成空值」**。站点在 iframe 里刷新会话时回 `name=; Max-Age=0`，
   旧实现把空值写回驱动 profile——**把有效登录 cookie 就地抹掉**，下一轮真实发送即未登录。
   现在按 RFC 6265 判定删除，翻译成 `expires: 0`。
2. **`__Secure-` / `__Host-` 前缀 cookie 被剥掉 `Secure`**。浏览器按前缀规则**直接丢弃**，
   playwright 侧同样抛错导致整批写不进去。现在保留/补齐 `Secure`，`__Host-` 补 `Path=/`。
3. **镜像转发时 profile 的 cookie 被 iframe 的旧值挤掉**。旧实现「请求带 cookie 就只用请求里的」，
   于是驱动 profile 里真正登录的那份**永远不再发给上游**。现在按 profile 优先、请求补缺合并。

### 等待时长（输入框底下的统计药丸）

`lib/wait-stats.js` 是纯计算层：本会话与累计**同口径**（都来自 relay 的 `sendWaitMs`），
文案由服务端一次算清（`/__webcode/wait-stats` 的 `label` / `detailRows`），
避免两处各写一份格式化后漂移。账本落盘 `webcode-wait-stats.json`，重启不清零。

**与官方统计同一栏（0.15.11 改为结构性方案）**：展示走官方
`conversation.composer.dock` 槽位，取官方 ui-chat `StatsPills` 的尺寸
（28px 高、24px 圆角、14px 线框图标、13px tabular-nums）。

同栏**不靠几何偏移**：`conversation.composer.dock` 的每个条目都是官方那条 dock
flex 行的直接子项，只要自己是 `inline-flex` 就自动同栏，居中、间距、换行全部
由官方 CSS 决定。（0.16.18 起不再 portal、也不再自建整行：DSH 0.1.6-alpha.2 的
`StatsPills` 已不渲染 `[data-composer-stats]`，原先那段「等官方行出现再 portal
进去」的 MutationObserver 因此删掉了。）

> 0.15.10 曾用负上边距把本行「拽」进官方那一行，那是几何猜测：官方行一旦
> 换行（右侧栏把输入区挤窄）或字体档位变化，两块内容就会叠在一起。0.15.11
> 起源码里不允许再出现给等待 wrap 算偏移量的代码，由测试钉住。

交互照官方 `stat-dialog` 契约：**默认只给一个短读数**（如「等待发送 12 s」；
秒级取整——因为正在等待时这个数会逐秒往上走），在途时显示**正在等的
那一段**并把它从起始时刻现算出来（官方那枚药丸同样是「开始就涨」，而不是等
结束才一次性给数），轮询同步提到 1 秒；等待结束、账本结算后自动回落到本会话累计。
点击才展开浮层，内含本会话与累计的完整明细（轮次、平均、限流、发送间隔目标、
距上次发送）。浮层尺寸/圆角/阴影/`dl` 网格逐项对齐官方 `stat-dialog.module.css`，
锚在药丸上右对齐展开。

关闭语义同样对齐官方 `StatsPills`（它由 `useStatDialog` + `useDismissOnOutsidePointer`
驱动，同一时刻只有一枚药丸开着）：**Esc 收起**，且**pointerdown 落在本组件之外
即收起**——于是点官方任何一枚药丸时本面板随之关闭。关闭边界取本组件自身而非
整行，点自己面板内部（含滚动条）不会误关。

**设置页不再重复统计等待时长**：原先那里另有一块「累计等待发送」网格，读的是
同一份账本、同一个数字，属于重复展示（0.15.10 已删除）。设置页「速度与等待」
只保留实测速度指标。

### 右栏

- 站点标签条支持**鼠标滚轮横向滚动**（原生非被动监听），去掉滚动条并加两端渐隐——
  消除「只能拖右滑栏、还有一点遮挡」。
- 「刷新 / 独立窗口 / 分屏 / 浮动」统一成同一套图标按钮（同高、同圆角、同状态表达），
  窄面板下自动只留图标。
- **多开不同网页**：`Ctrl+点击`站点在新分屏打开；或直接用面板上的「分屏 / 浮动」，
  走 DSH 官方 `sidebarRight.split` / `.float`，不自绘浮层（自绘正是遮挡的来源）。
- 站点栏 `z-index` 与 `flex:none` 保证永不被网页区遮住。

### 测试

新增 `test/cookies.test.mjs`（25 例）与 `test/wait-stats.test.mjs`（18 例）；
修掉 `test/mirror.test.mjs` 里把「剥掉 Secure」钉成期望行为的旧断言（它锁的正是本次修的 bug）；
`test/tool-loop.test.mjs` 补测试隔离——它此前读**用户真实**设置（`sendGapMs: 30000`），
退避变成 30s+30s+60s 撞上 120s 看门狗，用例「看环境脸色」。全套 **249/249**。

## 0.14.3

**修掉两个只有真机复验才看得见的 bug**（0.14.2 的单测全绿，但 GLM 第二轮仍然失败）。

复验第一步就发现：0.14.2 的会话**身份**修复完全正确（`result.sessionId` 不再是 null、
`webcode-sessions-glm.json` 不再是 `{}`），但第二轮**换了一个失败方式**：

```
✖ locator.fill: Timeout 30000ms exceeded
  - locator resolved to <textarea>appkey: "CF_APP_WAF", …</textarea>
  - element is not visible
```

1. **登录回退判定只数个数、不看可见性**。导航到 `?cid=` 时 GLM 返回阿里云滑块验证页
   （「滑动验证页面」），页面上 3 个 textarea 全是**隐藏**的 WAF 脚本模板；
   `locator(SEL.input).count() > 0` 于是判成「输入框在 = 已登录」，接着 `fill` 必然超时。
   现在改为 `visibleComposerCount()`：逐元素查可见性，且**遍历全部候选选择器**
   （GLM 的真实 composer 是裸 `<textarea>`，只认 `textarea#chat-input` 会漏掉它）。
2. **「是否已在目标会话上」用 URL 字符串前缀判断**。站点会把地址补成 `?lang=zh&cid=X`，
   而桥拼的目标是 `?cid=X` → `startsWith` 判为不同 → **白白整页重载**，正好撞上风控页。
   现在按**会话 id** 比较（`conversationIdFromUrl`）。
3. **风控页单独成一态**：`detectChallenge()` 认验证页文案与 WAF 脚本指纹，在登录判定
   **之前**调用；报错用 `navReason='challenge-page'` 与「会话过期」分开——否则用户按
   会话过期去查，永远查不到风控。

真机复验结果：GLM 连发两轮，`cid` 逐字相同、`sessionLostCount=0`、第二轮正文正确；
A-4b（重启后第一轮发送间隔）与 F（`:8931` 路径 `gapTargetMs=10000`）均通过。

## 0.14.2

**GLM/Z.ai 的会话身份与上下文预算（B/C/F 三组）。** 用户报「GLM 作为子代理时同一会话却
每轮新开对话」——真机探针查出根因：桥把「会话 id ↔ 地址」的知识硬编码成了 DeepSeek 的两种
形状（`?chat_session_id=` / `/a/chat/s/`），而 GLM 的地址栏是
`https://chatglm.cn/main/alltoolsdetail?lang=zh&cid=6aa6f08454b3a5a4e4f64a77`，
其 `cid` 与 SSE 首帧的 `conversation_id` **逐字相同**。旧实现读不到 → `sessionId` 恒 null →
`rememberConversation` 从不执行 → 每轮 `WEB_SESSION_LOST` → 上层 fresh 重开。

1. **会话地址形状按站点声明**（`providers.js` 的两张表 + `contract.conversationNav`），
   导航判定收成三态 `fresh / resume / unsupported`。**没有第四态**：`unsupported` 必须报错，
   不再像旧实现那样默默开一个新会话把增量发进去（那正是「跑着跑着变傻」）。
2. **解码器透出 `conversation_id`**（`GlmDecoder`，基类 `finish()` 统一带出）——身份取
   「地址优先、流兜底」两个来源。
3. **`WEB_SESSION_LOST` 不再静默**：`sessionLostCount` / `lastSessionLost` 进 `/status`，
   右栏显示「网页会话已丢失 N 次（桥已按重放首轮整段自愈）」。
4. **`zai` 故意不声明地址形状**：探针在 chat.z.ai 上只拿到裸根地址、且整轮 120s 超时
   （`replyChars:0`），**没有证据就不编形状**——编出来会导航到不存在的地址，比「不支持」更糟。
5. **上下文窗口声明有实测依据**：探针把 GLM composer 灌到 **120 万字符**、Z.ai 到 100 万字符，
   **全部逐字回读、没有一档被截断**，所以 1M 是有实测支撑的下界（不再是随手写的占位）。
   它是「本桥愿意让 transcript 长到多大」，**不是模型注意力窗口的规格**。
6. **发送前预算闸** `CONTEXT_WINDOW_EXCEEDED`：越界在**写入 composer 之前**就拒，报错文本带
   「多少字符 ≈ 多少 token > 声明窗口多少」与可行建议。旧实现只有填写**之后**的
   `PROMPT_TRUNCATED` 回读校验，报错只有长度差，看不出超了多少。
7. **窗口声明可见**：`GET /__webcode/context-windows` 列出每站点的声明值与**来源**。
8. **修 OpenAI 前端（`:8931`）绕过发送间隔**（真机 0.14.0 矩阵发现）：`lib/openai.js` 两条
   分支构造 `meta` 时都没带 `sendGapMs`，`clampSendGapMs(undefined) === 0`，于是设置页的间隔
   在这条路径上被整体绕过（实测 `gapTargetMs=0`）。改为注入**当场求值**的取值函数。

新增护栏：`test/context-budget.test.mjs`（11 项）、`test/glm-conversation.test.mjs`（17 项）、
`regression` 47/47（+2 接线断言）、`control-routes` 5/5（+`context-windows`）。

## 0.14.1

**工具协议分叉不再整轮作废，纯标签残片不再当正文外发。**

真机会话暴露出两个症状：一轮里工具调用「有时候没执行」，以及回复里夹杂 `</</` 这样的
错误调用碎片。取证后的根因是**解码器与增量通道的分叉**：decoder 对 `fragments` 做静默
全量替换时不补发增量，于是增量累计的 `acc` 与权威全文 `end.text` 在结构上分叉（两个方向
都实锤过：canonical 多出一个 `grep`、canonical 丢失一个 `edit`）。旧实现遇到分叉一律
`TOOL_PROTOCOL_INVALID` 把整轮扔掉，用户看到的就是「调用了但没执行」。

现在开块时保存已经配平的 JSON，发现分叉时改为**修复**而不是作废：同名第 k 个流式块与
第 k 个同名权威调用配对，流式块用它自己配平的 JSON 收口，没被覆盖的权威调用补发新块。
只有块连配平 JSON 都没有时才保留旧的抛错路径。另外，调用之间那些只剩 `</</` 的纯标签
碎片按空白同型跳过，不再混进正文。

回归护栏：`test/regression.test.mjs` 45/45（新增 3 条——分叉的两个方向、标签残片）。
新测试用临时 `profileDir`，避免 0.14.0 引入的发送间隔落盘成为跨测试的干扰通道。

## 0.14.0

**两个真机问题 + 四项既定改动。** 两个问题都是「先取证、后改」：

**① 发送间隔「好像没按设置来」。** 设置一直存得住，真正的原因是三条：基准取的是
「上一轮**结束**」而不是「上一轮**发出**」（一轮跑 20.9s 时，10 秒间隔只剩 7.6 秒
可见）；基准只在进程内存，**重启后第一轮零等待**；`sendWaitMs === 0` 时那条统计
整条不渲染——设了间隔反而「界面上什么都没有」。现在：判定改为 send-to-send 并
**落盘**（`webcode-send-state.json`，原子写、24h 过期），右栏「发送前等待」**恒可
核对**（未等待时也写明「距上次发送 20.9 s 已满足」），目标值与实际间隔都透出到
`/status`。

**② 网页端回复了但 Harness 这边卡住。** 网页流可能以 `status:'WIP'` 结束且**永不发
FINISHED**，驱动只能等 240s 总超时，界面上就是**无限「思考中」**。现在有三条防线：
驱动侧按「流停 **且** 页面 DOM 不再增长」双条件在秒级收束（只看流停会腰斩长回复，
所以是双条件）；适配器侧有「无进展」看门狗，把无限挂住变成一条带页面现场的明确
报错；`lastEndReason` / 超时现场进 `status`，右栏显示「网页流未收尾但内容已保住 N 次」。

**③ 模型显示名自带站点出处**（`z.ai/glm-5.3`），并修掉下拉里的重复行。

**④ 首轮提示词默认显示**：设置界面不再折叠，直接显示发送首条消息时注入网页的完整
内容。

0.16.25 起改为**按网站逐行列出**（每行一个网站，行内下拉可选该网站可用的协议；
下拉只**预览**模板，不改真实选路——每个网站实际用哪一支由桥按站点决定并标在行首）。
原因：协议只有三支、网站有十个，按协议列时用户得自己推断「我这个站算哪一支」；
每行的模板全文折进「查看该协议的完整模板」里，十份全文不会把页面淹掉。

三支协议与站点的对应（`variantIdForSite`，与 `serializeFirstTurn` 的真实分支一一对应）：

| 协议 | 站点 | 形状 |
|---|---|---|
| `official` | deepseek | DeepSeek 官方训练模板（原生工具调用格式） |
| `glm` | glm | ```json 代码块（该网页会抢走调用标签执行） |
| `default` | 其余全部 | `<tool_call>{…}</tool_call>` 标签 |

> 本次同时修掉一个**此前不可见**的错误：`deepseek` 曾被映射到 `default`（标签形状），
> 而它实际走官方模板分支——设置页因此给 deepseek 显示了一份它根本不会收到的模板。
> 之前下拉只有两支，用户不会去比对；改成「每行一个网站」后它立刻就是错的。

模板由 `agent-preset.serializeFirstTurn` 现算——**设置里看到的 = 真正发出去的**。
协议文本（首轮教学、增量轮再教学、解析失败自动续跑）全桥只有一处定义：
`agent-preset.transportNoteFor`。0.16.25 抽取它是为了自动续跑——续跑要把再教学提示
作为用户消息补发，那一轮看到的协议段必须与该站点**首轮逐字相同**；否则会出现
「首轮教 A、续跑重申 B」，模型在两者之间摇摆。

**⑤ 右栏规范化**：注册走 `ctx.effect` 生命周期，标签动作菜单、tablist 方向键导航，
样式改用 DSH 的 `--dsw-alias-*` token。

**⑥ 修掉两处「空转护栏」**：`client-render` 测试的 fetch mock 不是忠实 Response、
渲染器又没递归进 children——两处叠加导致**所有**数据路径静默失败、嵌套组件从未
渲染，旧断言等于空转。修好后 0.14.0 的新面板才真的被测到。

## 0.13.1

**交付物必须以 `present` 呈现，写进预设提示词。** 此前预设从未教过这件事，于是
模型写完文件只在**正文**里列一串路径——用户看到的是一堆点不动的纯文本，而不是
可点开的文件面板。现在预设新增「# 交付物呈现（present）」章节，讲清三件事：
只在正文写路径**不算**交付；`present` 让文件变成可点开的面板；要在最终答复
**之前**调用。首轮的 `[本地工具传输协议]` 尾部也带一句提醒（收尾那一刻最容易忘），
glm 的代码块分支与其它站点的标签分支都覆盖。
**仅在本会话真的注册了 `present` 时才教**——否则模型会去调一个不存在的工具，
白费一轮并撞 TOOL_UNKNOWN。

> 为什么必须升版本号：0.13.0 已发布过一份**不含**该提示词的包，同名同版本却换了
> 内容会让 pnpm 按版本号去重而静默不更新（实测安装副本仍是旧文件），也让构建指纹
> 失去意义。任何内容变更都必须伴随版本号变更。

## 0.13.0

**模型选择从「空承诺」变成真实切换。** 0.12.9 的驱动遇到 `auto` 直接返回、
**页面上一个动作都不做**，而每站目录里又只有这一条 auto——UI 有下拉，网页什么都不会变。
现在按站点声明**模型选择契约**（触发 / 选项 / 名字节点 / 回读），由 `lib/model-picker.js`
执行**精确名匹配 + 点击后回读确认**；契约缺失就如实报「未切换网页模型」，绝不假装成功。
真机实测目录（`test-mock/probe-model-dropdown.mjs` 全量 dump）：

- **z.ai**：`GLM-5.3-Flash` / `GLM-5.3` / `GLM-5.2`
- **GLM**：`GLM-5.3` / `GLM-5.3-Flash`
- **Kimi**：`快速` / `K3` / `K3 集群`
- **豆包**：`对话` / `工作`（分段控件形态，**本地默认 = 对话**）
- chatgpt / claude / gemini / qwen：未校准（诚实显示，不假装可选）

注意 `GLM-5.3` 是 `GLM-5.3-Flash` 的**前缀**，因此匹配必须是精确的
（`test/model-picker.test.mjs` 把这个陷阱钉死）。真机验证
`node test-mock/verify-model-switch.mjs` **11/11 PASS**。

**harness 截图能真的传到网页端了。** 旧实现只认 wire 形状的图片块，而 DSH 原生
（截图走的）是 `{type:'image', attachment}`——两者零交集，于是每次截图传图都被
**静默丢弃**。现在原生块经 `ctx.attachments.readImageRequest` 取像素（预算与
dsh-llm-deepseek 同档），并声明 `inputModalities` 防止宿主把图片提前换成文字；
上传后**必须看到页面上出现附件证据**才发送（拿不到就报错，绝不发一条没有图的消息）。
真机端到端 `node test-mock/real-vision-upload.mjs` PASS：上传已知色带图，
模型答「3条，红色、绿色、蓝色」。

**「检测」按钮不再报 JSON 解析错误。** 根因是 `verify-login` / `site-probe` /
`session-import` 三个 action 进了 action 表却**没进挂载清单**（手写数组漏了），
请求落到宿主兜底回 **405 + 空 body**，客户端在判断状态码之前就 `.json()`，
于是把真实原因吞成一句 `unexpected end of JSON data`。现在挂载清单从 action 表
**派生**（结构上不可能再漏），未知方法回 405 JSON、未知路径回 404 JSON，
客户端先读文本再按 content-type 解析。

**设置界面改为 harness 风格**：三段内联硬编码色合并为一张 token 化样式表
（`--dsw-alias-*` + fallback，16px 圆角卡片 / .5px 边框 / pill 按钮，与 DSH 自家
设置区同款）；站点行改为「状态点 + 名字 + 行内动作」，并**露出登录判定依据**
（`loginBasis` 一直有，此前 UI 把它丢了，于是「未登录」看起来像凭空断言）；
「检测」结果分三态，`待检查` 不再画成红色错误。

## 0.9.7

**工具调用「能执行」。** 真机 64 次工具报错全部是参数形状漂移（数字写成字符串
`"10"`、数组写成单对象、布尔写成字符串）。新增 `coerceArguments`：按工具 schema
**显式声明的类型**做定向纠偏，只做无歧义的方向，解析失败一律原样保留交给 DSH 报错。
网页调了本会话不存在的工具时，不再整轮作废，而是把「TOOL_UNKNOWN + 本会话可用工具
清单 + 请重试」作为这一轮的回复交回会话，下一轮模型自纠。

**关于「某些工具在 DeepSeek 下执行不了」。** 桥只负责把网页发来的调用正确解析、
纠偏、交付；**一个会话能用哪些工具由 DSH 的会话预设决定**——极简模式实测只注册
`pwsh`，此时 `subagent`/`write`/`edit` 本来就不可用。要跑多工具任务请把预设切到
标准模式。新增 `test/tool-loop.test.mjs` 把网页五种真实调用形状（`<tool_call>` /
全角 DSML / 裸 `<invoke>` / ```json 围栏 / `**Calling:**`）固定成回归。

## 0.9.6

**一轮连发多个工具调用不再出错。** 复现真机形状（模型一轮里连发 3 个 `read`）后，
把流式协议边界的三处缺陷一次改掉：同一个调用被开成 32 个块并整轮作废；游标推进后
又命中更早的收尾标签，`</tool_call>{"mcp_action":…` 整段漏回正文；把非调用的裸 `{`
当截断点导致正文下标卡死、流式循环挂住。现在边界**单调不减**、按边界下标去重、
用 `partialProtocolAt()` 扣住 `<t`/`<tool_cal`/`**Calling:` 这类半成品标记。
顺带修掉调用块关闭时把下标当块内容发出去（`block.text` 变成数字）。

## 0.9.5

**登录任意站点。** 设置页「连接 → 登录网站」下拉选择站点后点「登录所选网站」：打开
真实 Edge 窗口等你完成一次性登录，检测到已登录自动切回无头，并把结果（成功/已登录/
失败原因/耗时）回报到界面。旁边的「独立窗口打开」把该站点开成可交互的真实浏览器窗口，
与桥共用登录态。每站点独立 profile，互不串号；上次被强杀留下的 Chromium 单实例锁会在
启动前自动清理并重试一次（「退出过一次之后哪儿都登不上」的直接原因）。

**跑到一半突然停止已修。** 真机 13 份会话转录逐事件统计：12 次异常收尾全是
`web capture ended incomplete`——网页没发 `FINISHED`/`close` 就断流，旧解码器要求
两帧齐全，于是已经解出的正文和完整工具调用被一起丢掉。现在解码器把已解出内容带出来
并标 `partial`，驱动层接受这一轮；真的什么都没有、或结构坏掉，才失败。

**工具调用失败有据可查。** 真机 64 次工具报错全部是参数形状漂移（`"offset" must be a
number`、`"questions" must be an array`、缺 `description`），并新增 `TOOL_UNKNOWN`：
网页调了本会话不存在的工具时不再静默过滤成"收束"，而是报错并把可用工具名回传，下一轮
可自纠。游标指纹改为只锁工具名集合，工具描述升级不会再顶掉上下文游标。

**预览打不开会说明原因。** 实测 `chat.deepseek.com` 经镜像反代被 CloudFront 判成机器人
返回 403（glm/kimi/qwen/grok/zai 均 200）。镜像现在补常规浏览器指纹、识别 CDN/WAF
错误页并换成「为什么 + 改用独立窗口打开」的说明页；站内 302（豆包 `/` → `/chat/`）
改写为带镜像前缀，不再落在命名空间外变空壳。

**新增站点 z.ai**（`https://chat.z.ai`，OpenAI 兼容 SSE，静态域 `z-cdn.chatglm.cn`，
别名 `zai`/`z-ai`/`chat.z.ai`），实测镜像 200。

取证结论见 `doc/bridge-failure-ledger.md`（本机 13 份真实会话转录的逐事件统计；
原先指向的 `doc/incident-2026-09-11.md` 已于 2026-09-16 删除）。

## 0.9.4

协议文本不再进助手文本：截在正确的层。

真机会话里助手消息存的是整段协议原文（正文里出现 `<tool_call>` / 全角
DSML 标记加 JSON）。真因不在渲染，而在流式边界探测与解析器各认一套形态：
`parseAgentReply` 有 DSML 归一化，所以工具照常执行；而流式那句行内 `marker()`
只认半角标签 / 围栏 / Calling / 裸 `{`，对全角 DSML 恒返回 -1，于是协议原文被当
正文一路发出去。显示层折叠救不了它（那段是正文段落，不是 `<pre>`）。

- `findProtocolStart()`（`lib/agent-preset.js`）：与 `parseAgentReply` 共用同一套形态
  知识，探测协议起点与工具名；流式循环改用它。
- `stripProtocolText()`：下游兜底，保证写进会话的助手文本是散文。
- `normalizeDsml()`：归一化收为一处，探测与解析不再各写一份正则。

**工具闭环一字不改**：解析仍吃原文，只是“发往界面”的那一侧被截断。
回归：`test/protocol-leak.test.mjs`（9 项，真机夹具 `test/fixtures/leaked-dsml-reply.txt`）锁住
探测命中、解析照旧拿到完整调用、探测与解析形态不得漂移。
MIT。SSE 解码协议参考 MIT 项目 three-water666/webcode。产品展示名更新，安装包名及 provider ID 保持兼容。
