dsh-workbuddy-xdpool 0.3.0 → 0.4.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
@@ -4,6 +4,81 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 0.4.1 (2026-09-19)
8
+
9
+ 这次主要修了三个毛病。
10
+
11
+ ### 一、国际版账号终于能被识别了
12
+
13
+ 之前国际版账号一直检测不到。原因是筛选登录文件时只认「点号」拼出来的文件名,而国际版客户端登录后生成的文件是 `workbuddy-desktop-ai.info`(中间是连字符),于是被整个跳过了。
14
+
15
+ 现在改成只看文件后缀,不管文件名长什么样都能收进来。实测国内版 4 个、国际版 1 个账号都能正常识别。
16
+
17
+ ### 二、国内版和国际版不再共用同一份模型列表
18
+
19
+ 之前两个版本共用同一份模型清单,而且这份清单只从国内版账号拉取 —— 换句话说,国际版看到的其实一直是国内版的模型。
20
+
21
+ 现在两个版本各查各的:
22
+
23
+ - 国内版:16 个模型
24
+ - 国际版:21 个模型(之前只有凑数的 10 个,而且内容还是国内版的)
25
+
26
+ 国际版独有的 Auto / Fast / Balanced 这些模型现在也能正常显示。
27
+
28
+ 顺带修了国际版读取模型的方式:它的接口地址和客户端标识跟国内版都不一样,之前照搬国内版的写法,国际版那边会直接报错,所以才一直退回那份凑数的清单。
29
+
30
+ ### 三、模型选择器里同一个供应商不再被拆成两组
31
+
32
+ 之前模型选择器里「WorkBuddy XD Pool(国内版)」「(国际版)」会各出现两次,免费模型和付费模型被分到不同的组,中间还夹着别的插件,看着像重复注册了。
33
+
34
+ 根因是插件把每个模型都上报成「不支持图片」。宿主上如果装了图片增强类插件,它会给这些模型再补一条**名字一模一样**的条目,于是同一个名字就出现了两遍。
35
+
36
+ 现在按上游真实的能力上报,分组就合并回来了。
37
+
38
+ 另外卡片里的「图片输入」勾选也改了语义:勾选只表示**额外**支持图片,不会再反过来把没勾选的模型降级成「不支持」。
39
+
40
+ ## 0.4.0 (2026-09-16)
41
+
42
+ 本次更新来自两条用户反馈与一次上游兼容性对齐:账号现在可以**按顺序逐个用尽**(不再平均分摊),积分包**逐条显示具体到期时刻**,并修掉了一处会让所有接口返回 401 的凭据选择缺陷。
43
+
44
+ ### 新增
45
+
46
+ - **账号使用方式可切换(优先 / 轮换)**:卡片状态行下方新增开关,默认**优先模式**。
47
+
48
+ - **优先模式**:始终由同一个账号服务,直到它被限流才顺延到下一个;冷却结束后它**立刻回到队首**接管 —— 该账号的积分在冷却期间并未被消耗,所以无需从头排队。
49
+ - **轮换模式**:保留原来的平均分摊行为,供希望把额度摊平到各账号的用户选择。
50
+ - 切换**即时生效**并写入插件设置,**对所有账号、所有切换状态通用**,不需要重启宿主。
51
+ - 优先级顺序是确定性的:桌面 App 当前登录的账号 → 上游签发时间较新者 → 存储过期时间较晚者。此前顺序取决于文件名字典序,实际会出现 `0, 2, 1` 这类非预期排列。
52
+
53
+ - **积分包逐条显示到期时刻**:原先只给出「3 天内到期」的合计数,看不到具体时间;现在每个积分包单独标注它的到期时刻(精确到分钟,例如「到期 09/19 15:36」),3 天内到期的以琥珀色高亮。月度周期套餐显示下次刷新时刻(「刷新 10/01 00:00」)。
54
+
55
+ - 之所以精确到分钟而不是只给日期:上游发放一次性礼包的到期时刻是**任意的钟点**,并非「过 0 点即失效」,只显示日期会让人误判。
56
+
57
+ ### 布局
58
+
59
+ - **积分包列表改为两行网格**:包名与数量在首行左右分栏,到期时刻在次行跨栏并与包名左对齐。此前到期时刻被挤到第二行且从容器左边缘起排,列表看起来参差不齐。
60
+
61
+ ### 修复
62
+
63
+ - **同一账号存在多个凭据文件时会选中已失效的那一个,导致积分 / 签到 / 模型全部返回 401**。此前按**存储的 `expiresAt` 最大**来挑选凭据,但 `expiresAt` 描述的是「签发时有效期有多长」,**不代表上游仍然接受** —— 上游吊销 token 时不会同步改写该字段,于是一个早已失效的备份可以声称比真实可用的文件**更晚过期**,从而被长期选中。
64
+
65
+ - 现改用 **`auth.lastRefreshTime`(上游自己的签发时间)** 作为新鲜度判据,并在解析凭据时读入该字段。排序优先级:① 桌面 App 当前登录的 live 文件 → ② 签发时间较新者 → ③ 存储过期时间(仅作为文档缺少签发时间时的回退,保证比较是全序的)。
66
+
67
+ - **国际版登录域名识别不全**:参考桌面客户端自身的国际域名清单(`workbuddy.ai`、`workbuddy.cc`),并补充 CodeBuddy CLI 使用的 `codebuddy.ai`。此前只识别 `workbuddy.ai`,落在其他拼写上的国际账号会被误判为国内版,token 被发往国内网关而在 openresty 层被拒绝。
68
+
69
+ - **国际网关写死单一域名**:国际版账号按品牌域名区分且**互不通用**(在 `codebuddy.ai` 签发的 token 会被 `workbuddy.ai` 网关拒绝,反之亦然),因此网关地址改为**跟随凭据自身的域**,未识别的域名回落至桌面端网关。chat / billing / referer / 模型目录四处一并生效。
70
+
71
+ - **上游以 HTML 页面拒绝凭据时给出可执行提示**:识别 openresty / APISIX 的鉴权拒绝(401/403 且响应体是 HTML),改为提示「凭据已被上游网关拒绝,通常说明用的是旧登录留下的失效凭据;请重新登录 WorkBuddy 桌面端后在卡片中选择该账号」,不再原样抛出 HTML 片段。非鉴权类的非 JSON 响应保持原有的通用提示。
72
+
73
+ - **部分模型不显示思考档位**:上游部分模型的推理元数据使用**单数形式**(`effort` 而非 `supportedEfforts`),此前该形态会被直接丢弃。现将其规范化为插件已理解的复数形状,并携带声明的档位到 `defaultEffort`。
74
+
75
+ ### 测试
76
+
77
+ - 新增 `tests/pool-priority.test.ts`(10 例):优先模式下连续请求命中同一账号、仅在该账号冷却后顺延、冷却结束后回归队首、按模型冷却不影响其他模型;轮换模式的轮转与运行时切换;凭据新鲜度的四类排序(live 优先、签发时间优先、缺失签发时间时回退过期时间、秒/毫秒单位归一化)。
78
+ - 修正 `tests/failover.test.ts` 中依赖轮转顺序的限流桩:改为按**已出现的不同凭据数**计次,不再假设「token 序号即账号位置」,因此与账号排序解耦。
79
+ - 全套 **36 个测试通过**。
80
+
81
+
7
82
  ## 0.3.0 (2026-09-16)
8
83
 
9
84
  本次更新把卡片界面按参考插件的形态重做了一遍,并补齐了国内版 / 国际版的完整分离。
package/README.en.md CHANGED
@@ -15,11 +15,11 @@ Merge **every WorkBuddy account** you have ever signed into on this machine into
15
15
 
16
16
  **Settings card (Settings → Plugins → DSH WorkBuddy XD Pool)**
17
17
 
18
- ![WorkBuddy pool settings card: pool health, per-account panels, credit packages, totals](assets/settings-card.png)
18
+ ![WorkBuddy pool settings card: domestic/international tab strip, pool health, per-account panels, credit packages and totals, per-account daily check-in, and model management (enable, image input, context window)](assets/settings-card.png)
19
19
 
20
- **Model picker (rate multiplier baked into model.name; DSH 0.1.2 composer only reads name)**
20
+ **Model picker (domestic and international appear as two separate supplier groups; the rate multiplier is baked into model.name because the DSH 0.1.2 composer only reads name)**
21
21
 
22
- ![Model picker shows the rate and promo badge next to each model name](assets/model-picker.png)
22
+ ![Model picker: the domestic and international suppliers each form their own group, with the credit rate and promo badge shown next to every model name](assets/model-picker.png)
23
23
 
24
24
  **Domestic / international dual suppliers (each with its own accounts, credits and models, usable at the same time)**
25
25
 
package/README.md CHANGED
@@ -15,11 +15,11 @@
15
15
 
16
16
  **插件配置卡片(设置 → 插件 → DSH WorkBuddy XD Pool)**
17
17
 
18
- ![WorkBuddy 池设置卡片:池健康状态、账号面板、积分包、合计](assets/settings-card.png)
18
+ ![WorkBuddy 池设置卡片:国内版 / 国际版切换栏、池健康状态、账号面板、积分包与合计、每账号每日签到、模型管理(启用勾选 / 图片输入 / 上下文窗口)](assets/settings-card.png)
19
19
 
20
- **模型选择器(倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
20
+ **模型选择器(国内版 / 国际版 两个独立供应商分组;倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
21
21
 
22
- ![模型选择器每个模型名后显示倍率与促销标签](assets/model-picker.png)
22
+ ![模型选择器:国内版与国际版各占一个分组,每个模型名后显示积分倍率与促销标签](assets/model-picker.png)
23
23
 
24
24
  **国内版 / 国际版 双供应商(各自独立账号、积分与模型,可同时使用)**
25
25
 
Binary file
Binary file
package/lib/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { homedir, platform } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import "@earendil-works/pi-ai";
7
7
  import "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -15,7 +15,18 @@ const CN_BILLING_BASE = "https://www.codebuddy.cn";
15
15
  /** Global base for `workbuddy.ai` logins. */
16
16
  const GLOBAL_BASE = "https://www.workbuddy.ai";
17
17
  /** Client UA the desktop CLI uses. */
18
+ /** Client UA the desktop CLI uses — the CN gateway answers this one. */
18
19
  const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
20
+ /**
21
+ * Desktop app UA. The global gateway serves its product config only to this
22
+ * client channel: the CLI UA gets a truncated roster (or an HTTP 500), which
23
+ * is why the international catalog must be read with the desktop spelling.
24
+ */
25
+ const DESKTOP_UA = "WorkBuddy/5.5.2";
26
+ /** CN model catalog. */
27
+ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
28
+ /** Global product config, which carries the international model roster. */
29
+ const GLOBAL_CONFIG_PATH = "/v3/config";
19
30
  const JSON_TIMEOUT_MS = 3e4;
20
31
  const ERROR_BODY_LIMIT = 4096;
21
32
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
@@ -38,20 +49,47 @@ const HARD_CREDIT_MARKERS = [
38
49
  ];
39
50
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
40
51
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
52
+ /**
53
+ * Hosts the international product answers on, once each has been stripped of a
54
+ * leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
55
+ * the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
56
+ * CLI signs the same international account in at `codebuddy.ai`. All are served
57
+ * by one gateway stack, so all are `global` — missing a spelling sends those
58
+ * tokens to the CN gateway, which rejects them at the openresty layer with an
59
+ * HTML 401 instead of a business JSON error.
60
+ */
61
+ const GLOBAL_HOSTS = [
62
+ "workbuddy.ai",
63
+ "workbuddy.cc",
64
+ "codebuddy.ai"
65
+ ];
66
+ /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
41
67
  function regionOf(domain) {
42
68
  const lowered = domain.trim().toLowerCase();
43
- if (lowered.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
44
- if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
69
+ for (const host of GLOBAL_HOSTS) if (lowered === host || lowered.endsWith(`.${host}`)) return "global";
45
70
  return "cn";
46
71
  }
72
+ /**
73
+ * Gateway for a global credential.
74
+ *
75
+ * International accounts are NOT interchangeable across brand domains: a token
76
+ * issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway and vice
77
+ * versa, so the base must follow the credential's own domain rather than one
78
+ * hardcoded host. Anything unrecognised falls back to the desktop app's gateway.
79
+ */
80
+ function globalBase(credential) {
81
+ const lowered = credential.domain.trim().toLowerCase();
82
+ if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
83
+ return GLOBAL_BASE;
84
+ }
47
85
  function chatBase(credential) {
48
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
86
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
49
87
  }
50
88
  function billingBase(credential) {
51
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
89
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
52
90
  }
53
91
  function originReferer(credential) {
54
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
92
+ return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
55
93
  }
56
94
  /** Headers every upstream request shares. */
57
95
  function commonHeaders(credential) {
@@ -100,8 +138,23 @@ function billingHeaders(credential) {
100
138
  if (credential.domain !== "") headers["X-Domain"] = credential.domain;
101
139
  return headers;
102
140
  }
141
+ /**
142
+ * Gateway denials that arrive as an HTML page rather than a JSON envelope.
143
+ *
144
+ * openresty / APISIX reject a request before it reaches the product when the
145
+ * credential is one the gateway no longer honours — most often a stale sign-in
146
+ * left in the auth directory. The status alone (401) is not actionable and the
147
+ * HTML body leaks nothing useful, so this turns it into a sentence the user can
148
+ * act on.
149
+ */
150
+ function isGatewayHtmlRejection(status, text) {
151
+ if (status !== 401 && status !== 403) return false;
152
+ const head = text.slice(0, 512).toLowerCase();
153
+ return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
154
+ }
103
155
  async function readEnvelope(response) {
104
156
  const text = await response.text();
157
+ if (isGatewayHtmlRejection(response.status, text)) throw new Error("the WorkBuddy gateway rejected this credential (http 401). This usually means the account is using a stale sign-in the upstream no longer accepts: sign in again in the WorkBuddy desktop app, then pick the account on the plugin card. Run `dsh-workbuddy-xdpool doctor` to list every credential found.");
105
158
  let parsed;
106
159
  try {
107
160
  parsed = JSON.parse(text);
@@ -146,12 +199,57 @@ function parseCreditMultiplier(value) {
146
199
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
147
200
  }
148
201
  /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
202
+ /**
203
+ * The effort ladder the upstream's plural-form payloads declare across both
204
+ * gateways (the live union of every `supportedEfforts` list seen; `minimal` has
205
+ * never appeared). Both gateways also accept every level of it on
206
+ * singular-form models — medium/xhigh fold into high, low/max answer with their
207
+ * own budgets — so a singular `effort` value is a DEFAULT, never the model's
208
+ * only level.
209
+ */
210
+ const SINGULAR_EFFORT_LADDER = [
211
+ "low",
212
+ "medium",
213
+ "high",
214
+ "xhigh",
215
+ "max"
216
+ ];
217
+ /**
218
+ * True when `reasoning` arrives in the singular spelling: an `effort` string,
219
+ * with none of the plural-form fields alongside it. Seen on CN
220
+ * `deepseek-v4.1-flash` / `kimi-k3-1` / `glm-5.2` and global
221
+ * `deepseek-v4.1-flash` / `kimi-k3` / `gemini-3.5-flash`.
222
+ */
223
+ function isSingularEffortForm(raw) {
224
+ return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
225
+ }
226
+ /**
227
+ * Fold a singular-form `effort` into the plural shape the rest of the plugin
228
+ * already understands. Probes on both gateways show these models answer with
229
+ * distinct `reasoning_content` across the whole ladder — and do not think at
230
+ * all when no `reasoning_effort` is sent — so the fold widens
231
+ * `supportedEfforts` and carries the declared value into `defaultEffort`. An
232
+ * unrecognized `effort` passes through as the lone level.
233
+ */
234
+ function singularEffortLadder(raw) {
235
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
236
+ if (effort === void 0) return void 0;
237
+ return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
238
+ }
239
+ /**
240
+ * Parse the upstream's `reasoning` object; unknown shapes degrade to
241
+ * `undefined`. Both spellings normalize here: the plural form passes through as
242
+ * declared, and the singular `effort` form folds via
243
+ * {@link singularEffortLadder}.
244
+ */
149
245
  function parseReasoning(value) {
150
246
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
151
247
  const raw = value;
152
- const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
153
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
154
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
248
+ const singularForm = isSingularEffortForm(raw);
249
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
250
+ const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
251
+ const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
252
+ const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : singularForm ? true : void 0;
155
253
  if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
156
254
  return {
157
255
  ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
@@ -174,6 +272,7 @@ function parseUpstreamModel(value) {
174
272
  const creditMultiplier = parseCreditMultiplier(raw["credits"]);
175
273
  const reasoning = parseReasoning(raw["reasoning"]);
176
274
  const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
275
+ const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
177
276
  return {
178
277
  id,
179
278
  name,
@@ -183,7 +282,8 @@ function parseUpstreamModel(value) {
183
282
  ...reasoning === void 0 ? {} : { reasoning },
184
283
  ...descriptionZh === void 0 ? {} : { descriptionZh },
185
284
  ...descriptionEn === void 0 ? {} : { descriptionEn },
186
- ...supportsToolCall === void 0 ? {} : { supportsToolCall }
285
+ ...supportsToolCall === void 0 ? {} : { supportsToolCall },
286
+ ...supportsImages === void 0 ? {} : { supportsImages }
187
287
  };
188
288
  }
189
289
  var WorkBuddyUpstreamClient = class {
@@ -287,16 +387,43 @@ var WorkBuddyUpstreamClient = class {
287
387
  if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
288
388
  return outcome;
289
389
  }
290
- /** GET the personal model catalog, keeping the `cli` agent's models only. */
390
+ /**
391
+ * Fetch the model catalog, keeping the `cli` agent's models only.
392
+ *
393
+ * The two gateways are read differently, because they answer differently:
394
+ *
395
+ * - **CN** serves the roster at `/v2/enterprises/personal/models` and expects
396
+ * the CLI client spelling.
397
+ * - **Global** serves it as part of the product config at `/v3/config`, and
398
+ * only to the DESKTOP client channel. Asking the global host with the CLI UA
399
+ * yields a truncated roster, and the CN path answers HTTP 500 there — which
400
+ * is what left the international provider on its static fallback.
401
+ *
402
+ * Both documents share the `{ models, agents }` entry shape, so the parsing
403
+ * below is common to the two branches.
404
+ */
291
405
  async fetchModels(credential, signal) {
292
- const response = await this.fetchImpl(`${chatBase(credential)}/console/enterprises/personal/models`, {
293
- headers: {
294
- "Authorization": `Bearer ${credential.accessToken}`,
295
- "Accept": "application/json",
296
- "Origin": originReferer(credential),
297
- "Referer": `${originReferer(credential)}/`,
298
- "User-Agent": CLIENT_UA
299
- },
406
+ const global = regionOf(credential.domain) === "global";
407
+ const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
408
+ const headers = global ? {
409
+ "Authorization": `Bearer ${credential.accessToken}`,
410
+ "Accept": "application/json",
411
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid },
412
+ ...credential.domain === "" ? {} : { "X-Domain": credential.domain },
413
+ "X-Product": "SaaS",
414
+ "X-Requested-With": "XMLHttpRequest",
415
+ "Connection": "close",
416
+ "User-Agent": DESKTOP_UA
417
+ } : {
418
+ "Authorization": `Bearer ${credential.accessToken}`,
419
+ "Accept": "application/json",
420
+ "Origin": originReferer(credential),
421
+ "Referer": `${originReferer(credential)}/`,
422
+ "User-Agent": CLIENT_UA
423
+ };
424
+ if (!global && credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
425
+ const response = await this.fetchImpl(url, {
426
+ headers,
300
427
  ...signal === void 0 ? {} : { signal }
301
428
  });
302
429
  const envelope = await readEnvelope(response);
@@ -466,7 +593,6 @@ var WorkBuddyUpstreamClient = class {
466
593
  /** Live auth file name the WorkBuddy desktop app writes. */
467
594
  const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
468
595
  /** Snapshot files left behind by previous logins share this prefix. */
469
- const SNAPSHOT_PREFIX = "workbuddy-desktop.";
470
596
  /** Env override for the auth file or its directory. */
471
597
  const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
472
598
  function nonEmptyEnv(value) {
@@ -525,11 +651,13 @@ function parseWorkBuddyAuth(text, sourcePath) {
525
651
  if (accessToken === "") return void 0;
526
652
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
527
653
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
654
+ const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
528
655
  return {
529
656
  accessToken,
530
657
  refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
531
658
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
532
659
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
660
+ ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
533
661
  ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
534
662
  ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
535
663
  ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
@@ -542,6 +670,42 @@ function parseWorkBuddyAuth(text, sourcePath) {
542
670
  * Stable account id. `uin` is the billing identity the upstream keys on and
543
671
  * survives re-login; `uid` is the fallback.
544
672
  */
673
+ /**
674
+ * True when `path` is the desktop app's live sign-in (as opposed to a backup
675
+ * snapshot it left behind). The live file always wins: it is the session the
676
+ * app itself is using.
677
+ */
678
+ function isLiveAuthFile(path) {
679
+ return basename(path) === WORKBUDDY_LIVE_FILENAME;
680
+ }
681
+ /**
682
+ * Which of two credentials for the same account the pool should keep.
683
+ *
684
+ * Ordering, highest first:
685
+ *
686
+ * 1. the live file the desktop app is signed in with;
687
+ * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
688
+ * 3. the longer stored expiry, as a fallback for documents that carry no issue
689
+ * time (the plugin's own refreshed copy, older builds).
690
+ *
691
+ * The stored expiry alone is NOT a freshness signal: the upstream does not
692
+ * rewrite it when it revokes a token, so a long-dead backup can claim to expire
693
+ * later than the token that actually works. Selecting on it made every upstream
694
+ * call return 401 while a perfectly good credential sat in the same directory.
695
+ */
696
+ function compareFreshness(a, b) {
697
+ const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
698
+ const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
699
+ if (aLive !== bLive) return bLive - aLive;
700
+ const aIssued = a.lastRefreshAtMs ?? 0;
701
+ const bIssued = b.lastRefreshAtMs ?? 0;
702
+ if (aIssued !== bIssued) return bIssued - aIssued;
703
+ return b.expiresAtMs - a.expiresAtMs;
704
+ }
705
+ /** True when `candidate` should replace `incumbent` for the same account. */
706
+ function isFresher(candidate, incumbent) {
707
+ return compareFreshness(candidate, incumbent) < 0;
708
+ }
545
709
  function workbuddyAccountId(credential) {
546
710
  const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
547
711
  return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
@@ -553,6 +717,17 @@ function accountLabel(credential) {
553
717
  return discriminator === "" ? name : `${name}#${discriminator}`;
554
718
  }
555
719
  /** List the auth files in one directory: the live file plus every snapshot. */
720
+ /**
721
+ * Credential files in one auth directory, freshest first.
722
+ *
723
+ * Every `*.info` file counts, not just the timestamped `workbuddy-desktop.*`
724
+ * snapshots: the international client signs in as `workbuddy-desktop-ai.info`
725
+ * (a hyphen, not a dot), so a prefix test silently dropped every global
726
+ * credential and the global provider then saw an empty pool.
727
+ *
728
+ * Filenames are plain strings, and the ordering here is only a first pass —
729
+ * `isFresher` makes the real call once each file has been parsed.
730
+ */
556
731
  async function authFilesIn(dir) {
557
732
  let entries;
558
733
  try {
@@ -560,10 +735,7 @@ async function authFilesIn(dir) {
560
735
  } catch {
561
736
  return [];
562
737
  }
563
- const files = entries.filter((name) => {
564
- if (name === "workbuddy-desktop.info") return true;
565
- return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
566
- });
738
+ const files = entries.filter((name) => name.endsWith(".info"));
567
739
  files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
568
740
  return files.map((name) => join(dir, name));
569
741
  }
@@ -593,6 +765,8 @@ var WorkBuddyAccountPool = class {
593
765
  client;
594
766
  refreshMarginMs;
595
767
  accounts = [];
768
+ distribution;
769
+ /** Cursor for round-robin mode; unused under priority distribution. */
596
770
  cursor = 0;
597
771
  lastScanAtMs = 0;
598
772
  preferredId;
@@ -603,6 +777,7 @@ var WorkBuddyAccountPool = class {
603
777
  this.cooldownMs = options.cooldownMs ?? 6e4;
604
778
  this.client = options.client;
605
779
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
780
+ this.distribution = options.distribution ?? "priority";
606
781
  }
607
782
  /**
608
783
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -612,6 +787,7 @@ var WorkBuddyAccountPool = class {
612
787
  applyConfig(options) {
613
788
  if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
614
789
  if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
790
+ if (options.distribution !== void 0) this.distribution = options.distribution;
615
791
  }
616
792
  /** Rescan the auth directories and merge newly discovered accounts. */
617
793
  async scan() {
@@ -636,13 +812,15 @@ var WorkBuddyAccountPool = class {
636
812
  });
637
813
  continue;
638
814
  }
639
- if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
815
+ if (isFresher(credential, existing.credential)) byId.set(id, {
640
816
  ...existing,
641
817
  credential,
642
818
  label: accountLabel(credential)
643
819
  });
644
820
  }
645
- this.accounts = [...byId.values()];
821
+ const ordered = [...byId.values()];
822
+ ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
823
+ this.accounts = ordered;
646
824
  this.lastScanAtMs = Date.now();
647
825
  return this.accounts;
648
826
  }
@@ -669,12 +847,24 @@ var WorkBuddyAccountPool = class {
669
847
  });
670
848
  }
671
849
  /**
672
- * Pick the next usable account for an optional model. Scans on first use,
673
- * and rescans when every known account is cooling down — a fresh desktop
674
- * login is the usual way out of an exhausted pool. A preferred
675
- * (user-selected) account that is healthy is tried first; otherwise the
676
- * cursor round-robins so consecutive requests spread across accounts and a
677
- * still-cooling preferred account is skipped.
850
+ * Pick the account to serve a request.
851
+ *
852
+ * Two distributions, chosen by the `distribution` setting:
853
+ *
854
+ * - **priority** (default, and what the card ships with): one account serves
855
+ * every request until it is rate-limited, then the next in order takes over.
856
+ * Credits drain one account at a time, and a cooling account returns to the
857
+ * head of the queue the moment its window resets — it was never consumed, so
858
+ * it resumes straight away.
859
+ * - **round-robin**: consecutive requests rotate through the pool so spend
860
+ * spreads evenly across every account.
861
+ *
862
+ * In both modes an explicit user selection (`prefer`) heads the list, a
863
+ * cooling account is skipped for that model only, and an unrecognised setting
864
+ * falls back to priority.
865
+ *
866
+ * Scans on first use, and rescans when every known account is cooling down: a
867
+ * fresh desktop login is the usual way out of an exhausted pool.
678
868
  */
679
869
  async acquire(modelId, region) {
680
870
  if (this.accounts.length === 0) await this.scan();
@@ -684,21 +874,25 @@ var WorkBuddyAccountPool = class {
684
874
  pool = this.available(Date.now(), modelId, region);
685
875
  }
686
876
  if (pool.length === 0) return void 0;
687
- let start = this.cursor % pool.length;
688
877
  if (this.preferredId !== void 0) {
689
878
  const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
690
- if (preferredIndex !== -1) start = preferredIndex;
691
- }
692
- for (let step = 0; step < pool.length; step += 1) {
693
- const account = pool[(start + step) % pool.length];
694
- if (account === void 0) continue;
695
- await this.ensureFresh(account);
696
- this.cursor = (start + step + 1) % pool.length;
697
- return account;
879
+ if (preferredIndex > 0) {
880
+ const [preferred] = pool.splice(preferredIndex, 1);
881
+ if (preferred !== void 0) pool = [preferred, ...pool];
882
+ }
698
883
  }
699
- return pool[0];
884
+ const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
885
+ const account = pool[index];
886
+ if (account === void 0) return void 0;
887
+ if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
888
+ await this.ensureFresh(account);
889
+ return account;
700
890
  }
701
891
  /** Pin the account the plugin card should prefer; tokens stay out of settings. */
892
+ /** How the pool currently spreads requests. Shown on the card. */
893
+ currentDistribution() {
894
+ return this.distribution;
895
+ }
702
896
  prefer(accountId) {
703
897
  this.preferredId = accountId;
704
898
  }
@@ -891,7 +1085,7 @@ var WorkBuddyCatalog = class {
891
1085
  const budgets = this.selection.contextBudgets;
892
1086
  return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
893
1087
  const next = { ...model };
894
- if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
1088
+ if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
895
1089
  const budget = budgets?.[model.id];
896
1090
  if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
897
1091
  return next;
@@ -939,7 +1133,7 @@ function toModelInfo(model) {
939
1133
  name: model.name,
940
1134
  contextWindow: model.contextWindow,
941
1135
  maxOutputTokens: model.maxTokens,
942
- supportsImages: model.multimodal ?? false,
1136
+ supportsImages: model.supportsImages ?? false,
943
1137
  ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
944
1138
  ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
945
1139
  };
@@ -963,11 +1157,20 @@ function formatRates(status) {
963
1157
  z.object({
964
1158
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
965
1159
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1160
+ distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
966
1161
  enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
967
1162
  imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
968
1163
  contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
969
1164
  });
970
1165
  /** Assemble the runtime objects without registering anything. */
1166
+ /**
1167
+ * Assemble the runtime objects without registering anything.
1168
+ *
1169
+ * One catalog per region, mirroring the two shims: the CN and global gateways
1170
+ * do not advertise the same roster, and a shared catalog meant the picker showed
1171
+ * whichever list happened to be fetched first (always the CN one, since the
1172
+ * seeding step read `accounts[0]`).
1173
+ */
971
1174
  function createCore(logger) {
972
1175
  const client = new WorkBuddyUpstreamClient();
973
1176
  return {
@@ -975,7 +1178,10 @@ function createCore(logger) {
975
1178
  ...logger === void 0 ? {} : { logger },
976
1179
  client
977
1180
  }),
978
- catalog: new WorkBuddyCatalog(),
1181
+ catalogs: {
1182
+ cn: new WorkBuddyCatalog(),
1183
+ global: new WorkBuddyCatalog()
1184
+ },
979
1185
  client
980
1186
  };
981
1187
  }
@@ -1064,7 +1270,7 @@ async function commandStatus(args) {
1064
1270
  };
1065
1271
  }),
1066
1272
  cooling: accounts.filter((account) => account.cooldownUntilMs > Date.now()).length,
1067
- models: core.catalog.current().map((model) => ({
1273
+ models: core.catalogs.cn.current().map((model) => ({
1068
1274
  id: model.id,
1069
1275
  name: model.name,
1070
1276
  multiplier: model.multiplier