dsh-connect-workbuddy 5.5.2 → 5.6.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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.6.1 (2026-10-23)
4
+
5
+ > 补丁版本:修 5.6.0 自己引入的一处显示回退。**请求路径、provider 路由、计费与凭据一律未变。**
6
+
7
+ ### Fixes
8
+
9
+ - **模型选择器里积分倍率显示了两遍(#120 回归)**
10
+ - **症状**:5.6.0 之后选择器每一行都是 `DeepSeek-V4.1-Flash · x0.11 · x0.11`。截图在 5.6.0 发布后几分钟内报告。
11
+ - **根因**:宿主的模型名**本来就以倍率结尾**(`workBuddyDisplayName` 拼的是 `"<name> · x<multiplier>"`,宿主原样渲染),#120 又在这个字符串旁边追加了一个倍率 span,于是同一个数字出现两次。**不是 #120 的判定错了,是我加渲染时没注意那串名字里已经有一个。**
12
+ - **修法**:新增 `splitCatalogRate()`,把倍率**从宿主字符串里剥出来**、再画到能承载折后值的那个 span 里——始终只有一个数字,且它是此刻生效的那个。后缀按**形状**识别(`· x<数字>` 结尾),不按快照的 `baseRate` 比:目录价可能在快照与渲染之间变化,靠值比对会留下旧后缀、又变成两个数字,正是这个函数要消掉的东西。
13
+ - **测试**:新增 `tests/issue120-picker-rate-duplicate.spec.tsx`(8 条)——剥离后缀保留原值、折扣时**替换**而非追加、快照过期仍按形状剥离、无倍率的名字不动、无后缀时用快照值、无后缀时仍能显示折扣、名字中间的 `x` 不动、`x0.00`(免费)不被当成「没有倍率」。
14
+
15
+ ## 5.6.0 (2026-10-23)
16
+
17
+ > 特性版本:两条都来自 issue 报告——#120(错峰时段的积分倍率没跟着变)与 #124(企业/教育版账号积分恒为 0、姓名显示成登录别名)。**请求路径、provider 路由、渠道门与计费口径一律未变**;改的是「倍率与徽标同源同时钟」和「企业账号读自己那张计费表」。
18
+
19
+ ### Features
20
+
21
+ - **错峰时段的积分倍率跟随打折,卡片与模型选择器一起变(#120)**
22
+ - **报告**:模型行戴着「夜间折扣」徽标,旁边的数字还是原价 `x0.11`;真实折后价要去 WorkBuddy 客户端才看得到。徽标与价格来自**同一份** `/v3/config`、同一条促销条目,但插件只解析了 `badge`,把 `discount` 整个丢掉了。
23
+ - **修法**:`src/promo.ts` 新增 `WorkBuddyPromoDiscount` 与 `parseDiscount()`,读 `discount.discountedCredits`(`"0.06x"` 这种**把单位粘在值里**的字符串,`Number("0.06x")` 是 `NaN`,必须剥掉末尾 `x` 再转)+ `displayMode`;`factor` 只在字符串读不出时兜底,两者不一致时**字符串胜**——它才是用户实际被扣的那个数。
24
+ - **何时生效**:新增 `selectPromoMultiplier()`,只让**此刻正在运行**的促销打折。夜间折扣在上游是**两条条目**:一条带折扣 + 夜间时段,另一条是白天的常驻徽标(同款徽标、互补时段、无折扣)。白天那条只配画徽标、**不许改价**,否则卡片会把夜间价挂一整天。绝对有效期(`validFrom`/`validUntil`)也一并管住,活动过期即恢复原价。
25
+ - **两处渲染**:卡片模型行(`WorkBuddyCard.tsx` 的 `effectiveRate()`)与宿主模型选择器的标签(`WorkBuddyModelLabel.tsx`,同时显示 `· x0.06`)都用同一套判定;选择器原来根本不显示倍率,现在补上了——那一屏正是「现在到底多少钱」最该看到的地方。
26
+ - **README**(中英各一句):模型名里的倍率是**实付价**——活动时段显示折后价,窗口关闭自动回原价,不需要重启。
27
+ - **测试**:新增 `tests/issue120-promo-discount.spec.ts`(10 条)——`"0.06x"` / 裸数字 / `"0x"`(免费不等于缺失)/ `factor` 兜底 / 非有限值与非负值拒绝 / 未知 `displayMode` 忽略 / **白天常驻条目不降价** / 无折扣条目返回 `undefined`(保留目录价)/ 过期恢复 / 多条同时打折取最高优先级。
28
+
29
+ - **企业版(`type: ultimate`)账号读企业计费表,姓名显示真名(#124)**
30
+ - **报告**:企业/教育版账号积分恒为 0,账号名显示成登录别名。
31
+ - **积分修法**:`fetchCredits()` 按 `enterpriseId` 分流到 `/billing/meter/get-enterprise-user-usage`(**没有 `/v2` 前缀**)。个人端点对企业成员回的是 **HTTP 200 + 空 `Accounts` 列表**——一个形状合法但内容为假的「你没有积分」,插件不是被拒绝,是问错了表。企业表回 `{credit, limitNum, cycleResetTime}`,**`credit` 是已用额度**,余额是 `limitNum - credit`;把 `credit` 当余额读会把答案整个反转,比它替换掉的 0 更糟。`cycleResetTime` 不带时区标记,按**本地时间**手动拼(`Date.parse` 对这种形状是实现相关的)。
32
+ - **宁可报错也不报 0**:企业端点若回不出两个数字,抛错而不是返回 `total: 0`——后者正是 #124 报告的症状,等于把 bug 当作修复。
33
+ - **姓名修法**:新增 `preferredAccountName()`,顺序是 `displayName` → `enterpriseUserName` → `nickname`(登录别名垫底),空白串跳过。企业凭证的 `nickname` 是登录别名,把它放第一位会让卡片看起来在管别人的账号。`displayName` / `enterpriseUserName` 从桌面 auth 文档的 `account` 节点读,并随 DSH 凭证一起落盘。
34
+ - **跨次读取不丢**:新增 `mergeCredentialDisplayNames()`,`scanAll()` 合并同账号的两份副本时把姓名**双向带过去**——桌面 App 不总重写这两个字段,整条替换会静默退回别名,让 #124 的症状自己回来。
35
+ - **签到不再浪费请求**:企业网关没有消费者签到(对成员一律回 `10001`)。`WorkBuddyPoolTarget` 新增 `enterprise`,批次直接跳过并在日志里说「企业账号无每日签到」(新增 `skipped` 状态与新文案,中英各一条);跳过的行**不占批次间隔**——间隔是给请求留的。
36
+ - **测试**:新增 `tests/issue124-enterprise-credits.spec.ts`(14 条)——姓名优先级三级 + 空白跳过 + 合并保留/覆盖;企业端点被用而个人端点不被用、`limitNum - credit`、超额不为负、`Data` 包裹也能读、读不出余额即抛错、`cycleResetTime` 本地时间解析、非企业凭证仍走个人表。`tests/account-pool-run.spec.ts` 增 2 条(跳过企业账号、跳过的行不计间隔)。
37
+
3
38
  ## 5.5.2 (2026-10-23)
4
39
 
5
40
  > 补丁版本:两条修复都来自 issue 报告——#118(`WORKBUDDY_BODY_REPORT` 报告文件无界增长、回填整文件读写导致宿主 OOM)与 #122(界面文案审计)。**请求路径、provider 路由、模型目录、计费与凭据一律未变**;改的只有诊断文件的 I/O 形状、若干句用户可见文案与两处内部注释,外加三张 README 截图换成合成数据。
package/README.en.md CHANGED
@@ -35,7 +35,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
35
35
  - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a model you do not need the level on. **The 9 tabled models cannot be ticked at all** (their row is marked "refuses off" with an explanatory tooltip): the level is not merely an extra option — pi-ai uses it as the DEFAULT for every request that names no tier, and a compaction summary is exactly such a request, so one tick made every `/compact` for that model fail with HTTP 400 / code 11133 (issue #83). Models outside the table are still decided entirely by your selection and their own declaration; your ticks are saved on first save and recomputed no further.
36
36
 
37
37
  <img src="docs/assets/dsh-connect-workbuddy-model-picker.png" width="420" alt="DSH model picker: the same campaign badge (night discount / Free now) drawn after the model name, per region (no account data)" />
38
- - **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. **The same badge now appears in the DSH model picker**: every menu row draws the identical pill after the model name and before the load icon — menu rows use the upstream's short wording ("night", "free"), the composer trigger beside the input uses the full one ("night discount", "exclusive free"); judged from the same data at the same clock, because the screen where the model is chosen is exactly where "is it free right now" matters most. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
38
+ - **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **The multiplier shown is the price you actually pay**: while a “night discount” is running, the card and the picker both show the discounted `x0.06` instead of the catalog figure, and both go back on their own when the window closes — no restart needed (issue #120). **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. **The same badge now appears in the DSH model picker**: every menu row draws the identical pill after the model name and before the load icon — menu rows use the upstream's short wording ("night", "free"), the composer trigger beside the input uses the full one ("night discount", "exclusive free"); judged from the same data at the same clock, because the screen where the model is chosen is exactly where "is it free right now" matters most. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
39
39
  - **Model load on model rows (CN only)** — the upstream answers "how busy is this model right now" from a separate read-only probe (`POST /v2/chat/busy`, up to 50 ids per call, free, no account state touched). The plugin asks it once per catalog refresh and draws the level as a small figure beside the model name: **the number of people = how many are queueing** (healthy `normal` = one yellow figure, `busy` = two orange ones, `congested` = three red ones), while **idle is a green rocket** (nobody is queueing = full speed ahead) — idle is deliberately *not* drawn as "one green person", because at 12px "nobody is waiting" and "one person is waiting" have to be distinguishable at a glance; **unavailable** is a crossed-out figure. The probe's own `unknown` **draws nothing**: **"we were told nothing" is never reported as "all good"**. Colours follow the theme and the `title` tooltip ("极度拥堵") is still there. **The level is not persisted**: the catalog is a fact that survives a restart, a load level is a fact about that minute, so it is stamped onto the runtime catalog only and dies with the process; a failed probe logs one warning and never blocks the refresh. Measured on the CN gateway: the probe answers byte-identically under the CLI and the app identity, the 50-id ceiling is real (62 ids → HTTP 400), and 19 models come back in 186 ms. **The international region shows no load badge, because the international gateway simply does not have this endpoint** — measured, `www.workbuddy.ai` answers **404** for `/v2/chat/busy` (and for the neighbouring `/v3/chat/busy`, `/v2/models/busy` and friends), while the very same credential reads its `/v3/config` fine (28 models) and that document carries **no load or queue field at all**. This is not something the plugin left unimplemented, and it is not a configuration or permission problem: **upstream has never published this data for the international region**. So the international region shows the model name and campaign badges only; the failed probe degrades quietly to "no badge", never blocking the model list, and the plugin never guesses a load level from latency or credits.
40
40
  - **Queueing tells you your position** — upstream expresses a queue with three business codes (`6020` waiting, `6021` queue full, `6022` this user already at the maximum). Instead of passing along a raw upstream error, the plugin says "模型排队中:你排在第 8777 位,队列共 9021 个请求;约 7 秒后自动重试". **A queue does not trigger failover**: the line is shared platform-wide and bound to the model, so another account is handed the same position — no account-pool rotation, no extra requests burned. (The upstream's `/v2/chat/queue/status` returns an i18n KEY rather than a sentence, so the plugin never relays its `message` and always uses its own wording.) **The "resend once it is your turn" half is not implemented** — that needs the queue answer's real envelope on the OpenAI-compatible channel, and writing it unmeasured would mean code that never runs.
41
41
  - **Tabbed settings card (accounts / models / capabilities / diagnostics)** — the card used to be a single 3,500-pixel column that mixed two things with completely different commit semantics: **operating accounts** (who is serving, how much is left, switch order, check-in log) and **editing the catalog** (ticking models, context budgets, reasoning tiers, turning thinking off). An account change takes effect on the click; a catalog change is a draft that needs Save — so the Save button often sat three screens away from the thing it saves. Now each tab does one thing, and **switching tabs neither refetches nor drops an unsaved draft** (inactive panels stay mounted and are merely `hidden`, so they also leave the accessibility tree). The third tab, Capabilities, holds the **grants**: let the plugin drive the local CodeBuddy CLI, let the model buy an image on its own, where the credit readout is drawn. Each takes effect on the click and never passes through Save, and each is expected to grow sub-options of its own (a working directory for the CLI route, a size or price cap for the image tool) — which is why they no longer share one thin strip above the account table. The fourth tab, Diagnostics, is a read-only fact table: current region, upstream host, the account actually serving (by NAME, never by id), sign-in validity, how many models the catalog holds, how many are enabled, the pool switch, and the **raw text** of the upstream's most recent error (quoted, never translated or summarised) — its only reason to exist is to be pasted into a bug report, so it contains no control that changes a setting.
package/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  ## 功能特性
38
38
 
39
- - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片正是登录的入口(重新检测在账号池区块里)。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
39
+ - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片正是登录的入口(重新检测在账号池区块里)。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致;**活动时段显示的是折后价**——「夜间折扣」开着的时候,卡片与选择器上的数字一起变成 `x0.06`,活动一过自动回到原价,不需要重启(#120)。
40
40
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号池与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
41
41
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
42
42
  - **设置卡片分四栏:账号 / 模型 / 能力 / 诊断** —— 原来这张卡片是一列 3000 多像素的长页,把两类互不相干的事情混排在一起:**运维账号**(谁在服务、还剩多少、切换顺序、签到日志)与**编辑目录**(勾模型、上下文预算、推理档、关闭思考)。它们连提交语义都不同——账号改动点一下即生效,目录改动是草稿、必须点保存——所以「保存」按钮常常离它要保存的东西隔着三屏。现在分栏,一栏只做一件事;**切栏既不会重新拉取,也不会丢掉未保存的目录草稿**(非活动栏留在页面里,只是通过 `hidden` 不可见,因此也不进入无障碍树)。第三栏「能力」放的是**授权类开关**——允许调用本机 CodeBuddy CLI、允许模型自主生图、积分读数显示在哪——它们点一下即生效、从不经过保存,而且每一个都留着长自己子选项的位置(CLI 的工作目录、生图的尺寸或价格上限),所以不挤在账号表格上方那一小条里。第四栏「诊断」是只读的事实表:当前区域、上游域名、真正在服务的账号名、登录有效期、目录里共几个模型、启用了几个、账号池开关,以及上游最近一次错误的**原文**(原样引用,不翻译、不概括)——这一栏存在的唯一理由就是能被整段贴进问题反馈,所以里面没有任何会改设置的控件。
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
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-hcH37MkG.js";
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-CEYnXXF3.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -2051,6 +2051,43 @@ window.__ModuleLoader__.load({
2051
2051
  * Only ONE badge is drawn per model: a model row is one line wide, and two
2052
2052
  * badges would push the model name and the image/think checkboxes out of it.
2053
2053
  */
2054
+ /**
2055
+ * The multiplier to DRAW for a model right now: the promotion's discounted
2056
+ * price when a discount is running, otherwise the catalog's own figure.
2057
+ *
2058
+ * WHY THIS IS SEPARATE FROM THE BADGE. The badge answers "is this model on
2059
+ * offer"; this answers "at what price". They usually agree, but not always —
2060
+ * and when they disagreed the user saw a "night discount" badge next to the
2061
+ * full price (issue #120). The discount lives in the SAME entry that carries
2062
+ * the badge, so the two can be answered from one document and one clock.
2063
+ *
2064
+ * TWO RULES, both about not overstating an offer:
2065
+ *
2066
+ * 1. Only a promotion that is RUNNING at `nowMs` discounts the price. A
2067
+ * standing badge (the daytime half of a night-discount pair) is drawn
2068
+ * outside the discount hours, and it must NOT discount them — that is
2069
+ * exactly the bug this function exists to prevent. The badge admits
2070
+ * standing entries through `selectPromoBadge`; the price does not.
2071
+ * 2. `displayMode` must say `'replace'`, and the value must parse. An entry
2072
+ * with a badge but no readable discount changes nothing, so the catalog's
2073
+ * figure stands.
2074
+ *
2075
+ * The result is `undefined` when the catalog figure should be used unchanged —
2076
+ * the caller then keeps whatever it already draws. Returning the base value
2077
+ * from here would make a caller that has no base figure (a model whose
2078
+ * `credits` was absent) invent one.
2079
+ */
2080
+ function selectPromoMultiplier(promos, nowMs) {
2081
+ if (promos === void 0 || promos.length === 0) return void 0;
2082
+ const sorted = [...promos].sort((a, b) => b.priority - a.priority || (a.source === b.source ? 0 : a.source === "promotion" ? -1 : 1));
2083
+ for (const promo of sorted) {
2084
+ const discount = promo.discount;
2085
+ if (discount === void 0 || discount.multiplier === void 0) continue;
2086
+ if (discount.displayMode !== "replace") continue;
2087
+ if (!promoActiveAt(promo, nowMs)) continue;
2088
+ return discount.multiplier;
2089
+ }
2090
+ }
2054
2091
  function selectPromoBadge(promos, nowMs) {
2055
2092
  if (promos === void 0 || promos.length === 0) return void 0;
2056
2093
  const sorted = [...promos].sort((a, b) => b.priority - a.priority || (a.source === b.source ? 0 : a.source === "promotion" ? -1 : 1));
@@ -2696,6 +2733,7 @@ window.__ModuleLoader__.load({
2696
2733
  credit: String(row.credit ?? 0)
2697
2734
  }), "ok");
2698
2735
  else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
2736
+ else if (row.status === "skipped") appendLog(t("row.poolLogCheckinSkipped", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
2699
2737
  else appendLog(t("row.poolLogCheckinFailed", {
2700
2738
  accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
2701
2739
  message: row.message ?? t("row.requestFailed")
@@ -5733,6 +5771,18 @@ window.__ModuleLoader__.load({
5733
5771
  children: promoBadgeLabel(promo, language)
5734
5772
  });
5735
5773
  }
5774
+ /**
5775
+ * The multiplier to DRAW for a model right now (issue #120).
5776
+ *
5777
+ * A "night discount" badge used to sit next to the full price: the badge was
5778
+ * resolved against the clock while the number beside it was the catalog's
5779
+ * static figure. The discount lives in the same upstream entry, so both are
5780
+ * answered against the same clock — a promotion that is running and carries a
5781
+ * readable `replace` discount wins; otherwise the catalog figure stands.
5782
+ */
5783
+ function effectiveRate(model) {
5784
+ return selectPromoMultiplier(model.promos, Date.now()) ?? model.creditMultiplier;
5785
+ }
5736
5786
  /** Inject or refresh the shared card CSS for the current client bundle. */
5737
5787
  if (typeof document !== "undefined") {
5738
5788
  const cssId = "dsh-connect-workbuddy/client.css";
@@ -7397,9 +7447,9 @@ window.__ModuleLoader__.load({
7397
7447
  children: [
7398
7448
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7399
7449
  className: "dsm-workbuddy-model-name",
7400
- children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7450
+ children: [model.name, effectiveRate(model) === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7401
7451
  className: "dsm-workbuddy-model-name-rate",
7402
- children: ["x", model.creditMultiplier.toFixed(2)]
7452
+ children: ["x", effectiveRate(model).toFixed(2)]
7403
7453
  })]
7404
7454
  }),
7405
7455
  promoChip(model),
@@ -8680,10 +8730,15 @@ window.__ModuleLoader__.load({
8680
8730
  if (!response.ok) return void 0;
8681
8731
  const body = await response.json();
8682
8732
  const promos = {};
8683
- for (const model of body.models ?? []) if (model.promos !== void 0) promos[model.id] = model.promos;
8733
+ const rates = {};
8734
+ for (const model of body.models ?? []) {
8735
+ if (model.creditMultiplier !== void 0) rates[model.id] = model.creditMultiplier;
8736
+ if (model.promos !== void 0) promos[model.id] = model.promos;
8737
+ }
8684
8738
  const snapshot = {
8685
8739
  levels: body.busyLevels ?? {},
8686
- promos
8740
+ promos,
8741
+ rates
8687
8742
  };
8688
8743
  caches.set(region, {
8689
8744
  snapshot,
@@ -8785,10 +8840,16 @@ window.__ModuleLoader__.load({
8785
8840
  (0, react.useEffect)(() => subscribeMinute(() => setTick((count) => count + 1)), []);
8786
8841
  const level = snapshot?.levels[modelId];
8787
8842
  const promo = snapshot === void 0 ? void 0 : selectPromoBadge(snapshot.promos[modelId], Date.now());
8843
+ const discounted = snapshot === void 0 ? void 0 : selectPromoMultiplier(snapshot.promos[modelId], Date.now());
8844
+ const { base, rate } = splitCatalogRate(name, snapshot?.rates[modelId], discounted);
8788
8845
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8789
8846
  className: "dsm-workbuddy-model-label",
8790
8847
  children: [
8791
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: name }),
8848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: base }),
8849
+ rate === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
8850
+ className: "dsm-workbuddy-model-label-rate",
8851
+ children: [" · x", rate.toFixed(2)]
8852
+ }),
8792
8853
  promo === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PromoBadge, {
8793
8854
  promo,
8794
8855
  compact
@@ -8797,6 +8858,36 @@ window.__ModuleLoader__.load({
8797
8858
  ]
8798
8859
  });
8799
8860
  }
8861
+ /**
8862
+ * Split the host's display name into "name" and "the multiplier to show".
8863
+ *
8864
+ * `workBuddyDisplayName` builds `"<name> · x<multiplier>"` and the host renders
8865
+ * that string verbatim, so appending another rate next to it printed the figure
8866
+ * twice in the picker (`DeepSeek-V4.1-Flash · x0.11 · x0.11`). The suffix is
8867
+ * therefore pulled back OUT of the string and re-drawn in the span that can
8868
+ * carry the discounted value — exactly one number, and it is the one that holds
8869
+ * right now.
8870
+ *
8871
+ * The suffix is recognised by SHAPE, not by comparing against the snapshot's
8872
+ * `baseRate`: the two can disagree (a promotion can change the catalog figure
8873
+ * between the snapshot and this render), and leaving the suffix in place then
8874
+ * prints two numbers again — the very defect this function removes.
8875
+ *
8876
+ * @param name - The host's rendered name, possibly ending in ` · x<rate>`.
8877
+ * @param baseRate - The catalog multiplier, as the snapshot reports it.
8878
+ * @param discounted - The rate to show instead, when a discount is running.
8879
+ * @returns The name without its rate suffix, and the single rate to draw.
8880
+ * `undefined` means "draw no number" (the name had none and no
8881
+ * discount is running).
8882
+ */
8883
+ function splitCatalogRate(name, baseRate, discounted) {
8884
+ const match = / · x(\d+(?:\.\d+)?)$/.exec(name);
8885
+ const fromName = match === null ? void 0 : Number(match[1]);
8886
+ return {
8887
+ base: match === null ? name : name.slice(0, match.index),
8888
+ rate: discounted ?? fromName ?? baseRate
8889
+ };
8890
+ }
8800
8891
  //#endregion
8801
8892
  //#region src/client/CodebuddyLive.tsx
8802
8893
  /**
@@ -9413,6 +9504,7 @@ window.__ModuleLoader__.load({
9413
9504
  "row.poolLogCheckinStart": "Checking in {count} account(s), one at a time",
9414
9505
  "row.poolLogCheckinClaimed": "{accountName}: checked in (+{credit})",
9415
9506
  "row.poolLogCheckinAlready": "{accountName}: already checked in today — skipped",
9507
+ "row.poolLogCheckinSkipped": "{accountName}: enterprise account, no daily check-in",
9416
9508
  "row.poolLogCheckinFailed": "{accountName}: check-in failed — {message}",
9417
9509
  "row.poolLogCheckinDone": "Check-in finished",
9418
9510
  "row.poolLogBatchFailed": "Batch failed — nothing ran: {message}",
@@ -9791,6 +9883,7 @@ window.__ModuleLoader__.load({
9791
9883
  "row.poolLogCheckinStart": "开始签到 {count} 个账号(逐个执行)",
9792
9884
  "row.poolLogCheckinClaimed": "{accountName}:签到成功 +{credit}",
9793
9885
  "row.poolLogCheckinAlready": "{accountName}:今日已签到,跳过",
9886
+ "row.poolLogCheckinSkipped": "{accountName}:企业账号无每日签到",
9794
9887
  "row.poolLogCheckinFailed": "{accountName}:签到失败 —— {message}",
9795
9888
  "row.poolLogCheckinDone": "签到完成",
9796
9889
  "row.poolLogBatchFailed": "批量操作失败 —— 没有执行:{message}",
@@ -1546,6 +1546,37 @@ function parseDaily(value) {
1546
1546
  }
1547
1547
  return windows.length === 0 ? void 0 : windows;
1548
1548
  }
1549
+ /**
1550
+ * Read a multiplier out of the upstream's `discountedCredits` string.
1551
+ *
1552
+ * The upstream writes the unit INTO the value ("0x", "0.06x", "0.06"), so the
1553
+ * trailing `x` is stripped rather than handed to `Number()`. A value that does
1554
+ * not become a finite non-negative number is refused — a wrong price shown to
1555
+ * the user is worse than the normal price, because the normal price is at least
1556
+ * true.
1557
+ */
1558
+ function multiplierOfCredits(value) {
1559
+ const raw = text(value);
1560
+ if (raw === void 0) return void 0;
1561
+ const stripped = raw.replace(/x$/i, "").trim();
1562
+ if (stripped === "") return void 0;
1563
+ const parsed = Number(stripped);
1564
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
1565
+ }
1566
+ /** The upstream's `discount` → this plugin's discount shape. */
1567
+ function parseDiscount(value) {
1568
+ const raw = record(value);
1569
+ if (raw === void 0) return void 0;
1570
+ const fromString = multiplierOfCredits(raw["discountedCredits"]);
1571
+ const fromFactor = typeof raw["factor"] === "number" && Number.isFinite(raw["factor"]) && raw["factor"] >= 0 ? raw["factor"] : void 0;
1572
+ const multiplier = fromString ?? fromFactor;
1573
+ const displayMode = raw["displayMode"] === "replace" ? "replace" : void 0;
1574
+ if (multiplier === void 0 && displayMode === void 0) return void 0;
1575
+ return {
1576
+ ...multiplier === void 0 ? {} : { multiplier },
1577
+ ...displayMode === void 0 ? {} : { displayMode }
1578
+ };
1579
+ }
1549
1580
  /** The upstream's `schedule` → this plugin's schedule shape. */
1550
1581
  function parseSchedule(value) {
1551
1582
  const raw = record(value);
@@ -1575,6 +1606,7 @@ function parsePromo(value, source) {
1575
1606
  if (badge === void 0) return void 0;
1576
1607
  const hover = parseHover(raw["hover"]);
1577
1608
  const schedule = parseSchedule(raw["schedule"]);
1609
+ const discount = parseDiscount(raw["discount"]);
1578
1610
  const priority = typeof raw["priority"] === "number" && Number.isFinite(raw["priority"]) ? raw["priority"] : 0;
1579
1611
  return {
1580
1612
  id: text(raw["id"]) ?? `${source}:${modelIds.join(",")}`,
@@ -1583,6 +1615,7 @@ function parsePromo(value, source) {
1583
1615
  badge,
1584
1616
  ...hover === void 0 ? {} : { hover },
1585
1617
  ...schedule === void 0 ? {} : { schedule },
1618
+ ...discount === void 0 ? {} : { discount },
1586
1619
  priority
1587
1620
  };
1588
1621
  }
@@ -3205,6 +3238,24 @@ async function readEnvelope(response) {
3205
3238
  };
3206
3239
  }
3207
3240
  /**
3241
+ * Parse the enterprise endpoint's `cycleResetTime` ("2026-11-01 00:00:00").
3242
+ *
3243
+ * The value carries NO timezone marker, and the official client renders it as
3244
+ * local wall-clock time (it is what the user sees in "resets on 11/01 00:00"),
3245
+ * so it is read as LOCAL time rather than as UTC. `Date.parse` on that shape is
3246
+ * implementation-defined, which is exactly why it is assembled by hand.
3247
+ */
3248
+ function parseEnterpriseInstant(value) {
3249
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value.trim());
3250
+ if (match === null) {
3251
+ const fallback = Date.parse(value);
3252
+ return Number.isNaN(fallback) ? void 0 : fallback;
3253
+ }
3254
+ const [, y, mo, d, h, mi, se] = match;
3255
+ const parsed = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se ?? "0")).getTime();
3256
+ return Number.isNaN(parsed) ? void 0 : parsed;
3257
+ }
3258
+ /**
3208
3259
  * Fail an envelope whose business code is non-zero, classified like HTTP errors.
3209
3260
  *
3210
3261
  * A JSON body on a 401/403 is still a credential refusal: the edge answered in
@@ -3871,12 +3922,65 @@ var WorkBuddyUpstreamClient = class {
3871
3922
  };
3872
3923
  }
3873
3924
  /**
3925
+ * The ENTERPRISE credit table (issue #124).
3926
+ *
3927
+ * `/v2/billing/meter/get-user-resource` answers an enterprise member with
3928
+ * HTTP 200 and an empty `Accounts` list — a legally-shaped "you have no
3929
+ * credits" that is simply false, because the member's credits live under the
3930
+ * enterprise account. The official client branches on the account's
3931
+ * `type: "ultimate"` and calls `/billing/meter/get-enterprise-user-usage`
3932
+ * instead (no `/v2` prefix), which returns:
3933
+ *
3934
+ * { credit: 1234, limitNum: 5000, cycleResetTime: "2026-11-01 00:00:00" }
3935
+ *
3936
+ * What REMAINS is `limitNum - credit`. `credit` there is the amount SPENT,
3937
+ * not the amount left — reading it as a balance inverts the answer, which is
3938
+ * worse than the zero it replaces.
3939
+ *
3940
+ * @returns the credits, or `undefined` when this response carried no
3941
+ * readable balance (the caller turns that into an error rather than
3942
+ * reporting zero).
3943
+ */
3944
+ async fetchEnterpriseCredits(credential) {
3945
+ const response = await fetch(`${billingBase(credential)}/billing/meter/get-enterprise-user-usage`, {
3946
+ method: "POST",
3947
+ headers: billingHeaders(credential),
3948
+ body: JSON.stringify({}),
3949
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
3950
+ });
3951
+ const envelope = await readEnvelope(response);
3952
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
3953
+ const wrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
3954
+ const inner = typeof wrapper["Data"] === "object" && wrapper["Data"] !== null ? wrapper["Data"] : wrapper;
3955
+ const limit = typeof inner["limitNum"] === "number" && Number.isFinite(inner["limitNum"]) ? inner["limitNum"] : void 0;
3956
+ const used = typeof inner["credit"] === "number" && Number.isFinite(inner["credit"]) ? inner["credit"] : void 0;
3957
+ if (limit === void 0 || used === void 0) return void 0;
3958
+ const remain = Math.max(0, limit - used);
3959
+ const refreshAtMs = typeof inner["cycleResetTime"] === "string" ? parseEnterpriseInstant(inner["cycleResetTime"]) : void 0;
3960
+ return {
3961
+ total: remain,
3962
+ packages: [{
3963
+ packageName: "企业积分",
3964
+ remain,
3965
+ size: limit,
3966
+ monthly: true,
3967
+ ...refreshAtMs === void 0 ? {} : { refreshAtMs }
3968
+ }],
3969
+ expiringSoon: 0
3970
+ };
3971
+ }
3972
+ /**
3874
3973
  * POST the billing endpoint for the remaining credit, keeping every package
3875
3974
  * separate: the card groups monthly-cycle packages itself and lists the
3876
3975
  * nearest-expiring one-off packages, so aggregation here would lose the
3877
3976
  * dates it needs.
3878
3977
  */
3879
3978
  async fetchCredits(credential) {
3979
+ if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
3980
+ const enterprise = await this.fetchEnterpriseCredits(credential);
3981
+ if (enterprise !== void 0) return enterprise;
3982
+ throw new Error("workbuddy upstream enterprise credit: no readable balance in the response");
3983
+ }
3880
3984
  const now = /* @__PURE__ */ new Date();
3881
3985
  const format = (date) => [
3882
3986
  date.getFullYear().toString().padStart(4, "0"),
@@ -4027,6 +4131,49 @@ var WorkBuddyEncryptedCredentialError = class extends Error {
4027
4131
  function isEncryptedCredentialError(value) {
4028
4132
  return typeof value === "object" && value !== null && value.code === "WORKBUDDY_ENCRYPTED_CREDENTIAL";
4029
4133
  }
4134
+ /** One selectable local account, token-free. */
4135
+ /**
4136
+ * The name to SHOW for a credential (issue #124).
4137
+ *
4138
+ * The order matters and is not arbitrary: on an enterprise credential
4139
+ * `nickname` is the LOGIN ALIAS, so putting it first made the card look like it
4140
+ * was managing a stranger's account. The official client prefers the display
4141
+ * name, then the enterprise-side user name, and only then the alias.
4142
+ *
4143
+ * Blank spellings are skipped rather than preferred: the app writes `" "` in
4144
+ * places, and rendering whitespace where a name belongs is worse than falling
4145
+ * through to the alias.
4146
+ *
4147
+ * Exported because the same order has to hold in the store's account list, the
4148
+ * pool rows and the composer panel — three sites that must not drift.
4149
+ */
4150
+ function preferredAccountName(fields) {
4151
+ for (const candidate of [
4152
+ fields.displayName,
4153
+ fields.enterpriseUserName,
4154
+ fields.nickname
4155
+ ]) if (candidate !== void 0 && candidate.trim() !== "") return candidate;
4156
+ return "";
4157
+ }
4158
+ /**
4159
+ * Carry display names forward across a re-read of the same account.
4160
+ *
4161
+ * The desktop auth document is the only source for these fields and it does
4162
+ * not always publish them: a refresh that rewrites the document can drop
4163
+ * `displayName` while keeping the same sign-in. Replacing the whole record
4164
+ * would then silently fall back to the alias — the exact symptom #124 reports,
4165
+ * coming back on its own. So an absent field yields to the previous value and a
4166
+ * PRESENT one wins (it is the newer reading).
4167
+ */
4168
+ function mergeCredentialDisplayNames(previous, next) {
4169
+ if (previous === void 0) return next;
4170
+ return {
4171
+ ...next,
4172
+ ...next.displayName === void 0 && previous.displayName !== void 0 ? { displayName: previous.displayName } : {},
4173
+ ...next.enterpriseUserName === void 0 && previous.enterpriseUserName !== void 0 ? { enterpriseUserName: previous.enterpriseUserName } : {},
4174
+ ...next.nickname === void 0 && previous.nickname !== void 0 ? { nickname: previous.nickname } : {}
4175
+ };
4176
+ }
4030
4177
  /** Legacy single-copy basename (pre-dual-provider); kept as migration source. */
4031
4178
  const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
4032
4179
  /** Env variable that overrides the desktop auth-file location. */
@@ -4099,6 +4246,8 @@ function vaultDocument(credential, accountId, refreshedFrom) {
4099
4246
  uid: credential.uid,
4100
4247
  ...credential.enterpriseId === void 0 ? {} : { enterpriseId: credential.enterpriseId },
4101
4248
  ...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
4249
+ ...credential.displayName === void 0 ? {} : { displayName: credential.displayName },
4250
+ ...credential.enterpriseUserName === void 0 ? {} : { enterpriseUserName: credential.enterpriseUserName },
4102
4251
  ...credential.uin === void 0 ? {} : { uin: credential.uin }
4103
4252
  }
4104
4253
  };
@@ -4369,6 +4518,8 @@ function parseWorkBuddyAuth(text, filePath, atRestKey) {
4369
4518
  const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
4370
4519
  const enterpriseId = optionalCredentialField(identity["enterpriseId"], atRestKey);
4371
4520
  const nickname = optionalCredentialField(identity["nickname"], atRestKey);
4521
+ const displayName = optionalCredentialField(identity["displayName"], atRestKey);
4522
+ const enterpriseUserName = optionalCredentialField(identity["enterpriseUserName"], atRestKey);
4372
4523
  const uin = optionalCredentialField(identity["uin"], atRestKey);
4373
4524
  return {
4374
4525
  accessToken,
@@ -4379,6 +4530,8 @@ function parseWorkBuddyAuth(text, filePath, atRestKey) {
4379
4530
  uid: optionalCredentialField(identity["uid"], atRestKey) ?? "",
4380
4531
  ...enterpriseId === void 0 ? {} : { enterpriseId },
4381
4532
  ...nickname === void 0 ? {} : { nickname },
4533
+ ...displayName === void 0 ? {} : { displayName },
4534
+ ...enterpriseUserName === void 0 ? {} : { enterpriseUserName },
4382
4535
  ...uin === void 0 ? {} : { uin },
4383
4536
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
4384
4537
  source: "desktop",
@@ -4912,7 +5065,8 @@ var WorkBuddyCredentialStore = class {
4912
5065
  byId.set(id, credential);
4913
5066
  continue;
4914
5067
  }
4915
- if (isFresher(credential, existing)) byId.set(id, credential);
5068
+ if (isFresher(credential, existing)) byId.set(id, mergeCredentialDisplayNames(existing, credential));
5069
+ else byId.set(id, mergeCredentialDisplayNames(credential, existing));
4916
5070
  }
4917
5071
  for (const own of await this.readOwns()) {
4918
5072
  if (!this.matchesRegion(own.domain)) continue;
@@ -5006,8 +5160,9 @@ var WorkBuddyCredentialStore = class {
5006
5160
  const id = workbuddyAccountId(credential);
5007
5161
  return {
5008
5162
  id,
5009
- accountName: credential.nickname ?? "",
5163
+ accountName: preferredAccountName(credential),
5010
5164
  domain: credential.domain,
5165
+ ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? {} : { enterprise: true },
5011
5166
  source: credential.source,
5012
5167
  tokenExpiresAtMs: credential.expiresAtMs,
5013
5168
  filePath: credential.filePath,
@@ -5966,7 +6121,7 @@ function generationOf(version) {
5966
6121
  //#endregion
5967
6122
  //#region src/version.ts
5968
6123
  /** The npm package version this build was produced from. */
5969
- const WORKBUDDY_CONNECT_VERSION = "5.5.2";
6124
+ const WORKBUDDY_CONNECT_VERSION = "5.6.1";
5970
6125
  //#endregion
5971
6126
  //#region src/host-heartbeat.ts
5972
6127
  /**
package/lib/index.d.ts CHANGED
@@ -15,6 +15,21 @@ interface WorkBuddyCredential {
15
15
  uid: string;
16
16
  enterpriseId?: string;
17
17
  nickname?: string;
18
+ /**
19
+ * The account's DISPLAY name, when the app publishes one (issue #124).
20
+ *
21
+ * For an enterprise member `nickname` is the LOGIN ALIAS, not a person's
22
+ * name — a card showing it looks like it is managing somebody else's
23
+ * account. The official client prefers this field, then the enterprise user
24
+ * name, and only then the alias.
25
+ *
26
+ * Read from the desktop auth document when present. It is NOT always there
27
+ * (the app writes only what it has), which is why the read order has three
28
+ * steps rather than two.
29
+ */
30
+ displayName?: string;
31
+ /** The enterprise-side user name (`enterpriseUserName`), when published. */
32
+ enterpriseUserName?: string;
18
33
  uin?: string;
19
34
  /** Which auth file this came from; refreshes are always `dsh`. */
20
35
  source: 'desktop' | 'dsh';
@@ -138,7 +153,6 @@ interface WorkBuddyStoreOptions {
138
153
  */
139
154
  resolveAtRestKey?: () => Promise<Buffer | undefined>;
140
155
  }
141
- /** One selectable local account, token-free. */
142
156
  interface WorkBuddyAccountChoice {
143
157
  /** Stable id derived from `uin` (or `uid` when uin is absent). */
144
158
  id: string;
@@ -156,6 +170,15 @@ interface WorkBuddyAccountChoice {
156
170
  /** The auth file this account was read from; newest is preferred. */
157
171
  filePath: string;
158
172
  selected: boolean;
173
+ /**
174
+ * This account belongs to an enterprise (issue #124).
175
+ *
176
+ * Present only when `enterpriseId` is set. The credits come from the
177
+ * enterprise table and the consumer daily check-in does not exist for it, so
178
+ * callers branch on this FACT rather than on `status.active` (which reads
179
+ * `false` for every enterprise member).
180
+ */
181
+ enterprise?: boolean;
159
182
  }
160
183
  /** Legacy single-copy basename (pre-dual-provider); kept as migration source. */
161
184
  declare const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
@@ -740,6 +763,41 @@ interface WorkBuddyPromoSchedule {
740
763
  */
741
764
  dailyUnusable?: true;
742
765
  }
766
+ /**
767
+ * The upstream's own discounted price for a promotion, when it publishes one.
768
+ *
769
+ * This is what makes a "night discount" badge more than decoration: the badge
770
+ * says the model is on offer, and this field says HOW MUCH less it costs during
771
+ * the offer. The plugin used to drop it, so a model could wear a "night
772
+ * discount" badge while still showing its full price — the card and the
773
+ * composer readout then disagreed with what the user was actually charged.
774
+ *
775
+ * Shapes observed on the CN feed (2026-10-21, `docs/PROMO_BADGE.md`):
776
+ *
777
+ * { discountedCredits: "0x", displayMode: "replace", factor: 0 }
778
+ *
779
+ * `discountedCredits` is a STRING with the unit glued on ("0x", "0.06x"), not
780
+ * a number: `Number("0.06x")` is `NaN`, so it must be parsed, not coerced.
781
+ * `factor` is a ratio the upstream sometimes supplies (0.545 = "55% of the
782
+ * normal price"); it is used only when `discountedCredits` cannot be read, and
783
+ * the two disagreeing means the string wins — it is the figure the user will
784
+ * be charged.
785
+ */
786
+ interface WorkBuddyPromoDiscount {
787
+ /**
788
+ * The multiplier the user pays during the promotion, e.g. `0` for free.
789
+ *
790
+ * Parsed from `discountedCredits` ("0x" / "0.06x" / "0.06"), or derived from
791
+ * `factor` when only that is present. `undefined` when neither could be read.
792
+ */
793
+ multiplier?: number;
794
+ /**
795
+ * The upstream's display instruction. `'replace'` means "show this instead of
796
+ * the normal multiplier"; anything else is not yet observed, so it is not
797
+ * acted on.
798
+ */
799
+ displayMode?: 'replace';
800
+ }
743
801
  /** The upstream's explanatory copy for the badge. */
744
802
  interface WorkBuddyPromoHover {
745
803
  /** The Chinese wording. */
@@ -766,6 +824,10 @@ interface WorkBuddyPromo {
766
824
  * badge matters. Used to pick ONE badge per model when several apply.
767
825
  */
768
826
  priority: number;
827
+ /**
828
+ * The discounted price during the promotion, when the upstream publishes one.
829
+ */
830
+ discount?: WorkBuddyPromoDiscount;
769
831
  }
770
832
  //#endregion
771
833
  //#region src/busy.d.ts
@@ -1451,6 +1513,27 @@ declare class WorkBuddyUpstreamClient {
1451
1513
  claimBenefit(credential: WorkBuddyCredential, kind: WorkBuddyBenefit['kind']): Promise<void>;
1452
1514
  /** Claim today's check-in reward. The browser route guards this mutation. */
1453
1515
  claimDailyCheckin(credential: WorkBuddyCredential): Promise<WorkBuddyCheckinClaim>;
1516
+ /**
1517
+ * The ENTERPRISE credit table (issue #124).
1518
+ *
1519
+ * `/v2/billing/meter/get-user-resource` answers an enterprise member with
1520
+ * HTTP 200 and an empty `Accounts` list — a legally-shaped "you have no
1521
+ * credits" that is simply false, because the member's credits live under the
1522
+ * enterprise account. The official client branches on the account's
1523
+ * `type: "ultimate"` and calls `/billing/meter/get-enterprise-user-usage`
1524
+ * instead (no `/v2` prefix), which returns:
1525
+ *
1526
+ * { credit: 1234, limitNum: 5000, cycleResetTime: "2026-11-01 00:00:00" }
1527
+ *
1528
+ * What REMAINS is `limitNum - credit`. `credit` there is the amount SPENT,
1529
+ * not the amount left — reading it as a balance inverts the answer, which is
1530
+ * worse than the zero it replaces.
1531
+ *
1532
+ * @returns the credits, or `undefined` when this response carried no
1533
+ * readable balance (the caller turns that into an error rather than
1534
+ * reporting zero).
1535
+ */
1536
+ private fetchEnterpriseCredits;
1454
1537
  /**
1455
1538
  * POST the billing endpoint for the remaining credit, keeping every package
1456
1539
  * separate: the card groups monthly-cycle packages itself and lists the
@@ -2799,7 +2882,7 @@ interface WorkBuddyPoolMember {
2799
2882
  interface WorkBuddyPoolCheckinRow {
2800
2883
  accountId: string;
2801
2884
  accountName: string;
2802
- status: 'claimed' | 'already' | 'failed';
2885
+ status: 'claimed' | 'already' | 'failed' | 'skipped';
2803
2886
  /** Credits gained, when the upstream stated a figure. */
2804
2887
  credit?: number;
2805
2888
  /** Streak length after the claim, when the upstream stated one. */
@@ -3159,6 +3242,15 @@ interface WorkBuddyWebAccount {
3159
3242
  source: 'desktop' | 'dsh';
3160
3243
  tokenExpiresAtMs: number;
3161
3244
  selected: boolean;
3245
+ /**
3246
+ * This account belongs to an enterprise (issue #124).
3247
+ *
3248
+ * Its credits come from the enterprise table and its check-in is refused by
3249
+ * the upstream (`10001`), so the card must not offer the action for it. The
3250
+ * field is a FACT about the credential (`enterpriseId` is present), not a
3251
+ * guess from `status.active`: that reads `false` for every enterprise member.
3252
+ */
3253
+ enterprise?: boolean;
3162
3254
  }
3163
3255
  /**
3164
3256
  * One probed candidate path and why it yielded no account.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-hcH37MkG.js";
1
+ import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-CEYnXXF3.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -7367,8 +7367,19 @@ async function measureTps(input) {
7367
7367
  */
7368
7368
  async function checkinAllAccounts(targets, deps, nowMs = Date.now) {
7369
7369
  const rows = [];
7370
- for (const [index, target] of targets.entries()) {
7371
- if (index > 0) await (deps.wait ?? defaultWait)(400);
7370
+ let issued = 0;
7371
+ for (const target of targets) {
7372
+ if (target.enterprise === true) {
7373
+ rows.push({
7374
+ accountId: target.accountId,
7375
+ accountName: target.accountName,
7376
+ status: "skipped",
7377
+ message: "enterprise accounts have no daily check-in"
7378
+ });
7379
+ continue;
7380
+ }
7381
+ if (issued > 0) await (deps.wait ?? defaultWait)(400);
7382
+ issued += 1;
7372
7383
  rows.push(await checkinOne(target, deps));
7373
7384
  }
7374
7385
  return rows;
@@ -11105,7 +11116,8 @@ function apply(ctx, config) {
11105
11116
  async function poolTargets(region) {
11106
11117
  return (await poolMemberAccounts(region)).map((account) => ({
11107
11118
  accountId: account.id,
11108
- accountName: account.accountName
11119
+ accountName: account.accountName,
11120
+ ...account.enterprise === true ? { enterprise: true } : {}
11109
11121
  }));
11110
11122
  }
11111
11123
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
5
- "version": "5.5.2",
5
+ "version": "5.6.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",