# 更新日志(Changelog) 格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 ## 0.5.2 (2026-10-17) 用户实拍:**同一个账号在同一个面板里有两个积分数字** —— 面板表格 `ddd = 738`,面板顶部的触发器 `QD AI · 800`。不是显示错位,是**两条数据链**:一条读宿主缓存、一条现场读上游,而缓存**结构上永远不会自己变新**。 ### Fixed - **积分读数(触发器)在聊天扣费后永远不变,而面板表格会变 —— `/usage` 路由现在每次请求先现场刷一次积分再读缓存(对齐 workbuddy)。** - **机制(线上实测复现,不是推断)**:`/usage` 的 `credits` 取自 `usageEntryOf` → `usageCache`,而 `refreshUsage` 此前只有 **4 个调用点**(启动 / 每次配置写入 / 刷新模型 / 签到收尾)—— 聊天消耗积分不在这四个里,宿主侧也没有任何周期刷新(`setInterval` 零命中)、缓存没有 TTL。面板表格走另一条链(`accountCreditsOf` 每账号现场 `fetchUsage`),所以它是新的。三条独立证据:代码调用点枚举、宿主侧无定时器、以及**对运行中的宿主连打三次 `/usage`**:触发器恒为 800,实时值同期从 738 掉到 710(差距从 62 扩大到 90)。 - **对照基准**:同族 workbuddy 的 usage 路由每次请求都现场并发拉积分(`web-status.ts` 的 `Promise.allSettled` + `toCredits`)。Qoder 移植时把这一处换成了纯缓存读 —— 是**移植时的偏离**,不是上游限制(面板那条链证明实时读完全可行)。 - **修法**:`QoderWebRouteDeps` 新增可选 `refreshUsage` dep,`buildWebUsage` 在 `signed-in` 分支**先 await 刷新、再读缓存**。顺序是修复本身:读在刷之前 = 修了等于没修。dep 缺席(旧宿主)退回原来的纯缓存读,行为不变。 - **「每次请求都刷」会放大一个既有缺陷,所以必须同时补保旧值**:`fetchUsage` 把超时/5xx **吞成 `undefined`**(只有 401/403 抛出),而旧写法把 `undefined` 直接写进缓存 —— 一次网络抖动能把「数字旧」变成「没数字」,比原 bug 更糟。原来这条只在四个时机被触发所以少见,改成每请求一刷之后会被放大成常态。现在 `usage === undefined` 时**保留上一次的好快照与它的读取时刻**(`atMs` 不更新:它标的是数据何时读到,不是本次尝试的时刻),失败记成覆盖层。 - **顺带接通一处写了三版却从没人赋值的契约**:`QoderWebUsage.refreshError` 与卡片读它的分支早已存在(注释写着「a failed re-read never blanks the panel」),宿主此前从不赋值 —— 那段卡片分支是死代码。本版由 `usageCache.staleError` 映射过去,死代码活了。 - **页脚此前在替这个 bug 打掩护**:「最近刷新 00:22」显示的是**客户端 fetch 返回的时刻**,刷新失败时它照样往前走 —— 数字是旧的、时间显示刚刚,用户无从判断哪个能信(截图里 800 正是这样看起来像「刚刷新的」)。宿主手里一直有真实读取时刻 `atMs`,只是在路由里被丢掉;现在透传成 `creditsAtMs`,composer 优先显示它,两者不一致时标「未更新」。 - **并发合流**:`/usage` 的调用方不止一个(卡片 60s 轮询 + composer 300s + 面板打开 + 手动刷新),会几乎同时打到同一区域。`refreshUsageCoalesced` 让同区域同时只有一次上游读、后来者加入同一次 promise,结算时按身份比对再删(防竞态删除迟到的在飞记录)。 - **整体硬超时(8s)而不是只传 signal**:`fetchUsage` 先 `ensureSession()` 再带 signal 打 sash —— **signal 只管得住后半段**。会话过期时前半段(jobToken 交换)挂住的话 signal 还没生效,而 `/usage` 现在每请求都走这条路,卡片每 60s 再打一次会一层层堆上去。`withHardTimeout` 超时**结算成 `undefined`(走保旧值)而不是抛**:抛进 catch 分支会把缓存写成 `undefined`,「网络慢」就被显示成「没有积分数据」;同时**超时前失败必须照旧抛出**,把 `auth` 一起吞掉会把「PAT 坏了」显示成「这次没读到」,那是相反的劝告。 - **测试 +17(599 通过 / 7 跳过,总数 589 → 606)**:路由级 6 例(先刷后读的顺序、每请求都刷、旧宿主无 dep 时照常出文档、刷新链抛错不打回空白、`creditsAtMs` 透传、`staleError` → `refreshError` 映射);真实挂载级 3 例(两次请求读到**不同**数字、瞬时失败保旧数字且覆盖层会自己消失、四个并发只合流成一次上游读);`withHardTimeout` 4 例(含「超时后才到的 rejection 不变成 unhandled rejection」);composer 页脚 4 例。**变异验证(最终代码上逐条实测)**:路由不刷新(退回 0.5.1 的纯缓存读)→ 6 红;**真·顺序读反**(先取缓存值、再刷新、返回先取的那个)→ 3 红;退回「失败即清空缓存」→ 1 红;去掉合流 → 1 红;丢掉 `refreshError` 透传 → 2 红;客户端 `staleDisplay` 恒 false → 1 红;去掉硬超时 → 2 例挂到 30s 超时。 - 第一版这里记的是「顺序读反 → 11 红」,那个变异其实把函数改成了 `return undefined`(等于顺带删掉了缓存读),11 红是**改坏了三处**的结果、不是顺序的证据;重做成真正的「先取后刷再返回旧值」是 3 红。数字变小了,但这条断言这才真的在钉顺序。 - **顺手修掉自己引入的一处偶发红**:挂载级用例原先用固定 `setTimeout(900)` 等「挂载期那几读落定」,全量并行跑、机器负载高时并不安全(实测偶发一次)。改成等**上游读计数静置**(连续 250ms 不再变化):既确定(迟到读一律计入基线之前)又更快(该组 2849ms → 1226ms),连跑 6 轮全量全绿。 - **诚实记录两处**:① 上游读的频次上升了(每次 `/usage` 一次),但 `/usage` 此前**已经**在每请求为账号池成员现场读积分(`buildPoolView` → `memberCredits`),所以这不是新增的成本类别,正是 workbuddy 的形态;并发由合流收住。② 本轮**没有**在运行中的宿主里目视确认修复后的界面 —— 那需要重装插件 + 刷新页面;线上实测只证明了**修复前**的病灶(触发器恒 800、实时值同期 738 → 710)。 ## 0.5.1 (2026-10-16) 一条 Windows 专属修复:**设备风控组件的候选路径表只写了 macOS,导致 Windows 上国际版永远读不出积分,而国内版一切正常**。修复本身是「候选表漏了一整个平台」,但顺带钉住了同因的第二条家族教训(GUI 宿主 spawn 控制台程序闪黑窗)。纯修复、无新增用户可见设置、schema 未动,按本仓库先例(0.4.1–0.4.3)记 patch。 ### Fixed - **Windows 上取不到设备身份组件,国际版的积分与签到全部落空 —— UMID 候选表只列了 macOS 的安装位置。**(由 `746a715` 修入) - **机制**:国际版的用量与签到请求**必须**携带设备风控三件套(`Cosy-MachineToken/Code/Type`),这三件套由本机 Qoder 客户端随附的 UMID 组件(`runtime-info`)产出。`device-risk.ts` 的候选表此前只有 `/Applications/Qoder[ CN].app/Contents/Resources/umid/…` 这一族 macOS 路径 —— Windows 上不存在 `/Applications`,`findDeviceRiskComponent` 在 Windows **永远落空**,`fetchUsage` 随即降级。不对称正来自这里:**国内版不要求设备风控,所以在 Windows 上照常工作**,掩盖了国际版的失效。 - **修法**:新增 `windowsUmidCandidates()`,展开 `%LOCALAPPDATA%\Programs\\resources\umid\runtime-info.exe`,区域 → App 的优先级与 macOS 一致(国际版优先 `Qoder`、国内版优先 `Qoder CN`;设备身份与区域绑定,用错一侧会被风控拒绝),**追加**在 macOS 候选之后。`%LOCALAPPDATA%` 可能未定义(无窗口会话 / CI),此时返回空数组、照常走「macOS 候选 + 环境变量」的既有路径,不报错。 - **顺带钉住同因的第二件事**:组件 spawn 的选项提炼为 `RISK_PROCESS_OPTIONS` 并带 `windowsHide: true` —— DSH Desktop 宿主是 Electron GUI 进程(无 console),Windows 会给这种父进程的控制台子程序**每次新建一个可见窗口**,即「每读一次设备身份就闪一个黑框」。这与 `host-heartbeat.ts` 的 `PROCESS_PROBE_OPTIONS` 是同一条家族教训,那边早已钉过;POSIX 上该选项被忽略,但值必须写在选项里。 - **测试**:跨平台断言改认「目录名里的 App 名」而不是 `.app` 后缀(原 `toContain('Qoder CN.app')` 在 Windows 路径上必红);新增一条 `windowsHide` 钉桩(任意平台成立)。用例总数 588 → 589。本机 `pnpm run check` 全绿:582 通过 / 7 跳过。 - **如实记录**:本版**没有**在 Windows 实机上跑过 —— 手头这台是 macOS。`%LOCALAPPDATA%` 分支的正确性目前只有单测与代码走查支撑,Windows 上的真实命中(装过 Qoder 国际版客户端)尚未观测。 ## 0.5.0 (2026-10-14) > 版本号是 **minor** 而不是 patch:这一版新增了一个**用户可见的设置**(`regions..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..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:{:{…}}}`)与置灰。变异验证:删掉席位判定 → 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`,已用成对环视修掉(`(? 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` 升级为 `{ 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)。 - 新增回归测试,并做了**变异验证**:把合并去掉,该测试立刻失败(防止写成永远绿的断言)。 - **修掉真正的根因:`

` 的浏览器默认边距**(用户实拍「还是没有改善」)。前两次我都在调容器的 `gap`/`padding`,但那根本不是空白的来源 —— `.dsm-qoder-usage-hint` 渲染成 `

`,而**浏览器默认给 `p` 加 `margin:1em 0`(上下各 16px)**。本文件里其他每一条渲染成 `

` 的规则都写了 `margin:0`,**唯独它和 `.dsm-qoder-hint` 漏了**。于是那 16px 一直顶在那里,改容器当然「没有改善」。 - 修法:两条规则都显式 `margin:0`,上下间距真正交还给容器控制(`usage-hint` 用 `padding:6px 0 6px 19px`)。 - workbuddy 的同名规则有同样的遗漏(所以那边同样偏松),但此处按用户的判断修,不做「保持一致」。 - 新增一条**机制性**测试:凡渲染成 `

` 的类,其规则必须含 `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 的应用位置逐一对应。 - **卡片页脚恢复家族结构**:原 `