dsh-connect-workbuddy 5.7.0 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.8.1 (2026-10-24)
4
+
5
+ > 修复版本:解决 DSH 会话在国内区**每一步**都 `400 / 11128` 的死循环。根因不是账号、额度、网络,也不是「宿主提示词要你改」——是插件的救援梯子对网关的**精确子串门**有一个可证明的盲区:`You are a coding agent powered by <model> model.` 这条 agent 身份从句,稀疏零宽扰动打不到,于是每次请求都逐字节原样上线被拒。**凭据、计费、provider 注册均未改动**;请求路径新增的定向破坏只插不可见字符(实测真实提示 **+0.26% 字节**),不改模型读到的任何一个词。
6
+
7
+ ### Fixes
8
+
9
+ - **DSH 提示词里的 agent 身份从句现在在请求路径就被破坏,不再等被拒(#125 / `workbuddy2api-hub` #533)。**
10
+ - 两条独立实测指向同一机制:网关对被扫的 `system` / `assistant` 消息做的是**大小写不敏感的精确子串匹配**(不只是开头前缀),活的 DSH 提示词里有**三处**这样的 span——`You are an AI agent powered by DeepSeek Harness.`、`You are a coding agent powered by the <model> model.`、`You are interacting with the user through the DeepSeek Harness Web GUI …`——且**三处全改才 200**(「只改任意一两处不够」)。
11
+ - 盲区在第二处:它**没有相邻大写词对**(`WORD_INTERNAL_BREAK` 打不到)、**没有逗号/分号**、句末那个 `.` 又落在被禁子串**之后**(句读规则也打不到)。所以 5.7.0 的稀疏扰动清掉了第一、三处,却把第二处原样发出——那条请求于是一直 400,而错误回显把它标成「不在插件已知名单里」,反而把人引去改一条根本没问题的宿主提示。
12
+ - 修法是在 `prepareChatBody()` 里新增定向破坏 `breakIdentityClauses()`:在身份从句的**每个内部词界**后插一个 U+200B。实测三处 span 全部失配、**模型读到的每个词都不变**(与 #533 那条把 `powered by` 改写成 `built on` 的做法不同——那个会动措辞),真实 10,818 字提示只 **+0.26% 字节**(~28 个字符),足够便宜到无条件跑。前缀名单(`UNAPPROVED_CHANNEL_OPENINGS`)与它各司其职:名单管 offset 0 的前缀门,从句破坏管任意位置的子串门;顺序是**先补中性头、再破坏从句**,否则从句里的 U+200B 会让前缀谓词匹配不上、连累中性头这级防线(有测试钉住这个顺序)。
13
+ - **删掉 `perturbChannelFingerprints()` 的粗粒度幂等保护,改由各规则自身幂等(同上)。**
14
+ - 那句 `if (text.includes(U+200B)) return undefined` 原本是防重试叠加零宽。但请求路径现在**本来就会**先放几个 U+200B 进去,这句粗判会让整个梯子从第二级起**永久空转**——连 dense 兜底都被它挡掉,等于救援彻底失效。
15
+ - 幂等改由各规则自己保证:拉丁句读/逗号规则锚在尾随 `(\s+)` 上(U+200B 不是空白,已断的句子自然不再匹配);CJK 句读规则没有尾随空白可锚,**补 `(?!\u200b)` lookahead**,否则每次重试都会在 `。!` 后叠一个零宽;`breakIdentityClauses()` 靠 `\s+` 不匹配 U+200B 而自然幂等。三条各有测试。
16
+ - **`channelPerturbation: "always"` 从推荐手段降级为边缘手段。** 定向破坏已用 +0.26% 覆盖三处已知 span,密集扰动(+70.7%)只剩「出现定向破坏与稀疏都打不到的**未知** prose 指纹」这一种理由。默认仍是 `failure`,README 与 `docs/DESIGN.md` 的措辞同步改了,不再建议普通用户开 `always`。
17
+
18
+ ### Docs
19
+
20
+ - [`docs/DESIGN.md`](docs/DESIGN.md) 渠道门一节新增两条要点(定向破坏的动机与实测成本、幂等保护的取舍),并订正「`always` 是唯一可靠手段」这句已过时的说法。
21
+ - [`docs/CHANNEL-FINGERPRINT-RESEARCH.md`](docs/CHANNEL-FINGERPRINT-RESEARCH.md) 补 `§6.2`,记录本次落地如何比原建议 1/3(直接上密集扰动)更省,以及为何 `always` 降级。
22
+
23
+ ### Tests
24
+
25
+ - 新增 [`tests/identity-clause-break.spec.ts`](tests/identity-clause-break.spec.ts)(13 条):三处 span 全部失配、词不变、成本上界(<40 字符)、大小写/换行/任意空白、自然幂等、普通对话逐字节不变、assistant 历史也被破坏、`user`/`tool` 不动、以及移除粗判后三条扰动规则各自的幂等(含 CJK lookahead)。
26
+ - 因身份从句现在在请求路径就被破坏,`issue98` / `issue98-shim` / `upstream` 里几条钉「旧字节形态」的断言改为钉**不变量**(去掉 U+200B 后与宿主原文一致、前缀门仍补中性头、扰动仍是纯附加),并逐条加注释说明旧断言为何不再成立——包括 `upstream.spec.ts` 那条曾断言「中段指纹逐字节不变」的测试,它编码的正是 #125 证伪的那个信念。
27
+ - 完整套件 **2218 通过 / 2 条按平台跳过**(此前 2205),`typecheck` 与 `build` 通过。构建产物用「故意破坏源码、只留 `lib/`」的方式独立复验,确认定向破坏确实进了包(运行时对真实形状 prompt 报 0 处 span 存活)。
28
+
29
+ ## 5.8.0 (2026-10-24)
30
+
31
+ > 特性版本:把 `11128` 渠道拒绝与 `11140` 内容/账号限制**真正分开**(此前整族被误判成同一件事),补上企业/教育账号在账号池里丢失的身份标记,修掉「设置已落盘、按钮却永远卡在保存中」,并按 #132 提供与官方客户端一致的请求标识。**凭据、计费、provider 注册与默认发出的字节均未改动**:#132 的两项(`usageIdentity`、`keepPromptLast`)都是**默认关闭的开关**——前者只改显示用的请求标识,后者会改变上游会数到的消息顺序,而它们的实测证据来自报告者的账号与 5.3.1 构建、本插件未在真机复现,因此不以任何形式静默上线。
32
+
33
+ ### Features
34
+
35
+ - **`11128` 不再被当成内容政策拒绝(#127)。**
36
+ - 11128 家族(渠道未批准 / 会话 id 缺失 / 首条不是 system)与 11140 家族的 `msg` 都可能逐字为 `request illegal`,而 5.7.0 的分类器把这条**裸文本**当成 11140 的标记,于是渠道拒绝被分类成 `policy_reject`:账号池按「内容问题、换号无用」处理,用户看到的建议也是错的。现在分类以**业务码**为准,裸 `request illegal` 标记被删掉。
37
+ - 11140 的两种语义此前从未被区分:内容政策拒绝(请求级)与**账号被上游限制使用**(`账号存在异常行为…` / `restricted due to unusual activity`)。新增 `account_restricted` 错误类:它**值得换号**(实测 5.7.0 的 `isFailoverWorthy` 只排除 `client` / `model_busy` / `channel_reject` / `model_queue`,所以当时的 `policy_reject` **会**换号——「5.7.0 按换号无用处理」是错的),并把该账号**停权**(`account-restricted`,不进入冷却倒计时)。
38
+ - **停权的回池路径实测只有一条:手动「测试」该账号。** 账号 id 是 `uin`/`uid` 的 sha256,重新登录后仍是同一个 id,`readPoolProbes` 也不做过期清理,所以重登/导入**不会**清掉这条测量;「只有重新授权才回池」是不成立的表述(review #127 指出后已改正)。上游解除限制之后,在卡片上测一次拿到可用结果,这条测量被覆盖,账号才会重新成为候选。此外停权是**池内候选资格**层面的排除,不是请求级硬闸:全池没有可用候选时,`applyPoolSelection` 会清掉轮换覆盖、请求回到保存的选择,被停权的账号若正是它,仍会计费一次并被拒一次。
39
+ - 这一分界两处都做了反向测试钉住:`channel_rejected` 不换号、`account_restricted` 换号并写停权(新增 shim 级测试——此前删掉 `isFailoverWorthy` 里的这一类,全套 2196 条测试依然全绿,等于没有守卫)。
40
+ - **对齐官方客户端的请求标识(#132,报告者实测;本插件未在真机复测)。**
41
+ - 新增 `usageIdentity`(**默认 `false`**):开启后 chat 请求带上 `X-IDE-Type: CLI` / `X-IDE-Name: WorkBuddy` / `X-IDE-Version`,用量页的「使用端」由此从空白变成 `WorkBuddy`;版本号由 `PRODUCT_UA` 解析得出,不留第二个硬编码数字。**`User-Agent` 两种取值下都不变**——聊天始终以 CLI 通道发送,会话身份三元组绑在这条通道上。默认关的理由见下。
42
+ - 同一个开关下,**chat** 路径的会话身份三件套改为 **UUIDv7 的 32 位小写十六进制**形态并补 `X-Request-ID`(与 `X-Conversation-Message-ID` 同值,官方客户端也是同值):网关把这个 id 回显成 SSE 的 `id` 并写进用量页,此前带连字符的 v4 让同一账号的记录看起来像两种客户端。三件套在任何形状下都是**每请求现生成**、互不相同。**生图路径不受这个开关影响**,保持它 2026-10-21 实测过的带连字符 v4:#132 只测了 chat,而生图的 id **格式**从来不是那道闸问的事,不为了用量页好看把它扫进没人测过的形状(这条分界经变异测试确认能失败)。
43
+ - 新增 `keepPromptLast`(默认 `false`):用量页的「请求」列取自 body 里**最后一条** `user` 消息,而宿主把时间采样、`Current runtime context.`、`<system-reminder>` 追加在真实 prompt **之后**,于是那一列记的是注入块而不是用户输入。开启后把这些注入块**搬到真实 prompt 之前**——消息数、角色、正文都不变,只有顺序变。唯一的正文改动是剥掉**被记录那一条** prompt 的前导 `<acp …>mNNNNN</acp>` 标记(不剥的话那一列仍然以标记开头,也就没修好),而更早的消息**保留各自**的标记:模型靠它们引用历史,而「正在回答的这条」的编号是唯一不可能被引用的。默认关闭的理由:它改变上游会数到的消息顺序、并删掉真实 prompt 的几个前导字符,升级后不该静默变化;搬移只认实测过的注入前缀,普通对话在没有注入尾块时逐字节不变(含「不剥离标记」这条独立测试)。
44
+ - 报告者提议的第四项(把会话标题请求改写为隐藏模型 `wb-lite`)**未实现**:它不是格式对齐,而是给每个新会话增加一次真实计费请求,且依赖宿主标题模板的具体前缀。取舍写在 #132 的回复里。
45
+
46
+ - **余额胶囊会标出新模型(#128,5.7.0 之后合入、随本版发布)。**
47
+ - 胶囊的模型列表现在会显示「· N 新模型」,并给本机**第一次**见到的模型单独加标记。判定基准是本机记录的目录快照(`roster-seen-store.ts`,doctor 的 `rosterSeen` 一节会打印它的状态与可安全清空的版本号),而不是「比上次请求多了什么」——所以重启、切区域或重新登录都不会把同一批模型反复标成新的。
48
+ - 只有**真的新增**才计数:目录里消失又出现的模型、以及只有 tier/促销信息变化的行都不算,避免徽标长期亮着变成噪音。
49
+ - **CLI 桥接卡片补上模型/档位/区域顺序的快调选择器(5.7.0 之后合入、随本版发布)。**
50
+ - CLI 开关下方新增模型、档位与 `regionOrder` 三个快调项,改完即写回配置,不必进设置页翻。`regionOrder` 决定同一账号在 CN / global 两个 provider 之间的尝试顺序,此前只能在配置文件里改。
51
+
52
+ ### Fixes
53
+
54
+ - **账号池轮换后,卡片上的「当前选用」标在保存的选择上,不是正在计费的账号(报告截图)。**
55
+ - `current()` 会**先看**池的运行时轮换标记(`rotatedAccountId`),再看用户保存的选择;而 `accounts()` 回答 `selected` 时只看保存的选择。池子把请求轮换到 B 号后,shim 记在 B 号账上,卡片却把 A 号标成「当前选用」——截图里那枚标记落在被限流的号上,而胶囊与行内数字都属于另一个号。
56
+ - 同一份用量文档还会因此**点名两个账号**:`credential` 取自 `current()`(真正计费的那个),而 `account.accountId` / `accountName` 取自 `accounts()`(保存的那个)。现在 `accounts()` 用与 `current()` **完全相同**的优先级解析标记:有活跃轮换且该账号仍在本地,就标它;轮换指向已消失的账号时与 `current()` 一样回落;其余情况仍按「显式选择 / 默认最新」判断。清除轮换后标记随 `current()` 回到用户自己的选择,轮换本身仍然从不写设置。
57
+ - 面板里还有第二处同源分裂:行的**底色**读 credits 端点的 `selected`(保存的选择),**「当前选用」文字**读池文档的 `current`(被计费的账号)。池子一轮换,两者就指向不同行——报告者看到的「底色在一个号、文字在另一个号」。现在整行(底色、文字、切换按钮的禁用态)都读同一个答案;`selected` 只在没有池文档时才作为兜底。
58
+ - **企业/教育版账号在账号池里丢失 `enterprise` 标记(#129)。**
59
+ - 存储层早就算出了 `enterprise: true`,但三个转发点把它丢了:`otherAccounts`、`localPoolMembers`、`poolMembersOf`(`poolTargets` 在 5.6.0 就已带上)。结果企业账号在池卡片上显示一个**根本不存在的「领取」按钮**和一列「签到」,而企业成员点这两样都只会被 `10001` 拒绝。
60
+ - 现在这三个转发点都带上了标记,池行据此签到列显示「不适用(企业版)」、权益块整段隐藏;领取路由在**发起任何上游请求之前**就用 409 拒绝(`enterprise-account-unsupported`)。**Web 侧只到账号行这一层**:`toWebAccount` 与用量页的池账号行带标记,用量页顶部那个 `account` 对象没有——它这次修的是**名字**(改用 `preferredAccountName`,此前只取 `credential.nickname` 导致企业账号显示为空),不是标记;那里也不渲染签到/领取。输入框胶囊(`workBuddyAccountCredits`)同样不带标记,它不显示这两个按钮,因此没有对应缺陷。
61
+ - `10001` 这条实测的出处是 **5.6.0** 的企业账号签到记录,不是 5.7.x;**权益读接口照样返回活动(`active: false`)而写接口一律拒绝**这一点是 #129 报告者的观察,本插件没有在真机上复测过企业账号,所以读取结果不能作为「可以领」的依据这一结论仍成立,但它的证据等级是报告者账号级、不是插件实测。
62
+ - 用量页头部此前只取 `credential.nickname`,displayName 与 enterpriseUserName 都拿不到,企业账号显示为空;现在与池里共用同一个 `preferredAccountName` 顺序(displayName → enterpriseUserName → nickname),两处不再各写一份。
63
+ - **保存账号池偏好后按钮永远卡在「保存中…」(#131)。**
64
+ - 设置其实**已经落盘并通过回读校验**,卡住的只是随后那次用量刷新:写入与刷新共用同一个 `saving` 状态,而刷新可能因为账号被限制、网络抖动或上游慢响应而迟迟不返回,用户于是看到「没保存成功」的假象,草稿也被锁死。
65
+ - 现在 `saving` 只覆盖「已校验的设置写入」这一件事:写成功立刻解除 busy 并丢弃草稿,然后 **`await`** 那次回读(不是「再异步刷新」——它是串行等到的,只是结果只影响显示)。这带来一个明确的取舍:回读完成前,卡片上的行还是旧值;`refreshUsage` 失败时不抛而是 resolve,所以「没抛异常」不等于「读到了新值」,旧契约里「保留草稿直到刷新成功」的写法会在这个窗口里拿陈旧的 `saved` 去比对——因此回读结果与保存判定彻底分开。
66
+ - 刷新失败不影响「已保存」的判定,只显示一条**独立**提示(`row.poolSavedStaleRefresh`:「已保存,但面板未能重新读取。设置已写入;下次刷新后会显示。」),它与「保存失败」是两个节点:前者 `role="status"`、后者 `role="alert"`,不再复用 `row.poolSaveFailed`,也就不会出现「保存失败:已保存,但……」这种自相矛盾的一行。只有写入失败才保留草稿。模型选择的保存路径(`WorkBuddyCard.saveModels`)同步修正。
67
+
68
+ ### Docs
69
+
70
+ - [`docs/DESIGN.md`](docs/DESIGN.md) 补充 `11128` / `11140` 的分类依据(**按业务码,不按 `displayTips`,也不按裸 `request illegal`**)、`account-restricted` 的停权语义与它唯一实测的回池路径(手动「测试」),以及「渠道拒绝不换号、账号限制要停权」的分界。
71
+
72
+ ### Tests
73
+
74
+ - 新增 11128/11140 分类与账号限制切分(含按信封读码、转义/断行措辞、混合体归属三组反向测试)、企业标记贯穿三个池内转发点与 Web 账号行、领取 409 拒绝、保存后草稿丢弃与陈旧刷新提示(两条 UI 测试原先钉住的正是缺陷本身,已改正)、以及「`selected` 必须跟随 `current()` 的轮换优先级」两条(轮换中只许一个标记、轮换指向已消失账号时回落);会话身份三件套一节 9 条(默认形状不变 / 开启后的 v7+`X-IDE-*`+`X-Request-ID`+`User-Agent` 不变 / 生图不受开关影响);`keepPromptLast` 一节 10 条(默认不动、搬移正确、`system-reminder` 与 `<acp>` 形状、**取自真实会话的组合注入形状**、**不重排普通对话**这条反向测试、system 仍居首、只剥离被记录那一条的前导标记、array content 两种形状、无注入块时不剥离)。
75
+ - 完整套件 **2205 通过 / 2 条按平台跳过**,`typecheck` 与 `build` 通过。所有「守卫能失败」的说法都做过变异验证,逐条结果:X-IDE 或三件套改成「无视开关」→ 默认形状测试变红;开关改成永不生效 → 对齐测试变红;生图 `'image'` 改成 `'chat'`、或删掉 `shape === 'chat'` 判据 → 生图守卫变红(这条只有把 identity 真正传进 `imageHeaders` 之后才成立——`shape` 曾经是空转参数,那时改它不会变红,是真的假阳性,已修);删掉 `isFailoverWorthy` 里的 `account_restricted` → 新增的 shim 换号测试变红(此前全绿);把 11140 的码判定退回紧凑字面量或退回裸 `11140` 文本 → 新增的「按信封读码」测试变红(分别命中 `spaced: client` 与十六进制假阳性);让 `accounts()` 重新忽略轮换标记 → 新增的「标记跟随被计费账号」测试变红;把面板的底色改回读 `selected` → 新增的「底色与文字指向同一行」测试变红。
76
+
3
77
  ## 5.7.0 (2026-10-23)
4
78
 
5
79
  > 特性版本:加强 WorkBuddy 渠道校验(`11128`)的三级自愈救援,并改善失败证据回显。**凭据、计费、provider 注册与普通请求路径均未改动**;默认策略仍只在渠道校验拒绝后启用,可选 `channelPerturbation: "always"` 让已确认普通请求必失败的环境跳过无效首轮。
package/README.en.md CHANGED
@@ -48,6 +48,8 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
48
48
 
49
49
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a one-click test button and a last-refreshed time (account names redacted)" />
50
50
  - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
51
+ - **Enterprise / education accounts: check-in and Claim do not apply** — a seat carrying an `enterpriseId` shows **no check-in column, no benefit campaigns, and no Claim request** in the pool (5.8.0, issue #129): upstream refuses the personal-entitlement **write** endpoints for such members (measured `10001`), while its **read** endpoint still lists the campaigns as unclaimed — so "the read said available" can never be the reason to write. Their credits live in a different book too (enterprise usage = limit − used), and the personal credit table simply returns an empty list. The flag now reaches the pool rows (`localPoolMembers`, `poolMembersOf`, `otherAccounts`) and the web account rows, and it is attached the moment an account enters the pool rather than after a click discovers it is unsupported. It is deliberately **not** on the usage page's top-level `account` object (that one was fixed for the display **name** only, via `preferredAccountName`) and not on the composer pill, which renders neither button.
52
+ - **Optional: make the usage page record these calls like the official client's (5.8.0, issue #132, OFF by default)** — with `usageIdentity` on, chat requests carry `X-IDE-Name: WorkBuddy` (plus `X-IDE-Type: CLI` and a version taken from the existing product UA, not a second hard-coded number), so the usage page's client column is no longer blank, and the conversation-identity triple becomes the official **32-character lower-case hex UUIDv7** with `X-Request-ID` alongside it — the gateway echoes that id as the SSE `id` and writes it into the usage page, so hyphenated v4 ids made one account's rows look like two different clients — while "fresh per request, all distinct" has not been relaxed. **`User-Agent` is untouched under either setting**: billing and rate limits recognise the CLI channel. The image endpoint does not follow this switch; it keeps the shape measured on 2026-10-21. It is off by default because the shape fixes nothing on its own (the three `11128` doors read message openings, a missing conversation id, and whether the first message is a system prompt — no header moves any of them) and the only measurement is a reporter's on an older build, so a purely cosmetic alignment does not get to put every account at risk first. There is also an opt-in `keepPromptLast` (**off by default**): the usage page takes the request text from the LAST `user` message, and the host appends its time samples, runtime context and `<system-reminder>` blocks AFTER what you actually typed, so that column recorded injected text; when enabled it moves those injected blocks **above the real prompt** — order only, except that the recorded prompt's own leading `<acp …>mNNNNN</acp>` marker is stripped (leave it and that column still opens with a marker, i.e. nothing was fixed); earlier messages **keep** theirs, because the model cites history by those ids and the only id that can never matter is the message being answered now. It stays off by default because the order is something the upstream counts and a few leading characters are removed from the real prompt. The move matches only the host's own injected prefixes, and a conversation with no injected tail is untouched byte for byte — marker included — pinned by tests written for both.
51
53
  - **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
52
54
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
53
55
  - **Add accounts by QR sign-in, import/export them in bulk, test or delete them one by one** — the WorkBuddy desktop app is no longer a prerequisite: the card's **Sign in with QR code** button (labelled **Sign in on the web** for the international build — that region uses a browser login page, there is no QR; it sits just above **More settings** and acts immediately, with nothing to save) opens a dialog and takes you **straight to the authorization page** in your browser; if the browser blocks that, the dialog's link, copy and **Open page again** are the fallback. Every credential lands in the plugin's own vault (`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`, one file per account), and the desktop app's auth files stay **read-only and are never modified**. Each row of the account table gains **Test** and **Remove**: a row test reuses the same probe as the batch run, so the two are comparable; removing deletes only the copy the plugin stored and **hides** the account: it disappears at once and the automatic poll cannot bring it back, while **Detect accounts again** restores it (signing in or importing the same account clears the hide too). **The desktop app's sign-in is never touched** — removing means "this plugin no longer lists it", not "sign out of the app". **Export / import accounts** moves credentials to another machine: pick accounts to export a JSON file → choose a file and read a **redacted preview** (field names and whether a token is present; the token itself never enters the preview) → tick what to import. An import overwrites per account rather than duplicating, and **only a record positively identified as belonging to that region is stored** — a domain the plugin cannot classify is skipped and counted as skipped. See [Account pool](#account-pool).
@@ -152,6 +154,7 @@ The last two rows are deliberate: check-in and testing are MANUAL actions over t
152
154
  | Rate limited | Out of the pool **temporarily**, returning on the reset time the upstream stated (and when it states none, the card says so rather than **inventing a countdown**) |
153
155
  | Out of credit | Out of the pool, returning at the monthly package's refresh time |
154
156
  | Credential rejected / model not found / upstream unreachable / failed | Out of the pool until a later probe refreshes the result |
157
+ | **Account restricted upstream** | **Benched for good; waiting fixes nothing** (5.8.0). The upstream names this ACCOUNT ("unusual activity"), so automatic failover **does** route around it. That is the opposite of a channel-verification refusal, which is about the REQUEST: switching accounts there would only burn credit on the same 400, so the plugin does not switch. The only real way back is a **manual "test"** of that account: the pool id is a hash of `uin`/`uid`, so re-authorising produces the same id and nothing clears the stored measurement (a re-login does **not** lift this bench). Re-test after the upstream lifts the restriction and it becomes a candidate again. The plugin **invents no countdown**; the bench only removes it from rotation — with no usable candidate left the request still falls to your saved account |
155
158
 
156
159
  The key line: **"rate limited" is not "unusable"**. It is precisely the state most worth waiting on — treating it as permanently dead throws away the accounts that would have served you best.
157
160
 
@@ -163,7 +166,7 @@ The region status line changed with it: **while the pool is on it does not name
163
166
 
164
167
  ### Saving and drafts: why the card has two Save buttons
165
168
 
166
- One for model management, one for the pool — **deliberately kept apart** rather than merged: the two draft domains differ, and merging them means one failed write drags the other down (a catalog that will not save would stop you saving pool preferences too); a shared Discard would also throw away both drafts on a single misclick. Both sides use a **verified write** — **a write that did not land never discards the draft**, or your edits are lost for good.
169
+ One for model management, one for the pool — **deliberately kept apart** rather than merged: the two draft domains differ, and merging them means one failed write drags the other down (a catalog that will not save would stop you saving pool preferences too); a shared Discard would also throw away both drafts on a single misclick. Both sides use a **verified write** — **a write that did not land never discards the draft**, or your edits are lost for good. The busy spinner covers **only that verified write** (5.8.0, issue #131): as soon as it lands, the button clears and the draft is discarded, and the usage refresh that follows is display only — a slow or failed refresh never re-labelling a saved setting as "not saved", it only adds a line saying the setting saved but the list has not refreshed. Both used to share one loading, so a single slow refresh could spin forever on data that had long since been written.
167
170
 
168
171
  One class of state in the pool **does not go through a draft**: probe results, cooldowns, and the account that served a request. Those are **observations**, written frequently by the plugin itself. The reason is practical — drafts are overwrite-based, so if observations lived in one, a single manual **Save** could roll the result a timer had just written **back to a several-minute-old value**.
169
172
 
package/README.md CHANGED
@@ -55,6 +55,8 @@
55
55
 
56
56
  <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、一键测试按钮与最近刷新时间(账号名已打码)" />
57
57
  - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**签到勾选的账号**、**测试勾选的账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
58
+ - **企业版 / 教育版账号:签到与「领取」不适用** —— 带 `enterpriseId` 的席位(企业版或教育版)在池子里**不显示签到列、不显示福利活动,也不会发出领取请求**(5.8.0,issue #129):上游对这类成员的个人权益**写**接口一律拒绝(实测 `10001`),而它的**读**接口照样把活动列出来、标着「未领」——所以「读到了」绝不能当成「可以领」。积分也在另一本账上(企业用量 = 上限 − 已用),个人积分表对这种账号返回空列表。这个判断贯穿池卡片、输入框胶囊与 Web 状态页,并且在账号进入池子的那一刻就带上标记,而不是等用户点了按钮才发现不支持。
59
+ - **可选:让上游用量页把这笔记成官方客户端发的那笔(5.8.0,issue #132,默认关闭)** —— 开 `usageIdentity` 后 chat 请求带上 `X-IDE-Name: WorkBuddy`(连同 `X-IDE-Type: CLI` 与版本号;版本号取自现有的产品 UA,不留第二个硬编码数字),用量页的「使用端」由此不再空白;会话身份三件套同时改成官方客户端那种 **32 位十六进制 UUIDv7** 并补上 `X-Request-ID`——网关把这个 id 回显成 SSE 的 `id` 并写进用量页,此前带连字符的 v4 与官方记录摆在同一页上像两种客户端;**每请求现生成、互不相同**这条没有放松。**`User-Agent` 在两种取值下都一字未动**——账单与限流认的是 CLI 通道;生图路径也不跟着这个开关变,它保持自己 2026-10-21 实测过的形状。**默认关**的理由:形状本身不修任何东西(`11128` 的三道门读的是消息开头、会话 id 有没有、首条是不是 system,头改不动其中任何一条),而实测证据来自报告者的账号与旧版本、本插件未在真机复现——一次纯显示对齐不该让所有账号先承担风险。另有可选的 `keepPromptLast`(**默认关闭**):用量页的「请求」列取自 body 里**最后一条** `user` 消息,而宿主把时间采样、运行时上下文、`<system-reminder>` 追加在你真正输入的那句**之后**,那一列于是常年记的是注入块;开启后把这些注入块**搬到真实输入之前**(只变顺序;另外会剥掉**被记录那一条**真实输入前面的 `<acp …>mNNNNN</acp>` 标记,否则那一列仍然以标记开头而等于没修,更早的消息一律保留自己的标记,因为模型靠它们引用历史)。默认关的理由是它会改变上游会数到的消息顺序、并删掉真实输入的几个前导字符;搬移只匹配宿主自己的注入前缀,而普通对话在没有注入尾块时逐字节不变(连标记也不剥)——这两点各有专门测试钉住。
58
60
  - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
59
61
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
60
62
  - **扫码添加账号、批量导入导出、逐账号测试/删除** —— 不必先装 WorkBuddy 桌面 App:卡片里的「扫码登录」按钮(国际版为「网页登录」——那边是浏览器登录页,没有二维码;这一行就在「更多设置」上方,点一下立即生效,不需要保存)点开即弹出对话框,并在浏览器里**自动打开**授权页;被浏览器拦截时对话框内提供链接、复制与「重新打开页面」兜底。所有凭据统一落到插件自己的凭据库(`$DSH_HOME/workbuddy-vault/<region>/<accountId>.json`,一账号一文件),**桌面 App 的认证文件始终只读、永不被改动**。账号表格每一行右侧有「测试」与「删除」:测试与批量测试走同一套探测,结果可比;删除只删插件自己存的那份,并把这个账号**暂时隐藏**:删掉后立刻消失,也不会被自动轮询带回来;点「重新检测账号」即可找回(重新登录或导入同一账号也会清除隐藏记录)。**桌面 App 的登录始终不动**——删除是「本插件不再列出它」,不是退出 App 登录。「导出 / 导入账号」可把凭据搬到另一台机器:多选导出 JSON → 选文件后先看**脱敏预览**(只显示字段与是否有 token,明文不进预览)→ 勾选导入。导入按账号去重覆盖,**只有确认属于该区域**的记录才入库,认不出的域名会被跳过并如实计入「已跳过」。详见[账号池](#账号池)。
@@ -164,6 +166,7 @@ DSH PiAiAdapter(每个 provider 一套)
164
166
  | 积分耗尽 | 出池,按月周期包的刷新时间回池 |
165
167
  | 连不上上游 / 失败 | **暂时**出池,30 分钟后自动回池——这类结果说的往往不是账号,而是一次网络抖动或上游 5xx |
166
168
  | 凭据被拒 / 模型不存在 | 出池,**只能靠手动「测试」回池**:一个需要重新登录,一个是本区域不再提供该模型,等待都修不好 |
169
+ | **账号被上游限制使用** | **停权,等待修不好**(5.8.0)。上游的话是「账号存在异常行为,已被限制使用」——它点名的是**这个账号**,所以自动换号**会**绕开它去用下一个。这与「渠道校验失败」正好相反:那条说的是**这个请求**,换号只会白烧一次额度,因此**不**换号。**回池的实际出路只有手动「测试」**:账号 id 是 `uin`/`uid` 的哈希,重新登录后仍是同一个 id,插件也不会因为重登清掉这条测量(「重新授权就回池」不成立);上游解除限制后测一次通过,它才会重新成为候选。插件**不给它编一个倒计时**,但停权只作用于轮换候选资格——全池没有可用账号时,请求仍会落到你保存的那个账号上 |
167
170
 
168
171
  关键一条:**「被限流」「连不上上游」都不等于不可用**。它们恰恰是最值得等的那两类状态——当成永久失效剔除,正好丢掉池子里本来最能用的账号,而且**当池内每个成员都被这样剔除时,池子就没有任何候选,自动换号会无处可去**,你只会看到上游原始报错。
169
172
 
@@ -171,7 +174,7 @@ DSH PiAiAdapter(每个 provider 一套)
171
174
 
172
175
  ### 保存与草稿:为什么卡片上有两个「保存」
173
176
 
174
- 模型管理一个、账号池一个,这是**刻意保留**而非漏合并:两者的草稿域不同,合并后一次写入失败会连累另一边(模型目录写不进去,就连池偏好也存不了);一个共享的「放弃」还会在一次误点里同时丢掉两份草稿。两边都是**验证写入**——**写不成功不丢弃草稿**,否则用户的编辑就永久丢了。
177
+ 模型管理一个、账号池一个,这是**刻意保留**而非漏合并:两者的草稿域不同,合并后一次写入失败会连累另一边(模型目录写不进去,就连池偏好也存不了);一个共享的「放弃」还会在一次误点里同时丢掉两份草稿。两边都是**验证写入**——**写不成功不丢弃草稿**,否则用户的编辑就永久丢了。「保存中」那个转圈**只覆盖这次已校验的写入**(5.8.0,issue #131):写入一落地就解除忙碌、丢弃草稿,随后的用量刷新只是显示——刷新慢或失败都**不会**把已经保存成功的设置说成「没保存上」,只会另附一行「已保存,但面板未能重新读取。设置已写入;下次刷新后会显示。」(`role="status"`,与「保存失败」的 `role="alert"` 是两个独立节点)。此前两者共用同一个 loading,于是一次慢刷新能让按钮永远转下去,而数据其实早就已经落盘。
175
178
 
176
179
  池里还有一类状态**不走草稿**:探测结果、冷却时间、当前轮换到的账号。它们是**观测事实**,由插件自己高频写入。原因很实际——草稿是覆盖式的,若观测结果也进草稿,你手工点一次「保存」就可能把插件刚写进去的结果**回退成几分钟前的旧值**。
177
180
 
package/docs/WINDOWS.md CHANGED
@@ -47,6 +47,12 @@ Windows 在本项目里不是「顺带能跑」的平台,而是**有专属分
47
47
  | **B CI 实测** | `windows-latest` 上跑过,覆盖跨平台正确性 | 套件全绿;`tests/auth.spec.ts` 的 `win32` 分支断言(注入 `platform`/`home`/`env`,**任何主机上都能跑**) |
48
48
  | **C 约定推导** | 按平台约定写出 + 单元测试钉住,**未在真机逐项验证** | 四个凭据/可执行文件候选路径中**未在本机命中的那些**(见 §7 记录:本机只命中了 `%LOCALAPPDATA%\Programs\WorkBuddy` 与 `%LOCALAPPDATA%\...\auth`,其余候选仍属 C 档) |
49
49
 
50
+ **档位决定用途,不只是一句标注**(2026-10-24 补写,来源是 #132 的实际取舍):
51
+
52
+ - **A / B 档**可以支撑「这是正确行为」的断言,也就可以作为默认值上线。
53
+ - **C 档**只支撑「按约定应当如此」,**不足以改动默认发出的字节**。C 档结论要上线,只能做成**默认关闭的开关**,并在文档里写明「报告者实测、本插件未复现」——因为它错的时候,代价落在每一个没选择它的用户身上。
54
+ - **D 档**只能作为待办登记,不得写进行为说明。
55
+
50
56
  > **2026-10-16 更正(issue #68 逼出来的)**:上面 ① 此前写作「`tests/at-rest.spec.ts` 里**固定向量** `keyId=9127dea1b44020a7`」,并把该测试当作这条观察的守卫。**那个说法是错的**:观察本身成立(§7 的记录也独立记着它),但**推导出该 keyId 的那把 secret 从未被留存**,所以测试只能断言 16 位十六进制**形态**,无法复现该值 —— 它此前并未真正钉住这个向量,而文档把它读成了「已钉住」。
51
57
  >
52
58
  > 现已把测试改名为「derives a 16-hex-char id, which is the shape the real envelopes carry」并在注释里写明它不证明什么;**推导规则**(哈希 base64 串而非解码字节、取 `sha256(key)` 前 16 位十六进制)由它上面的两条测试真正钉住 —— 那两条才是否证得动的。
package/lib/bin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-CVz2J0Xt.js";
2
+ import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, It as hostPeersInfo, M as legacyWorkbuddyOwnAuthPath, Ot as findWorkbuddyAppExecutableWithSource, S as WORKBUDDY_AUTH_FILE_ENV, St as WORKBUDDY_APP_EXECUTABLE_ENV, V as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-CM-09T4U.js";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { realpathSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
5
6
  //#region src/bin.ts
@@ -25,8 +26,12 @@ import { fileURLToPath } from "node:url";
25
26
  * 3 → 4: added `hostPeers` (issue #47). Consumers that switch on the version
26
27
  * see the field appear rather than having to guess whether its absence means
27
28
  * "old build" or "no peer".
29
+ *
30
+ * 4 → 5: added `rosterSeen`, so a first-sighting history that is corrupt or in
31
+ * a version this build cannot read is REPORTED rather than silently answered as
32
+ * "no new models".
28
33
  */
29
- const JSON_SCHEMA_VERSION = 4;
34
+ const JSON_SCHEMA_VERSION = 5;
30
35
  /** Both regions, in reporting order. */
31
36
  const REGIONS = ["cn", "global"];
32
37
  /** Region labels for human output. */
@@ -65,6 +70,39 @@ function makeAnyStore() {
65
70
  const client = new WorkBuddyUpstreamClient();
66
71
  return new WorkBuddyCredentialStore({ refresh: (credential) => client.refreshToken(credential) });
67
72
  }
73
+ /**
74
+ * Read one region's first-sighting history and describe its payload.
75
+ *
76
+ * Read-only: this CLI never writes history, so running `doctor` cannot change
77
+ * who counts as new. The point is that a corrupt file or one written by a newer
78
+ * build is REPORTED here — without this, both would surface as "no new models",
79
+ * which is a statement about this machine's catalogue and never a file.
80
+ */
81
+ async function describeRosterSeen(region) {
82
+ const path = rosterSeenPath(region);
83
+ let raw = null;
84
+ try {
85
+ raw = JSON.parse(await readFile(path, "utf8"));
86
+ } catch {
87
+ raw = null;
88
+ }
89
+ const described = describeRosterSeenPayload(raw);
90
+ if (described === null) return {
91
+ path,
92
+ present: raw !== null,
93
+ readable: false
94
+ };
95
+ const version = raw["version"];
96
+ return {
97
+ path,
98
+ present: true,
99
+ readable: true,
100
+ versionKnown: described.versionKnown,
101
+ ...typeof version === "number" ? { version } : {},
102
+ ids: described.ids,
103
+ fresh: described.fresh
104
+ };
105
+ }
68
106
  async function doctor(jsonOutput) {
69
107
  const anyStore = makeAnyStore();
70
108
  const desktopPresent = await anyStore.desktopFilePresent();
@@ -99,6 +137,10 @@ async function doctor(jsonOutput) {
99
137
  * user can tell the two states apart without reading a stack trace.
100
138
  */
101
139
  const hostPeers = await hostPeersInfo();
140
+ const rosterSeen = await Promise.all(REGIONS.map(async (region) => ({
141
+ region,
142
+ summary: await describeRosterSeen(region)
143
+ })));
102
144
  const report = {
103
145
  schemaVersion: JSON_SCHEMA_VERSION,
104
146
  package: "dsh-connect-workbuddy",
@@ -164,12 +206,18 @@ async function doctor(jsonOutput) {
164
206
  cn: FALLBACK_WORKBUDDY_MODELS.length,
165
207
  global: FALLBACK_WORKBUDDY_MODELS_GLOBAL.length
166
208
  },
209
+ rosterSeen: Object.fromEntries(rosterSeen.map(({ region, summary }) => [region, summary])),
167
210
  hints: [
168
211
  ...anySignedIn ? [] : ["Sign in once in the WorkBuddy desktop app (either region), then run status again."],
169
212
  ...desktopPresent ? [] : [`No WorkBuddy desktop auth file at the expected path; set ${WORKBUDDY_AUTH_FILE_ENV} if it lives elsewhere.`],
170
213
  ...appExecutable === void 0 ? [`The WorkBuddy desktop app was not found, so encrypted credential fields cannot be opened. On Windows the install registration was already checked, so the app is either not installed or its uninstall entry is missing; set ${WORKBUDDY_APP_EXECUTABLE_ENV} to its executable if it is installed elsewhere.`] : [],
171
214
  ...hostAlive ? [] : ["Host bundle not running in this DSH profile (or the process exited). The browser card and providers are unavailable until DSH starts the plugin."],
172
- ...piAi.generation === "legacy" && piAi.resolvedLocally ? [`This plugin resolves pi-ai ${piAi.version ?? "(unreadable version)"} (the 0.85 context shape) while DSH 0.2.0+ hosts normalize with 0.87; the adapter bridges that gap, but if tools still misbehave, remove any nested pi-ai copy so the plugin resolves the host's.`] : []
215
+ ...piAi.generation === "legacy" && piAi.resolvedLocally ? [`This plugin resolves pi-ai ${piAi.version ?? "(unreadable version)"} (the 0.85 context shape) while DSH 0.2.0+ hosts normalize with 0.87; the adapter bridges that gap, but if tools still misbehave, remove any nested pi-ai copy so the plugin resolves the host's.`] : [],
216
+ ...rosterSeen.flatMap(({ region, summary }) => {
217
+ if (!summary.readable) return summary.present ? [`${REGION_LABELS[region]} first-sighting history is present but unreadable (${summary.path}); remove it to reseed on the next reconcile.`] : [];
218
+ if (summary.versionKnown === false) return [`${REGION_LABELS[region]} first-sighting history is version ${summary.version === void 0 ? "unknown" : summary.version}, which this build cannot read (${summary.path}); it is answered as empty, so the pill shows no new-model marker.`];
219
+ return [];
220
+ })
173
221
  ]
174
222
  };
175
223
  if (jsonOutput) printJson(report);
@@ -182,6 +230,13 @@ async function doctor(jsonOutput) {
182
230
  `Host bundle: ${hostAlive ? `running (pid ${heartbeat.pid})` : heartbeat !== void 0 ? "stale heartbeat (process exited)" : "not started"}`,
183
231
  ...regionLists.flatMap(({ region, accounts }) => [`${REGION_LABELS[region]} accounts: ${accounts.length} (own copy ${workbuddyOwnAuthPath(region)})`, ...accounts.map((account) => ` - ${account.accountName === "" ? "(unnamed)" : account.accountName} (${account.id})${account.selected ? " [selected]" : ""} expires ${new Date(account.tokenExpiresAtMs).toISOString()}`)]),
184
232
  `Static fallback models: CN ${report.fallbackModels.cn}, Global ${report.fallbackModels.global}`,
233
+ ...REGIONS.map((region) => {
234
+ const summary = report.rosterSeen[region];
235
+ if (summary === void 0) return `New-model history (${REGION_LABELS[region]}): not available`;
236
+ if (!summary.readable) return `New-model history (${REGION_LABELS[region]}): ${summary.present ? "unreadable" : "not started"} (${summary.path})`;
237
+ if (summary.versionKnown === false) return `New-model history (${REGION_LABELS[region]}): version ${summary.version === void 0 ? "unknown" : summary.version} not readable (${summary.path})`;
238
+ return `New-model history (${REGION_LABELS[region]}): ${summary.ids ?? 0} recorded, ${summary.fresh ?? 0} new`;
239
+ }),
185
240
  ...report.hints.map((hint) => `Hint: ${hint}`),
186
241
  ""
187
242
  ].join("\n"));