dsh-connect-workbuddy 2.0.0 → 2.0.2

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.2 (2026-09-15)
4
+
5
+ ### Fixes
6
+
7
+ - **修复「同一账号多个凭据文件时选中已失效的那一个」,导致积分/签到/模型全部返回 openresty HTML 401**。此前按**存储的 `expiresAt` 最大**来挑选凭据,但 `expiresAt` 描述的是「签发时有效期有多长」,**不代表上游仍然接受** —— 上游吊销 token 时不会同步改写该字段,于是一个早已失效的备份文件可以声称比真实可用的文件**更晚过期**,从而永远被选中。
8
+
9
+ - **实测复现**:本机某账号有 8 个凭据文件,其中 `2026-07-08` 的备份声称 `2027-07-06` 过期(最远),而真正可用的当前登录文件只到 `2026-11-14`。旧逻辑因此始终选中那个 07-08 的死文件,三个端点(`get-user-resource` / `checkin-activity-status` / `models`)全部返回 `401 Authorization Required` + `openresty`。
10
+ - **改用 `auth.lastRefreshTime` 作为新鲜度判据**:这是上游自己的签发时间,是唯一可靠的信号。新增 `WorkBuddyCredential.lastRefreshAtMs` 字段并在解析时读入;`preferred()`、多账号去重、以及插件自有副本的合并**三处**统一改用新的 `isFresher()` 比较器。
11
+ - **排序优先级**:① live 文件 `workbuddy-desktop.info`(应用当前登录)→ ② `lastRefreshAtMs` 较新者 → ③ `expiresAtMs`(仅作为文档缺失该字段时的回退,保证比较是全序的)。
12
+ - **实测验证**:切换判据后,本机 11 个账号选中的凭据**全部返回 200**(此前 `老丁`、`LaoDing` 等均命中死文件)。
13
+ - 插件自有刷新副本(无 `lastRefreshTime`)不参与签发时间比较:它只在**确实更长寿**时取代桌面端备份,且**永不顶替 live 登录**。
14
+
15
+ - **上游返回 HTML 401 时给出可执行的提示**,不再原样抛出 HTML 片段。识别 openresty / APISIX 的鉴权拒绝,改为提示「凭据已被上游网关拒绝,通常说明选中的是旧登录留下的失效凭据;请重新登录 WorkBuddy 桌面端后在卡片中选择该账号,可运行 `dsh-connect-workbuddy doctor` 查看全部凭据」。非鉴权类的非 JSON 响应(如 502 网关错误)保持原有的通用提示。
16
+
17
+ ### Tests
18
+
19
+ - `auth.spec.ts` 新增 4 例:**备份之间**按签发时间而非存储过期时间选择(回归锚点,旧逻辑下必失败)、文档缺失 `lastRefreshTime` 时回退到过期时间、`auth.lastRefreshTime` 的正确解析、缺失该字段时保持 `undefined`。
20
+ - `upstream.spec.ts` 新增 2 例:网关 HTML 401 被转写为可执行提示(且不泄漏 `<html>`)、非鉴权类的非 JSON 失败仍保留通用提示。
21
+ - 全套 **126 个测试通过**。
22
+
23
+ ## 2.0.1 (2026-09-15)
24
+
25
+ ### Fixes
26
+
27
+ - **修复 `codebuddy.ai` 国际账号被判为国内、上游返回 HTML 401(issue #4)**。国际版产品有两个品牌域名:WorkBuddy AI **桌面端**在 `workbuddy.ai` 登录,而 **CodeBuddy CLI** 把同一个国际账号登录在 `codebuddy.ai`。`regionOf()` 此前只认 `workbuddy.ai`,于是 CLI 登录的 `www.codebuddy.ai` 凭据被判为 `cn`,其 token 被发往国内网关(`copilot.tencent.com` / `www.codebuddy.cn`),在 openresty 层被直接拒绝,返回 **HTML** 401 而非业务 JSON —— 表现为积分、签到、模型列表与对话**全部失败**(报告者最初描述为「仅积分失败」,后经其本人确认实际是全挂,与该根因一致)。
28
+ - **`regionOf()` 增补第二个国际品牌域名**:`codebuddy.ai` / `*.codebuddy.ai` → `global`。判定仍按凭据自带的 `domain` 字段,无需任何手动开关。
29
+ - **新增 `globalBase(credential)`:国际版网关跟随凭据自身的 `domain`**,不再写死 `www.workbuddy.ai`。两个品牌域名的凭据**互不通用**(在 `codebuddy.ai` 签发的 token 会被 `workbuddy.ai` 网关拒绝,反之亦然),因此 base 必须跟随凭据。`chatBase` / `billingBase` / `originReferer` / `fetchModels` 四处全部改用 `globalBase(credential)`;未识别的国际域名回落至 `www.workbuddy.ai`。
30
+ - **token 刷新同样受益**:刷新端点走 `chatBase(credential)`,此前 `codebuddy.ai` 账号会向国内网关刷新;现在跟随凭据域名。
31
+ - 国内版(`codebuddy.cn` / `workbuddy.cn` / `copilot.tencent.com`)行为完全不变。
32
+
33
+ > 实测依据(只读探测,未使用任何凭据):`www.workbuddy.ai` 与 `www.codebuddy.ai` 对 `/v3/config` 均返回 **200 + 同一 JSON envelope**,对 `/v2/billing/meter/get-user-resource` 均返回 **401 + 同形 HTML**(未带 token),确认两者是同一套网关栈的不同品牌域名,路径形态一致。
34
+
35
+ ### Docs
36
+
37
+ - `scripts/probe-global-billing-401.mjs` 同步:`regionOf` / `globalBase` 与发布版保持一致;「凭据中是否出现 codebuddy.ai」的判定改为**中性陈述**——该脚本扫的是桌面端凭据目录,而 `codebuddy.ai` 来自 CLI 登录,扫不到只能说明本机没有 CLI 登录,不构成「该域名不存在」的结论(此前正是这个措辞导致了 issue #4 中的误判)。
38
+
39
+ ### Tests
40
+
41
+ - `upstream.spec.ts` 新增 3 例:`codebuddy.ai` / `*.codebuddy.ai` / 大小写与空白归一化判为 `global`,且 `www.codebuddy.cn`、`notcodebuddy.ai` 不被宽松后缀匹配误升为国际;`globalBase` 跟随凭据域名并正确回落;`fetchModels` 对 `www.codebuddy.ai` 凭据请求 **`https://www.codebuddy.ai/v3/config`**(回归锚点,验证不再误发国内网关)。
42
+
3
43
  ## 2.0.0 (2026-09-15)
4
44
 
5
45
  ### Breaking Changes
package/README.en.md CHANGED
@@ -42,9 +42,9 @@ DSH PiAiAdapter (one stack per provider)
42
42
  -> DSH executes local tools and returns their results
43
43
  ```
44
44
 
45
- The CN and international sides each own a complete runtime stack — credential store, model catalog, loopback shim, adapter — with visibility filtered by credential domain (`workbuddy.ai` → international, anything else → CN), so **both regions' accounts can be signed in and used by different sessions at the same time**.
45
+ The CN and international sides each own a complete runtime stack — credential store, model catalog, loopback shim, adapter — with visibility filtered by credential domain (`workbuddy.ai` / `codebuddy.ai` → international, anything else → CN), so **both regions' accounts can be signed in and used by different sessions at the same time**. The international product has two brand domains: the desktop app signs in at `workbuddy.ai`, while the CodeBuddy CLI signs in at `codebuddy.ai` — both are international.
46
46
 
47
- The model catalog comes from each region's correct source: CN reads `/v2/enterprises/personal/models`, while international reads `www.workbuddy.ai/v3/config` — the config service serves a different roster per client channel, and only the desktop user agent yields the account's real 20-model list (including the free `deepseek-v4.1-flash`). The credits overview comes from `https://www.codebuddy.cn/v2/billing/meter/get-user-resource` (international accounts auto-route to `https://www.workbuddy.ai`); all are read-only.
47
+ The model catalog comes from each region's correct source: CN reads `/v2/enterprises/personal/models`, while international reads `<global gateway>/v3/config` — the config service serves a different roster per client channel, and only the desktop user agent yields the account's real 20-model list (including the free `deepseek-v4.1-flash`). The credits overview comes from `https://www.codebuddy.cn/v2/billing/meter/get-user-resource` (international accounts auto-route to the global gateway their credential belongs to, `workbuddy.ai` or `codebuddy.ai` — tokens are not interchangeable across the two brand domains); all are read-only.
48
48
 
49
49
  Credentials are read (read-only) from the WorkBuddy desktop app's own auth file. Refreshed tokens are kept per region in `$DSH_HOME/.workbuddy-auth.cn.json` and `$DSH_HOME/.workbuddy-auth.global.json` (two simultaneously signed-in accounts never overwrite each other; the legacy single file `.workbuddy-auth.json` is still read as a migration source); the desktop app's file is never written.
50
50
 
package/README.md CHANGED
@@ -48,9 +48,9 @@ DSH PiAiAdapter(每个 provider 一套)
48
48
  -> DSH 本地执行工具并回传结果
49
49
  ```
50
50
 
51
- 国内版与国际版各持一套完整的运行时栈——凭据 store、模型 catalog、回环 shim、adapter——按凭据域名(`workbuddy.ai` → 国际版,其余 → 国内版)隔离可见账号,所以**两个区域的账号可以同时在线、同时被不同会话使用**。
51
+ 国内版与国际版各持一套完整的运行时栈——凭据 store、模型 catalog、回环 shim、adapter——按凭据域名(`workbuddy.ai` / `codebuddy.ai` → 国际版,其余 → 国内版)隔离可见账号,所以**两个区域的账号可以同时在线、同时被不同会话使用**。国际版有两个品牌域名:桌面端登录在 `workbuddy.ai`,CodeBuddy CLI 登录在 `codebuddy.ai`,两者都是国际版。
52
52
 
53
- 模型目录按区域取自各自的正确来源:国内版走 `/v2/enterprises/personal/models`,国际版走 `www.workbuddy.ai/v3/config`(配置服务按客户端渠道返回不同清单,国际版必须用桌面端 User-Agent 才能取到账号真实的 20 个模型,含免费的 `deepseek-v4.1-flash`)。积分概览走 `https://www.codebuddy.cn/v2/billing/meter/get-user-resource`(国际版账号自动路由到 `https://www.workbuddy.ai`),均为只读接口。
53
+ 模型目录按区域取自各自的正确来源:国内版走 `/v2/enterprises/personal/models`,国际版走 `<国际网关>/v3/config`(配置服务按客户端渠道返回不同清单,国际版必须用桌面端 User-Agent 才能取到账号真实的 20 个模型,含免费的 `deepseek-v4.1-flash`)。积分概览走 `https://www.codebuddy.cn/v2/billing/meter/get-user-resource`(国际版账号自动路由到其凭据所属的国际网关,`workbuddy.ai` 或 `codebuddy.ai`——两个品牌域名的凭据互不通用),均为只读接口。
54
54
 
55
55
  凭据读取自 WorkBuddy 桌面 App 自身的 auth 文件(只读);刷新得到的 token 按区域存放在 `$DSH_HOME/.workbuddy-auth.cn.json` 与 `$DSH_HOME/.workbuddy-auth.global.json`(双账号同时在线互不覆盖;旧的单文件 `.workbuddy-auth.json` 作为迁移来源保留读取),桌面端文件永不被写入。
56
56
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-sqjxgXkW.js";
2
+ import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CaS5Koaw.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -78,20 +78,46 @@ function classifyUpstreamError(status, body) {
78
78
  if (status >= 400) return "client";
79
79
  return "client";
80
80
  }
81
- /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
81
+ /**
82
+ * Region for a login domain; an empty domain means CN (matching upstream tooling).
83
+ *
84
+ * The international product is reachable under TWO brand domains: the WorkBuddy
85
+ * AI desktop app signs in at `workbuddy.ai`, while the CodeBuddy CLI signs the
86
+ * same international account in at `codebuddy.ai` (verified against a real
87
+ * credential file, issue #4). Both are served by the same gateway stack — a
88
+ * read-only probe shows `/v3/config` answering HTTP 200 with the same JSON
89
+ * envelope on both hosts — so both classify as `global`. Missing the
90
+ * `codebuddy.ai` spelling sent those tokens to the CN gateway, which rejected
91
+ * them at the openresty layer with an HTML 401.
92
+ */
82
93
  function regionOf(domain) {
83
94
  const lowered = domain.trim().toLowerCase();
84
95
  if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
96
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "global";
85
97
  return "cn";
86
98
  }
99
+ /**
100
+ * Gateway for a global credential.
101
+ *
102
+ * International accounts are NOT interchangeable across brand domains: a token
103
+ * issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway (and vice
104
+ * versa), so the base must follow the credential's OWN domain rather than a
105
+ * single hardcoded host. Anything unrecognised falls back to `workbuddy.ai`,
106
+ * the desktop app's gateway.
107
+ */
108
+ function globalBase(domain) {
109
+ const lowered = domain.trim().toLowerCase();
110
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
111
+ return GLOBAL_BASE;
112
+ }
87
113
  function chatBase(credential) {
88
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
114
+ return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_CHAT_BASE;
89
115
  }
90
116
  function billingBase(credential) {
91
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
117
+ return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_BILLING_BASE;
92
118
  }
93
119
  function originReferer(credential) {
94
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
120
+ return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_BILLING_BASE;
95
121
  }
96
122
  /** Headers every upstream request shares. */
97
123
  function commonHeaders(credential) {
@@ -195,12 +221,27 @@ function normalizeToolChoice(obj) {
195
221
  }
196
222
  delete obj["tool_choice"];
197
223
  }
224
+ /**
225
+ * Gateway (openresty/APISIX) rejection of a token it no longer accepts.
226
+ *
227
+ * The business APIs answer JSON; an edge rejection answers an HTML error page
228
+ * instead. A 401 that is not JSON therefore means the credential was refused
229
+ * before routing — almost always a revoked/expired token rather than a bug in
230
+ * the request. Detected from the body so a proxy's own error page (which would
231
+ * also be HTML) is still described accurately.
232
+ */
233
+ function isGatewayAuthRejection(status, text) {
234
+ if (status !== 401 && status !== 403) return false;
235
+ const lower = text.toLowerCase();
236
+ return lower.includes("openresty") || lower.includes("apisix") || lower.includes("authorization required");
237
+ }
198
238
  async function readEnvelope(response) {
199
239
  const text = await response.text();
200
240
  let parsed;
201
241
  try {
202
242
  parsed = JSON.parse(text);
203
243
  } catch {
244
+ if (isGatewayAuthRejection(response.status, text)) throw new Error(`workbuddy: the signed-in credential was rejected by the upstream gateway (http ${response.status}). The stored token is no longer accepted — most likely a stale credential file from an earlier sign-in was selected. Re-sign in to the WorkBuddy desktop app, then pick that account in the plugin card. Run \`dsh-connect-workbuddy doctor\` to list every discovered credential.`);
204
245
  throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
205
246
  }
206
247
  if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
@@ -369,7 +410,7 @@ var WorkBuddyUpstreamClient = class {
369
410
  async fetchModels(credential, signal) {
370
411
  const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
371
412
  if (regionOf(credential.domain) === "global") {
372
- const response = await fetch(`${GLOBAL_BASE}${GLOBAL_CONFIG_PATH}`, {
413
+ const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
373
414
  headers: {
374
415
  "Authorization": `Bearer ${credential.accessToken}`,
375
416
  "Accept": "application/json",
@@ -647,6 +688,7 @@ function parseWorkBuddyAuth(text, filePath) {
647
688
  if (accessToken === "") return void 0;
648
689
  const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
649
690
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
691
+ const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
650
692
  const enterpriseId = optionalString(identity["enterpriseId"]);
651
693
  const nickname = optionalString(identity["nickname"]);
652
694
  const uin = optionalString(identity["uin"]);
@@ -660,6 +702,7 @@ function parseWorkBuddyAuth(text, filePath) {
660
702
  ...enterpriseId === void 0 ? {} : { enterpriseId },
661
703
  ...nickname === void 0 ? {} : { nickname },
662
704
  ...uin === void 0 ? {} : { uin },
705
+ ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
663
706
  source: "desktop",
664
707
  filePath
665
708
  };
@@ -678,6 +721,32 @@ function fileRank(path) {
678
721
  return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
679
722
  }
680
723
  /**
724
+ * Whether `candidate` is a better pick than `incumbent` for the same account.
725
+ *
726
+ * Ordering, strongest signal first:
727
+ *
728
+ * 1. the live `workbuddy-desktop.info` (the app's current sign-in);
729
+ * 2. the most recent `lastRefreshAtMs` — the upstream's own issuance time;
730
+ * 3. `expiresAtMs`, only as a fallback for documents that omit the field.
731
+ *
732
+ * Step 2 is what makes this correct. `expiresAt` describes how long the token
733
+ * was VALID FOR at issue time, not whether it is still accepted: a revoked
734
+ * backup keeps a far-future `expiresAt` (2027 in the observed case) and would
735
+ * otherwise outrank the working live credential, which is exactly how a
736
+ * signed-in account turned into an upstream HTML 401.
737
+ */
738
+ function isFresher(candidate, incumbent) {
739
+ const rankDiff = fileRank(candidate.filePath) - fileRank(incumbent.filePath);
740
+ if (rankDiff !== 0) return rankDiff < 0;
741
+ const candidateRefresh = candidate.lastRefreshAtMs;
742
+ const incumbentRefresh = incumbent.lastRefreshAtMs;
743
+ if (candidateRefresh !== void 0 && incumbentRefresh !== void 0) {
744
+ if (candidateRefresh !== incumbentRefresh) return candidateRefresh > incumbentRefresh;
745
+ } else if (candidateRefresh !== void 0) return true;
746
+ else if (incumbentRefresh !== void 0) return false;
747
+ return candidate.expiresAtMs > incumbent.expiresAtMs;
748
+ }
749
+ /**
681
750
  * Filename of a path regardless of the host separator: Windows paths use `\`
682
751
  * and this helper must keep working when a Windows path is compared on a
683
752
  * POSIX host (e.g. tests injecting a Windows-style auth dir).
@@ -880,13 +949,14 @@ var WorkBuddyCredentialStore = class {
880
949
  byId.set(id, credential);
881
950
  continue;
882
951
  }
883
- if (fileRank(credential.filePath) < fileRank(existing.filePath) || fileRank(credential.filePath) === fileRank(existing.filePath) && credential.expiresAtMs > existing.expiresAtMs) byId.set(id, credential);
952
+ if (isFresher(credential, existing)) byId.set(id, credential);
884
953
  }
885
954
  for (const own of await this.readOwns()) {
886
955
  if (!this.matchesRegion(own.domain)) continue;
887
956
  const id = workbuddyAccountId(own);
888
957
  const existing = byId.get(id);
889
- if (existing === void 0 || own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
958
+ if (existing === void 0) byId.set(id, own);
959
+ else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
890
960
  }
891
961
  return [...byId.values()];
892
962
  }
@@ -899,11 +969,7 @@ var WorkBuddyCredentialStore = class {
899
969
  */
900
970
  preferred(credentials) {
901
971
  if (credentials.length === 0) return void 0;
902
- return credentials.reduce((best, credential) => {
903
- if (fileRank(credential.filePath) < fileRank(best.filePath)) return credential;
904
- if (fileRank(credential.filePath) === fileRank(best.filePath) && credential.expiresAtMs > best.expiresAtMs) return credential;
905
- return best;
906
- });
972
+ return credentials.reduce((best, credential) => isFresher(credential, best) ? credential : best);
907
973
  }
908
974
  /** Token-free account list for the plugin card. */
909
975
  async accounts() {
@@ -1304,7 +1370,7 @@ var WorkBuddyCatalog = class {
1304
1370
  //#endregion
1305
1371
  //#region src/version.ts
1306
1372
  /** The npm package version this build was produced from. */
1307
- const WORKBUDDY_CONNECT_VERSION = "2.0.0";
1373
+ const WORKBUDDY_CONNECT_VERSION = "2.0.2";
1308
1374
  //#endregion
1309
1375
  //#region src/host-heartbeat.ts
1310
1376
  /**
package/lib/index.d.ts CHANGED
@@ -19,6 +19,17 @@ interface WorkBuddyCredential {
19
19
  source: 'desktop' | 'dsh';
20
20
  /** Absolute path of the auth file this credential was read from. */
21
21
  filePath: string;
22
+ /**
23
+ * Epoch ms the upstream last issued this token (`auth.lastRefreshTime`).
24
+ *
25
+ * This is the ONLY trustworthy freshness signal. `expiresAtMs` cannot be
26
+ * used for ranking: when the upstream revokes a token it leaves the stored
27
+ * `expiresAt` untouched, so a long-dead backup can claim a LATER expiry than
28
+ * the live sign-in (observed on a real machine — a 2026-07-08 backup claimed
29
+ * 2027-07-06 while the live file expired 2026-11-14, and only the live file
30
+ * was accepted). Undefined when the document omits the field.
31
+ */
32
+ lastRefreshAtMs?: number;
22
33
  }
23
34
  /** Read-only sign-in summary for status and doctor output. */
24
35
  interface WorkBuddyAuthStatus {
@@ -322,7 +333,18 @@ type WorkBuddyChatResult = {
322
333
  };
323
334
  /** Classify an upstream failure from its HTTP status and body excerpt. */
324
335
  declare function classifyUpstreamError(status: number, body: string): UpstreamErrorKind;
325
- /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
336
+ /**
337
+ * Region for a login domain; an empty domain means CN (matching upstream tooling).
338
+ *
339
+ * The international product is reachable under TWO brand domains: the WorkBuddy
340
+ * AI desktop app signs in at `workbuddy.ai`, while the CodeBuddy CLI signs the
341
+ * same international account in at `codebuddy.ai` (verified against a real
342
+ * credential file, issue #4). Both are served by the same gateway stack — a
343
+ * read-only probe shows `/v3/config` answering HTTP 200 with the same JSON
344
+ * envelope on both hosts — so both classify as `global`. Missing the
345
+ * `codebuddy.ai` spelling sent those tokens to the CN gateway, which rejected
346
+ * them at the openresty layer with an HTML 401.
347
+ */
326
348
  declare function regionOf(domain: string): WorkBuddyRegion;
327
349
  /**
328
350
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-sqjxgXkW.js";
1
+ import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CaS5Koaw.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { createProvider } from "@earendil-works/pi-ai";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "Connect locally signed-in WorkBuddy models to DeepSeek Harness with a read-only credits overview and model management.",
5
- "version": "2.0.0",
5
+ "version": "2.0.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",