# 更新日志（Changelog）

格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)，版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。

## 0.5.0 (2026-10-14)

> 版本号是 **minor** 而不是 patch：这一版新增了一个**用户可见的设置**（`regions.<region>.creditPosition`，积分读数的位置）和一个**新的渲染席位**（`conversation.composer.dock`），配置 schema 也多了一个字段。按语义化版本这是特性，不是修错 —— 与同族 `dsh-connect-workbuddy` 对同一改动走 **4.5.0**（minor）的判断保持一致。

对上同族 `dsh-connect-workbuddy` 的 **4.5.0（issue #62）**：那个项目收到报告——**装了第三方插件（`dsh-auto-continue`）之后，输入框里的积分余额就不显示了**——根因是**位置本身**，于是把积分读数做成「两个位置可选」。本插件此前只注册了同一个席位，因此带着同一个潜在缺陷，这一版把该改动对齐过来。

### Added

- **积分读数位置可选：输入框工具栏 / 输入框下方**（对齐 workbuddy issue #62，其 commit `f16b05f`）。
  - **根因不是插件冲突，而是席位语义**：`conversation.input.left`（输入框工具栏）是**共享的 list 插槽**——shell 自己的控件和任何其他 bundle 都注册在那里，而 **list 插槽无法保证每个占用者都可见**。所以「另一个插件装进来，读数就没了」不是谁把谁挤掉的偶发，而是该席位结构上就不作保证。本插件此前只注册这一个席位，因而同样暴露。已用**装机运行时代码**核对（不是推断）：`dsh-client-ui-conversation/lib/client.js` 里两个席位都是 `{kind:"list", scope:"session"}`，且 `conversation.composer.dock` 的渲染条件是 `variant === "composer" && input !== undefined && sessionId !== undefined`。
  - **新增设置 `regions.<region>.creditPosition = 'composer' | 'stats'`，默认 `composer`** —— 默认值取该设置存在之前**唯一**的席位，所以**升级不会移动屏幕上任何东西**（读盘走本地读取而非 schema 默认值，配置在此之前写下的也读作 `composer`）。用联合类型而非布尔：第三个席位是可预见的，`showAtTop: boolean` 只能迁移、不能扩展。
  - **两个席位都无条件注册，决定权收在 gate 一处**（席位不符即 `return null`）。反过来「按设置只注册被选中的席位」意味着每次改设置都要**重新注册插槽**，而本仓库已写明这种绑定是脆的（晚到的 settings 命名空间必须产生新注册而非改闭包）。
  - **`stats` 席位 = `conversation.composer.dock`**，`order: 10` 排在 shell 自己的轮次/tok/s/缓存命中统计（注册在 0）**之后**：自带统计仍在最左，读数是**追加**而不是顶替。
  - **判定顺序是「归属 → 两个开关 → 席位」**：被关掉的区域在两个席位都**不**渲染，而不是先在非当前席位画一帧再被区域判定拒掉（那会让用户看到读数闪一下）。
  - **卡片新增二选一控件**，与「在对话框显示积分」开关同处一行、紧随其后；读数走 **Host 的 usage 应答**而不是浏览器设置镜像——与那个开关同一个实测理由（在受影响的部署上镜像从不拾取经 Host 保存端点写入的值，控件会一直显示旧席位）。写入沿用 `writeRegionCreditPosition` 的**回读校验**。关掉积分开关时控件**置灰而非消失**：两个席位此时都不渲染，位置无从谈起，但「有这个选项」这件事本身不该从界面上蒸发。
  - **测试 +14**：新增 `tests/credit-position.spec.tsx`（10 例）钉住读写往返（含「设置存在之前写下的配置读作 `composer`」与 volatile 代理的 `===` 陷阱）、按区域而非全局、兄弟字段与另一区域不被写入吃掉、归属判定，以及**gate 只在被点名的席位渲染**；`tests/card-render.spec.tsx`（+4 例）钉住卡片控件的值来自 Host 应答、回落 composer、写入 body 的形状（`{field:'regions', value:{<region>:{…}}}`）与置灰。变异验证：删掉席位判定 → 2 例红；读取端恒返回 `composer` → 4 例红；默认值改成 `stats` → 1 例红；卡片写错字段名 → 1 例红；卡片读数硬编码 → 1 例红。全量 581 通过 / 7 跳过。
  - **未做（如实记录）**：没有实机核对运行中的插槽树 —— 该席位是否真的在 UI 上出现，需要 `pnpm run dev:web` 加页面刷新，本轮只验证了插槽名、`kind/scope` 与渲染条件（读的是装机产物），以及 gate/卡片的决策逻辑。

## 0.4.3 (2026-10-13)

两条用户实拍：**国际版对话报「API 密钥无效」而卡片只说「用量已达上限」**，以及**两个区域每次保存都会让思考强度消失**。两条都不是上游的错，是我们自己把答案读丢了。

### Fixed

- **「API 密钥无效」是假警报：宿主的分类器读的是文案正则，而我们把上游的 `HTTP 403` 原样送了进去。**
  - **根因（执行验证，不是推断）**：宿主对 pi-ai 失败**不按 HTTP 状态分类，而是对文案跑正则**（`app.asar` 内 `classifyPiAiError`），且 **`401|403` 那道判据排在配额那道之前**：

    ```js
    if (/\b(?:401|403)\b/.test(message)) return "AUTH";   // ← 第一道，命中即返回
    if (isQuotaExceededError(message))   return QUOTA_EXCEEDED_CODE;
    ```

    本插件为溯源一直把上游原文（`上游信封错误: HTTP 403 {"code":"110","message":"Billing daily count exceeded"}`）拼进这条文案。`110` 是**配额族**（当日用量用尽，明天自愈），403 只是上游承载该业务码的习惯 —— 于是第一道正则把配额截走判成 `AUTH`，宿主再用中立文案 `message.failure.AUTH`（**「API 密钥无效」**）覆盖掉提供方自己的解释。实测 `isQuotaExceededError` 对**同一串文本**返回 `true`：分类错是**顺序**造成的，不是判据不认。
  - **为什么两个界面说法不一致**：卡片读的是上游本身（`refreshUsage` → `upstream.fetchUsage`），**从不经过 shim**，所以它显示的是真实配额状态；对话走 shim，在分类那一层被读成了凭据问题。两个数字都对，只是对话那侧的名字错了。
  - **修法**：新增 `hostMessageText(kind, message)` / `hostFailureMessage(kind, status, message, suffix)`，把裸的 `401/403` 改写成 `4xx`，**只对非 `auth` 档这么做**。`auth` 必须原文送达 —— 上游真吐 `401 TOKEN_EXPIRE` 时 AUTH 正是正确答案，而「未配置 PAT」那条中文没有状态码，宿主会原样显示（比换成「API 密钥无效」更有用）。状态码**由 formatter 自己加、也由它自己净化**：调用方自己拼 `(http ${status})` 会绕过净化，第一版正是这么漏的。
  - **覆盖四条真路径，一条都不能少**（第一版只补了其中一条）：外层 `handleRequest` catch、`resolveServing` 凭据解析 catch、`handleChat` 首字节前失败、以及**流内错误帧** `writeErrorChunk`。最后一条最容易漏：宿主对 `case "error"` 分支拿 `message.errorMessage` 跑**同一个**分类器，而此时 200 已提交、用户已经在看一个半截回复 —— 分类错在这里最难排查。
  - **顺带修掉同一类缺陷的动态版本**：`模型 ${model} 不支持图像输入` 里的 model 来自**请求体**。目录里若存在 `gpt-401` 这类名字，原文送达会把「不支持图片」读成「API 密钥无效」。这条 400 同样过净化。
  - **边界**：状态码必须**整词**替换（`1401`、`4030`、`v403`、`code_403` 都是溯源信息，不能动），且右边要是数字或「小数点+数字」都不算独立状态码 —— 对抗审核抓到 `401.5` 会被旧判据 `\b` 改写成非法的 `4xx.5`，已用成对环视修掉（`(?<![\w.])(?:401|403)(?!\d)(?!\.\d)`）。
  - **测试不再手抄宿主正则**：`tests/host-piai.ts` 直接**执行**从 `app.asar` 逐字提取的源码（`tests/fixtures/host-piai-classifier.json`，带 asar 的 sha256 与 DSH 版本）。手抄的副本会跟宿主一起漂移，「测试通过」就只证明副本自洽 —— 这正是这处缺陷潜行一个版本的原因。另有一条自检把 golden 与**装机宿主**逐字比对（宿主没装则跳过），宿主升级会直接变红并提示重新提取。
- **每次保存都把「思考强度」丢掉：客户端手写映射漏了两个字段，而读取端的判据是 `length > 0`。**
  - **机制**（有一处必须说准）：schemastery **不剥未声明的键**，真正发生的是**缺席的 `z.array().required(false)` 被补成 `[]`**。所以链条是：卡片手写 `lastCatalog` 条目时**从没写过** `thinkingEfforts` / `defaultThinkingEffort` → 落盘缺席 → 解析补 `[]` → 读取端 `modelFromStored` 判 `length > 0` 不成立 → 档位选择器消失（用户实拍「每次保存都会思考强度不见了」）。schema 里那三个字段的声明仍然必需，但必需的理由是**判据与「缺席补 []」之间的落差**，不是剥键 —— `src/index.ts` 里原先写「zod 静默剥掉」的注释是错的，已更正。
  - **还有一处看不见的漂移同轮修掉**：客户端写 `maxTokens`，而读取端读的是 `maxOutputTokens` —— 名字对不上，永远读不回、静默回落 32768 默认值；`nativeContextWindow` 则无人消费，纯落盘垃圾。
  - **修法照同族 `dsh-connect-workbuddy` 的 `toPersistedWorkBuddyModel`**：**spread 整行再按名剔除卡片独有字段，不逐个字段手写白名单**。workbuddy 的注释写着「omitting it made one Save silently strip the declaration from every model」—— 同一处缺陷它已经出过一次诊。白名单只要漏一个字段，那次保存就把该字段从每个模型上抹掉，而且**没有任何东西会报错**（保存的落地校验只比对 `key` 列表）。三对重命名（`name`/`displayName`、`maxTokens`/`maxOutputTokens`、`creditMultiplier`/`priceFactor`）现在只在投影函数里出现一次，并与读取端逐字对齐。同时删掉与本函数重复、且写着读取端读不出来的字段名的死代码 `toPersistedQoderModel`。
  - **落盘存原生窗口**（`contextWindow: model.nativeContextWindow`）是刻意的：生效窗口是用户预算的决定，读回时由 `applyContextBudgets` 重新施加；若落盘 clamp 后的值，用户撤掉预算后窗口会「焊死」回不到原生。
  - **测试 +10（`tests/persisted-catalog.spec.tsx`）**：调用真正的 `buildModelsPayload`，把它**真实输出**喂给**插件自己导出的 `Config` schema**（不是手抄 schema），再按**读取端判据**断言读回了什么。同样照 workbuddy 补了一条「不产出值为 `undefined` 的属性」用例 —— 投影改成 spread 之后这条从「锦上添花」变成「必需」（显式 `undefined` 会被设置写入端那道严格 JSON codec 拒收，整次保存失败）。
  - **诚实记录**：既有的 `tests/index.spec.ts` 往返用例**手写**了一份「理想的」payload 去喂 schema，它证明的是「schema 不吞字段」，而不是「客户端真的写了这些字段」—— 所以 546 个测试没有一个发现这个缺陷。真实保存路径从未被任何测试执行过，这是它长期潜伏的直接原因。变异验证：把投影退回旧手写映射 → `persisted-catalog.spec.tsx` 3 例变红，而 `index.spec.ts` 18 例**全绿**。

## 0.4.2 (2026-10-13)

用户实拍：国际版账号「ddd」显示 **「✗ Qoder 拒绝了这个账号的 PAT——请添加新 PAT 或换一个账号」**，并附一句「我感觉大概率还是我们出了问题」。**这个判断是对的**：账号一切正常，PAT 没有任何问题，是我们把上游的一句「今天用满了」读成了「你的凭据坏了」。0.4.1 修的是文案自相矛盾，这一版修的是**判据本身**。

同族 `dsh-connect-workbuddy` 在 4.4.2 修掉了那层**保存/删除/导入时的忙碌遮罩**（卡片是近黑板子、spinner 与外壳不同拍），本版把该改动逐条对齐过来 —— 两族共用同一套 `dsm-*` 卡片语言，遮罩也该是同一套。

### Fixed

- **任何 HTTP 403 都被判成「凭据被拒」—— 业务码明明就在同一个响应体里，却没被读。**
  - **实测（本机真实 PAT，`scripts/probe-cosy.mjs global`，只读）**：

    | 检查 | 结果 |
    | --- | --- |
    | PAT 换令牌 | ✅ 成功（姓名可读，`hasOauthToken: true`） |
    | 模型目录 | ✅ 15 个模型，`qfmodel` 在列 |
    | 积分（sash 面） | ✅ `userQuota {total:300, used:0, remaining:300}` + `addOnQuota {total:200}`，`isQuotaExceeded: false` |
    | **对话（chat 面）** | ❌ `HTTP 403 {"code":"110","message":"Billing daily count exceeded"}` |
    | 换 `efficient` 重试 | ❌ **同一个 110** —— 证明与模型 key 无关 |

  - **官方码表**（[docs.qoder.com/zh/cli/sdk/errors](https://docs.qoder.com/zh/cli/sdk/errors)）是决定性依据：`105` 才是「登录态或 Access Token 已过期 → 换新凭据」；**`110` = 「达到当日用量限制 → 等待用量周期重置」**，`113`/`115`–`119`/`122` 同属用量与配额族。也就是说「403 就是凭据坏了」这个推断**在协议层面根本不成立**——403 只是承载码的容器。
  - **三层缺陷叠加，缺一不可**（这也是它一直没被查出来的原因）：
    1. `classifyBusinessCode` 只认 `105` / `115` / `402` / `6004`，**`110` 根本不在表里**，落到 `unknown`；
    2. 更关键：`403 + 110` **从来没走到过 `classifyBusinessCode`**。SSE 信封分支与 chat 的非 200 分支都只调 `classifyHttpStatus`，于是 `403 → auth`；
    3. `onServingFailure` 把上游原文**丢掉了**（参数写成 `_message`），落盘的记录只剩一句结论 —— 实测那条 `credential-rejected` 记录**连 `message` 字段都没有**，卡片因此只能空口指控，而真相就在被丢掉的那串字符里。探针路径早就带原文（`redactUpstreamText`），**两条路径写同一个文件却只有一个带**。
  - **危害不止文案难听**：`credential-rejected` 在账号池里是**无条件永久排除**（`exclusionOf` 刻意不看冷却期，理由写在注释里：令牌被拒，等也没用）。这个理由对 `105` 成立、对 `110` 恰好相反 —— 一个明天自动恢复的账号被踢出池且**不自愈**，同时被要求去做一件根本无效的事（换 PAT）。
  - **修法**：新增 `classifyFailure(status, detail)` —— **业务码优先于 HTTP 状态，认不出的码才回落状态**（`TOKEN_EXPIRE` 这类字符串码、非 JSON 的 body、空 body 全部维持原行为，真被拒的那一侧不能被改坏）；`businessCodeOf` 把上游两种写法（`"110"` 与裸数字 `110`）归一，取不到就返回 `undefined`，**绝不猜**；官方码表按族补全（配额 / 请求 / 瞬时）；SSE 信封与 `chatStream` 两条真路径接入；`onServingFailure` 的那段内联字面量抽成模块级纯函数 `liveRequestProbe`，**带上脱敏后的原文**。
  - **修好误判后暴露出第二个错（同轮改掉）**：110 正确落到 `out-of-credit` 档之后，既有文案是「积分不足 / Not enough credits」—— **而这个账号实测还有 500 积分、`isQuotaExceeded: false`**。`110` 说的是「当日用量**次数**」，与余额无关；照发就是第二句自相矛盾的话（0.4.1 刚为第一句发过补丁）。改成「用量已达上限 / Usage limit reached」，排除芯片与「池里没有可用账号」的提示语**同口径**（否则同一张卡片上两个词指同一件事）。带恢复时刻的那格也去掉了「积分包」这个限定 —— 恢复时刻也可能来自上游的重置句子，写死来源就是编造。
  - **现场复核（改动后打真实上游）**：同一条请求现在报 `kind=quota-exhausted`，不再是 `auth`。
  - **测试 +27（515 → 542）**：`tests/upstream.spec.ts` +17（逐字锚定现场抓到的 `HTTP 403 {"code":"110","message":"Billing daily count exceeded"}`；整族码覆盖；`105` 仍判 auth；认不出的码回落；**六条真路径全部端到端钉住**），`tests/index.spec.ts` +6（`liveRequestProbe` 的映射、原文必带、空原因不写成空串、**PAT 不落盘**、内容策略与不可映射 kind 不写池），`tests/locales.spec.ts` +4（渲染后的实际文字，不是 key 名）。
  - **真接线是六条，不是两条 —— 这是本轮最该诚实记下的一句。** 第一稿只给 `chatStream` 的非 200 分支和 SSE 信封补了端到端测试，**忘了还有四个调用点同样只按状态分类却一个测试都没有**（换令牌 `/jobToken`、积分主路径 `/me/usage`、签到 `/campaigns/**`、模型列表 `/model/list`）。改完那四处之后做了「退化回只看状态」的变异：前两个与信封都立刻被接住，而**模型列表那条跑出的是全绿** —— 又一个漏掉的接线。补上 `fetchModels` 钉住它之后，六个调用点各自退化**都**被对应测试接住。教训还是同一个：分判据（`classifyFailure`）写得再准，**只要有一条真路径没接线，全量测试照样绿**。
  - **八组变异测试**：业务码优先整体关掉 / 信封路径退回只看状态 / `chatStream` 退回只看状态 / 换令牌路径退回只看状态 / 积分主路径退回只看状态 / 签到路径退回只看状态 / 模型列表路径退回只看状态（第一遍**没被接住**，补测试后才接住）/ 丢掉上游原文 / 从配额族删掉 `110` / `businessCodeOf` 不认裸数字 / 文案退回「积分耗尽」与「积分包 {at} 刷新」—— 各自被对应测试接住。
  - **两处诚实记录**：(a) 第一轮变异（只改 `chatStream` 那一行）跑出全绿，正是「真路径没测试」的直接证据 —— 这不是免责，是当时漏掉另外四处的根因；(b) 一次变异脚本的 perl 正则报错（未闭合括号）导致该组**根本没落地**，当时的「通过」是假信号，重跑并校验「变异确已落地」之后才有结论。
- **既有误判记录会不会一直卡着用户**：不会，但要说清路径。升级后第一次对话仍会失败（今天确实用满了），而这次失败会**覆写**那条记录为 `out-of-credit` —— 它带 30 分钟兜底冷却，会自动回池。`resolveServing`（首发射给谁）**不看探针**，只有池内故障转移与卡片「已排除」芯片看，所以直接选中该账号的用户始终能正常用它。上游对 110 **没给恢复时刻**（英文原文里没有可解析的重置句子），插件因此**不自造零点**，按既有的 30 分钟兜底重试；真正的恢复以用量周期为准。
- **忙碌遮罩（保存 / 删除 / 导入时那层）改用宿主主题令牌，spinner 换成宿主同款弧线。** 对齐同族 `dsh-connect-workbuddy` 4.4.2（同一处缺陷的修复），三处自造值逐一换掉：
  - 遮罩底色原写死 `rgba(0,0,0,.38)` —— 既不跟主题也没有宿主的模糊。改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`（亮 `.24` / 暗 `.5`）+ `--dsw-mask-blur`，与外壳里其他遮罩一致。
  - 卡片底色原用 `--dsw-alias-bg-primary`，**这个变量在宿主令牌集里根本不存在**（宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay`），于是一路回退到本文件写死的近黑 `#1b1c20` —— 这就是「一块黑板子、上面的字看不清」的根因。改用宿主给弹层用的 `--dsw-alias-bg-layer-2`，亮暗两套各自成立；阴影也换成宿主自己的 `--dsw-elevation-prominent`。
  - 提示行原用 `label-tertiary` —— 那是宿主最暗的一档（亮 `neutral-bluish-600` / 暗 `neutral-bluish-400`），给元数据用的，不该承载一句要等的人必须读的话。改用 `label-secondary`。
  - **spinner**：原是 18px 的 CSS border 环、`.7s` 匀速转，比宿主自己的状态转圈（`_dsh-state-dot-spin`：SVG 弧线 + dash 呼吸、`1.5s`）快了近一倍 —— 比起形状，**这个速度差才是它看着廉价的主因**。改为同款弧线几何与 `1.5s` 节奏，`currentColor` 继承所在层级，reduced-motion 降级为静态弧线（与宿主降级方式一致），并补 `aria-hidden` 免得 `role="status"` 之外多报一次。
  - 测试 +4（`tests/styles.spec.ts` 6 → 10）。**其中一条断言第一版是空转的，已修**：`toContain('--dsw-alias-bg-layer-2')` 对全文匹配 —— 该令牌在文件里另有 7 处出处，于是把遮罩退回自造值也照样通过。改成按 `ruleOf(...)` 取**遮罩规则自己的声明体**断言，五组变异（卡片底色退回 `bg-primary` / 遮罩退回写死黑幕 / hint 退回 `label-tertiary` / spinner 退回 18px border 环 / 删掉 dash keyframes）才各自转红。另有一条守卫按**源文件顶层语句数**断言，专防「模板字符串里的反引号提前截断整个样式表、而 typecheck 与 build 都不报错」（同族真实踩过）。

## 0.4.1 (2026-10-13)

0.4.0 上线后一小时里回来的两个小修：**签到单元格那句自相矛盾的文案**（用户实拍），以及**连红 6 次的 windows CI job**。后者顺带暴露了一个流程洞：发布只跑本地 `check`（macOS），没人看 CI。

### Fixed

- **签到那一格「已签到 · 00:39:22 后刷新」自相矛盾 —— 两句话说的是不同的窗**（用户实拍，并直接指出：「这个是 39 分钟后可以签到，所以不能显示已签到」）。
  - **根因是两个字段来自两个窗口**：`checkedInToday` 是**上一窗**的 `claimStatus`，而 `checkinRefreshAtMs` 是那一窗的 `endAt` —— 也就是**下一窗什么时候开**。并排读就成了「今天已经签完，可 39 分钟后又有事可做」。`due` 态更糟：「已签到 · 现在可以领取」，七个字里自我否定。
  - **修法：一格只说一句话**，四态各一句 —— 未知 / 「已签到」（上游没给开窗时刻）/ 「`HH:MM:SS` 后可再签到」/ 「现在可以签到」。去掉「已签到」**不丢任何事实**：「**再**签到」本身已经蕴含这一窗签过了。
  - **「后刷新」也一并改掉**：旧措辞字面说的是「这一行多久刷新」，而那个数字其实是**可再签的时刻**，两回事。悬停提示同步点明，并与 Qoder 网页的倒计时对齐（同一个 `endAt`）。
  - 样式：`next` 那格去掉 `margin-left`（它现在是这一格的主句，不再是附注），保留 `tabular-nums` 防逐秒抖动。
  - **测试的结构性缺口（记下来，这类文案 bug 的唯一防线）**：`tests/pool-render.spec.tsx` 的 `t` 是**保留 key** 的假翻译器 —— 它能钉住「用了哪个 key」，**永远钉不住「这句话读起来通不通」**；而且此前**没有任何测试 import 过 `locales`**。这句矛盾文案就是这么发出去的。新增 `tests/locales.spec.ts`，用**真实 locale 表**断言用户实际看到的字符串（含 `{countdown}` 渲染后的整句），钉住「不并排已签到」「不说刷新」「title 里不放 Markdown 星号」。三组变异测试：把「已签到」并回倒计时格 / 文案改回「后刷新」/ 并回到 due 态 —— 各自被对应测试接住。

### Tests

- **CI 的 windows job 连红 6 次 —— 断言了 Windows 表达不了的东西。** `expected 438 to be 384`（`0o666` vs `0o600`），出在 `tests/accounts.spec.ts` 的保险库权限位那条；ubuntu job 一直是绿的。
  - **不是写入路径的 bug**：`src/accounts.ts` 确实请求了 `mode: 0o600`。是 node 在 Windows 上只把「只读」一位映射进 `st_mode`，普通文件一律报 `0o666` —— 写什么权限都读不回来；那边的文件保护由 `%USERPROFILE%` 的 NTFS ACL 承担，与 mode 位无关。
  - **为什么红了 6 次没人管**：这条断言是 `5002aa4` **引入时就带着病**（上一个绿 run `0c865e1` 还没有这个文件），此后每次 push 都精确红在同一条上。真正的问题不是断言写错，是**没人看 CI** —— 发布流程只跑本地 `pnpm run check`（绿），而本地是 macOS，恰好是这条断言能过的平台。
  - **修法**：`it.skipIf(process.platform === 'win32')`，**不是**把断言改成 `0o666` —— 那等于把平台限制钉成「正确行为」，将来写入路径真的丢掉 0600 就再也测不出来。跳过会留在 CI 报告里（已核实：windows job 打印 `accounts.spec.ts (24 tests | 1 skipped)`）。顺带把这条测试拆成两个（区域分键 vs 权限位），失败时能直接看出是哪一半。

### Docs

- **`RELEASING.md` 新增第 5.5 步「CI 双平台必须转绿（不得跳过）」。** 上面的红灯红了 6 次而无人察觉，根因是发布门禁只有第 1 步的本地 `pnpm run check` —— 本机是 macOS，恰好是那条断言能过的平台，而 CI 的 windows job 从来没进过清单。新步骤要求：核对最新 run 的 `headSha` **就是**本次推的提交、**两个 job 都 success**（不是只看 overall）、有 skipped 时确认是有意的跳过。第 1 步旁加了指向它的警示。
  - **编号用 5.5 而不重排 6-10**：正文里已有「第 7 步」「第 8 步」「第 9 步」的交叉引用（2FA、registry 核验、GitHub Release），重排会让它们全部错位 —— 沿用 §2.5 的既有先例。

## 0.4.0 (2026-10-13)

0.3.0 发布后挖出的四条修复，外加一条有意的旧行为移除。四条里两条是 0.3.0 自己带出来的伤：**npm 上 0.3.0 的 peer 范围装不进当前 DSH**（装上即 `ERESOLVE`，必须尽快覆盖掉），以及**国际版需要的第二族令牌**；另两条是用户实拍回来的（思考强度间歇性消失、签到倒计时）。其中思考强度那条经历了一次返工 —— 第一版修复在生产路径上是**死代码**，险些带着假说明发布，经过与返工都记在条目里。

### Fixed

- **国际版积分与签到恒为 `credential-rejected` —— 国际版需要【两族并存】的令牌，而插件只有一族**（用户实拍；cn 侧完全正常，所以这个问题只在国际版显形）。
  - **实测（本机两个真实 PAT，只读；唯一变动的是「怎么换令牌」）**：

    | 面 | 认哪一族 | 国际版 + 旧令牌 | 国际版 + 新令牌 |
    | --- | --- | --- | --- |
    | chat / cosy（模型对话） | `/algo/api/v3/user/jobToken` 的 `securityOauthToken` | ✅ got-delta | ❌ 403 code 105 登录态失效 |
    | sash / 额度（积分、签到） | `/api/v1/jobToken/exchange` 的 `token` | ❌ 401 TOKEN_EXPIRE | ✅ 200 |

    国内版两族**等价**，所以这个差异在国内版永远看不出来 —— 这就是它反复查不出来的原因，也是「模型能用、只有积分读不到」这个奇怪组合的来源。
  - **更早的两处误判**（记在这里，因为它们塑造了排查路径）：先把 401 归因为「PAT 失效」，再归因为「国际版 sash 不开放该账号」。两者都被矩阵证伪 —— 截图显示网页端有 300 套餐 + 100 资源包积分，且换一枚新令牌后 sash 全线 200。
  - **修法**：`QoderSessionHolder` 增加 `sashToken` / `sashExpireAtMs`；`exchangeJobToken` 里**额外**换一次 `/api/v1/jobToken/exchange`，`jobTokenGet`（用量）与 `sashRequest`（签到/活动）的 Authorization 改用它，chat / cosy 那一族原样不动。
    - **必须在交换处 `await`**：放后台的话，紧接着的第一次读积分会带着空令牌去，拿到一个**与「真被拒」无法区分**的 401（实测：第一次 401、第二次 200）。这个坑本轮真实踩到了。
    - **body 键是 `personal_token`**：`personal_access_token`（eghrhegpe/dsh-connect-qoder 用的拼写）在当前上游回 **400 BadRequest**，两个区域都是。
    - **sash 交换失败不抛**：只让「积分读不到」，不让 chat / 对话跟着挂 —— 那是两条独立链路。
  - **测试**：新增 `tests/live-intl-session.spec.ts`（**打真实上游，默认跳过**，`QODER_LIVE_TESTS=1` 打开）钉住 7 条事实：两区域各自的积分、签到、对话，外加两个区域不共用 baseUrl。改动前它精确失败在「global 积分 / 签到」两条而 chat 两条仍绿；改动后 7 条全绿。
  - 另有 6 条桩测固定契约（两族都换、sash 用新令牌、失败不抛、body 键、以及**第一发就带对令牌**的竞态守卫），全部做过变异测试：回落旧令牌 / 换回 fire-and-forget 各自被接住。
  - 参考实现：Liki4/qodercli2api、shuishuipingan/qoder2api-hub、eghrhegpe/dsh-connect-qoder（三者都走 `/api/v1/jobToken/exchange`）。

- **peer 范围静默排除整条 `0.2.0-rc.*` 宿主线 —— 从 npm 装到当前 DSH 就会 `ERESOLVE`。**
  - **现象**：13 条 `@deepseek-ai/dsh-*` peer 写的是 `>=0.1.7-rc.1 <0.3.0-0`。这条范围**看着很宽，却匹配不到任何 `0.2.0-rc.*`**：node-semver 只有当范围里*某个*比较符与该版本的 `major.minor.patch` 元组完全一致、且自身也带预发布标签时，才放行预发布版本；`0.1.7-rc.1` 与 `0.3.0-0` 都不落在 `0.2.0` 元组上，于是 `0.2.0-rc.1` / `0.2.0-rc.2` 被**静默**排除。而 `@deepseek-ai/dsh` 的 `latest` **正是 `0.2.0-rc.2`** —— 从 npm 安装本插件的用户会撞上 `ERESOLVE`，还得自己手工绕过。
  - **为什么本地与 CI 全绿**：本机 profile 是 `link:` 安装，**完全不走 peer 解析**；而 `tests/dsh-line.spec.ts` 当时自带一个只认「两个比较符」的简易实现，它对预发布版本的判断与 node-semver **相反** —— 一条**假绿**测试，而且它断言的恰好是错误的那一侧（用例写着「0.2.x 线仍放行」，实际并不放行）。
  - **修法**：范围改为**两条分支** `>=0.1.7-rc.1 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0`（peer 与 dev 共 26 处一并改），`tests/dsh-line.spec.ts` 的比较器换成**真实的 node-semver**（`semver` 提为显式 devDependency —— 此前只作为传递依赖存在），删掉已无用的手写比较器；新增两条用例：已发布的 `0.2.0-rc.*` 必须被放行（回归）、有意排除的 `0.1.7-alpha.*` / `0.2.1-alpha.*` / `0.3.0-rc.1` 仍须被挡在外；并让 peer 与 dev 的口径一致。两条 README 的「受支持的宿主范围」表与 `RELEASING.md` 的发布核对清单同步更新。
  - **依据**：`awesome-dsh-plugin/awesome-dsh-plugin` 的 `contributing.md`「A peer range without an explicit prerelease branch silently excludes every prerelease build of the harness」。
- **思考强度「有时候有、有时候没有」—— 目录只在启动时拉一次，而保存的快照全是空档位**（用户实拍）。
  - **实测本机状态**（只读检查，未发起任何领取）：保存的 `regions.cn.lastCatalog` 里 **14 个模型的 `thinkingEfforts` 全部是 `[]`**，`defaultThinkingEffort` 一个都没有；而同一时刻上游实时目录里 **9 个模型明确声明了档位**（`qmodel_38max` / `qfmodel` → `low, medium, xhigh` 默认 medium；`dmodel` → `high, max` 默认 max；`dfmodel` / `gmodel` / `kmodel` / `gfmodel` / `gm51model` / `kmodel_latest` 各自不同）。`[]` 是**旧 schema 把档位字段剥掉时**留下的产物 —— 也就是说这份快照根本不是「上游没声明」，而是「它来自一个没拉到档位的时刻」。
  - **为什么是间歇性的**：`fullCatalog`（唯一带档位的目录）是**纯内存**的，而目录只在**启动时**拉一次。于是：点一次「从 Qoder 刷新」→ 实时目录装上 → 档位出现；重启 / 重新读取 → 实时目录空 → 退回那份全空快照 → 档位消失。「有没有点过刷新、进程活了多久」就是「有时候有、有时候没有」的全部来源。
  - **修法**：`displayModels`（卡片 `/usage` 路由读的就是它）读到「本进程还没成功拉过实时目录 + 手上的目录一个档位都没声明」时，触发一次后台重拉（fire-and-forget + 在飞标记去重，**不阻塞**这次读 —— `displayModels` 是被 `/usage` 路由同步调用的读路径，在里面 await 网络会让每次读卡片都等一次上游），**继续服务手上的目录**：模型不能从卡片上消失，缺的只是档位，拉回来之后的下一次读取自然补上。「本进程还没拉到实时目录」这个守卫不可省 —— 上游**真的**没声明档位是合法状态，去掉守卫后每 60 秒一次的卡片轮询都会拖一发模型列表请求，永不停。
  - **返工记录（第一版修复是死代码，险些带着假说明发布）**：第一版把重拉挂在 `displayModels` 的「运行时目录为空」分支上，三层缺陷叠加，使它在生产路径上**一次都不会执行**：
    1. 调用处 `if (!serve.models) return serve.fallback` —— `serve.models` 恒为数组，而 `[]` 是 truthy，这个分支永远不走；
    2. `serveSavedCatalog` 的 stale 路径返回 `fallback: []`，兜底目录从未被填上（与它自己的文档注释「返回 `{ models: [], fallback: [...] }`」直接矛盾）；
    3. 根因：运行时目录 `catalog` 构造时就种了 9 个静态兜底模型、`set()` 拒绝空数组，所以 `live.length > 0` 恒成立、永远提前返回 —— 挂在它后面的整个 lastCatalog 分支（`serveSavedCatalog` 唯一的调用处）不可达。
    当时的集成测试只断言 `ctx.llm.listModels`（适配器路径，读运行时目录），恰好绕开 `displayModels` —— 于是测试全绿、修复无效，CHANGELOG 差一点写下一个没发生的修复。这轮把空档位判据挪到**真正被服务的 live 列表**上、删掉不可达分支，并把测试改成走**真实的 `/usage` 路由**（假 webServer 捕获路由，见「签到积分不刷新」那条）。
  - **判据用 `length > 0` 而不是「字段在不在」**：旧 schema 留下的是**空数组**而不是缺席，所以「字段在」恰恰不能作为「声明过」的证据。两种「没有档位」（旧快照的空数组 vs 上游真的没声明）从数据上分不开，**要不要重拉由目录来源决定**（见修法里的守卫）；判据与动作继续打包在 `serveSavedCatalog(models, onStale)` 里 —— 拆开接线时，把调用处变异掉单测照样全绿。
  - **测试**（`tests/index.spec.ts`，+2 走真实 `/usage` 路由）：一条钉**自愈** —— 启动拉取失败后，第一次读卡片**继续服务旧快照里的模型**（模型不消失、无档位），同时触发一次重拉；落地后下一次读，档位来自实时目录。另一条钉**守卫** —— 上游诚实声明「无档位」时，实时目录落地后的卡片轮询不再打模型列表端点。三组变异测试：删掉触发（复现原死代码）/ 去掉守卫（读路径变打网络循环）/ 恢复 `models: []` 的旧契约 —— 各自被对应测试接住。
- **每日签到：领取之后不说「还有多久刷新」—— 而「已领取」这句话离开它是残缺的**（用户实拍官网页面上的 `09:22:23` 倒计时，并指出领取成功要判断是否有倒计时）。
  - **先去看接口，而不是猜**：用本地那个能用的 PAT 只读拉了一次 `GET /sash/api/v1/me/campaigns`（**没有领取任何东西**）。实测活动行是：
    ```json
    { "campaignId": "01a12102-…", "campaignKey": "act-20261009-057",
      "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED",
      "startAt": 1791770400, "endAt": 1791856740,
      "benefit": { "kind": "CREDITS", "amount": 100,
                  "validity": { "mode": "RELATIVE_DAYS", "days": 30 } } }
    ```
    换算（UTC+8）：`startAt` = 当天 **10:00:00**，`endAt` = 次日 **09:59:00** —— 与活动文案「每日 10:00（UTC+8）刷新」一致。所以页面上那个 `09:22:23` 就是 **`endAt − 当前时间`**。
  - **领取不是「今天到此为止」**：领取只是把这一窗从 `CLAIMABLE` 变成 `CLAIMED`，而下一窗什么时候开，上游早就写在 `endAt` 里了。此前插件只回一个 `already-claimed` / `claimed`，UI 只能说「已签到」—— 用户会以为要等到第二天零点。
  - **修法**（`endAt` 是 epoch **秒**，转毫秒只发生在它变成时间戳的那一刻；解析层保持秒，否则 `startAt`/`endAt` 单位会不一致）：
    - `QoderCampaign` 解析 `startAt` / `endAt`；`QoderCheckinOutcome` 在 `claimed` **与 `already-claimed` 两条路**（含 409 幂等）都带上 `nextRefreshAtMs`；
    - 宿主快照从 `Record<id, boolean>` 升级为 `{ claimed, refreshAtMs? }`，写进成员行的 `checkinRefreshAtMs`；
    - 客户端新增 `countdownText()`（`HH:MM:SS`，秒级向上取整，小时不封顶），由既有的 20 秒 `nowMs` 心跳重绘；样式用等宽数字（tabular-nums），否则位数一变整格宽度就抖、表格列跟着晃。
    - **上游没给 `endAt` 就不显示倒计时**，绝不用本地零点或「上次 +24h」编一个。
  - **顺带修掉一个真缺陷**：客户端最初把「已领取但上游没给时间」与「倒计时走完了」都表达成 `countdownText(...) === ''`，于是前者被渲染成「现在可以领取」—— 把「不知道下一窗什么时候开」说成了「已经可以领了」。改成四态（`off` / `unknown` / `ticking` / `due`）。测试接住了它：先写成两态时那条断言立刻失败。
  - 测试 +17（`tests/checkin-countdown.spec.ts` 新建 7 条、`tests/upstream.spec.ts` +5、`tests/pool-render.spec.tsx` +5），**用实测值锚定**（`endAt = 1791856740`，倒计时断言精确到 `09:22:23`）。四组变异测试：`already-claimed` 丢掉刷新时刻 / `unknown` 塌成 `due` / 宿主快照丢掉 `refreshAtMs` / 上游缺字段时仍然编一个 —— 各自被对应测试接住。
  - **本条的单元格文案在 0.4.1 被推翻**（「已签到 · 倒计时」并排自相矛盾）—— 见 0.4.1 的第一条。发布说明里那句「已签到的行显示『已签到 · HH:MM:SS 后刷新』」随之改正。

### Changed

- **回复流不再注入「将由账号 … 回答本次问题」。** 账号池成员接管请求时，此前会在回复正文最前面带一行这个提示（`poolServingNotice`，账号标识打码）。宿主侧不再提供该注入点，回复正文从模型自己的输出开始；shim 的注入能力仍保留，只是本插件不再用它。

## 0.3.0 (2026-10-12)

三条主线：**每日签到真的接通了**（我早期把它判成「Qoder 没有签到」，那是没实测就下的结论）、**与 dsh-connect-workbuddy 的逐模块对等审计**，以及收尾的**国际版（global）对等审计**（结论见 [`docs/audit/AUDIT-REPORT.md`](docs/audit/AUDIT-REPORT.md)）。对等审计的每条都以 workbuddy 的已验证行为为基准，并对照 pi-ai / DSH 宿主源码逐条核实。

国际版审计最重要的结论**不是**国际版缺代码：一是它此前从未被执行过（`~/.dsh` 下没有 `.qoder-accounts.global.json` / `.qoder-pool.global.json`，CLI `doctor` 实测 `global: vault=0`），二是审计顺带挖出两个**双区域都受影响**的功能性 bug（账号转移的导出/导入往返、CLI 子命令分发）——见下面的 Fixed。

### Features

本版新增的用户可见能力（详细经过记在下面的 Fixed / Changed 里）：

- **每日签到（CN + 国际版）** —— 「一键签到所有账号」+ 池表格的「签到」列；先查状态再领取（幂等），国际版按区域读取本机 Qoder 客户端的 UMID 设备身份组件产出风控头，取不到时如实报错。
- **思考强度可选且真的发得出去** —— 卡片按模型只列上游声明过的档位，选定即保存、每个请求带上（顶层 `reasoningEffort` + `parameters.reasoning_effort` 双写，与官方客户端一致）。
- **「在对话框显示积分」开关移进账号池标题行**，少占一整行。

### Fixed

- **国际版 tab「还是缺少账号池这些功能」—— 同一处区域分发上的两个独立缺陷**（用户实拍，接在「国际版看起来就是空的」那轮之后）。
  - **为什么「cn 一切正常」查不出来**：两个缺陷都只伤**非 cn** 的那个 tab，cn 自己完全不受影响。上一轮修的是「signed-out 时账号池不渲染」（`renderPool` 读 `signIn`），那个确实修好了；但只要 usage 文档**根本没落地**，卡片仍然什么都不渲染 —— 症状看起来一样，根因完全不同。
  - **缺陷 1：挂载时只读 `cn`。** `useEffect` 里只有 `void refresh('cn')`，另一个区域要等 **60 秒**轮询才第一次有数据。切到国际版就是一片空白（连账号池与「添加 PAT」一起），要自己等一分钟才长出来。现在挂载即读**两个**区域，并在切 tab 时若该区域还没读过就立刻补读一次（不依赖轮询时机）。
  - **缺陷 2：两个区域共用一个 `AbortController`。** `refresh()` 第一行 `controllers.current?.abort()` 取消的是「上一次发起的那个请求」，而那个请求可能属于**另一个区域**：轮询里 `for (const region of QODER_REGIONS) void refresh(region)` 背靠背连发两次，cn 的请求刚建好就被 global 那次取消（反之亦然）。**同一时刻只有一个区域的 usage 能落地。** 现在每区域一个 controller：取消只用于「同区域连续刷新只认最后一次」，跨区域互不干涉。
  - **测试**（`tests/card-render.spec.tsx`，+4 条）：用**同一份 signed-in 文档**渲染两个 tab 并逐项比对池/成员行/模型行/签到/测试/添加 PAT/导出导入/签到列；挂载时两个区域都被读到；**推进 60 秒轮询断言两区刷新互不取消**；两 tab 渲染清单完全相等。
  - 每条都做过变异测试：还原「只读 cn」/「共享 controller」/「`renderPool` 读 `signIn`」各自被对应测试接住。其中共享 controller 那条**只在轮询路径上可测** —— 挂载读修好之后它被掩盖了，第一版挂在挂载路径上，变异后仍然通过（说明那条断言是空的），已改成推进轮询后配对「被取消的请求之后是否还有同区域的请求」。
- **领了签到积分，卡片上的积分不刷新**（用户实拍：「我领了积分，但是积分没刷新」）。
  - **根因**：`runCheckinBatch` 收尾只 `delete usageCache[region]` —— **作废**了缓存，却没有任何人去重新填充它。于是积分停在签到前的数字，直到某个**与签到无关**的动作（改配置 / 点「从 Qoder 刷新」）顺带触发 `refreshUsage`。等于把「读到新值」推给了一个不会到来的下一次无关动作。
  - **修法**：签到后直接 `await refreshUsage(region)`。
  - **必须 await 而不是 fire-and-forget**：客户端收到签到响应后**立刻**调 `onChanged()` 重新读一次 usage（`AccountPool.runCheckinBatch` 末尾）。宿主若不等待，这次重读会发生在上游返回之前、读到刚被作废的空缓存 —— 那是竞态，不是修复。
  - 测试（`tests/index.spec.ts`）挂一个假 `webServer` 把路由抓下来，**真的调那条 checkin 路由**，断言签到之后 usage **被再读了一次**。断言基线取在挂载期的读取**全部落定之后** —— 第一版取早了，一次迟到的挂载期读取混进来，断言就假通过了（这正是它一度在 bug 还在时仍然通过的原因）。变异测试确认：换回 `delete usageCache` 立刻失败。
- **账号池标题行整体对齐 workbuddy**（用户要求：「这部分区域的样式整体对齐 workbuddy」）：做了一次机械对比，而不是逐处猜。结论是**样式层已经高度一致**——表格 21 条同名规则逐字相同、DOM 类名顺序也完全一致；账号工具框与设置区 9 条同样一致。本轮真正修掉的是标题行：
  - `.dsm-qoder-pool-head`：`gap` 12px → **10px**、去掉 `justify-content:space-between`（workbuddy 同规）。
  - 积分开关改为 workbuddy 的 `pool-head-extra` 形态：**紧挨标题**的直接子项，而不是我上一轮自创的 `.dsm-qoder-pool-head-right` 分组（那多包一层、还改了换行与间距行为）。那个自创类已删除。
  - 「当前使用」芯片改用 `margin-left:auto` 靠右（workbuddy 同规）：开关文案长度随区域变化时芯片不再左右晃。
  - `.dsm-qoder-credits-switch`：`display:flex` → `inline-flex`（它现在嵌在标题行里）。
  - 保留三处**有意保留**的差异（都是前几轮用户明确要求的，不回退）：账号池 `padding-top:8px`（要求收紧空档）、保存行去掉 `flex-wrap`、按钮组去掉 `flex:none`（要求两条保存行样式统一）。
- **tab 栏不再顶着上面的分隔线**（用户实拍：「国际版 国内版 不要顶着上面这条线，最基本的统一样式」）：`.dsm-plugin-card-body` 这条家族共享规则只有 `padding:0 0 8px`——**上内边距为 0**，所以正文的**首个元素必须自带顶部间距**。workbuddy 的 tab 栏正是被 `.dsm-workbuddy-usage`（`padding:16px 0 4px`）托住的；qoder 这边在上一轮删掉积分块时，把那层容器一并拆掉了，于是 tab 栏直接贴在分隔线上。现在用同一条规则 `.dsm-qoder-usage`（本就存在）恢复同样的 16px 呼吸空间——不是新加特例，是恢复与同族插件一致的结构。
- **「在对话框显示积分」开关移到账号池标题行**（用户要求：「放到账号池这个标题旁边吧，节约点空间」）：它此前在卡片正文里独占一行，约 30px 高却只有一个勾选框。现在渲染在 `pool-head` 右端、排在「当前使用」芯片**之前**（可操作的控件在状态之前）。卡片仍然持有写权限与区域，所以由卡片构造这个节点、经 `creditsSwitch` prop 交给账号池安置（与 `accountTools` 同规）；原来那个只为它存在的 `.dsm-qoder-credits-row` 外壳随之删除。语义也更顺：它决定「积分怎么显示」，而积分明细正是下面那张表的内容。
- **「在对话框显示积分」那行上移**（用户实拍：「红色的部分向上靠拢一些」）：额度明细移除后，那一行仍套着为明细列表准备的 `.dsm-qoder-usage`（`padding:16px 0 4px` + `gap:16px`），连同 `.dsm-qoder-credits-switch` 自己的 `margin:8px 0 2px` 与账号池区块的 `padding-top:16px`，三处叠加出约 19px 的空档。现在：这一行换成自己的外壳 `.dsm-qoder-credits-row`（`padding:12px 0 2px`）、开关的 margin 归零（间距由外壳统一负责）、账号池的 `padding-top` 收到 8px。分隔线之上只剩约 11px。
- **国际版签到接通 —— 两个区域能力对齐**（用户要求：「国际版也照着国内版做」）。
  - 盘点后发现**只有一处**不对称：`checkinSupported: reg => reg === 'cn'`。网关、模型目录、对话、PAT、账号池、积分都已按 region 对称（区域配置表里两边字段一致）。
  - 国际版的差别只在认证细节：它的签到活动**只下发给带设备风控头的请求**（`Cosy-MachineToken` / `Cosy-MachineCode` / `Cosy-MachineType`）。国内版实测不需要，多发反而是噪音。
  - 新增 `src/device-risk.ts`：按区域读取本机 Qoder 客户端自带的 UMID 组件（`umid/runtime-info`），它直接产出这三件套。国内版优先 `Qoder CN.app`、国际版优先 `Qoder.app` —— 设备身份与区域绑定，用错一侧会被风控拒绝。结果按 50 分钟进程内缓存（参考实现同量级），避免每次签到都 fork 进程。
  - 组件不存在时**如实报错**，而不是静默降级成「无头请求」—— 那样只会拿到空活动列表，用户看到「今天没有活动」而真实原因是取不到设备身份。可用 `QODER_UMID_PATH` 显式指定路径（该值是**优先**而非唯一来源：指错了路只要本机还有客户端就继续用）。
  - `QoderRegionConfig` 新增 `requiresDeviceRisk`，由 `sashRequest` 按区域条件附加请求头。
  - 实测：本机两个 App 都在，各自命中自己的组件；CN 请求只带 `cosy-clienttype`，国际版带齐三个 machine 头。
- **「添加 PAT」那一行补上就地说明**（用户要求：「添加 PAT 后添加文本（登录网页 qoder.com / qoder.com.cn → 服务集成 → 新建令牌），国际国内域名不同」）：
  - 文案放在**入口这一行**（标签与按钮之间），而不是只留在对话框里的文档链接 —— 用户看到这个入口的瞬间，最想知道的就是「去哪弄一个令牌」。
  - 域名逐区域不同，由 `region` 决定：国内 `qoder.com.cn`、国际 `qoder.com`。
  - 新增 `QODER_CONSOLE_DOMAIN` 常量，与已有的 `knownQoderRegionOfDomain`（域名反查区域）**放在一起维护** —— 同一组事实的两种读法，分开放就很容易漂移而不被发现。
  - 注意国际版用的是**控制台域名 `qoder.com`**，不是网关用的 `qoder.sh`：用户在浏览器里看到的是前者。
  - `.dsm-qoder-bar-hint` 用 `flex:1 1 auto` + `min-width:0`：吃掉标签与按钮之间的余量，窄卡片上折行到按钮上方，而不是把按钮挤出可视区。
  - 新增 4 条渲染测试（两种区域的域名、同一行的顺序、域名表本身），防止两处域名漂移。
- **修掉 400 `developer is not one of [...]`：必须显式关闭 pi-ai 的 developer 角色**（用户实拍上游 400）。
  - pi-ai 的 openai-completions API 这样决定系统提示词的角色：
    ```js
    const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole
    const role = useDeveloperRole ? 'developer' : 'system'
    ```
    Qoder 上游只接受 `system` / `assistant` / `user` / `tool` / `function`，收到 `developer` 直接 400。
  - **这个分支只在同时满足「reasoning 为真」与「compat 未显式关闭」时才走。** 我让档位真正生效后 `reasoning` 从 false 变 true，把第一个条件打开了；`supportsDeveloperRole` 的默认值是**开启**，于是第二个条件也成立 —— 两个改动叠加才暴露出来（这也是为什么它此前一直没出现）。
  - 修法：`compat` 显式写 `supportsDeveloperRole: false`，系统提示词恒以 `system` 角色发出。`supportsReasoningEffort` 保持不变（档位功能不受影响）。
  - 新增两条回归测试：从真实适配器取出 pi-ai provider，断言推理模型 `reasoning === true` **且** `compat.supportsDeveloperRole === false`；非推理模型同样关闭。
- **「Provider is not configured」的真正原因：`auth.apiKey.resolve` 返回了 `undefined`**（用户实拍，并再次提示我去查参考项目 —— 两次都对）。
  - **错误文案的真实来源**（不在我们的代码里，在 vendored 的 pi-ai 里）：
    ```js
    // pi-ai dist/models.js → applyAuth
    const resolution = await this.getAuth(model, {...})
    if (!resolution) throw new ModelsError('auth',
      `Provider is not configured: ${model.provider}`)   // ← 用户看到的那句
    ```
    而 `resolve.js` 在「没有存储凭据」时会退回 ambient，调用 provider 自己的 `auth.apiKey.resolve({ credential: undefined })`。
  - **我们的 resolver 无条件 `return undefined`** → `getAuth` 返回 undefined → 直接抛错。**问题不在档位、不在目录、不在区域注册，而在这一行的返回值形状。**
  - **正确形状**（`dsh-llm-pi-ai` 自己的 `harnessApiKeyAuth()` 就是这样）：
    ```js
    resolve: ({ credential }) => Promise.resolve({
      auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
      source: name,
    })
    ```
    关键：**即使没有 key 也必须返回一个对象**（`auth: {}`），把「是否真的需要 key」交给协议层判断；返回 `undefined` 才等于宣称「这个 provider 根本没配置」。
  - 本 route 的真实凭据不从这里来 —— 它靠 shim 的进程内 secret 认证，secret 由 `PiAiAdapter` 的 `resolveApiKey` 逐请求提供。所以 resolver 报「没有 key」是诚实的、也是正确的，只要它是一个对象。
  - 测试：新增两条，**走真实的 `createQoderAdapter`**（从 `piProvider.auth.apiKey.resolve` 取 resolver 调用，而不是复制一份实现），并做了**变异验证**：退回旧实现后两条立刻失败。
  - 前面两轮（档位 map 全 null 会作废整条路由）也是真实缺陷、也修掉了 —— 但它不是「not configured」的原因；我当时把两个不同层级的失败混为一谈了。
- **修掉「Provider is not configured: qoder」的真正根因**（用户实拍，并提示「之前参考的项目都是怎么做的」——对照 trae/workbuddy 的 `modelErrors`/`defaultContextWindow` 注释后，去 DSH 内核源码里找到了硬校验）。
  - **根因**：DSH 内核（`dsh-llm`）对适配器返回的推理元数据是**硬校验**的：
    ```js
    if (reasoning.efforts.length === 0)
      throw new LlmError('adapter returned invalid reasoning metadata …', 'INVALID_MODEL_REASONING')
    ```
    而 `dsh-llm-pi-ai` 的 `getSupportedThinkingLevels()` 把 `thinkingLevelMap[level] === null` 判为「不支持」，并把 `xhigh`/`max` 额外要求 `!== undefined`。于是**「全 null 的 map」解析出零个档位**。
  - 我此前在「上游没声明档位」时**仍然**返回全 null 的 map。上游的 `auto` 与 `qmodel_latest` 恰恰不声明 `thinking_config.efforts` → 零档位 → **整条 provider 路由被 DSH 作废**。用户看到的是「Provider is not configured: qoder」，而不是「这个模型没有档位」。
  - **修法**：`qoderThinkingLevelMap` 在「没有一个真实档位」时返回 `undefined`（而不是全 null 的 map）。DSH 读作「该模型不提供推理」→ 不渲染档位选择器（正确的降级），路由保持健康。
  - 新增两条回归测试：一条复刻 DSH 的 `getSupportedThinkingLevels()`，对九种上游形状断言「map 为 undefined，或至少解析出一个真实档位」——即**任何模型都不会让整条路由作废**。
  - 教训：`provider 整条路由」与「单模型能力」是两个层级。单模型的元数据写错，代价是整条 provider 消失，所以「降级」必须比「声明」更保守。
- **思考档位改为只映射上游**真正声明的档位**（用户要求：「学习 workbuddy 一样，显示可以选择的档位，然后保存到 DSH 自己的模型设置里」）。
  - 对照 workbuddy 发现两处关键差异：
    1. **workbuddy 的档位是「只显示」的纯文本**（`model.reasoning.supportedEfforts.join(' / ')`），它不保存用户选择；用户要的是**可选 + 存到 DSH**，所以保留 qoder 已有的可保存选择，只对齐它「显示哪些档位」的判断。
    2. **workbuddy 的 `workBuddyThinkingLevelMap` 只映射上游声明过的档位**（`supported.includes(level) ? level : null`）——DSH 的模型设置里因此只出现真实存在的档位。而 qoder 此前把六个档位**全部**置 `null`，只在 `off` 上有效：我当时的结论是「Qoder 的线是布尔 `is_reasoning`，表达不了半档」。**那个结论是错的** —— `thinking_config.enabled.efforts` 逐模型声明了真实档位，且不统一（Qwen 系 `xhigh/low/medium`，DeepSeek/GLM 系 `high/low/max`）。
  - 修法：`qoderThinkingLevelMap` 按声明映射**同名**档位，未声明的置 `null`；上游声明可关闭时额外给 `off`。上游没声明任何档位时仍给出 map（全 `null`），因为模型确实是推理模型 —— DSH 据此知道它支持推理，只是没有可选项。
  - 请求线不变：shim 侧仍按 `reasoning_effort` → 顶层 `reasoningEffort` + `parameters.reasoning_effort` 发送。
  - 实测效果：DSH 模型设置里 `Qwen3.8-Flash` 出现 low/medium/xhigh，`DeepSeek-V4-Pro` 出现 high/max（+可关闭）。
- **卡片改读实时目录，思考档位这才真正出得来**（用户实拍「还是没有思考强度」——即使刚点过「从 Qoder 刷新」）。
  - 上一个提交只修了 schema（字段不再被剥掉），但**没有解决「卡片读的是哪份目录」**。
  - `displayModels()` 的优先序是「保存的 lastCatalog → 实时目录 → 静态兜底」，而卡片读的就是它。`lastCatalog` 是**上一次保存时的快照**：上游后来声明的档位（`thinking_config`）只存在于实时目录里，快照里没有 → 卡片永远不渲染档位选择器。更糟的是旧 schema 已把档位从快照里剥掉，于是那份快照**永远不会**再有档位。
  - 修法：
    - 新增 `fullCatalog[region]` 保存**未过滤的实时目录**（`refreshRegionCatalog` 拉到的完整列表，含上游声明的档位）；
    - `displayModels()` 改为**实时目录优先**：反映上游当前声明了什么，保存快照退居兜底。
  - 静态兜底列表（`QODER_FALLBACK_MODELS`）**没有**档位是刻意的 —— 它是猜的，不该编造上游没声明的档位名；此时卡片不渲染档位选择器，等真实目录到达后自动出现。
  - 验证：实测上游确实返回档位（`qfmodel` → `['xhigh','low','medium']`，默认 `medium`；`dmodel` → `['high','max']`），且 `parseUpstreamModel` 解析正确。
- **修掉「拉取的思考模式又不见了」：保存的模型目录丢字段**（用户实拍）。
  - 客户端**确实**把 `thinkingEfforts` / `defaultThinkingEffort` / `contextTiers` 写进了 payload（`buildModelsPayload` 里三个字段都在）。
  - 但宿主的 `modelConfig` **没有声明**它们。schemastery 解析保存的 `lastCatalog` 时会**静默剥掉未声明的字段** —— 于是磁盘上根本没有这些字段，重启后 `thinkingEfforts === undefined`，卡片按「缺失就不渲染」的规则把整个档位选择器隐藏掉。
  - `modelFromStored` 同样没有把这两项读回来。
  - 修法：schema 里补上三个字段（`required(false)`，保持「缺失即不干预」），`modelFromStored` 把它们读回。
  - 新增回归测试：用真实的 region schema 验证「保存 → 读回」三个字段都不丢。
  - **教训**：这是同一类 bug 的第三例——「客户端写了，但读回时被丢弃」。写字段与读字段必须成对，且要有 round-trip 测试兜底。
- **修掉「保存模型保存不了」：`__save` 路由没有按区域合并**（用户报告「现在保存模型保存不了是什么原因」）。
  - **客户端**只发它要写的那**一个**区域（`{ [region]: value }`）——它读不到另一侧的当前状态，无法自己做合并。
  - **宿主路由此前**把这个 body **整个**写进 settings（`deps.settings.set(field, body.value)`），于是**保存 cn 模型的这一次操作会静默删掉整个 global 槽位**。
  - 后果已经发生：磁盘 `cordis.patch.yml` 里 `regions` 只剩 `cn`，`global` 的全部配置被抹平。用户看到的现象是「保存不了」，而实际被抹掉的是另一侧的一切。
  - 修法：宿主有 `settings.describe()` 能读到现值，所以合并放在服务端做（`mergeSlotWise`）：请求里出现的区域**整份替换**，未出现的区域**原样保留**；顶层其他字段一并保留。槽位内部的字段级合并仍由客户端的 `nextRegionModels` 负责（它从现槽位起 merge）。
  - 新增回归测试，并做了**变异验证**：把合并去掉，该测试立刻失败（防止写成永远绿的断言）。
- **修掉真正的根因：`<p>` 的浏览器默认边距**（用户实拍「还是没有改善」）。前两次我都在调容器的 `gap`/`padding`，但那根本不是空白的来源 —— `.dsm-qoder-usage-hint` 渲染成 `<p>`，而**浏览器默认给 `p` 加 `margin:1em 0`（上下各 16px）**。本文件里其他每一条渲染成 `<p>` 的规则都写了 `margin:0`，**唯独它和 `.dsm-qoder-hint` 漏了**。于是那 16px 一直顶在那里，改容器当然「没有改善」。
  - 修法：两条规则都显式 `margin:0`，上下间距真正交还给容器控制（`usage-hint` 用 `padding:6px 0 6px 19px`）。
  - workbuddy 的同名规则有同样的遗漏（所以那边同样偏松），但此处按用户的判断修，不做「保持一致」。
  - 新增一条**机制性**测试：凡渲染成 `<p>` 的类，其规则必须含 `margin:`。这类 bug 靠看截图很难定位（浏览器默认样式不出现在任何 CSS 文件里），但可以机械拦住。
  - 同时修掉了测试辅助函数 `ruleOf` 的正则未转义类名里的点 —— 它此前会把 `.dsm-qoder-hint` 当成通配模式匹配，可能命中别的规则或匹配不到（假绿灯）。
- **「保存的模型」终于真的生效了（第 2 次修，这次找到了架构层面的根因）**（用户实拍：「模型的保存还是不对」）。我上一次只改 `deriveCatalog` 本身，那是治标。真正的根因是**两个模型视图被我混成了一层** —— workbuddy 分得很清楚：
  | 视图 | 数据源 | 是否按勾选过滤 | 谁在读 |
  |---|---|---|---|
  | 运行时目录 | `stacks[region].catalog` | **✅ 过滤** | 适配器 `getModels()` → **DSH 内置选择器** |
  | 卡片显示 | `displayModels()` | ❌ 不过滤 | 卡片的模型列表（要能勾上未勾的） |
  - 我此前把过滤放在 `registerModelDiscovery` 回调里，而 **DSH 的选择器读的是运行时目录**（经适配器 `getModels()`）—— 过滤根本没作用到用户看到的那份列表上。
  - 更要命的是 `applySelection()`：它在**每次配置写入时**跑，把完整目录重新装回运行时目录，直接覆盖掉刷新时的过滤结果。这就是「保存了还是一整份」的直接原因。
  - 现在：运行时目录在两处（刷新、配置写入）都按勾选过滤；卡片改走新增的 `displayModels()`（上次保存 → 实时目录 → 静态兜底，workbuddy 同规），所以未勾选的模型仍在卡片里可见、可勾。
- **DSH 选择器里的模型名补上倍率**（用户实拍：「模型没有倍数」）。卡片自己的列表早已把倍率画在名字旁，但适配器交给 DSH 的 `name` 是裸名字（`info.displayName`），于是同一个模型在两处显示不一致。现在与 workbuddy 同规，`toPiModel` 用 `modelDisplayName(info)`，选择器里显示成 `Qwen3.7-Max · x1.00`。纯显示改动：DSH 侧所有关联都按模型 id 做（选择器当前项、会话事件、agent 默认模型、请求线上的 `model`）。
- **接通每日签到（Qoder 确实有，此前是我误判为「没有」）**（用户指出「qoder有签到啊」——用户是对的）。我早期把 `checkinSupported` 写死为 false，注释还写着「网关没有签到端点」，那是**没实测就下的结论**：真实路径叫 `campaigns`（活动/权益），名字里没有「签到」二字，所以照着 checkin / signin / reward 猜必然 404（实测猜了 13 个）。以「我猜不到」论证「不存在」是方法论上的错误。
  - **协议**（社区参考实现 L0NE-6/Qoder-Daily，已在本机 CN 账号实测通过）：`GET {openapi}/sash/api/v1/me/campaigns?forceRefresh=true` 拉活动，`POST …/{campaignId}/claim` 领取；认证用与用量查询同一套 `jt-` 令牌，另需 `cosy-clienttype:10` 与 `user-agent:Qoder`。只有 `actionType=CLAIM_BENEFIT` 且 `claimStatus=CLAIMABLE` 的可领；重复领取（`CLAIMED` / 409 / `ALREADY_CLAIMED` / `replayed`）是幂等的「今日已领」，**不算失败**。实测你的账号返回 100 Credits / 30 天有效、状态 `CLAIMED`，与桌面端截图一致。
  - **`checkinSupported` 改为真实检测**：CN 为 true、国际版暂为 false（国际版同样有该活动，但需要本机 UMID 组件生成的设备风控头 `Cosy-MachineToken/MachineCode/MachineType`，未接通——字段保留，卡片按它隐藏整列）。
  - **UI 对齐 workbuddy**：账号池表格补回「签到」列（5 列 grid，规则本就存在）、批量动作里补回「一键签到所有账号」（primary、排最前，签到是唯一带来收益的动作），以及签到日志（逐账号三态：已领取 N Credits / 今天已领过 / 失败原因）。签到单元格是**三态**而不是两态——还没读到的账号说「未知」，不说「未签到」，否则会与用户刚亲眼看到的领取结果矛盾。
  - 签到会改变积分余额，因此批次结束后作废该区域的用量缓存，卡片不会继续显示领取前的数字。
- **卡片正文不再重复额度明细**（用户实拍：「红色框出来的部分，也不需要了，因为下面已经显示了」）：那行「附加额度 · 92 / 100 (12/31, 08:00 到期)」与账号池表格的「积分」列是同一组数字（连日期的分钟都一致），而池里是**逐账号**显示、粒度更细。成功状态下这一块不再渲染；`renderCredits` / `bucketLabel` / `formatDate` 三个随之失去引用的函数一并删除。
  - **但「读不到」的情况保留**：池行只能显示一个「—」，说不出原因，而 `creditsError`（凭据被拒 / 上游未响应）只存在于这份 usage 文档里——池行拿不到。所以现在只渲染失败文案，成功时什么都不显示。这一条同样有测试守着（做过变异验证：把失败文案也删掉，测试会失败）。
- **幽灵成员（凭据已删除的池成员）改为自动清理，不再提示用户**（用户实拍：「这个我之前移除了 2 个 PAT，你应该直接就清理掉，而不是再给我这么一个提示，而且我测试的时候这两个也会涵盖进去」）。三处一起改：
  - **客户端自愈**：`memberAccountIds` 里留有已删除凭据的 id 时，静默把名单收敛为实际存在的成员并写回（`pruningRef` 防重入；只在不脏、不忙、可写时动手——正在编辑草稿时偷改会覆盖用户的选择）。原先那句「有 N 个已保存的账号当前没有可用的 PAT……**再保存一次即可移除它们**」把插件的内部记账问题推给了用户：他删完 PAT 还得回来再点一次保存，而他本来就不想要这些账号。文案键 `poolGhostMembers` 与 `ghostMemberIds` helper 一并删除。
  - **测试路径不再遍历空壳**（用户指出的「测试的时候这两个也会涵盖进去」）：批量测试此前对 `memberAccountIds` **原样**建目标，于是每个幽灵 id 都被「测」一遍，只得到一条假的 `credential-rejected`（`no stored credential for this account`），白跑一轮还往池里写脏数据。现在先 `credentialFor` 过滤，只测真的有凭据的成员。
  - 残留的探针记录无害、也不再增长：failover 排名在 `credentialFor === undefined` 时就 `continue`，压根不会读到它们的探针；而新的批量测试不会再为幽灵 id 写记录。
- **账号行删掉「重新读取账号」与「移除」两个按钮**（用户实拍：「现在都不用授权文件了，哪里还有什么重新读取账号」）：
  - 「重新读取账号」做的事（重读快照 + 刷新）现在由账号池的测试/添加等操作自动完成，而「重新读取**授权文件**」这个语义本身早已过时——凭据走 PAT，不再依赖授权文件。用户说得对，这个按钮不该存在。
  - 「移除 · <账号>」与账号池表格的「移除」是**两个不同操作**（前者删保险库里的 PAT 记录，后者只把账号移出池成员），摆在一起只会让人分不清删的是哪一个。保留池里的那个；真要清理凭据用 `dsh-connect-qoder logout`。
  - 连带清理：`rescanAccounts` / `removeVaultAccount` 两个函数、`confirmRemoveId` state、三个只被它们用到的导入（`QODER_ACCOUNTS_PATH` / `QODER_ACCOUNTS_REFRESH_PATH` / `withQoderRouteAction`）、以及 8 个随之失去引用的文案键。账号选择器（多账号时手动指定）保留——它仍是账号池不可用时唯一的指定入口。
- **积分区不再把同一组数字说两遍**（用户实拍圈出要求清理）：顶部那行「92 Token 余额 · 最近积分包 12/31 过期」是**合计**，紧接着的「附加额度 · 92 / 100 (12/31 到期)」是同一组数字的**明细**（还带进度条）。合计行删除，只留明细；`usageToken` / `usageNearestExpiry` / `usageExpiringSoon` 三个随之失去引用的文案键一并清掉。「即将过期」的信息没有丢——账号池的每个账号行各自标 `pool-soon` / `pool-soon-plain`，那里的粒度更有用（哪个账号要过期）。顺带删掉 `renderCredits` 里一个算出来从没用过的 `pct` 变量。
- **积分（用量）查询一直 401 —— 用错了凭据**（用户实拍「没有积分」，并指出令牌明明是新的）。`fetchUsage` 打 `{openapi}/sash/api/v2/me/usage` 时带的是 **COSY 签名 bearer**（和聊天/模型列表同一套），而该端点不认它。实测三种凭据（2026-10，CN，同一张有效令牌）：

  | 凭据 | 结果 |
  | --- | --- |
  | 裸 PAT `pt-…` | 401 `TOKEN_EXPIRE / token is not active` |
  | COSY 签名 bearer | 401 `TOKEN_INVALID / invalid token` |
  | jobToken 的 `securityOauthToken`（`jt-…`） | **200 + 完整 `qoderUsage`** |

  `sash` 是**浏览器会话面**的接口：缺 Authorization 时它回 `UNAUTHORIZED / missing cookie header`，可见它本为 cookie 会话设计，既不收 PAT 也不认 cosy 协议头。`TOKEN_EXPIRE / token is not active` 这条消息误导性极强——它让人以为令牌失效，实际只是「这种凭据不适用于这个端点」。新增 `jobTokenGet()`（只换 Authorization，不带 cosy-* 头）专供 `sash` 系列取数；`fetchUsage` 改用它，PAT 直连降为兜底。测试锁住「带的必须是 jt- 令牌」。
  - 顺带修正 `docs/PAT.md`：原文据此把「积分读不到」写成了「令牌可能坏了」，已改为「令牌有效性不能只看单一端点」并附上面这张实测表。
- **限流（429）对宿主的 code 改写为 `quota_exceeded`**（HTTP 状态仍 429，上游消息原文透传）。原 `rate_limit_exceeded` 会被宿主归入 `RATE_LIMIT` **原地重试集**（默认 5 次指数退避），把重试预算烧在小时级冷却上——正是 workbuddy 用 `KIND_HOST_CODE` 修掉的同一个缺陷形状。
- **账号池接管通知改走首个内容帧**（原为 `choices: []` 的自定义 `notice` 帧）：pi-ai 的 chunk 循环对无 choice 的帧直接 `continue`，换号通知此前**用户完全不可见**。现在以引用块前缀写进 `choices[0].delta.content`。
- **换号重试前增加 2 秒可中断间隔**（对齐 workbuddy 的 `FAILOVER_ACCOUNT_GAP_MS`）：上游限流按请求体积触发，零间隔连环重试会让每个候选账号撞上同一堵墙。
- **策略层重复返回已试账号时 shim 侧自行刹车**（`tried` 检查不再只依赖策略层）。
- **客户端中途断开即中止上游请求**（`res` 的 close → AbortController），且断开前的失败不再记成账号池测量（断开不是账号的错；workbuddy 同规）。
- **补回 Origin 回环校验**（插件注释自称「Host / Origin / Content-Type 三重校验」，实际漏了 Origin）与**请求体 64MB 上限**（超界回 413，先排干上传再回写，避免 socket 被拆后客户端连状态码都看不到）；新增 `/healthz`。
- **`tool_choice:"none"` 落实为不带 tools**：cosy 模板没有 `tool_choice` 字段，其他形态无法透传，但「宿主禁止工具」必须生效（workbuddy `prepareChatBody` 同义）。
- **模型行的「免费」标记不再误用「总量」文案键**：新增 `row.modelFree`（免费 / Free）。
- **模型行的模型名恢复显示 `displayName`**：原误渲染为 `row.usageTotal` 文案（每行模型名都显示成「总量 / total」），与免费标记是同一个占位键漏改的两个实例。
- **区域 tab 栏恢复 workbuddy 同构的两层结构**：原实现把按钮与开关的嵌套写反，并把 `dsm-qoder-tab-dot`（7px 状态圆点的样式类）拼到了 tab **容器**上——圆点规则会把承载元素缩成 7px 圆，整条 tab 栏塌成两个被裁切的小药丸。现为 `tab-cell > button.tab + label.tab-switch`，激活态与停用态（`tab-active` / `tab-off`）落在按钮上，并补 `role="tablist"` / `role="tab"` / `aria-selected`。
- **设置页上卡片默认展开**：`open` 初始值由 `view === 'summary'` 改为 `view === 'page'`（与 workbuddy 同规）；此前 bundle 页打开时卡片是收起的。
- **卡片按钮字号归一到 13px**：「重新读取账号」（原带 `pool-small-btn`，12px）、「从 Qoder 刷新」（原误带行内探测类的 `model-probe`，11px）、账号行的删除与两步确认按钮（原 12px）全部去掉小尺寸覆盖类；`model-probe`（11px）只保留给模型行内的「测一测」，`pool-small-btn`（12px）只保留给账号池表格内的行按钮——与 workbuddy 的应用位置逐一对应。
- **卡片页脚恢复家族结构**：原 `<footer class="dsm-qoder-foot">` 的类**从未有对应 CSS 规则**——链接渲染成浏览器默认蓝、`margin-left:auto` 因无 flex 容器失效，两段文字挤作一团。现与 workbuddy 同构：仓库链接以 `usage-cheer`（灰色 13px + ★）放入保存行左侧，「放弃修改」按钮恒在（未改动时禁用，不再条件渲染），保存错误改为按存在条件渲染（原实现恒渲染一个空 `div`，平白吃掉一段垂直间距）。
- **删除页脚常驻的「如何创建 PAT？」提示行**（用户实拍要求）：workbuddy 的 `usage-hint` 只在有话要说时出现（需要重新登录 / 选择丢失），从不作常驻页脚。该指南入口移进「添加 PAT」弹窗——用户打开那个弹窗的时刻正是需要它的时刻；卡片页脚自此不再有任何常驻行。
- **账号选择器改为按需出现**（`accounts.length > 1` 才渲染）：零账号时该行只剩一个不可操作的下拉，选项文本是「自动 —— 设置 → 环境变量 → 保险库」，对没有账号的用户是一句无法解读的话；单账号时「自动」等于唯一候选，切换是空操作。两种情况都不再画这一行。
- **模型行的名称与倍率对齐 workbuddy 渲染**：倍率由本地化句子 `积分 0.79x` 改为 workbuddy 的 `(0.79x)` 括号形态（`toFixed(2)` 固定两位，免费模型显示 `(0.00x)`）；模型名下方那行模型 id 不再渲染（workbuddy 的行从不显示 id），id 降级为名称的 `title` 悬停提示。随之删除失去引用的 `row.modelRate` 文案键与 `.dsm-qoder-model-id` 样式。
- **账号池「目标测试模型」下拉显示模型名与倍率**（原直接渲染模型 **id**，如 `qmodel_latest`）：改为 workbuddy 的 `名字 (x0.79)` 形态。为此 `RenderSettings` 的 `catalog` 由 id 数组改为模型对象数组。
- **账号池目录补回真实倍率**：路由层投影（`poolModelOf` 与 pool 视图的 `targetModels`）此前只对免费模型写 `creditMultiplier: 0`，**付费模型一个数字都没有**——选项读起来像免费。现改为透传上游 `priceFactor`；未声明倍率的仍保持缺省（不编造数字）。
- **区块顺序对齐 workbuddy**：账号池管理（账号池表格与设置）从模型列表**之后**移到**之前**（账号行 → 账号池 → 模型列表）。原顺序要求用户先翻过整张模型表才够得到池设置；workbuddy 的 `AccountPool` 就在模型区之前。
- **模型行体验对齐 workbuddy（四项）**：
  - 测试按钮从「每行独占一个 details 行」挪进**头部行右端**，说明文字只作按钮悬停提示（`title`）——此前未测试的每个模型行都铺着一整段相同的「对该模型发一条真实聊天请求……」说明；探测结果行改为仅在有结果时渲染（workbuddy 的 `result === undefined → null` 同规），并改为单段 `<p role="status">`（结果 + ` · 原因`，全文进 `title`）。
  - 删除「推理强度：」空尾巴标签：它来自 `t('row.modelReasoning', { efforts: '' }).replace(': ', '')` 的 hack——全角冒号根本匹配不到 ASCII `': '`，且 Qoder 上游不给 effort 列表，这个标签永远只渲染半个句子。随之删除失去引用的 `row.modelReasoning` 文案键。
  - 上下文档位选择器从 details 行挪进头部行（workbuddy 的 context-budget fieldset 同位）。
  - 列表尾部补上能力声明注记（`row.modelCapabilityPending`，「仅展示 Qoder 接口明确公布的模型能力。」——locale 键早已存在但此前从未渲染），并补 `.dsm-qoder-model-capability-note` 样式。
- **倍率为 1 的模型也显示 `(1.00x)`**：`webModelOf` 曾把 `priceFactor === 1` 当「无倍率」丢掉（兜底目录全部中招，卡片上一个倍率都看不到）；`modelDisplayName` 同样在 1 时省略后缀。两边都改为一律透传/照写，与 workbuddy 卡片、DSH 选择器及 Qoder 自家菜单（`· x1.0`）一致。


- **恢复建议从未实测，却在断言（用户实拍）**：卡片上的「本区域还有其他账号，但刚才都没能通过上游校验」是**猜**出来的——`web-status` 写的是 `reloginRequired: accounts.length <= 1`，只要存在第二条记录就宣称「可以切换一个试试」，而那条记录可能同样过期（用户实拍：同一个邮箱的两条记录，一条已过期，卡片却让它去切换）。`credential-recovery.ts` 里早就写好了一个会**逐个探测**其余账号、只把真正答过话的那个承诺给用户的实现，但**从未被任何地方调用**。现在接线为 `credentialRecovery` dep：切换建议基于实测，「其他账号也没通过」也基于实测。
- **「移除账号」按钮渲染了整句说明**：按钮文字用的是 `row.accountRemoveHint`——那是一整句解释（「把这个账号的 PAT 从插件保险库移除。设置中的 PAT、环境变量 PAT 与 Qoder 官网本身的登录不受影响。」），按钮因此撑成一大块；两个同名账号的按钮挤在一起，完全读不出是干什么的。新增短标签 `row.accountRemoveShort`（移除 / Remove），整句说明留在 `title` 上继续可达。
- **同名账号无法区分**：同一个邮箱重新签发 PAT 会得到**两条**记录（id 由 PAT 推导，是不同的），两条都叫同一个名字——下拉与删除按钮上只显示名字，「移除哪一个」「当前在用哪一个」都看不出答案。现在重名时补 id 尾号（末 4 位），正在计费的那条带「使用中」标记；下拉与删除按钮共用同一套标签，避免「在下拉里选中一个、到按钮上又认不出」。
- **添加 PAT 时填的显示名没有任何地方使用**（用户实拍指出）：客户端一直会把这个名字随请求发给宿主，但路由里的 `add` 分支**完全忽略它**——它只调 `validateForRegion` 拿上游解析出的名字并写库，于是用户给 PAT 起的名字（「公司号」「备用」）填了等于没填，列表里显示的永远是上游那个标识。现在**用户填的名字优先**，没填才退回上游解析出的名字（两者都空则写空串，卡片渲染「未命名」）；顺带让命中已存在 id 的添加也按本次输入改名。成功提示「已添加「X»。在上方选中它即可使用」也随之改掉——账号选择器只在多个账号时才出现，单账号下那句话没有对应控件。
  - **why it existed**: workbuddy 走 OAuth，昵称由上游直接给出，没有「手动填名字」这一步，所以对等审计时这一条没有可对照的实现——它是 qoder 特有的 PAT 模式才需要的功能，只做了前端、没接到存储。
- **凭据被上游拒时静默成空白（「积分看不到」的真正原因）**：实测过期 PAT 打上游返回 `401 TOKEN_EXPIRE / token is not active`，而 `fetchUsage` 把**所有**错误一律吞成 `undefined`，宿主于是走成功分支写入 `{usage: undefined}`——卡片既没有积分、也没有任何错误提示，只剩一片空白，用户无从判断发生了什么。现在 `fetchUsage` 区分两类失败：**凭据被拒（401/403）抛出**（两条回退路径都拒绝才抛，保留原始错误），其他错误（超时、5xx、网关抖动）仍返回 `undefined`（那是「这次没读到」，报成凭据被拒会把用户引去换一个没问题的 PAT）；宿主把 `kind === 'auth'` 记为 `credential-rejected`，卡片随之渲染「凭据被拒」区块与切换/添加 PAT 的出路。
  - 同一处静默还导致 `credential-recovery` 的凭据可用性判定**永远为真**：它靠 `try { await fetchUsage } catch { usable = false }` 判断，而原实现正常返回 `undefined`，`await` 不抛错——过期凭据因此被判定为可用，于是「切换到另一个账号」的恢复建议建立在错误的可用性判断上。
- **思考强度：从「一行只读文字」做成真正可用的设置**（用户指出「没有地方可以选择」）。上游模型条目带 `thinking_config.enabled.efforts`（实测形状 `{xhigh:{}, low:{}, medium:{is_default:true}}`，键即档位名）；上一轮只在模型行渲染了一行只读文字，这一轮补上真正的选择器与**发送链路**：逐模型保存的 `thinkingEfforts`（一项设置——选好保存后每个请求自动带上，与 Qoder 客户端的 `reasoning-effort-changed` 同形态），卡片上是带「跟随上游默认」选项的下拉。
  - **档位确实发得出去**（修正上一轮的错误判断）：我此前查了 `model_config` 只有四个字段就断言「发不了档位」——查得不够。档位走的是**另一个字段**：反编译 Qoder 官方客户端（`app.asar.unpacked` 里的 agent SDK）可见请求体为 `{..., reasoningEffort:"none", parameters:{...A.parameters, reasoning_effort:"none"}}`，即顶层 `reasoningEffort` 加 `parameters.reasoning_effort` 双写。现在两个位置都写，与官方客户端一致。
  - **选中档位即开启思考**：档位与布尔 `is_reasoning` 是同一件事的两个粒度，所以保存了档位就按开启处理；宿主明确要求 `reasoning_effort: "off"` 时让位（那一刻的意图优先于设置）。
  - **键序不可信**：上游给的顺序是 `xhigh, low, medium`，直接显示会让用户以为 xhigh 是最弱档；解析按强度语义序表（`minimal<low<medium<high<xhigh<max`）排序，未知档位名排在已知档之后以保证顺序稳定。
  - **不是每个模型都有档位**：`auto`、`qmodel_latest`、`mmodel` 等实测 `efforts` 缺失，此时字段缺席、卡片不渲染控件（空控件会让人以为「支持思考却没有强度可选」）；档位名也不统一（Qwen 系 `low/medium/xhigh`、DeepSeek/GLM 系 `low/high/max`），直接显示上游档位名、不做本地化与跨模型比较。

- **积分与目录的成败解耦**：`refreshUsage` 此前链在目录刷新成功的分支里——动态目录一挂（启动时网络抖动、网关超时……），积分就永远不来，卡片上「看不到积分」。现在目录失败只落兜底目录，积分照常拉取。
- **账号池投影的倍率规则统一**：`index.ts` 的 `poolModelsOf` 是第三处「只对免费模型写 0、付费模型没数字」的投影（前两处在 web-status，上一轮已修）。三处现统一为「免费折叠为 0（`resolveTargetModel` 只认 `=== 0` 的契约），付费透传真实 `priceFactor`」。
- **卡片头部补 `aria-label`**（「展开/收起：Qoder Connect」）：图标按钮此前对读屏没有名字（workbuddy 同规）。
- **模型行的图片/关闭思考复选框恒渲染**：是否可用只看写权限，不再按上游字段有无隐藏控件（workbuddy 同规）——隐藏会让能力未知的模型行缺控件，行与行控件数量不一、视觉抖动。
- **区域 error 分支不再渲染空壳**：usage 拉取失败时此前因 `regionOn` 兜底为 true 而继续渲染账号行/账号池/模型列表的空架子；现在与 workbuddy 同规，只留错误行。
- **「凭据被拒」区块重构为 workbuddy 同构**：`usage-error` 容器（`role="alert"`）+ `strong`/`p` 结构；「可切换 / 无号可切 / 有号但都失败」三态说明互斥，切换动作是独立按钮（busy 时显示「正在读取…」）。新增 `row.credentialRejectedSwitchAction` 文案键。
- **模型保存/区域开关/积分开关与账号池保存互斥**：账号池经新增的 `onBusyChange` 上报保存窗口，池保存期间卡片侧三类写入一律禁用（两边写同一个 region slot，并发会以旧快照互相回滚）；模型「保存」按钮同时补上「至少启用一个模型」的门栏（workbuddy 同规）。
- **账号池活动日志跨折叠/跨 tab 留存**：日志从组件 state 迁入模块级 `logStore`（每区域一份，上限 50 条，「清空」同时清 store）——此前折叠卡片或切换 tab 就清空历史，看起来像操作没发生（workbuddy 同规）。
- **账号池两张表合并回一张成员表（workbuddy 同构）**：成员复选框、状态点（排除=红/琥珀、未测=灰、测过=绿/琥珀）、账号名 +「使用中」标签 + 成员标签 + 测试时间/排除原因、积分（总数 + 临期高亮/最近到期）、探针（结果 + 来源/时长）、行操作同处一行；原「只读状态表 + 独立勾选表」双表拆除。成员表头带选中计数与全选/清空；区块顺序重排为 表 → 目标告警 → 设置（目标模型带来源一句话「当前：xx（你指定/自动挑选的免费模型）」）→ 区域说明 → 日志；陈旧目标在 select 中回退显示原值（`targetModelId || staleTargetModelId`），保存/行删除期间盖 BusyOverlay。
- **账号池成员行的积分列接通了数据**：此前 `buildPoolView` 从不填 `credits`/`expiringSoon`/`nearestExpiryMs`，积分列恒为「—」。新增 `memberCredits` dep（按成员逐账号读 usage，单账号失败只让该行显示「—」），并把读数写入 10 分钟快照供请求路径的 failover 排名使用（`rankPool` 的积分打分项此前永远按 0 排；workbuddy 的 `creditsSnapshot` 同规）。
- **账号池设置区补回外壳与保存行细节（workbuddy 同构）**：设置区自己的小标题（「账号池设置」）+ 脏标记 + 说明行（「下面是偏好设置：点保存才生效……」——locale 键早已存在但从未渲染）；保存行左端补仓库链接（★ 鼓励一下），「放弃修改」改为恒渲染（未改动时禁用，不再条件渲染导致这一行左右跳动），并去掉保存行里与标题重复的脏标记。
- **账号池保存行挪到设置卡片之外**（用户实拍：「还是不对」——上一轮我只改了 CSS 规则，没找到真正原因）：
  - **真正的根因是嵌套**：`pool-save-bar` 被放在了 `pool-settings`（那张 `overflow:hidden` 的带边框圆角卡片）**内部**，于是左端的仓库链接紧贴卡片边框，而上方的「启用账号池」内缩 14px——同一条左对齐线断成两截。workbuddy 里它是那张卡片的**兄弟**。现在移出卡片，它自带的 `border-top` 也正是这一行与卡片之间的分隔。
  - 顺带修掉两条规则的措辞差异（池那条多写了 `flex-wrap:wrap`、按钮组多写 `flex:none`）——同一个「保存行」不该有两套写法。加了 `tests/styles.spec.ts`（纯文本 CSS 断言，不需要 DOM）与 `pool-render` 的嵌套断言把两件事都钉住。
- **删掉账号池尾部重复的区域说明**（用户实拍：「这行字不要，都重复了」）：`poolRegionNote`（「国内版与国际版是完全独立的两个账号池：账号、开关、目标模型互不影响」）与 tab 栏下方的 `tabHint`（「每个 tab 是一个独立供应商……一侧的改动不影响另一侧」）说的是同一件事。删掉池区那条渲染与两份文案键。同时清掉两个本轮失去引用的文案键（`modelReasoning` / `modelReasoningDefault`——思考强度从只读文字改成选择器后不再使用）。
- **探针「来源」药丸接上未知态样式**：`.dsm-qoder-pool-prov-unknown`（带描边）规则早已存在但 JSX 从未使用——来源未知的旧记录此前与「测试写入」渲染得一模一样。同时把失败原因从独立药丸改为并入探针主行（`结果 · 原因`，workbuddy 的 `probeLine` 同规）：该药丸原被硬编码成琥珀色的「live-request」样式，而原因可来自任何失败结果，颜色在说谎；全文仍在行 `title` 中。
- **账号工具移到账号池设置区顶部的虚线框**（用户实拍对照）：「添加 PAT / 导出导入账号」两行此前直接堆在账号行末尾、没有外框；现在与 workbuddy 同构——`pool-tools` 虚线框落在「账号池设置」标题之下、设置说明行之上（两个工具立即生效，说明行讲的是「下面这些偏好要保存」，顺序颠倒会让读者以为工具也要保存）。为此 `AddAccount`/`AccountTransfer` 由卡片注入账号池（`accountTools` prop），因为它们需要卡片的刷新与区域。同时删掉两行常驻说明（`.dsm-qoder-bar-hint` 用法）：workbuddy 的对应行只在等待/完成/出错时出现状态文本，常驻说明把每行撑成三行高，而它讲的「本区域生效的账号」「导出为 JSON…」分别是账号行与弹窗里的话题。导出按钮补上 `accounts.length === 0` 门栏。
- **「正在计费」的行补回左侧竖条**：`.dsm-qoder-pool-row-current` 此前只有 7% 透明的背景色，暗色主题下几乎看不出哪一行在用；现补上 workbuddy 同款的 `inset 3px 0 0` 品牌色竖条（同一个类名的第二条规则）。
- **上下文档位：去掉标签，并修掉重复档位**（用户实拍）：
  - **200K 出现两次**——为了「显式回落原生值」额外前置了一个原生窗口选项，而上游的 `contextTiers` 本身就以原生窗口开头（实测 `[200000, 400000, 1000000]`），于是同一个值渲染了两遍。现在选项**只**来自上游声明的档位。宿主侧 `contextTiersOf` 本来就用 `new Set` 去过重，重复纯粹是客户端多加的那一项。
  - 标签「上下文」也一并去掉（上一轮刚从「DSH 上下文预算」缩短而来）：它紧挨着模型自己的上下文信息（`最大上下文 200,000`），下拉里的 200K / 400K / 1M 已经说明在选什么。可见文字去掉，无障碍名 `aria-label` 保留——读屏用户不能因此失去这一行的含义。
  - **字体样式突兀**（用户实拍）：两个选择器此前复用了账号池表格的 `dsm-qoder-pool-num`——13px、74px 宽、8px 圆角的**实心方块**，放进 11px 的模型行（meta 行与头部控件行）里格外扎眼。改为新的 `.dsm-qoder-inline-select`：无边框、无底色、字号与所在行一致（11px / 16px 行高），读起来像这一行的文字而不是表单控件；箭头由外层 `.dsm-qoder-inline` 的 `::after` 绘制（原生箭头在暗色主题下不可控），hover 才提亮、focus 才出现焦点环。选项列表单独给背景色——系统绘制的下拉不继承行内样式，不加会渲染成一列透明文字。

- **国际版（零凭据区域）整块空白，连「添加 PAT」入口都没有**（用户实拍：「国际版看起来就是空的」）。
  - **根因**：`QoderCard.renderPool()` 第一行是 `if (signIn?.pool === undefined) return <span />`，而 `signIn` 只在 `status === 'signed-in'` 时存在。一个 PAT 都没有的区域**永远**拿到 `signed-out`，于是账号池整块不渲染 —— 连同由它渲染的「添加 PAT / 导出 / 导入账号」一起消失。**最需要添加第一个账号的页面，恰恰没有任何添加账号的入口。**
  - **为什么之前没查出来**：上一轮修的是同一件事的**宿主那一半**（`buildWebUsage` 的 `signed-out` 支不再提前返回，现在也带上 `pool`），宿主侧单测也确实过了。但客户端还锁在 `signIn` 上 —— 两侧必须同时改，零凭据的区域才会既能看到「这里还没有账号」的说法，又拿得到添加入口。这是**一次只修一半**留下的坑。
  - **修法**：`renderPool` 改读 `usage`（`status === 'error'` 仍排除：那份文档没读成，它的 `pool` 字段根本不存在，渲染空壳只会把错误行挤到下面，与 workbuddy 同规）。`AccountTransfer` 的账号列表同步改读 `doc.accounts`。
  - 新增 4 条卡片级回归测试（`tests/card-render.spec.tsx`）：零凭据时账号池渲染 / 「添加 PAT」与导出导入入口都在 / 空池说「还没有账号」而非「成员限流」/ `status=error` 时不渲染空壳。**做过变异测试**：把 `renderPool` 改回读 `signIn`（即还原这个 bug），3 条立刻失败。
  - 审计报告 `docs/audit/AUDIT-REPORT.md` 的结论「国际版代码无缺失、只是从未配置」需要修正为：**代码曾经缺失一个客户端渲染门槛**，零凭据区域因此不可配置。本条即其修复。
- **行内那个「移除」根本不是删除 —— 移出池与彻底删除此前是同一颗按钮**（用户实拍：「我国内版本，删除唯一的号，但是号并没有删除干净」）。
  - **根因**：`POST /pool?action=remove` 这一支只做了 `updateMembership(...)`，也就是**把 id 从 `memberAccountIds` 里剔掉**——凭据（`~/.dsh/.qoder-accounts.<region>.json`）一行都没动。UI 上按钮却写着「移除」，和账号行里另一个已删掉的「移除 · <账号>」（那个是真删 vault 的）共用同一套文案，读起来就是删除。实测磁盘：点完之后 vault 里那条记录（`pat-5d76517ac7699f87`）原封不动，配置里 `accountId` 也还指着它；下一次「全选」又能勾回来。这就是「删不掉」的完整机制。
  - **修法**：按 workbuddy 的划分把两件事彻底分开 ——
    - **移出池 = 取消勾选并保存**。行内不再有任何「移出池」按钮：勾选框已经在表达「是不是成员」，再给一个只改同一件事的控件，两处迟早会不一致（这次的 bug 就是这么来的）。
    - **彻底删除 = 行内「删除」按钮 + 两步内联二次确认**。宿主侧删掉 vault 条目，并**连带收掉配置里指向它的两处残骸**：区域的 `accountId`（否则卡片会一直说「所选账号已不存在」）与 `memberAccountIds`（否则留下一条勾不上的幽灵 id）。
  - **settings / env 的 PAT 明确拒绝**（409 + `account-not-deletable`）而不是回一个「其实没删掉什么」的 200：那类凭据由插件之外管理，删 vault 条目对它无效；卡片据 `source` 直接不给那些行显示删除按钮。id 先按 `isQoderAccountId` 的形状校验（400），不让畸形值走到宿主内部。
  - **危险动作才配危险样式**：静止态的「删除」保持中性 outline（它和「测试」并排，每次看都要重新判断哪个会丢数据）；红色留给确认条里那颗「彻底删除」，也就是用户已经明确表达意图的那一刻。确认条就地展开在那一行的操作格里，不弹窗——确认的是什么必须和被确认的行在同一视线内。
  - 新增 11 条测试：宿主侧 6 条（真删 vault 条目、配置残骸两处都收、删 A 不动 B 的选择、settings/env 被拒、畸形 id 400、缺 id 400），客户端侧 5 条（没有「移除」按钮、点第一下不发请求、确认才发且带对路由与 id、「保留」能撤掉、settings/env 行无删除按钮、失败时翻出结构化文案）。全部做过变异测试：去掉真删 / 去掉残留清理 / 去掉确认闸门 / 无视 `source` 四种改法各自被对应的测试接住。
- **一个账号都没有时，账号池整段不渲染 —— 于是没有地方添加账号**（用户实拍：「没有账号的时候显示的不对，账号池这个功能也要有，否则没地方添加账号」）。
  - **根因**：`buildWebUsage` 在 `serving === undefined`（没有任何可用凭据）时**提前返回**，那一支刻意不带 `pool` 字段，理由是「不出现 pool 段就等于没有任何账号正在使用」。而客户端的 `renderPool()` 在 `pool === undefined` 时直接返回空 `<span/>` —— **「添加 PAT / 导入账号」这两件工具恰好由账号池区渲染**。于是「一个账号都没有」这个最需要添加入口的状态，反而完全没有添加入口。原来的单元测试还把这条行为写成了断言（`signed-out 时不出现 pool 段`），所以改动前一直没人发现。
  - **修法**：signed-out 支也构建并带上账号池视图（`servingAccountId` 传 `undefined`，`current` 自然全为 false）。「当前没有正在计费的账号」改由**每一行的 `current`** 表达，不再靠整段缺席来暗示。`QoderWebUsage` 的 signed-out 变体相应加上可选 `pool`。
  - 顺手修掉一处**误导文案**：`poolNoCandidate`（「成员要么限流、要么积分耗尽……」）此前不判断有没有账号，空池也会显示，读起来像账号出了问题而不是还没添加。现在只在**真的有账号但都不可用**时才说，空池改为新的空状态 `row.poolEmptyHint`（中英双语，指向「添加 PAT」与「导出 / 导入账号」）。
  - 新增 4 条测试：宿主侧「零账号仍然给出 pool 视图、且没有任何一行 `current`」与反向的「没有 pool dep 时不凭空造池」；客户端侧「零账号仍渲染账号池 + 空状态 + 添加账号入口，且不说 poolNoCandidate」与「有账号但都不可用才显示警告」。宿主侧那条做过变异测试（去掉 pool 即失败）。
- **客户端 bundle 的模块 id 必须等于包名 —— 改包名把插件整个搞挂过。** 包名从 `dsh-connect-qoder` 改成带 scope 的 `@dmh2002/dsh-connect-qoder` 后，插件在设置页报「启用失败 … failed to import」，渲染进程控制台的真实错误是：
  `client-modules: duplicate factory registration for "dsh-connect-qoder" (bundle executed twice without invalidate?)`。
  - **根因**：`tsdown.config.ts` 里 `PLUGIN_ID` 是**硬编码**的旧名，而 shell 的客户端模块花名册是按**包名**给每一行建键的（`resolveMeta` → `packageName`，取自 package.json 的 `name`）；模块加载器又靠「bundle 声明的 id ↔ 花名册行的 id」配对来记录 revision 并在重建时 invalidate（`graphRows` / `reloadTargets` 都以它建键）。两者一旦不等，注册就永远挂不到自己那一行上、**永远不会被 invalidate**，bundle 第二次执行即抛 duplicate。
  - **修法**：`PLUGIN_ID` 改为从 `package.json` 的 `name` **推导**（与 `PACKAGE_VERSION` 同源），从此改包名不可能再把 id 落下。已核对同 profile 里两个正常工作的 scoped 插件（`@linxin666/dsh-client-ui-git-graph`、`@changfenhuang/dsh-genui`）——它们的 bundle id 都等于自己的 scoped 包名，本项目此前是唯一的例外。
  - 新增 `tests/plugin-id.spec.ts` 守卫（含一条「旧写法必然失败」的回归断言，并校验 `lib/client.js` 里真的声明了这个 id）：单元测试此前完全看不见这个 bug（全绿），只有真机激活才暴露，所以用构建配置层的断言钉住它。
  - 注意 `cordis.patch.yml` 的 entry `id`（`dsh-connect-qoder`）**故意保持不带 scope**：它承担设置命名空间与 `/plugins/<id>/…` 路由，改名会孤儿化老配置。那是**另一个**标识，不是这个模块 id。
  - **另外还需要在 profile 侧修一处**（不随包发布，故不在本仓库改动范围内）：本地是 `"dsh-connect-qoder": "link:…"`（unscoped key），`node_modules` 下只有 unscoped 软链；而 `cordis.patch.yml` 的 `name` 是 scoped 的，`@dmh2002/dsh-connect-qoder` 因此 `ERR_MODULE_NOT_FOUND`。需要把 profile 的依赖键与 `dsh.profile.bundles` 一并对齐成 scoped 名并重装（或改用插件管理器的「安装本地 bundle」重新登记），详见本次排障结论。
- **配置卡片正文整片消失（用户报「之前做的功能菜单都没了」）—— 槽位 key 同样按包名建，这是同一身份漂移的第二处。**
  改包名后，插件管理页里插件仍显示**运行中**、图标 / 描述 / 组件行都在，但**配置卡片正文什么都不渲染**（国内版/国际版 tab、模型管理、账号池、积分整片空白），而且**没有任何报错**。
  - **根因**：DSH 的插件配置页把槽位按**包标识**建键 —— `plugins.bundle.config` 用**裸包名**，`plugins.row.config` 用 **`<包名>#<行 id>`**（行 id 就是 bundle patch 里声明的 entry `id`）。而 `src/client/index.tsx` 把这两个 key **硬编码**成旧的 unscoped 名（`dsh-connect-qoder` / `dsh-connect-qoder#dsh-connect-qoder`）。包名改成 `@dmh2002/dsh-connect-qoder` 后 key 对不上，槽位注册**静默失配** —— 它包在 try/catch 里，所以连 console 都不会红。
  - **修法**：包名改为**构建期注入**（`__DSH_QODER_PACKAGE_NAME__`，与 `__DSH_QODER_VERSION__` 同源、都取自 package.json）；新增 `src/client/slot-keys.ts`（无 React 依赖的纯函数 `qoderCardSlotKeys(packageName, rowId)`）供卡片注册使用；`vitest.config.ts` 镜像同一组 define，让测试看到真实包名。
  - 新增 5 条测试（`tests/plugin-id.spec.ts`）：key 格式、bundle key 等于真实包名、row key 等于 `<包名>#<entry id>`、**「旧硬编码 key 必然失配」的回归断言**，以及「构建产物真的用这组 key 注册」的产物断言。该产物断言在 `lib/` 不存在时**跳过而非抛错**（`lib/` 是 gitignore 的，且 `pnpm run check` 先跑测试、后跑构建）。
  - 已做变异测试：把 `index.tsx` 改回硬编码旧 key，产物断言即失败。
- **账号转移的导出/导入往返彻底修好（双区域都坏过）**：`exportAccounts` 此前直接吐 vault 内部字段 `{ pat, accountName, id }`——**既不调用 `transferRecordOf`，也就没有 `domain` 字段，字段名也不是 snake_case**。而导入侧的区域门只认 `domain`，于是用户导出的文件再导入时**全判 skipped（导出即导入 = 0 条成功）**，功能等于不可用。
  - 修法：导出改走 `transferRecordsFor(region, accounts)`（新增于 `src/transfer.ts`，`access_token` / `uid` / `nickname` / `domain` 四字段齐全，domain 由 `QODER_REGION_TRANSFER_DOMAIN` 按区域给出）。
  - `QoderTransferDeps.exportAccounts` 的返回类型从「vault 形状」收紧为 `QoderTransferRecord[]`，**从类型上**挡住这种形状回退——原类型本身就是这个 bug 的共谋。
  - `QODER_REGION_TRANSFER_DOMAIN` 由既有的 `knownQoderRegionOfDomain` **推导**而来（而不是手抄第二份域名表），两处不可能再漂移；漏登记某区域会在模块加载即抛错。
  - **补了往返回归测试**（`tests/transfer.spec.ts` 的 `export → import round trip`）：导出 → 原样喂回导入 → 断言 `imported === 导出条数`，cn 与 global 各一条，外加交叉导入必须跳过。
  - 这个 bug 之前能存活，是因为两侧的单元测试各自都是绿的：`transferRecordOf` 单独测（对）、区域门单独测（对），但**没有任何测试把真实导出路径接到导入路径上**。往返测试就是为堵住这条接缝。
- **CLI 三条子命令从未真正跑起来**：`main()` 过滤 argv 的末项写成了 `REGION_KEYS.includes(arg)`（只保留 `cn` / `global`），于是**命令词自己被过滤掉**，`command` 恒为 `undefined`，`doctor` / `status` / `logout` 全部只会打印 usage 后退出。`src/bin.ts` 此前没有任何测试文件，所以一路无人发现。
  - 修法：语义改正为「保留**不是** flag、**不是**区域名的实参」，并抽成纯函数 `commandArgOf()`（分发规则的唯一实现）。
  - 新增 `tests/bin.spec.ts`：覆盖命令词、`--json` 任意位置、`--region` 及其实参、纯 flag 输入，以及一条**回归断言**（旧写法的等价实现对 `['doctor']` 返回 `undefined`，新实现返回 `'doctor'`）。
  - 实测（`node lib/bin.js doctor --json`）现在真的输出两区域状态，其中 `global: vault=0` —— 这条输出也正是「国际版从未被执行」的直接证据。
- **国际版的用量/积分查询补发设备风控头**：`requiresDeviceRisk` 的语义是「该区域的 sash 请求都要带 `Cosy-MachineToken/Code/Type`」，但它只被签到路径（`sashRequest`）消费；用量走的 `jobTokenGet` 与直连回退 `fetchUsageDirect` 都只发 `accept` + `authorization`。已用注入 fetch 实测证实：修复前 global 的 `/sash/api/v2/me/usage` 请求**零个** machine 头。现在两条路径都按区域附加（国内版仍不带，不多发噪音）。
  - 补 3 条回归测试：国际版用量带齐三件套、国内版不带、**回退路径也带**（迫使主路径 401 走回退后断言两次请求都带头）。
  - 这三条都做了变异测试：单独还原任一处的修复，对应测试即失败。

### Changed

- **插件图标改回 LaoDing 插件家族通用的 LD 品牌标**：`icons/dsh-connect-qoder-{128,64}.png` 与卡片头部内联 data URI 均与 dsh-connect-trae / dsh-connect-workbuddy 字节一致（家族约定：图标标识「同一家族的插件」，产品区分由标题与描述承载）。取代 0.2.0 的 Qoder 品牌紫白 Q 磁贴。
- `src/upstream.ts` 文件头注释此前称国际版的 models 走 `api2.qoder.sh`、jobToken 走 `center.qoder.sh`，而代码三个端点都指向 `api1.qoder.sh`。实测（2026-10）三者对本插件用到的端点**完全等效**（对同一假 PAT 都回 `401 personal token is invalid` 而非 `Signature invalid`），故统一为单网关主机并写进注释；将来若要按端点分流，应改 `QODER_REGIONS` 配置表而非只改注释。
- `src/index.ts` 的 `registration.cn.directory` 单句柄（一次 `registerConfigurableProviders` 登记两侧）补了说明注释，避免读成「global 漏了 directory 句柄」。
- **npm 包名改为 `@dmh2002/dsh-connect-qoder`** —— 无作用域的 `dsh-connect-qoder` 已被他人占用（发布者 `gghskl`，仓库 `hdhgsysh/dsh-connect-qoder`，实为 `eghrhegpe` 那份「读本机登录、零配置」的实现，已在注册表内），本项目的 PAT 路线发不到那个名字下。**插件在 DSH 内的标识不变**：`cordis.patch.yml` 的 entry `id`、设置命名空间、`/plugins/dsh-connect-qoder/…` 路由与 CLI 程序名仍是 `dsh-connect-qoder`，老用户升级后配置照旧生效；随包改变的只有 `name` 与安装命令。

### Docs

- 新增 [`docs/PAT.md`](docs/PAT.md)：PAT 是什么、从控制台哪里签发（国内版/国际版各自入口）、卡片粘贴与环境变量两条配置路径、优先级与常见问题；README 的 PAT 段落改为指向它并补上发证入口。`package.json` 的 `files` 由 `docs/assets` 改为 `docs`，使 `docs/*.md` 随包发布（此前 README 与包内的 DESIGN 文档链接在 npm 页面上是死链）。
- **README 双语全面重写并配图** —— 顶部改为设置卡片实机截图，功能特性区补入模型选择器、模型管理、composer 积分面板三张图；新增「账号池」章节（自动换号规则、那个开关到底控制什么、排序规则、两个批量动作）与「市场收录与展示」「已知限制」「免责声明」三节。同时修掉一批过时描述：Qoder **有**每日签到（此前写「没有」）、按钮叫「测试」而非「测一测」、积分逐账号显示在账号池里、倍率的 `(0.50x)` 形态与模型名示例。
- `screenshots.json` 与 `docs/assets/` 落地四张实机截图（设置卡片 / 模型管理 / composer 积分 / 模型选择器），供 GitHub 展示与插件市场审核读取。
- `THIRD_PARTY_NOTICES.md` 补 `L0NE-6/Qoder-Daily`（MIT）条目：每日签到的协议参照，并写明本项目独立实现的两点差异（逐账号「先查后领」的幂等、取不到设备身份时如实报错而不是静默降级）。
- `docs/PAT.md` 补「每日签到（实测协议）」一节，并修掉一处标题粘连导致的排版错误。
- `docs/DESIGN.md` 与 `src/*/` 头部注释里「Qoder 没有每日签到」的过时结论全部改正（`status-paths.ts` / `account-pool.ts` / `web-status.ts` / `client/AccountPool.tsx` / `client/locales.ts` / `client/QoderCard.tsx`）。
- **`RELEASING.md` 与 `package.json` 的包名一并对齐到作用域名**：验证命令、`npm view` / registry 直连核验、打包内容清单与暂存发布判据里的包名全部改为 `@dmh2002/dsh-connect-qoder`（registry 路径用 `%2f` 编码作用域斜杠），并新增一步「发布后核对 profile 侧的 scoped 依赖键」——本轮改名的两处静默故障（bundle 模块 id、配置槽位 key）都源于「包名改了但别处没跟上」，流程里必须留一道闸。
- 收录草稿 `awesome-dsh-plugin-submission/`：描述按现行能力重写（补多账号池 / 签到 / 自动换号），并记下与注册表内同名条目（`eghrhegpe/dsh-connect-qoder`，读本机登录路线）的差异，提 PR 时直接引用；同时改正两处过时前置说明（现行规则**没有**提交数门槛；npm 映射由 registry 自动采集，**条目里手写 `npm:` 会被拒**）。
- **命令行诊断的调用方式改写为实测结果**：README 双语原先写 `dsh plugin --profile desktop exec …`，本机跑不通的原因是 PATH 里的 `dsh` 包装脚本仍指向一个已不存在的 app 路径（与插件无关）；改为主张「进入 profile 目录用 `pnpm exec dsh-connect-qoder …`」，并写明两条实测边界——裸路径 `node node_modules/dsh-connect-qoder/lib/bin.js` 在改名后必然 `MODULE_NOT_FOUND`（软链已搬到 `@dmh2002/` 下，缺的是入口文件不是 peer），以及 CLI 所需的宿主 peer（`dsh-atomic-write` / `dsh-home-paths`）由 pnpm 随包装进 profile、`auto-install-peers` 关掉时才会缺。「从 npm 装的副本在宿主之外跑 CLI 必然失败」这一原先写进 README 的结论**经实测不成立**，已删除；「已知限制」同步改写（英文版补上对应条目，此前中英不对称）。
- `THIRD_PARTY_NOTICES.md` 的图标一节改对了：0.3.0 起图标是**本作者原创**的家族 LD 标，不再随包分发任何第三方美术作品（0.2.0 曾用过 Qoder 客户端图标本体）。

### Tests

- `tests/shim.spec.ts` 从 10 用例扩到 20 用例：非 loopback Origin 拒绝 / 回环 Origin 放行、请求体上限 413、`/healthz` 鉴权、首字节前失败回 JSON 且 code 翻译、流中失败仍走 SSE 错误帧、`tool_choice:"none"` 丢 tools、换号可见通知、耗尽时每个失败账号都记测量且消息注明尝试数、重复候选刹车、换号间隔生效、400 不换号。
- 新增首个**客户端真实渲染**测试 `tests/card-render.spec.tsx`（jsdom 挂载真组件，key-preserving 翻译）：断言 tab 栏结构（圆点只在按钮内、激活/停用类落在按钮上）与模型行文案（displayName 与 `row.modelFree`，而不是键字面量）。本轮三个视觉缺陷全部来自「363 个 host 测试从不执行组件」这一盲区，此文件补上这一层。

## 0.2.0 (2026-01-01)

与同族插件 `dsh-connect-workbuddy`（参考实现）对齐外观与能力，同时贴合 Qoder 的 PAT 上游现实。

### Features

- **多账号池（PAT）** —— 每区域一个 `$DSH_HOME/.qoder-accounts.<region>.json` 本地 vault（0600），叠加设置 PAT 与环境变量，三者按 settings → env → vault 去重合并；卡片可新增 / 移除 / 改名账号，也可显式选择某账号服务（`regions.<region>.accountId`，留空自动）。
- **账号池按实测排序与故障转移** —— 「测一测」批量对每个成员发真实请求，结果落 `$DSH_HOME/.qoder-pool.<region>.json`；聊天流在首字节前按 `rankPool`（可用性 → 额度从高到低 → 到期最近 → id）自动换账号，失败回写 `live-request` 探测记录。Qoder **没有每日签到**，故 `checkinSupported` 恒为 `false`、卡片不出现签到列。
- **账号转移（导入 / 导出）** —— 按区域导出 vault 账号为传输文件，预览后按索引导入；跨区域域名（`qoder.com.cn` / `qoder.sh`）正向匹配，不匹配的记录计入 `skipped`。
- **PAT 校验与增删** —— 新增 PAT 时经 Qoder `JobToken` 交换校验并取回显示名，失败即拒绝；移除只删 vault 条目，不碰设置 PAT / 环境变量。
- **上下文预算（context tiers）** —— 从上游 `context_config` 解析模型声明的档位，卡片按档位选择上下文预算，仅在已声明档位内生效（否则回落原生值）。
- **积分读数进 composer** —— `conversation.input.left` 插槽（order 4）按区域显示 `QD CN` / `QD AI` 与当前账号余额，受每区域 `showCreditsInMainUi`（默认开，opt-out）控制；每账号明细走 `account-credits` 路由。
- **CLI 增强** —— `doctor` 增报 pi-ai 代际、宿主心跳存活、vault 账号计数；`status` 增报凭据来源与当前剩余积分；新增 `logout [--region cn|global]` 清除本区域 vault 账号与账号池测量副本。
- **回环路由扩展** —— 新增 `usage`（每区域快照）、`account-credits`、`accounts add/remove`、`pool test/remove`、`transfer export/preview/import`、`__save`（两段式合并，防部分写入误删兄弟区域）。

### Changed

- **客户端对齐同族 `dsm-*` 视觉语言** —— `dsm-qoder-*` 组件类 + 与 workbuddy 逐字一致的共享 `.dsm-plugin-card*` / `.dsm-btn*`；文案迁移到 `row.*` / `composer.*` 双语键；新增账号池表、账号转移面板、PAT 新增表单、composer 积分读数与忙碌遮罩等组件。
- **PI 代际兼容桥** —— 适配器按 `piAiRuntimeInfo()` 代际门控：legacy pi-ai 把 0.87 形态 transcript 折回 0.85 形态（system 消息与 tools 折叠到 `systemPrompt`/`tools`），modern 代际原样透传；Qoder 上游接受 user-first 会话，故不注入兜底 system prompt。
- **推理档位** —— Qoder 推理线上量是布尔 `model_config.is_reasoning`（非 effort 档），故 `qoderThinkingLevelMap` 只表达 `off`（仅 `canDisableThinking` 模型提供），各 effort 档位映射 `null`。
- **插件图标** —— 改用 Qoder 桌面版**应用图标本体**（`icon.icns` 解出的白底黑 Q），替换此前那张与深色卡片几乎融为一体的黑底磁贴；共享 `.dsm-plugin-card-icon`（32×32、radius 7px）与 workbuddy 逐字节一致。
- **探针结果文案统一** —— 抽出 `client/probe-label.ts` 作为**唯一**渲染规则：模型行与账号池表此前各写一套（耗时一处毫秒一处秒、恢复时刻一处只有时:分:秒一处是日期+时间），同一份测量两种说法；并补回缺失的耗时（`probeOkMs`）与上游给出的恢复时刻（`probeRateLimitedAt` / `probeOutOfCreditAt` + 相对时长）。

### Fixed

- **图片输入的默认值不再采信平台标记** —— 上游 `is_vl` 是**平台**声明而非模型原生能力（内置目录里几乎全是 `true`，连纯文本模型也是），据此默认勾选会在纯文本模型上开出图片输入、一发图即 400。改为按精确 model id 键控的人工核对表（`native-modality.ts`），只有文档确认 `multimodal` 才预勾选，未核实一律不勾。
- **图片勾选改为独立持久集合** `regions.<region>.imageModelKeys`（原与 `lastCatalog` 快照耦合，目录重写可能连带改变图像答案），运行时只从该集合派生图像能力。
- **「可关闭思考」增加校正机制** —— 上游声明的 `canDisableThinking` 不可全信（workbuddy 实测有模型对 `reasoning_effort:"off"` 直接 400，且失败会在此后每次请求重演）。新增 `off-thinking.ts`：拒绝集 + 首读种子 + 运行时盖印。本插件暂无实测样本，拒绝集留空而非虚构（行为不倒退，有样本时加一行即可生效）。
- **脱敏缺陷** —— `redactUpstreamText` 原实现 `'$1[redacted]'` 把含密钥的捕获组原样保留，等于没有脱敏；改为 `key=` 保留键名抹值、裸 `pt-` token 整段替换。
- **账号删除改为二次确认** —— vault 是这些账号的**唯一**存储位置，硬删无法找回（workbuddy 的删除是可找回的「暂时隐藏」，故它不需要确认）；两步内联确认替代一键删除。
- **探针结果按区域分别存放** —— 原为扁平命名空间，切换 tab 会串台；改为与 workbuddy 同构的按区域键控，刷新目录不再丢失已测结果。
- **账号池目标下拉补失效项** —— 已保存但已下架的 `targetModelId` 缺少自己的 option 时，受控 select 会退回显示第一项而值仍是旧 id（显示与取值分叉，下次保存又把旧 id 写回）；补一个 disabled 的失效项。

### Docs

- README / README.en.md（双语）更新为 0.2.0 的多账号池、转移、上下文预算与 composer 积分能力。
- 新增 [`docs/DESIGN.md`](docs/DESIGN.md)：模块结构、凭据与账号池、以及从 workbuddy 继承的三条「已修缺陷」教训与取舍依据。
- 新增 `scripts/probe-cosy.mjs`（不依赖宿主直连 cosy 链路验证）与 `scripts/dump-vault.mjs`（只读 dump vault / 账号池测量），并加 `pnpm run probe:cosy` / `probe:vault` 入口。

### Tests

- 测试覆盖从 9 个 spec / 82 用例扩到 **21 个 spec / 349 用例**：为此前零测试的 host 模块补齐规格（account-pool 三件套、accounts vault、credentials、transfer、probe、credential-recovery、web-status 全路由、adapter、native-modality、off-thinking，并扩展 catalog）。
- 测试基建对齐 workbuddy：per-file 隔离 `DSH_HOME`（`tests/setup-per-file-home.ts` + `vitest.config.ts` 的 env 隔离），避免 vault/账号池/心跳文件在用例间互相污染。

## 0.1.0 (2026-01-01)

首个公开发布版本。

### Features

- **双区域并行接入** —— 国内版注册为 `qoder` provider（`gateway.qoder.com.cn`），国际版注册为 `qoder-global` provider（`api1.qoder.sh`）；有 PAT 的区域其模型出现在 DSH 模型选择器里，两个 provider 可同时使用。
- **PAT 凭据** —— 设置卡片内粘贴（`regions.<region>.pat`）或环境变量（`QODER_PERSONAL_ACCESS_TOKEN` / `QODERCN_PERSONAL_ACCESS_TOKEN`），设置项优先；凭据只写入本地插件配置，从不进入网络请求。
- **模型目录与倍率** —— 从 cosy 网关实时拉取模型清单，价格因子以内嵌 `· xN.NN` 显示；逐项勾选启用（`enabledModelKeys`），保存立即生效；上游不可达时回退内置静态目录。
- **用量概览** —— 每区域只读展示基础额度 / 附加额度 / 组织额度的已用、总量、剩余、占比与到期时间。
- **模型可用性测试** —— 卡片内对单个模型「测一测」，直达上游确认可用性，限流 / 凭据无效 / 内容审核分别报因。
- **区域开关（opt-out）** —— 每个区域右侧勾选框，显式取消勾选即把该 provider 从选择器整体撤下；账号配置全部保留，重新勾选即恢复。
- **安全 loopback shim** —— 每区域一个随机端口 + 进程内随机 secret，真实 PAT 不交给 pi-ai。
- **命令行诊断** —— `dsh-connect-qoder doctor|status [--json]`，无需浏览器即可确认 PAT 来源、指纹派生与上游可达性。

### Docs

- README / README.en.md（双语）、CHANGELOG、THIRD_PARTY_NOTICES、RELEASING（发布流程）。
- 收录草稿：`awesome-dsh-plugin-submission/`（Awesome DSH Plugin 注册表）。

### 说明

- 首次发布按 `RELEASING.md` 流程执行；`npm publish` 若走暂存发布（staged publishing），需 2FA 批准后再核对 registry。
