dsh-connect-workbuddy 4.4.4 → 4.4.5

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.5 (2026-10-14)
4
+
5
+ ### Fixes
6
+
7
+ - **积分余额读错了「家族」:月度套餐被按容量池(上限值)计入,国内、国际都偏高一个完整月度额度;同时漏读了小数。这条**推翻** 4.3.3 对 issue #48 的判断。**(issue #58 报告者报告,维护者用官方客户端截图定案)
8
+ - **判据(决定性)**:维护者自己账号的官方客户端截图 —— `总剩余积分 2,196.61 = 套餐基础积分 500 + 平台奖励积分 1,696.61`,而**同一份账单响应**里 `Σ CycleCapacityRemainPrecise = 2,196.61`(分毫不差),`Σ CapacityRemain = 2,196`。**那个「.61」就是证据**:整数 `CapacityRemain` 无论怎么加都加不出小数位,因为小数只存在于 `*Precise` 字段里,而它属于**周期**家族。客户端自己的三个桶(套餐基础 / 平台奖励 / 购买)与(月度周期、gift 周期、购买)一一对应。
9
+ - **为什么池值不是余额**:一个周期已用尽的月度套餐仍返回 `CapacityRemain=500, CapacityUsed=0`(「一点没用、全都还在」),而周期说 `CycleCapacityUsed=500`。本机 **8/8** 个国内账号上,池值与周期值只在**周期未被使用**时相等;周期一旦开始消耗,按池读数就**整整高出一个月度额度**(LaoDing 500、老丁 500、两次实测各差 56 / 115)。
10
+ - **国际侧的独立佐证**:三个国际账号返回 `CapacityRemain=100`,而国际网关**用 `14018 Credits exhausted` 拒绝它们的对话请求** —— 池值在那里不是可花的额度。也就是说 #48 当初认定的「误报 0」其实是真值,报告者看到的 200 来自 gift 包(或宽限路径)。
11
+ - **修法**:
12
+ - 余额一律读**周期**家族(`CycleCapacityRemain`),**两种套餐统一** —— gift 的两套字段实测完全相等,所以统一读周期等于**删掉了那个出错的月度分支**:不再有「哪种包读哪个字段」这种可以搞错的特殊情况。
13
+ - **读 `*Precise`**(字符串,带小数)优先,整数拼写回退。这是 2,196.61 里那个 `.61` 的来源,此前完全没读。
14
+ - `size` 同样取周期家族(`CycleCapacitySize` → `CapacitySize` 回退),这样「已剩 0 / 共 100」读得通。
15
+ - 完全没有周期家族的行(例如只带 `CapacitySize`/`CapacityRemain` 的合成行)回退读池,避免静默报 0。
16
+ - **删除 `cycleRemain` 字段**:余额本身就是周期值了,它成了同义重复;`refreshAtMs`(周期续期点)才是「本周期已用完、什么时候恢复」的表达。gift 的丢弃规则(耗尽/过期)、`expiringSoon`、`nearestExpiryMs` 均未改变。
17
+ - **显示只取整数部分(向下取整)**:余额现在带小数(`2,196.61`),卡片不显示小数位。取**向下取整**而非四舍五入:四舍五入会把 `2,196.61` 印成 `2,197`,而官方客户端显示 `2,196.61` —— 一张刚跟客户端核对过的卡片反而看起来差 1;而且 `0.61` 向上取整会印成 `1`,等于告诉读者他有一个并不存在的积分可花。三处显示(编辑器读数、账号池积分列、模型容量)统一走新增的 `src/client/credit-format.ts`,守卫 `tests/credit-format.spec.ts`(5 例)。
18
+ - **实测核对**(构建后的插件,真实账号):`177****417… total=2196.61` —— 与客户端截图**逐位一致**;`LaoDing 647.01`、`老丁 1360.13`(旧读数为 1147 / 1860)。国际三个账号 `0.00`,与 `14018` 一致。
19
+ - **守卫**:`tests/issue58-credit-balance-cycle.spec.ts`(9 例,替换掉 `tests/issue48-monthly-pool-credits.spec.ts` —— 后者的断言钉的正是被推翻的规则)。覆盖:total == Σ 周期精确值(含小数)、周期耗尽时读周期而非池、国际耗尽行为 0、周期未用时两种读法一致(说明不是「所有数字都变了」)、无周期家族回退、精确/整数两种拼写、gift 丢弃规则不变、负值不进 total、`expiringSoon` 与 total 同源。**变异验证**:把余额改回读池 → 3 例变红;把 `*Precise` 读法去掉 → 4 例变红。
20
+ - **另一处新增工具**:`scripts/probe-credits-raw.mjs` —— 逐包原样打印账单响应(含未被读取的字段)并给出三个候选口径的合计,让「客户端显示的到底是哪个字段」这件事可以被独立复现,而不必相信本文件的叙述。
21
+
22
+ - **账号池的「已不再登录」警告只报数量、不报是谁,而且没有任何一行可以处理。**(issue #60 报告者)
23
+ - **现象**:账号池下方出现 `有 1 个已保存的账号在本机已不再登录……再保存一次即可移除它们。` —— 用户既不知道是哪个账号,界面里也找不到对应的行,更**没有办法执行「再保存一次」**:这句话描述的移除动作,在当时的数据结构下无处可做。
24
+ - **三条独立成因**(报告者逐条给出代码位置,本地逐条复核属实):
25
+ 1. `ghostMemberIds()` 只返回**裸 id**,把当时就在手边的名称丢掉了;
26
+ 2. 文案模板只有 `{count}`,而同文件的 `row.poolSelectAria` / `row.poolCurrentHeader` 都带 `{accountName}` —— 不是没东西可显示,是这条文案没设计成能显示;
27
+ 3. 幽灵账号**没有行**:账号表格渲染的是**本机已登录**的名册,而幽灵恰恰是「不在名册里」的那一个,所以「哪一行该打标记」在旧结构下无解。
28
+ - **修法**(两条,各自成立):
29
+ - **让测量记录记住名称**:`WorkBuddyPoolProbe` 新增可选 `accountName`,由**两个写入方**在拿到账号的那一刻写下(测试批次一直持有该名称;实时聊天那条在取不到名称时**继承**旧值)。选择存在测量文件而非设置里的原因与 4.4.4 同源:**名称只在被测量的那一刻确定可知**,登录消失后别处都查不到;而设置槽会被「草稿 → 保存」覆盖,实测数据不该住在里面。`mergePoolProbe()` 对缺失名称**继承**、对新名称**覆盖**(与 `retryAtMs` 同一口径:**缺失不等于声明**)。
30
+ - **给幽灵一个可见且可处理的位置**:警告句改为指名(`{count}` + `{names}`),下面渲染**每个幽灵一个只读徽标 + ✕ 移除按钮**。移除是**草稿编辑**(与取消勾选一个已列出账号完全同构),仍然由「保存」提交 —— 旧文案承诺的那个动作,现在真的做得到了。
31
+ - **取不到名称时不编造**:徽标退回 **id 前缀**(`e44e62b3…`)。前缀不是好标签,但它是**诚实的**,且足以把两个幽灵分开;换成「第 1 个」这类更好看的位置标签,会把用户指向错误的账号。宿主**只**为名册里没有的 id 下发名称,避免同一件事有两个可能互相矛盾的来源。
32
+ - **守卫**:新增 `tests/issue60-ghost-member-labels.spec.ts`(14 例)—— 命名、空名退回前缀、顺序稳定、三名截断、`mergePoolProbe` 的继承/覆盖/两字段同时继承、`probeUpdatesOf` 写名与空名不写、磁盘往返。**变异验证**:把名称继承改回整体替换 → 2 例变红。
33
+
34
+ ### Docs
35
+
36
+ - 新增 `scripts/probe-region-token-accounting.mjs`:同一段英文文本在国内外两个网关下各发一次,比较上游自报的 `prompt_tokens`,并判定该网关的**缓存计账口径**(`prompt_tokens` 是否包含命中缓存的部分)。用于 issue #59 的根因定位;只读、按账号逐个报告失败原因。
37
+ - 新增 `scripts/probe-credits-raw.mjs`:逐包原样打印账单响应并列出三个候选口径的合计,用于核对「官方客户端显示的余额到底是哪个字段」(issue #58);只读,账号名做遮蔽。
38
+
3
39
  ## 4.4.4 (2026-10-13)
4
40
 
5
41
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-BwbDWvzw.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Bj7JvSYJ.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
@@ -692,6 +692,42 @@ window.__ModuleLoader__.load({
692
692
  }
693
693
  }
694
694
  //#endregion
695
+ //#region src/client/credit-format.ts
696
+ /**
697
+ * How a credit balance is written on screen.
698
+ *
699
+ * `4.4.5` made the balance exact: it now comes from the upstream's `*Precise`
700
+ * family, so the number carries a fraction (`2,196.61`) where it used to be a
701
+ * whole integer. The card does not show that fraction — a narrow row reads
702
+ * better, and the product's own breakdown (套餐基础 / 平台奖励 / 购买) is the
703
+ * place to look for detail.
704
+ *
705
+ * Taking the FLOOR rather than rounding is the one decision here, and it is not
706
+ * cosmetic:
707
+ *
708
+ * - a balance is a quantity you either have or do not. `0.61` rounded UP prints
709
+ * `1`, which tells the reader they can spend a credit they do not have;
710
+ * - the official client shows `2,196.61`, so its integer part is `2,196` — the
711
+ * floor. Rounding would print `2,197` and make a card that had JUST been
712
+ * reconciled against the client look one credit off, which is the exact
713
+ * complaint (issue #58) this display rule is downstream of.
714
+ *
715
+ * Grouping is kept because a four-figure balance is otherwise hard to read at a
716
+ * glance, and it is the only reason this is not `Math.floor(value).toString()`.
717
+ *
718
+ * @module dsh-connect-workbuddy/client/credit-format
719
+ */
720
+ /**
721
+ * Format a credit balance for display: whole credits, grouped, floored.
722
+ *
723
+ * `Math.floor` before `toLocaleString` (rather than a `maximumFractionDigits`
724
+ * option) is deliberate: the option ROUNDS, so it would print `2,197` for
725
+ * `2,196.61`.
726
+ */
727
+ function formatCredits(value) {
728
+ return Math.floor(value).toLocaleString("en-US");
729
+ }
730
+ //#endregion
695
731
  //#region src/client/BusyOverlay.tsx
696
732
  /**
697
733
  * The shared busy veil's spinner: an arc that breathes while it turns.
@@ -1054,6 +1090,42 @@ window.__ModuleLoader__.load({
1054
1090
  function ghostMemberIds(draftMembers, listedIds) {
1055
1091
  return draftMembers.filter((id) => !listedIds.has(id));
1056
1092
  }
1093
+ /** How many characters of an id are shown when no name was ever measured. */
1094
+ const GHOST_ID_PREFIX = 8;
1095
+ /**
1096
+ * Label saved member ids that resolve to no local sign-in (issue #60).
1097
+ *
1098
+ * The old warning was a bare count, which named no account and had no
1099
+ * corresponding row — the user could neither identify the member nor act on it,
1100
+ * even though the collected name was in hand at write time. This turns the same
1101
+ * set into something renderable and specific.
1102
+ *
1103
+ * `names` is the Host's last-measured map; a missing entry means no measurement
1104
+ * ever recorded a name, not that the account has none. Ids keep the order the
1105
+ * saved list has, so the rendered list does not reshuffle between polls.
1106
+ */
1107
+ function ghostMembers(draftMembers, listedIds, names = {}) {
1108
+ return ghostMemberIds(draftMembers, listedIds).map((id) => {
1109
+ const measured = names[id];
1110
+ const named = typeof measured === "string" && measured !== "";
1111
+ return {
1112
+ id,
1113
+ label: named ? measured : `${id.slice(0, GHOST_ID_PREFIX)}…`,
1114
+ named
1115
+ };
1116
+ });
1117
+ }
1118
+ /**
1119
+ * The names to show in one sentence, capped so the warning stays readable.
1120
+ *
1121
+ * Beyond three the sentence would wrap into a paragraph, so it ends with an
1122
+ * ellipsis — the removable list rendered underneath carries the full set, which
1123
+ * is why truncating here cannot hide a member.
1124
+ */
1125
+ function ghostNameList(ghosts, limit = 3) {
1126
+ const shown = ghosts.slice(0, limit).map((ghost) => ghost.label).join("、");
1127
+ return ghosts.length <= limit ? shown : `${shown}…`;
1128
+ }
1057
1129
  /**
1058
1130
  * How many accounts a batch will really touch, as the log announces it.
1059
1131
  *
@@ -1205,7 +1277,7 @@ window.__ModuleLoader__.load({
1205
1277
  }
1206
1278
  }
1207
1279
  function formatNumber$1(value) {
1208
- return new Intl.NumberFormat(void 0, { maximumFractionDigits: 0 }).format(value);
1280
+ return formatCredits(value);
1209
1281
  }
1210
1282
  /**
1211
1283
  * `MM/DD, HH:mm` on a 24-hour clock.
@@ -1314,8 +1386,15 @@ window.__ModuleLoader__.load({
1314
1386
  listedIds,
1315
1387
  dirty
1316
1388
  });
1317
- /** Saved ids with no matching sign-in; surfaced rather than silently ignored. */
1318
- const ghostMembers = ghostMemberIds(active.memberAccountIds, listedIds);
1389
+ /**
1390
+ * Saved ids with no matching sign-in; surfaced rather than silently ignored.
1391
+ *
1392
+ * Named from the Host's last-measured map (issue #60): the id alone told the
1393
+ * user nothing, because nothing on screen shows ids. `pool.memberNames` can
1394
+ * only be absent for an older Host or an account never measured, and the
1395
+ * helper then falls back to an id prefix instead of a blank.
1396
+ */
1397
+ const ghostMembers$1 = ghostMembers(active.memberAccountIds, listedIds, pool?.memberNames ?? {});
1319
1398
  /**
1320
1399
  * Pool MEMBERS that can serve right now.
1321
1400
  *
@@ -1420,6 +1499,22 @@ window.__ModuleLoader__.load({
1420
1499
  settingsScope,
1421
1500
  t
1422
1501
  ]);
1502
+ /**
1503
+ * Drop one ghost member from the draft.
1504
+ *
1505
+ * A DRAFT edit, not a write: membership is one of the preferences the Save
1506
+ * button commits, and a control that wrote straight through would leave the
1507
+ * panel claiming "unsaved changes" for an edit it had already persisted. The
1508
+ * ghost rows therefore behave exactly like unchecking a listed account, which
1509
+ * is the behaviour the old copy ("save again to drop them") described but
1510
+ * gave the user no way to perform — the ids had no rows to uncheck.
1511
+ */
1512
+ const removeGhost = (0, react.useCallback)((accountId) => {
1513
+ editDraft((current) => ({
1514
+ ...current,
1515
+ memberAccountIds: current.memberAccountIds.filter((id) => id !== accountId)
1516
+ }));
1517
+ }, [editDraft]);
1423
1518
  /** Check or uncheck one account in this pool. */
1424
1519
  const toggleMember = (0, react.useCallback)((accountId, next) => {
1425
1520
  editDraft((current) => {
@@ -1756,10 +1851,25 @@ window.__ModuleLoader__.load({
1756
1851
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1757
1852
  className: "dsm-workbuddy-pool-warn",
1758
1853
  role: "status"
1759
- }, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers.length === 0 ? null : (0, react.createElement)("p", {
1854
+ }, `${t("row.poolNoneSelected")} ${t("row.poolNoneSelectedHint")}`) : null, ghostMembers$1.length === 0 ? null : (0, react.createElement)("div", { className: "dsm-workbuddy-pool-ghosts" }, (0, react.createElement)("p", {
1760
1855
  className: "dsm-workbuddy-pool-warn",
1761
1856
  role: "status"
1762
- }, t("row.poolGhostMembers", { count: ghostMembers.length })), dirty && !sameIds(active.memberAccountIds, saved.memberAccountIds) ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolUnsavedMembers")) : null), pool.targetModelSource === "none" ? (0, react.createElement)("p", {
1857
+ }, t("row.poolGhostMembers", {
1858
+ count: ghostMembers$1.length,
1859
+ names: ghostNameList(ghostMembers$1)
1860
+ })), (0, react.createElement)("ul", { className: "dsm-workbuddy-pool-ghost-list" }, ghostMembers$1.map((ghost) => (0, react.createElement)("li", {
1861
+ key: ghost.id,
1862
+ className: "dsm-workbuddy-pool-ghost"
1863
+ }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-ghost-name" }, ghost.label), (0, react.createElement)("button", {
1864
+ type: "button",
1865
+ className: "dsm-workbuddy-pool-ghost-remove",
1866
+ title: t(ghost.named ? "row.poolGhostRemoveNamed" : "row.poolGhostRemoveUnnamed", { account: ghost.label }),
1867
+ "aria-label": t("row.poolGhostRemove", { account: ghost.label }),
1868
+ disabled: saving,
1869
+ onClick: () => {
1870
+ removeGhost(ghost.id);
1871
+ }
1872
+ }, "✕"))))), dirty && !sameIds(active.memberAccountIds, saved.memberAccountIds) ? (0, react.createElement)("p", { className: "dsm-workbuddy-pool-dirty" }, t("row.poolUnsavedMembers")) : null), pool.targetModelSource === "none" ? (0, react.createElement)("p", {
1763
1873
  className: "dsm-workbuddy-pool-warn",
1764
1874
  role: "status"
1765
1875
  }, `${t("row.poolTargetNone")} ${t("row.poolTargetNoneHint")}`) : null, pool.targetModelSource === "stale" ? (0, react.createElement)("div", { className: "dsm-workbuddy-pool-conflict" }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-conflict-main" }, (0, react.createElement)("b", null, t("row.poolTargetStale", { model: pool.staleTargetModelId ?? "" })), (0, react.createElement)("span", null, t("row.poolTargetStaleHint"))), (0, react.createElement)("button", {
@@ -2987,6 +3097,16 @@ window.__ModuleLoader__.load({
2987
3097
  .dsm-workbuddy-pool-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#999)}
2988
3098
  .dsm-workbuddy-pool-note{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary,#999)}
2989
3099
  .dsm-workbuddy-pool-warn{margin:0;padding:9px 11px;border-radius:9px;font-size:12px;line-height:17px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
3100
+ /* Ghost members (issue #60): the warning plus one removable chip per id. The
3101
+ chips are what make the warning actionable — a ghost has no row in the table
3102
+ above, because that roster is the sign-ins this machine still has. */
3103
+ .dsm-workbuddy-pool-ghosts{display:flex;flex-direction:column;gap:6px}
3104
+ .dsm-workbuddy-pool-ghost-list{display:flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none}
3105
+ .dsm-workbuddy-pool-ghost{display:inline-flex;align-items:center;gap:4px;padding:2px 4px 2px 9px;border-radius:999px;font-size:12px;line-height:18px;color:var(--dsw-alias-state-warn-primary,#f59e0b);background:rgba(245,158,11,.09);border:1px solid rgba(245,158,11,.3)}
3106
+ .dsm-workbuddy-pool-ghost-name{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3107
+ .dsm-workbuddy-pool-ghost-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:0;border-radius:999px;font-size:11px;line-height:1;cursor:pointer;color:inherit;background:transparent}
3108
+ .dsm-workbuddy-pool-ghost-remove:hover:not(:disabled){background:rgba(245,158,11,.2)}
3109
+ .dsm-workbuddy-pool-ghost-remove:disabled{cursor:default;opacity:.5}
2990
3110
  .dsm-workbuddy-pool-error{margin:0;font-size:12px;line-height:17px;color:var(--dsw-alias-state-error-primary,#ef4444);white-space:pre-line}
2991
3111
  .dsm-workbuddy-pool-table{border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-2,#24262c)}
2992
3112
  .dsm-workbuddy-pool-row{display:grid;grid-template-columns:1.6fr 1fr 1.3fr .9fr .8fr;gap:10px;align-items:center;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1,#2c2d31);font-size:13px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
@@ -3542,7 +3662,7 @@ window.__ModuleLoader__.load({
3542
3662
  function formatNumber(value) {
3543
3663
  return new Intl.NumberFormat(void 0, {
3544
3664
  minimumFractionDigits: 0,
3545
- maximumFractionDigits: 2
3665
+ maximumFractionDigits: 0
3546
3666
  }).format(value);
3547
3667
  }
3548
3668
  /**
@@ -4865,10 +4985,6 @@ window.__ModuleLoader__.load({
4865
4985
  const value = usage.showCreditsInMainUi;
4866
4986
  return typeof value === "boolean" ? value : void 0;
4867
4987
  }
4868
- /** Group the digits so a four-figure balance stays readable in a narrow row. */
4869
- function formatCredits(value) {
4870
- return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
4871
- }
4872
4988
  function formatClock(value) {
4873
4989
  return new Date(value).toLocaleTimeString("zh-CN", {
4874
4990
  hour: "2-digit",
@@ -5497,7 +5613,10 @@ window.__ModuleLoader__.load({
5497
5613
  "row.poolSelectedCount": "{count} of {total} selected",
5498
5614
  "row.poolNoneSelected": "No account is checked into this pool.",
5499
5615
  "row.poolNoneSelectedHint": "Check at least one account above and save. Until then both buttons stay disabled, because a pool with no members has nothing to run on.",
5500
- "row.poolGhostMembers": "{count} saved account(s) are no longer signed in on this machine, so they are not counted and not used. Save again to drop them.",
5616
+ "row.poolGhostMembers": "{count} pool member(s) are no longer signed in on this machine, so they are not counted and not used: {names}. Remove any you no longer need below.",
5617
+ "row.poolGhostRemove": "Remove {account} from this pool",
5618
+ "row.poolGhostRemoveNamed": "Remove {account} from this pool. This account is no longer signed in on this machine.",
5619
+ "row.poolGhostRemoveUnnamed": "Remove {account} from this pool. No name was ever recorded for this account, so what you see is the start of its id.",
5501
5620
  "row.poolSelectAria": "Include {accountName} in this region's account pool",
5502
5621
  "row.poolMember": "In pool",
5503
5622
  "row.poolCurrentBadge": "In use",
@@ -5760,7 +5879,10 @@ window.__ModuleLoader__.load({
5760
5879
  "row.poolSelectedCount": "已选 {count} / {total}",
5761
5880
  "row.poolNoneSelected": "这个池里还没有勾选任何账号。",
5762
5881
  "row.poolNoneSelectedHint": "请在上方勾选至少一个账号并保存。在此之前两个按钮都不可用 —— 空池没有可执行的对象。",
5763
- "row.poolGhostMembers": "有 {count} 个已保存的账号在本机已不再登录,因此不计入、也不会被使用。再保存一次即可移除它们。",
5882
+ "row.poolGhostMembers": "有 {count} 个已选入本池的账号在本机已不再登录,因此不计入、也不会被使用:{names}。不需要的可在下方逐个移除。",
5883
+ "row.poolGhostRemove": "把 {account} 移出本池",
5884
+ "row.poolGhostRemoveNamed": "把 {account} 移出本池。该账号在本机已不再登录。",
5885
+ "row.poolGhostRemoveUnnamed": "把 {account} 移出本池。这个账号从未记录过名字,显示的是它 id 的开头几位。",
5764
5886
  "row.poolSelectAria": "把 {accountName} 加入本区域的账号池",
5765
5887
  "row.poolMember": "已在池中",
5766
5888
  "row.poolCurrentBadge": "当前使用",
@@ -1829,10 +1829,18 @@ var WorkBuddyUpstreamClient = class {
1829
1829
  if (typeof raw !== "object" || raw === null) continue;
1830
1830
  const account = raw;
1831
1831
  const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
1832
+ const preciseField = (key) => {
1833
+ const raw = account[`${key}Precise`];
1834
+ if (typeof raw === "string" && raw !== "") {
1835
+ const parsed = Number.parseFloat(raw);
1836
+ if (Number.isFinite(parsed)) return parsed;
1837
+ }
1838
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
1839
+ return numberField(key);
1840
+ };
1832
1841
  const monthly = numberField("CapacityType") === 4;
1833
- const size = numberField("CapacitySize") || numberField("CycleCapacitySize");
1834
- const remain = numberField("CapacityRemain");
1835
- const cycleRemain = monthly ? numberField("CycleCapacityRemain") : void 0;
1842
+ const size = preciseField("CycleCapacitySize") || preciseField("CapacitySize");
1843
+ const remain = account["CycleCapacityRemain"] !== void 0 || account["CycleCapacityRemainPrecise"] !== void 0 ? preciseField("CycleCapacityRemain") : preciseField("CapacityRemain");
1836
1844
  const cappedRemain = remain < 0 ? 0 : remain;
1837
1845
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1838
1846
  const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
@@ -1849,7 +1857,6 @@ var WorkBuddyUpstreamClient = class {
1849
1857
  remain: cappedRemain,
1850
1858
  size,
1851
1859
  monthly,
1852
- ...cycleRemain === void 0 ? {} : { cycleRemain: Math.max(0, cycleRemain) },
1853
1860
  ...refreshAtMs === void 0 ? {} : { refreshAtMs },
1854
1861
  ...expiresAtMs === void 0 ? {} : { expiresAtMs }
1855
1862
  });
@@ -3800,7 +3807,7 @@ function generationOf(version) {
3800
3807
  //#endregion
3801
3808
  //#region src/version.ts
3802
3809
  /** The npm package version this build was produced from. */
3803
- const WORKBUDDY_CONNECT_VERSION = "4.4.4";
3810
+ const WORKBUDDY_CONNECT_VERSION = "4.4.5";
3804
3811
  //#endregion
3805
3812
  //#region src/host-heartbeat.ts
3806
3813
  /**
package/lib/index.d.ts CHANGED
@@ -640,23 +640,36 @@ interface WorkBuddyUpstreamModel {
640
640
  interface WorkBuddyCreditPackage {
641
641
  packageName: string;
642
642
  /**
643
- * Remaining credit, always read from `CapacityRemain` (issue #48).
643
+ * Remaining credit, always read from the CYCLE family (issue #58).
644
644
  *
645
645
  * The upstream reports a monthly resource (`CapacityType 4`) with TWO
646
- * capacity families, and they answer different questions:
647
- *
648
- * - `CapacityRemain` / `CapacitySize` / `CapacityUsed` — the capacity POOL.
649
- * - `CycleCapacityRemain` / `CycleCapacitySize` / `CycleCapacityUsed` — the
650
- * CURRENT BILLING CYCLE.
651
- *
652
- * "How much credit do I have" is the pool, so that is what this field
653
- * carries. Reading `CycleCapacityRemain` here was the defect: on three real
654
- * global accounts the upstream returned `CapacityRemain=100` together with
655
- * `CycleCapacityRemain=0` and `CycleCapacityUsed=100` inside an UNFINISHED
656
- * cycle (`CycleEndTime` days out, `Status=0`), so the plugin reported 0
657
- * credits for accounts that were not exhausted at all.
646
+ * capacity families, and for a long time this field carried the POOL one on
647
+ * the theory that the pool answers "how much do I have" (issue #48). That was
648
+ * wrong, and the official client settles it: on a live CN account the client
649
+ * shows `总剩余积分 2,196.61` = `套餐基础积分 500` + `平台奖励积分 1,696.61`,
650
+ * which is EXACTLY Σ `CycleCapacityRemainPrecise` over the same response
651
+ * (1696.61 + 500), while Σ `CapacityRemain` is 2,196 — the pool figure lands
652
+ * on the right integer only because that account's cycle is untouched, and
653
+ * the cent is what proves which family the client reads.
654
+ *
655
+ * The pool family is not a balance at all: on a monthly resource whose cycle
656
+ * is fully spent it still reports `CapacityRemain=500, CapacityUsed=0`, i.e.
657
+ * "nothing used, everything left", while the cycle reports
658
+ * `CycleCapacityUsed=500`. Read as a balance it OVER-reports by the whole
659
+ * monthly allowance of every account (measured on 8/8 CN accounts: pool and
660
+ * cycle agree only where the cycle is untouched).
661
+ *
662
+ * Independent confirmation comes from the international gateway: three global
663
+ * accounts report `CapacityRemain=100` while the gateway REFUSES their chat
664
+ * requests with `14018 Credits exhausted`, so the pool figure is not spendable
665
+ * credit there either.
666
+ *
667
+ * A gift (`CapacityType 1`) carries both families with identical values, so
668
+ * reading the cycle uniformly costs nothing and removes the branch that
669
+ * produced the defect.
658
670
  */
659
671
  remain: number;
672
+ /** The package's capacity, from the same family as {@link remain}. */
660
673
  size: number;
661
674
  /** CapacityType 4: refreshed every cycle and never expires. */
662
675
  monthly: boolean;
@@ -664,16 +677,6 @@ interface WorkBuddyCreditPackage {
664
677
  refreshAtMs?: number;
665
678
  /** One-off expiry; the package disappears from the account at this time. */
666
679
  expiresAtMs?: number;
667
- /**
668
- * Remaining credit in the CURRENT cycle; only on monthly packages.
669
- *
670
- * Kept beside {@link remain} because it answers the one question the pool
671
- * total cannot: whether THIS cycle's allowance is spent. That is what makes
672
- * `refreshAtMs` meaningful — "you are out until the cycle resets" is a
673
- * different statement from "you are out of credit", and only the cycle
674
- * figure can tell them apart.
675
- */
676
- cycleRemain?: number;
677
680
  }
678
681
  /** Aggregated credit answer for one credential. */
679
682
  interface WorkBuddyCredits {
@@ -1941,6 +1944,24 @@ interface WorkBuddyPoolProbe {
1941
1944
  * old probe files stay readable rather than being discarded.
1942
1945
  */
1943
1946
  message?: string;
1947
+ /**
1948
+ * The account's display name AT THE MOMENT IT WAS MEASURED (issue #60).
1949
+ *
1950
+ * Stored because a saved pool member can outlive its sign-in. Once the
1951
+ * desktop app drops a login, that id resolves to no listed account, so the
1952
+ * card has nothing left to render for it — and the old warning ("1 saved
1953
+ * account is no longer signed in") was a count with no subject, leaving the
1954
+ * user unable to tell WHICH account had gone. The name was in hand at write
1955
+ * time (both writers already carry it) and nowhere afterwards, so the record
1956
+ * is the only place it can survive.
1957
+ *
1958
+ * ABSENT on records written before this field existed, and when the desktop
1959
+ * app recorded no name: the card then falls back to a short id prefix rather
1960
+ * than inventing a label. Snapshots are never refreshed from a LISTING — only
1961
+ * a real measurement writes this, so a stale name cannot be "confirmed" by a
1962
+ * routine scan that never saw the account.
1963
+ */
1964
+ accountName?: string;
1944
1965
  }
1945
1966
  /** One account's live credit situation, as the pool ranks it. */
1946
1967
  interface WorkBuddyPoolCredits {
@@ -2104,13 +2125,6 @@ interface WorkBuddyWebCreditPackage {
2104
2125
  size: number;
2105
2126
  /** CapacityType 4: refreshed every cycle and never expires. */
2106
2127
  monthly: boolean;
2107
- /**
2108
- * Balance of the CURRENT cycle (`CycleCapacityRemain`); only on monthly
2109
- * packages. `0` on a live package means this cycle's allowance is spent,
2110
- * which is what {@link cycleRefreshMs} is telling the user to wait for — and
2111
- * is deliberately NOT the same as {@link remain} being 0 (issue #48).
2112
- */
2113
- cycleRemain?: number;
2114
2128
  /** Next cycle start (the monthly refresh point) in ms; only on monthly packages. */
2115
2129
  cycleRefreshMs?: number;
2116
2130
  /** One-off expiry in ms; the package disappears from the account then. */
@@ -2567,6 +2581,17 @@ interface WorkBuddyWebPool {
2567
2581
  * ran on ZERO accounts and reported success.
2568
2582
  */
2569
2583
  effectiveMemberAccountIds?: readonly string[];
2584
+ /**
2585
+ * Last measured display name per saved member id (issue #60).
2586
+ *
2587
+ * Only ids the roster above does NOT list are useful here: those are the
2588
+ * members whose sign-in has gone, and whose name therefore survives nowhere
2589
+ * else. The card renders a name when there is one and a short id prefix when
2590
+ * there is not — the point is that the ghost warning stops being a
2591
+ * subjectless count ("1 saved account is no longer signed in") that the user
2592
+ * has no way to act on.
2593
+ */
2594
+ memberNames?: Readonly<Record<string, string>>;
2570
2595
  accounts: readonly WorkBuddyWebPoolAccount[];
2571
2596
  /**
2572
2597
  * The models a manual target can be chosen from.
@@ -2837,6 +2862,16 @@ interface WorkBuddyPoolDeps {
2837
2862
  * checked) instead of passing the guard and running a batch over nothing.
2838
2863
  */
2839
2864
  effectiveMemberAccountIds?(region: WorkBuddyRegion): Promise<readonly string[]>;
2865
+ /**
2866
+ * Last display name measured for each of a region's saved member ids
2867
+ * (issue #60), from the runtime probe store — no upstream call.
2868
+ *
2869
+ * The card needs this for the one case it cannot answer from its own props: a
2870
+ * saved member whose sign-in is gone resolves to no listed account, so its
2871
+ * name exists ONLY in the measurement that once saw it. Optional: a Host
2872
+ * built without it still renders, with the id-prefix fallback.
2873
+ */
2874
+ memberNames?(region: WorkBuddyRegion): Promise<Readonly<Record<string, string>>>;
2840
2875
  /**
2841
2876
  * Test ONE account against the region's target model.
2842
2877
  *
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BwbDWvzw.js";
1
+ import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-Bj7JvSYJ.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -2567,6 +2567,7 @@ function probeUpdatesOf(rows) {
2567
2567
  outcome: row.result.outcome,
2568
2568
  atMs,
2569
2569
  source: "test-batch",
2570
+ ...row.accountName === "" ? {} : { accountName: row.accountName },
2570
2571
  ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
2571
2572
  ...row.result.message === void 0 ? {} : { message: row.result.message }
2572
2573
  };
@@ -3317,7 +3318,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
3317
3318
  function isProbe(value) {
3318
3319
  if (typeof value !== "object" || value === null) return false;
3319
3320
  const candidate = value;
3320
- return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3321
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3321
3322
  }
3322
3323
  /**
3323
3324
  * One region's path for measured pool facts.
@@ -3366,7 +3367,8 @@ async function readPoolProbes(region) {
3366
3367
  atMs: probe.atMs,
3367
3368
  ...knownProbeSource(probe.source) === void 0 ? {} : { source: knownProbeSource(probe.source) },
3368
3369
  ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
3369
- ...probe.message === void 0 ? {} : { message: probe.message }
3370
+ ...probe.message === void 0 ? {} : { message: probe.message },
3371
+ ...probe.accountName === void 0 ? {} : { accountName: probe.accountName }
3370
3372
  };
3371
3373
  }
3372
3374
  return probes;
@@ -3414,10 +3416,14 @@ function knownProbeSource(value) {
3414
3416
  */
3415
3417
  function mergePoolProbe(existing, update) {
3416
3418
  if (existing === void 0) return update;
3417
- if (update.retryAtMs !== void 0) return update;
3418
- if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return update;
3419
- return {
3419
+ const merged = update.accountName === void 0 && existing.accountName !== void 0 ? {
3420
3420
  ...update,
3421
+ accountName: existing.accountName
3422
+ } : update;
3423
+ if (update.retryAtMs !== void 0) return merged;
3424
+ if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return merged;
3425
+ return {
3426
+ ...merged,
3421
3427
  retryAtMs: existing.retryAtMs
3422
3428
  };
3423
3429
  }
@@ -3794,7 +3800,6 @@ function toCredits(answer) {
3794
3800
  remain: pack.remain,
3795
3801
  size: pack.size,
3796
3802
  monthly: pack.monthly,
3797
- ...pack.cycleRemain === void 0 ? {} : { cycleRemain: pack.cycleRemain },
3798
3803
  ...pack.refreshAtMs === void 0 ? {} : { cycleRefreshMs: pack.refreshAtMs },
3799
3804
  ...pack.expiresAtMs === void 0 ? {} : { expiresAtMs: pack.expiresAtMs }
3800
3805
  })),
@@ -4015,14 +4020,25 @@ async function workBuddyWebPool(deps, region) {
4015
4020
  const rankedIds = new Set(ranked.map((row) => row.account.id));
4016
4021
  const others = await pool.otherAccounts?.(region) ?? [];
4017
4022
  const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
4018
- const accounts = [...ranked.map((row) => ({
4023
+ const rows = [...ranked.map((row) => ({
4019
4024
  account: row.account,
4020
4025
  member: true,
4021
4026
  ranked: row
4022
4027
  })), ...others.filter((account) => !rankedIds.has(account.id)).map((account) => ({
4023
4028
  account,
4024
4029
  member: false
4025
- }))].map(({ account, member, ranked: row }) => {
4030
+ }))];
4031
+ /**
4032
+ * Names for saved members the roster above does NOT contain (issue #60).
4033
+ *
4034
+ * Read for the SAVED ids only, and only from the measurement record: those
4035
+ * are exactly the accounts the card cannot name from `accounts`, because
4036
+ * nothing about them is left on this machine except the fact that they were
4037
+ * once measured. Best-effort — a failed read names nothing, and the card
4038
+ * falls back to an id prefix rather than inventing a label.
4039
+ */
4040
+ const memberNames = await pool.memberNames?.(region).catch(() => void 0) ?? void 0;
4041
+ const accounts = rows.map(({ account, member, ranked: row }) => {
4026
4042
  const measured = byId.get(account.id);
4027
4043
  return {
4028
4044
  accountId: account.id,
@@ -4054,6 +4070,7 @@ async function workBuddyWebPool(deps, region) {
4054
4070
  memberAccountIds: preferences.memberAccountIds,
4055
4071
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4056
4072
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
4073
+ ...memberNames === void 0 ? {} : { memberNames },
4057
4074
  accounts,
4058
4075
  catalog: deps.displayModels(region).map((model) => ({
4059
4076
  id: model.id,
@@ -5140,6 +5157,27 @@ function apply(ctx, config) {
5140
5157
  return effectiveMembersOf(saved, new Set(accounts.map((account) => account.id)));
5141
5158
  },
5142
5159
  /**
5160
+ * Last measured display name per saved member id (issue #60).
5161
+ *
5162
+ * Answers the one question the card cannot answer itself: a saved member
5163
+ * whose sign-in is gone has no row in the roster, so the name the pool
5164
+ * once saw is the only thing that can identify it. Read from the runtime
5165
+ * probe store — no upstream call, and no credential touched.
5166
+ *
5167
+ * Best-effort, and deliberately NOT filtered to ghosts here: the route
5168
+ * knows which ids are unlisted (it built the roster) and names only
5169
+ * those, so this stays a plain lookup with one meaning.
5170
+ */
5171
+ memberNames: async (region) => {
5172
+ const probes = await readPoolProbes(region);
5173
+ const names = {};
5174
+ for (const [accountId, probe] of Object.entries(probes)) {
5175
+ if (probe.accountName === void 0 || probe.accountName === "") continue;
5176
+ names[accountId] = probe.accountName;
5177
+ }
5178
+ return names;
5179
+ },
5180
+ /**
5143
5181
  * Local sign-ins the pool does NOT cover.
5144
5182
  *
5145
5183
  * Sent so the card can offer them to check in. Membership is an explicit
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": "4.4.4",
5
+ "version": "4.4.5",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",