dsh-workbuddy-xdpool 1.7.4 → 1.7.6

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,109 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.7.6 — 一个读不出来的凭据文件,不再带走整个账号池
8
+
9
+ > 你切了 4 个账号登录,界面显示 4 个;重启之后只剩 2 个 —— 不是账号没了,是它们被一个文件拖累了。
10
+
11
+ **你会看到**:用 WorkBuddy Switch 切换登录几个国内版账号后,卡片上账号数对不上——重启少了两个。
12
+
13
+ **为什么**:认证目录里**同时存在好几种文件**:明文的老格式、5.6.0+ 起的加密格式、写到一半的、还有插件不认识的版本。这些**混在一起是常态**,不是错误。
14
+
15
+ 但旧代码里,只要**碰到一个加密又打不开的文件**(WorkBuddy 没运行、或者取不到它的密钥),就会**抛异常终止整个扫描** —— 于是那些**明明读得好好的账号也被一起丢掉**。一个坏文件,带走整个池。
16
+
17
+ 我复现了这个行为:3 个正常文件 + 1 个打不开的,结果**不是返回 3 个,而是整个扫描抛错、0 个账号**。
18
+
19
+ **怎么修的**:
20
+
21
+ - **单个文件失败只跳过它自己**,不会连累其他账号。这是核心修复;
22
+ - **跳过的文件会被记录并显示在卡片上**:现在账号区会明确写「有 N 个凭据文件读不出来」,并**列出文件名和原因**。其中「已加密 — 启动一次 WorkBuddy 才能取到密钥」是可操作的;而"不是凭据文件"就只是个无关文件。区分开很重要,不然你会去修一个压根修不好的东西。
23
+
24
+ 这样账号数就**可信了**:不会再出现「磁盘上 4 个文件,界面显示 2 个账号」这种说不清的落差。
25
+
26
+ ### 顺带修的第二个问题:pi-ai 跨代误用
27
+
28
+ 另一份反馈报的是「每轮对话必崩,报 `Cannot read properties of undefined` 且不可重试」。根因是**同一条调用链上混用了两代 pi-ai** —— 插件用 0.85.x 组装 provider,宿主适配器用 0.87.x 消费事件流,两代对终态消息的形状约定不同,接缝处直接抛异常。而宿主把它归为不可重试的 `PI_AI_ERROR`,栈还被丢弃,所以极难排查。
29
+
30
+ **为什么 `package.json` 的范围拦不住**:旧的 peer 声明是 `>=0.82.1 <0.85.0`,**上界把宿主的 0.87 代际整个排除在外** —— 等于把「跨代混用」变成了一种**合法的安装组合**。版本范围只能描述"我能接受什么",描述不了"机器上实际装的是哪一份"。
31
+
32
+ **所以改成两条**:
33
+
34
+ - **放开上界**(`>=0.82.1`),不再把宿主代际排除成非法组合;
35
+ - **加载时实测并告警**:比对插件这份与宿主那份 pi-ai 的代际,**不一致就在日志里点名两个版本并给出修法**(用 pnpm `overrides` 把代际对齐宿主)。
36
+
37
+ 只告警、**不阻止启动** —— 版本不一致也可能是无害的,因为一个猜测就让插件拒绝加载会更糟。
38
+
39
+ ### 验证
40
+
41
+ - 类型检查全绿,测试 **313 项全绿**(+9),**连续两次全量运行稳定**。
42
+ - 新增 `tests/account-discovery.test.ts`(7 项):单个坏文件不拖垮扫描、**全部文件都坏时也不抛错**、跳过的文件带原因上报、坏文件不会被算成账号、非 JSON / 结构不对的文件同样只跳过。
43
+ - 新增 `tests/pi-ai-generation.test.ts`(9 项):代际归约(补丁差异不算跨代)、**0.85 / 0.87 分裂能被识别**、任一侧解析不到时判为未知、告警文案包含两个版本与修法。
44
+ - **反向验证**:把「加密错误重新抛出」放回 → 4 项变红,确认测试确实能抓住这个缺陷。
45
+ - 构建后校验产物**已内联**代际检测模块(无外部 `.ts` 引用),并直接加载 `lib/index.js` 确认可运行。
46
+
47
+ ### 已知未覆盖
48
+
49
+ 「每轮必崩」的那份报告里,还建议了**宿主侧**两处加固(`mapStopReason` 对空 `content` 加守卫、`classifyPiAiError` 保留原始 `error.stack`)。那是 DSH 宿主的代码,不在这个插件仓库内,需要另开 issue 到 DSH。
50
+
51
+ ## 1.7.5 — 模型列表会告诉你「现在用哪个不花钱」
52
+
53
+ > Hy4 preview 晚上 23 点到早上 8 点免费,Hy3 一直在免费期 —— 现在卡片上直接看得出来。
54
+
55
+ **你会看到**:模型列表里,**当前免费的排在最上面**并带标记:
56
+
57
+ ```
58
+ [免费] hy3 x0.00
59
+ auto
60
+ (23:00 起免费 · 活动至 10-31) hy4-preview x0.29
61
+ hy3-x x0.05
62
+ ```
63
+
64
+ 到了晚上 23 点,同一行会变成:
65
+
66
+ ```
67
+ [免费至 08:00 · 活动至 10-31] hy4-preview x0.29
68
+ [免费] hy3 x0.00
69
+ ```
70
+
71
+ **为什么需要专门做**:因为**上游接口不说这件事**。我拉了真实的国内版接口逐字段核对:
72
+
73
+ | 信息 | 上游提供吗 |
74
+ |---|---|
75
+ | Hy3 限免 | ✅ 提供 —— `credits: "x0.00"`,倍率本身就是 0,我们一直在正确显示 |
76
+ | Hy4 preview **夜间**限免 | ❌ **不提供** —— 倍率恒为 `x0.29`,**没有任何时段字段** |
77
+ | Hy4 preview **新用户 14 天**额度 | ❌ **不提供** —— 积分包里也没有这类条目 |
78
+
79
+ 所以夜间限免这条规则,只能由插件自己带。我把它写成一张**规则表**(`src/promo.ts`),包含「哪个平台、哪些 id 前缀、哪些小时、有效期到哪天」。
80
+
81
+ **几个刻意的设计**:
82
+
83
+ - **规则区分平台**。这是**国内版活动**,所以规则里带 `region: 'cn'`,**国际版完全不受影响** —— 国际版的 `hy4-preview-f` 显示「免费」是因为**上游倍率真的是 0**(客观事实),不是我们套了活动规则;
84
+ - **免费状态是「此刻」的,不是模型固有属性**。所以同一天里 `hy4-preview` 的标记会变 —— 这才是真实情况;
85
+ - **窗口外只给一个低调的虚线提示**(`23:00 起免费`),**不给「免费」徽章**。因为它此刻**确实要花积分**,标成免费会让你花错钱;
86
+ - **只有「此刻免费」的才置顶**。窗口外的模型不置顶,否则就是把一个要花钱的模型举到最前面,误导性更强;
87
+ - **标出活动截止日**(`活动至 10-31`)。方便你安排什么时候用 —— 而且活动一旦延期,改日期就能看到,不用翻更新日志;
88
+ - **规则带过期日期**,到 10 月 31 日之后**自动失效**,不需要有人记得来删代码;
89
+ - **`hy4-preview` 用前缀匹配**,因为上游有 `hy4-preview` 和 `hy4-preview-x` 两个 id,是同一款产品。
90
+
91
+ ### 关于「新用户 14 天免费额度」——明确没做
92
+
93
+ 你提到「新用户没用过 Hy4 preview,还能免费 14 天,要区分出来」。**这个我做不到,而且硬做会更糟**:
94
+
95
+ 上游**完全不记录**「这个账户有没有用过 Hy4 preview」—— 模型目录里没有、积分包里也没有(我实测过,44 个积分包里没有一个试用额度条目)。
96
+
97
+ 唯一能猜的办法是「插件自己记住第一次调用的时间」。但这有个致命问题:**它只知道「用本插件之后」的历史**。你之前要是在官方客户端用过 Hy4 preview,插件照样会以为你是新用户,然后给你标一个**假的「免费」** —— 而你会因此放心去用,结果**扣了积分**。
98
+
99
+ **给你一个假的「免费」,比不给你标记更糟**,因为它直接改变你的花钱决定。所以这条留白,不做推测。
100
+
101
+ 如果你确实需要,可行的做法是**你自己确认**:卡片上显示的是客观事实(倍率 + 夜间窗口),你清楚自己是不是新用户,看一眼就知道该不该用。
102
+
103
+ ### 验证
104
+
105
+ - 类型检查全绿,测试 **297 项全绿**(+19)。
106
+ - 新增 `tests/promo.test.ts`:23:00–08:00 窗口内/外的判定、跨午夜(23 点在窗口内、8 点不在)、窗口关闭时间(`免费至 08:00`)、下次开启时间(`23:00 起免费`)、同族 id(`hy4-preview` 与 `hy4-preview-x`)、零倍率模型全天免费且不误入夜间规则、**到期自动失效**(10/31 有效、11/1 失效)、**免费置顶只认「此刻」免费**、**活动截止日随状态带出**、**国际版不受活动影响**(但零倍率模型在两边都算免费)。
107
+ - **反向验证**:清空夜间小时 → 7 项变红;删掉过期日期 → 2 项变红;**移除平台过滤 → 「国际版不受影响」变红**。确认测试真的能抓住这三类缺陷。
108
+ - 用**两个平台的真实清单**(国内版 16 个 / 国际版 24 个)跑卡片渲染逻辑,在 12:00 与 23:30 核对:国内版 `hy4-preview` 正确显示活动徽章与截止日,**国际版完全不受活动规则影响**。
109
+
7
110
  ## 1.7.4 — 装在中文文件夹里的 WorkBuddy,终于找得到了
8
111
 
9
112
  > 「腾讯」两个字,让插件整整看不见一个装好、登录着、正在运行的 WorkBuddy。
package/lib/bin.js CHANGED
@@ -9,6 +9,7 @@ import "@earendil-works/pi-ai";
9
9
  import "@earendil-works/pi-ai/api/openai-completions.lazy";
10
10
  import "@deepseek-ai/dsh-llm";
11
11
  import "@deepseek-ai/dsh-llm-pi-ai";
12
+ import { fileURLToPath } from "node:url";
12
13
  //#region src/upstream.ts
13
14
  /**
14
15
  * Backoff between catalog-fetch attempts.
@@ -2347,10 +2348,6 @@ var WorkBuddyEncryptedCredentialError = class extends Error {
2347
2348
  this.name = "WorkBuddyEncryptedCredentialError";
2348
2349
  }
2349
2350
  };
2350
- /** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
2351
- function isEncryptedCredentialError(value) {
2352
- return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
2353
- }
2354
2351
  function decryptableString(value, decrypt) {
2355
2352
  if (typeof value === "string") return {
2356
2353
  value,
@@ -2504,6 +2501,18 @@ async function authFilesIn(dir) {
2504
2501
  files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
2505
2502
  return files.map((name) => join(dir, name));
2506
2503
  }
2504
+ /**
2505
+ * One credential file, or undefined when it cannot be used.
2506
+ *
2507
+ * Returns undefined — rather than throwing — for every failure mode, so ONE bad
2508
+ * file cannot empty the pool. That is the whole point: an auth directory blends
2509
+ * plain files, encrypted files, half-written files and files from an app version
2510
+ * this plugin cannot read, and a scan that aborts on the first unusable one
2511
+ * reports "you have 2 accounts" when it means "I could not open the other two".
2512
+ *
2513
+ * The caller surfaces the ones it skipped, so the count never silently
2514
+ * disagrees with what is on disk.
2515
+ */
2507
2516
  async function readCredential(path) {
2508
2517
  let text;
2509
2518
  try {
@@ -2514,8 +2523,7 @@ async function readCredential(path) {
2514
2523
  const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
2515
2524
  try {
2516
2525
  return parseWorkBuddyAuth(text, path, decrypt);
2517
- } catch (error) {
2518
- if (isEncryptedCredentialError(error)) throw error;
2526
+ } catch {
2519
2527
  return;
2520
2528
  }
2521
2529
  }
@@ -2555,6 +2563,29 @@ async function cheapIdentityIdFromFile(path) {
2555
2563
  }
2556
2564
  }
2557
2565
  /**
2566
+ * Why a file could not become an account.
2567
+ *
2568
+ * Distinguishing "encrypted" matters most: it is actionable (start the desktop
2569
+ * app, or set the executable override), whereas "malformed" means the file is
2570
+ * simply not a credential. Reporting them alike would send the user looking for
2571
+ * a fix that cannot work.
2572
+ */
2573
+ async function skipReasonFor(path) {
2574
+ let text;
2575
+ try {
2576
+ text = await readFile(path, "utf8");
2577
+ } catch {
2578
+ return "unreadable";
2579
+ }
2580
+ if (text.includes("\"$wbEncrypted\"")) return "encrypted";
2581
+ try {
2582
+ JSON.parse(text);
2583
+ return "malformed";
2584
+ } catch {
2585
+ return "malformed";
2586
+ }
2587
+ }
2588
+ /**
2558
2589
  * Build the field opener, or undefined when the app cannot supply its key.
2559
2590
  *
2560
2591
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2619,6 +2650,13 @@ var WorkBuddyAccountPool = class {
2619
2650
  client;
2620
2651
  refreshMarginMs;
2621
2652
  accounts = [];
2653
+ /**
2654
+ * Files the last scan could not read, with the reason.
2655
+ *
2656
+ * Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
2657
+ * the difference between accounts being gone and files being unopenable.
2658
+ */
2659
+ skippedFiles = [];
2622
2660
  distribution;
2623
2661
  /** Cursor for round-robin mode; unused under priority distribution. */
2624
2662
  cursor = 0;
@@ -2720,19 +2758,38 @@ var WorkBuddyAccountPool = class {
2720
2758
  ignoredIdsInOrder() {
2721
2759
  return [...this.ignoredIds];
2722
2760
  }
2761
+ /**
2762
+ * Credential files the last scan could not read, with the reason.
2763
+ *
2764
+ * Exposed because a short account list is otherwise indistinguishable from a
2765
+ * broken one: with this, the card can say "2 accounts, 2 files unreadable"
2766
+ * instead of silently showing half a pool.
2767
+ */
2768
+ skippedFilesInOrder() {
2769
+ return this.skippedFiles;
2770
+ }
2723
2771
  /** Rescan the auth directories and merge newly discovered accounts. */
2724
2772
  async scan() {
2725
2773
  const found = [];
2774
+ const skipped = [];
2726
2775
  for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2727
2776
  if (this.ignoredIds.size > 0) {
2728
2777
  const cheapId = await cheapIdentityIdFromFile(file);
2729
2778
  if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2730
2779
  }
2731
2780
  const credential = await readCredential(file);
2732
- if (credential === void 0) continue;
2781
+ if (credential === void 0) {
2782
+ skipped.push({
2783
+ path: file,
2784
+ reason: await skipReasonFor(file)
2785
+ });
2786
+ continue;
2787
+ }
2733
2788
  if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2734
2789
  found.push(credential);
2735
2790
  }
2791
+ this.skippedFiles = skipped;
2792
+ if (skipped.length > 0) this.logger?.warn?.(`dsh-workbuddy-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
2736
2793
  const byId = /* @__PURE__ */ new Map();
2737
2794
  for (const account of this.accounts) byId.set(account.id, account);
2738
2795
  for (const credential of found) {
@@ -4899,8 +4956,14 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
4899
4956
  if (next.length !== current.length) await writeIgnoredAccounts(next, path);
4900
4957
  return next;
4901
4958
  }
4902
- //#endregion
4903
- //#region src/index.ts
4959
+ fileURLToPath(new URL("..", import.meta.url));
4960
+ (() => {
4961
+ try {
4962
+ return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
4963
+ } catch {
4964
+ return "";
4965
+ }
4966
+ })();
4904
4967
  /**
4905
4968
  * One region's model-selection schema.
4906
4969
  *
package/lib/client.js CHANGED
@@ -273,6 +273,19 @@ window.__ModuleLoader__.load({
273
273
  .dsm-workbuddy-xdpool-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
274
274
  .dsm-workbuddy-xdpool-model-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
275
275
  .dsm-workbuddy-xdpool-model-meta-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;background:rgba(174,179,187,.11);color:var(--dsw-alias-label-secondary,#c6c9d0)}
276
+ /* "free from HH:00": quieter than the badge on purpose — the model costs
277
+ credits right now, so it must not read as a promo the user can spend against. */
278
+ .dsm-workbuddy-xdpool-model-meta-later{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;border:1px dashed color-mix(in oklab, var(--dsw-alias-label-dimmed,#9aa0a8) 55%, transparent);color:var(--dsw-alias-label-tertiary,#9aa0a8)}
279
+ /* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
280
+ not a claim about the current price. */
281
+ .dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
282
+ /* Unreadable credential files: a warning tint, since it explains a smaller pool
283
+ and "encrypted" is actionable (start the app once). */
284
+ .dsm-workbuddy-xdpool-skipped{margin:6px 0 0;padding:7px 10px;border-radius:8px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));display:flex;flex-direction:column;gap:3px}
285
+ .dsm-workbuddy-xdpool-skipped-summary{margin:0;font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-state-warning-primary,#d97706)}
286
+ .dsm-workbuddy-xdpool-skipped-row{margin:0;display:flex;gap:8px;align-items:baseline;font-size:11px;line-height:16px;flex-wrap:wrap}
287
+ .dsm-workbuddy-xdpool-skipped-file{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--dsw-alias-label-secondary,#c6c9d0);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
288
+ .dsm-workbuddy-xdpool-skipped-reason{color:var(--dsw-alias-label-tertiary,#9aa0a8)}
276
289
  .dsm-workbuddy-xdpool-model-cap{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
277
290
  /* Model row: checkbox + image toggle + context-budget radios. */
278
291
  .dsm-workbuddy-xdpool-model-off{opacity:.55}
@@ -366,6 +379,104 @@ window.__ModuleLoader__.load({
366
379
  .dsm-workbuddy-xdpool-reserve-note-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
367
380
  .dsm-workbuddy-xdpool-reserve-note-bad{color:var(--dsw-alias-state-error-primary,#ef4444);font-weight:600}
368
381
  `.trim();
382
+ //#endregion
383
+ //#region src/promo.ts
384
+ /**
385
+ * Promotions in force.
386
+ *
387
+ * Source: the WorkBuddy announcement extending Hy3's free tier and
388
+ * Hy4-preview's night window to **2026-10-31**. Hy3 needs no entry here (its
389
+ * CN price is already zero upstream); only Hy4-preview's time window does,
390
+ * because the gateway charges for it around the clock.
391
+ *
392
+ * When a promotion is extended or a new one starts, edit this table. When one
393
+ * ends, either delete the row or let `until` lapse — both stop the badge.
394
+ */
395
+ const PROMO_RULES = [{
396
+ idPrefix: "hy4-preview",
397
+ region: "cn",
398
+ hours: [
399
+ 23,
400
+ 0,
401
+ 1,
402
+ 2,
403
+ 3,
404
+ 4,
405
+ 5,
406
+ 6,
407
+ 7
408
+ ],
409
+ until: "2026-10-31",
410
+ label: "night"
411
+ }];
412
+ /** `YYYY-MM-DD` for a Date in LOCAL time (the promotions are announced locally). */
413
+ function localDayKey(date) {
414
+ const p = (n) => String(n).padStart(2, "0");
415
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
416
+ }
417
+ /** Whether a rule is still in force on `date`. */
418
+ function ruleActive(rule, date) {
419
+ return localDayKey(date) <= rule.until;
420
+ }
421
+ /** The first hour the window reopens, searching forward from `hour`. */
422
+ function nextWindowHour(rule, hour) {
423
+ for (let step = 1; step <= 24; step += 1) {
424
+ const candidate = (hour + step) % 24;
425
+ if (rule.hours.includes(candidate)) return candidate;
426
+ }
427
+ }
428
+ /** The hour the CURRENT window closes, searching forward from `hour`. */
429
+ function windowEndHour(rule, hour) {
430
+ for (let step = 1; step <= 24; step += 1) {
431
+ const candidate = (hour + step) % 24;
432
+ if (!rule.hours.includes(candidate)) return candidate;
433
+ }
434
+ }
435
+ /**
436
+ * The promotion status of one model at `now`, for one region.
437
+ *
438
+ * `now` is injected so the behaviour is testable without freezing the clock —
439
+ * a time-dependent badge that can only be tested by waiting is a badge nobody
440
+ * tests. `region` is required for the same reason the rules carry one: these
441
+ * campaigns are regional, and applying a domestic rule to the international
442
+ * roster would invent a discount that gateway does not offer.
443
+ */
444
+ function promoStatusFor(model, now = /* @__PURE__ */ new Date(), region = "cn") {
445
+ if (model.multiplier === 0) return { kind: "free" };
446
+ for (const rule of PROMO_RULES) {
447
+ if (rule.region !== region) continue;
448
+ if (!model.id.startsWith(rule.idPrefix)) continue;
449
+ if (!ruleActive(rule, now)) continue;
450
+ const hour = now.getHours();
451
+ if (rule.hours.includes(hour)) {
452
+ const untilHour = windowEndHour(rule, hour);
453
+ return {
454
+ kind: "night",
455
+ label: rule.label,
456
+ untilHour: untilHour ?? hour,
457
+ promoUntil: rule.until
458
+ };
459
+ }
460
+ const nextHour = nextWindowHour(rule, hour);
461
+ if (nextHour !== void 0) return {
462
+ kind: "night-later",
463
+ label: rule.label,
464
+ nextHour,
465
+ promoUntil: rule.until
466
+ };
467
+ }
468
+ }
469
+ /**
470
+ * Whether a model is free RIGHT NOW, for sorting.
471
+ *
472
+ * Only the two "currently free" kinds count. `night-later` deliberately does
473
+ * NOT sort to the top: it costs credits at this moment, and floating a
474
+ * credit-priced model above cheaper ones would misrepresent the list.
475
+ */
476
+ function isFreeNow(model, now = /* @__PURE__ */ new Date(), region = "cn") {
477
+ const status = promoStatusFor(model, now, region);
478
+ return status?.kind === "free" || status?.kind === "night";
479
+ }
369
480
  //#endregion
370
481
  //#region src/client/PoolCard.tsx
371
482
  /**
@@ -546,17 +657,23 @@ window.__ModuleLoader__.load({
546
657
  /**
547
658
  * The promo badge for one model, or undefined when it has none.
548
659
  *
549
- * `free` is read off the CREDIT MULTIPLIER, not off a tag. Neither gateway ever
550
- * sends a literal `free` tag: the global roster marks its zero-cost models
551
- * (`hy3`, `hy4-preview-f`, `deepseek-v4.1-flash`) as `credits: "x0.00"`, so
552
- * waiting for a tag meant the badge NEVER appeared — including on models that
553
- * genuinely cost nothing.
660
+ * Two sources, because the gateway models one of them and not the other:
554
661
  *
555
- * An explicit free-ish tag still wins when present, so a gateway that starts
556
- * tagging them keeps working without another change.
662
+ * - `free` comes from the CREDIT MULTIPLIER. Neither gateway ever sends a
663
+ * literal `free` tag, but it does price its free models at `credits: "x0.00"`
664
+ * (CN `hy3`), so the multiplier is the honest signal.
665
+ * - the NIGHT window comes from {@link promoStatusFor}, because the gateway
666
+ * charges `x0.29` for `hy4-preview` around the clock and has no field for a
667
+ * time-of-day rule. See that module for why the 14-day newcomer allowance is
668
+ * deliberately not modelled.
669
+ *
670
+ * `region` is threaded through because the campaigns are REGIONAL: the
671
+ * Hy3 / Hy4-preview extension is a domestic promotion, so a global roster must
672
+ * not pick up a discount that gateway does not offer.
557
673
  */
558
- function tagFor(model) {
674
+ function tagFor(model, now, region) {
559
675
  const tags = model.tags ?? [];
676
+ if (promoStatusFor(model, now, region)?.kind === "night") return "night";
560
677
  if (tags.includes("free") || model.multiplier === 0) return "free";
561
678
  if (tags.includes("limited-free")) return "limited";
562
679
  if (tags.includes("night-discount")) return "night";
@@ -837,6 +954,17 @@ window.__ModuleLoader__.load({
837
954
  const modelsEditable = settingsWritable;
838
955
  const modelsDirty = draft !== void 0 && status !== void 0 && draftIsDirty(status, draft);
839
956
  const enabledCount = Object.values(modelDraft).filter((entry) => entry.enabled).length;
957
+ /**
958
+ * One clock reading per render, shared by the sort and every row.
959
+ *
960
+ * Taken once so the list cannot disagree with itself: calling `new Date()`
961
+ * per row would let a model be "free" for the badge and "not free" for the
962
+ * sort if the window boundary happened to fall between two rows.
963
+ *
964
+ * A promotion boundary is not a re-render trigger — the card refreshes on its
965
+ * own poll, which is frequent enough for a badge that changes twice a day.
966
+ */
967
+ const now = /* @__PURE__ */ new Date();
840
968
  const toggleModel = (id) => {
841
969
  if (status === void 0) return;
842
970
  const base = draft ?? draftFromStatus(status);
@@ -1383,6 +1511,22 @@ window.__ModuleLoader__.load({
1383
1511
  }) ?? `${accountCount} account(s) · ${cooling} cooling`
1384
1512
  })]
1385
1513
  }),
1514
+ status?.skippedFiles === void 0 || status.skippedFiles.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1515
+ className: "dsm-workbuddy-xdpool-skipped",
1516
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1517
+ className: "dsm-workbuddy-xdpool-skipped-summary",
1518
+ children: t?.("row.skippedFiles", { count: status.skippedFiles.length }) ?? `${status.skippedFiles.length} credential file(s) could not be read`
1519
+ }), status.skippedFiles.slice(0, 4).map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1520
+ className: "dsm-workbuddy-xdpool-skipped-row",
1521
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1522
+ className: "dsm-workbuddy-xdpool-skipped-file",
1523
+ children: entry.file
1524
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1525
+ className: "dsm-workbuddy-xdpool-skipped-reason",
1526
+ children: entry.reason === "encrypted" ? t?.("row.skippedEncrypted") ?? "encrypted — start WorkBuddy once" : entry.reason === "unreadable" ? t?.("row.skippedUnreadable") ?? "file could not be read" : t?.("row.skippedMalformed") ?? "not a credential file"
1527
+ })]
1528
+ }, entry.file))]
1529
+ }),
1386
1530
  (() => {
1387
1531
  const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
1388
1532
  if (active === void 0) return null;
@@ -1510,8 +1654,9 @@ window.__ModuleLoader__.load({
1510
1654
  })]
1511
1655
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1512
1656
  className: "dsm-workbuddy-xdpool-model-list",
1513
- children: status?.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
1657
+ children: [...status?.models ?? []].sort((a, b) => Number(isFreeNow(b, now, activeRegion)) - Number(isFreeNow(a, now, activeRegion))).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
1514
1658
  model,
1659
+ region: activeRegion,
1515
1660
  t,
1516
1661
  draft: modelDraft[model.id] ?? {
1517
1662
  enabled: model.enabled,
@@ -1865,9 +2010,26 @@ window.__ModuleLoader__.load({
1865
2010
  * context radios stay disabled rather than pretending an edit took hold. The
1866
2011
  * draft lives in the parent, so this component only ever reports intent.
1867
2012
  */
1868
- function ModelRow({ model, t, draft, editable, onToggle, onToggleImage, onBudget }) {
1869
- const tag = tagFor(model);
1870
- const tagText = tag === "free" ? t?.("row.free") ?? "free" : tag === "limited" ? t?.("row.limitedFree") ?? "limited free" : tag === "night" ? t?.("row.nightDiscount") ?? "night" : null;
2013
+ function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
2014
+ const now = /* @__PURE__ */ new Date();
2015
+ const tag = tagFor(model, now, region);
2016
+ const promo = promoStatusFor(model, now, region);
2017
+ const tagText = tag === "free" ? t?.("row.free") ?? "free" : tag === "limited" ? t?.("row.limitedFree") ?? "limited free" : tag === "night" ? t?.("row.nightFreeNow", { time: `${String(promo?.kind === "night" ? promo.untilHour : 0).padStart(2, "0")}:00` }) ?? "free until 08:00" : null;
2018
+ /**
2019
+ * The "cheaper later" hint: the model is on a promotion but the window is
2020
+ * closed. Deliberately NOT a "free" badge — it costs credits at this moment,
2021
+ * and telling the user otherwise would change what they spend.
2022
+ */
2023
+ const laterHint = promo?.kind === "night-later" ? t?.("row.nightFreeLater", { time: `${String(promo.nextHour).padStart(2, "0")}:00` }) ?? `free from ${String(promo.nextHour).padStart(2, "0")}:00` : null;
2024
+ /**
2025
+ * How long the campaign itself runs, e.g. "活动至 10-31".
2026
+ *
2027
+ * Shown on both the free and the not-yet-free row, because the useful question
2028
+ * is not only "is it free now" but "until when is this offer good at all" —
2029
+ * an extension changes that date, and a user planning around the promotion
2030
+ * needs it visible rather than buried in a changelog.
2031
+ */
2032
+ const promoUntil = promo === void 0 || promo.kind === "free" ? null : t?.("row.promoUntil", { date: promo.promoUntil.slice(5).replace("-", "-") }) ?? `promo until ${promo.promoUntil}`;
1871
2033
  const native = model.nativeContextWindow;
1872
2034
  const capped = native > DEFAULT_CONTEXT_BUDGET;
1873
2035
  const currentBudget = draft.budget ?? native;
@@ -1939,6 +2101,14 @@ window.__ModuleLoader__.load({
1939
2101
  className: "dsm-workbuddy-xdpool-model-meta-tag",
1940
2102
  children: tagText
1941
2103
  }),
2104
+ laterHint === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2105
+ className: "dsm-workbuddy-xdpool-model-meta-later",
2106
+ children: laterHint
2107
+ }),
2108
+ promoUntil === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2109
+ className: "dsm-workbuddy-xdpool-model-meta-promo",
2110
+ children: promoUntil
2111
+ }),
1942
2112
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1943
2113
  className: "dsm-workbuddy-xdpool-model-cap",
1944
2114
  children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
@@ -2094,6 +2264,13 @@ window.__ModuleLoader__.load({
2094
2264
  "row.free": "free",
2095
2265
  "row.limitedFree": "limited free",
2096
2266
  "row.nightDiscount": "night",
2267
+ "row.nightFreeNow": "free until {time}",
2268
+ "row.nightFreeLater": "free from {time}",
2269
+ "row.promoUntil": "promo to {date}",
2270
+ "row.skippedFiles": "{count} credential file(s) could not be read",
2271
+ "row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
2272
+ "row.skippedUnreadable": "file could not be read",
2273
+ "row.skippedMalformed": "not a credential file",
2097
2274
  "row.imageCapable": "image input",
2098
2275
  "row.rate": "{rate}x credits",
2099
2276
  "row.accountsRescan": "Detect accounts again",
@@ -2239,6 +2416,13 @@ window.__ModuleLoader__.load({
2239
2416
  "row.free": "免费",
2240
2417
  "row.limitedFree": "限量免费",
2241
2418
  "row.nightDiscount": "夜间",
2419
+ "row.nightFreeNow": "免费至 {time}",
2420
+ "row.nightFreeLater": "{time} 起免费",
2421
+ "row.promoUntil": "活动至 {date}",
2422
+ "row.skippedFiles": "有 {count} 个凭据文件读不出来",
2423
+ "row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
2424
+ "row.skippedUnreadable": "文件读不出来",
2425
+ "row.skippedMalformed": "不是凭据文件",
2242
2426
  "row.imageCapable": "图片输入",
2243
2427
  "row.rate": "{rate}x 积分",
2244
2428
  "row.accountsRescan": "重新检测账号",
package/lib/index.d.ts CHANGED
@@ -752,6 +752,18 @@ export declare const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
752
752
  /** Env override for the auth file or its directory. */
753
753
  export declare const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
754
754
  /** One parsed WorkBuddy credential. */
755
+ /**
756
+ * A credential file the pool could not turn into an account.
757
+ *
758
+ * Reported (not swallowed) because the count is otherwise a lie: a directory
759
+ * holding four files that yields two accounts looks like two accounts were
760
+ * deleted, when in fact two files were unreadable. Naming the file and the
761
+ * reason is what makes the difference visible.
762
+ */
763
+ interface WorkBuddySkippedFile {
764
+ path: string;
765
+ reason: 'encrypted' | 'unreadable' | 'malformed';
766
+ }
755
767
  interface WorkBuddyCredential {
756
768
  accessToken: string;
757
769
  refreshToken: string;
@@ -862,6 +874,13 @@ export declare class WorkBuddyAccountPool {
862
874
  private readonly client;
863
875
  private readonly refreshMarginMs;
864
876
  private accounts;
877
+ /**
878
+ * Files the last scan could not read, with the reason.
879
+ *
880
+ * Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
881
+ * the difference between accounts being gone and files being unopenable.
882
+ */
883
+ private skippedFiles;
865
884
  private distribution;
866
885
  /** Cursor for round-robin mode; unused under priority distribution. */
867
886
  private cursor;
@@ -948,6 +967,14 @@ export declare class WorkBuddyAccountPool {
948
967
  isIgnored(accountId: string): boolean;
949
968
  /** Every ignored id currently in force, in insertion order. */
950
969
  ignoredIdsInOrder(): string[];
970
+ /**
971
+ * Credential files the last scan could not read, with the reason.
972
+ *
973
+ * Exposed because a short account list is otherwise indistinguishable from a
974
+ * broken one: with this, the card can say "2 accounts, 2 files unreadable"
975
+ * instead of silently showing half a pool.
976
+ */
977
+ skippedFilesInOrder(): readonly WorkBuddySkippedFile[];
951
978
  /** Rescan the auth directories and merge newly discovered accounts. */
952
979
  scan(): Promise<WorkBuddyAccount[]>;
953
980
  /** All accounts, cooldown state included. */
@@ -1345,6 +1372,21 @@ interface PoolWebStatus {
1345
1372
  catalogUpdatedAt?: string;
1346
1373
  /** Why the last fetch failed, when it did. Redacted and length-capped. */
1347
1374
  catalogError?: string;
1375
+ /**
1376
+ * Credential files on disk that did NOT become accounts.
1377
+ *
1378
+ * Reported so the account count can be trusted: without this, a directory
1379
+ * holding four files that yields two accounts looks like two accounts were
1380
+ * deleted, when really two files could not be opened (most often an encrypted
1381
+ * credential the desktop app was not running to unlock).
1382
+ */
1383
+ skippedFiles?: readonly PoolWebSkippedFile[];
1384
+ }
1385
+ /** A credential file the pool could not read. */
1386
+ interface PoolWebSkippedFile {
1387
+ /** Basename only; the full path lives in the desktop app's auth directory. */
1388
+ file: string;
1389
+ reason: 'encrypted' | 'unreadable' | 'malformed';
1348
1390
  }
1349
1391
  /** How a region's model list was obtained. */
1350
1392
  type PoolWebCatalogSource = 'live' | 'fallback';
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from "node:module";
1
2
  import { basename, dirname, join, resolve } from "node:path";
2
3
  import z from "@deepseek-ai/schemastery";
3
4
  import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -11,6 +12,7 @@ import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/d
11
12
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
12
13
  import { createServer } from "node:http";
13
14
  import { Readable } from "node:stream";
15
+ import { fileURLToPath } from "node:url";
14
16
  //#region src/upstream.ts
15
17
  /**
16
18
  * Backoff between catalog-fetch attempts.
@@ -2454,10 +2456,6 @@ var WorkBuddyEncryptedCredentialError = class extends Error {
2454
2456
  this.name = "WorkBuddyEncryptedCredentialError";
2455
2457
  }
2456
2458
  };
2457
- /** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
2458
- function isEncryptedCredentialError(value) {
2459
- return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
2460
- }
2461
2459
  function decryptableString(value, decrypt) {
2462
2460
  if (typeof value === "string") return {
2463
2461
  value,
@@ -2611,6 +2609,18 @@ async function authFilesIn(dir) {
2611
2609
  files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
2612
2610
  return files.map((name) => join(dir, name));
2613
2611
  }
2612
+ /**
2613
+ * One credential file, or undefined when it cannot be used.
2614
+ *
2615
+ * Returns undefined — rather than throwing — for every failure mode, so ONE bad
2616
+ * file cannot empty the pool. That is the whole point: an auth directory blends
2617
+ * plain files, encrypted files, half-written files and files from an app version
2618
+ * this plugin cannot read, and a scan that aborts on the first unusable one
2619
+ * reports "you have 2 accounts" when it means "I could not open the other two".
2620
+ *
2621
+ * The caller surfaces the ones it skipped, so the count never silently
2622
+ * disagrees with what is on disk.
2623
+ */
2614
2624
  async function readCredential(path) {
2615
2625
  let text;
2616
2626
  try {
@@ -2621,8 +2631,7 @@ async function readCredential(path) {
2621
2631
  const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
2622
2632
  try {
2623
2633
  return parseWorkBuddyAuth(text, path, decrypt);
2624
- } catch (error) {
2625
- if (isEncryptedCredentialError(error)) throw error;
2634
+ } catch {
2626
2635
  return;
2627
2636
  }
2628
2637
  }
@@ -2662,6 +2671,29 @@ async function cheapIdentityIdFromFile(path) {
2662
2671
  }
2663
2672
  }
2664
2673
  /**
2674
+ * Why a file could not become an account.
2675
+ *
2676
+ * Distinguishing "encrypted" matters most: it is actionable (start the desktop
2677
+ * app, or set the executable override), whereas "malformed" means the file is
2678
+ * simply not a credential. Reporting them alike would send the user looking for
2679
+ * a fix that cannot work.
2680
+ */
2681
+ async function skipReasonFor(path) {
2682
+ let text;
2683
+ try {
2684
+ text = await readFile(path, "utf8");
2685
+ } catch {
2686
+ return "unreadable";
2687
+ }
2688
+ if (text.includes("\"$wbEncrypted\"")) return "encrypted";
2689
+ try {
2690
+ JSON.parse(text);
2691
+ return "malformed";
2692
+ } catch {
2693
+ return "malformed";
2694
+ }
2695
+ }
2696
+ /**
2665
2697
  * Build the field opener, or undefined when the app cannot supply its key.
2666
2698
  *
2667
2699
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2726,6 +2758,13 @@ var WorkBuddyAccountPool = class {
2726
2758
  client;
2727
2759
  refreshMarginMs;
2728
2760
  accounts = [];
2761
+ /**
2762
+ * Files the last scan could not read, with the reason.
2763
+ *
2764
+ * Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
2765
+ * the difference between accounts being gone and files being unopenable.
2766
+ */
2767
+ skippedFiles = [];
2729
2768
  distribution;
2730
2769
  /** Cursor for round-robin mode; unused under priority distribution. */
2731
2770
  cursor = 0;
@@ -2827,19 +2866,38 @@ var WorkBuddyAccountPool = class {
2827
2866
  ignoredIdsInOrder() {
2828
2867
  return [...this.ignoredIds];
2829
2868
  }
2869
+ /**
2870
+ * Credential files the last scan could not read, with the reason.
2871
+ *
2872
+ * Exposed because a short account list is otherwise indistinguishable from a
2873
+ * broken one: with this, the card can say "2 accounts, 2 files unreadable"
2874
+ * instead of silently showing half a pool.
2875
+ */
2876
+ skippedFilesInOrder() {
2877
+ return this.skippedFiles;
2878
+ }
2830
2879
  /** Rescan the auth directories and merge newly discovered accounts. */
2831
2880
  async scan() {
2832
2881
  const found = [];
2882
+ const skipped = [];
2833
2883
  for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2834
2884
  if (this.ignoredIds.size > 0) {
2835
2885
  const cheapId = await cheapIdentityIdFromFile(file);
2836
2886
  if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2837
2887
  }
2838
2888
  const credential = await readCredential(file);
2839
- if (credential === void 0) continue;
2889
+ if (credential === void 0) {
2890
+ skipped.push({
2891
+ path: file,
2892
+ reason: await skipReasonFor(file)
2893
+ });
2894
+ continue;
2895
+ }
2840
2896
  if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2841
2897
  found.push(credential);
2842
2898
  }
2899
+ this.skippedFiles = skipped;
2900
+ if (skipped.length > 0) this.logger?.warn?.(`dsh-workbuddy-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
2843
2901
  const byId = /* @__PURE__ */ new Map();
2844
2902
  for (const account of this.accounts) byId.set(account.id, account);
2845
2903
  for (const credential of found) {
@@ -6275,7 +6333,11 @@ async function poolWebStatus(deps, region = "cn") {
6275
6333
  ignored: deps.ignoredAccounts?.() ?? [],
6276
6334
  catalogSource: deps.catalogs[region].currentSource(),
6277
6335
  ...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
6278
- ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) }
6336
+ ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
6337
+ ...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
6338
+ file: basename(entry.path),
6339
+ reason: entry.reason
6340
+ })) }
6279
6341
  };
6280
6342
  }
6281
6343
  /**
@@ -6682,6 +6744,95 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6682
6744
  return next;
6683
6745
  }
6684
6746
  //#endregion
6747
+ //#region pi-ai-generation.ts
6748
+ /**
6749
+ * Cross-generation guard for `@earendil-works/pi-ai`.
6750
+ *
6751
+ * The failure this exists to catch: the plugin assembles its provider with ONE
6752
+ * copy of pi-ai while the host's `PiAiAdapter` consumes the event stream with
6753
+ * ANOTHER. The two generations disagree on the shape of the terminal message,
6754
+ * and the seam between them throws `Cannot read properties of undefined
6755
+ * (reading 'length')` inside the host adapter — which the host then classifies
6756
+ * as a non-retryable `PI_AI_ERROR`. From the user's side it is "every turn
6757
+ * fails, immediately, with no content and no useful error".
6758
+ *
6759
+ * A `package.json` range cannot prevent this. The old peer range was
6760
+ * `>=0.82.1 <0.85.0` while the host shipped 0.87.x: the upper bound excluded the
6761
+ * host's generation outright, which turned a cross-generation mix into a
6762
+ * legitimate install. Ranges describe what a package tolerates; they cannot
6763
+ * describe what the OTHER resolved copy on the same machine happens to be.
6764
+ *
6765
+ * So this checks the resolved reality at load time and says so, loudly, in the
6766
+ * log. It does NOT refuse to start: a version mismatch might well be benign, and
6767
+ * a plugin that hard-fails on a guess would be worse than one that warns.
6768
+ *
6769
+ * @module dsh-workbuddy-xdpool/pi-ai-generation
6770
+ */
6771
+ /** The package whose generation must agree between plugin and host. */
6772
+ const PI_AI_PACKAGE = "@earendil-works/pi-ai";
6773
+ /**
6774
+ * The major.minor of a version string, which is what "generation" means here.
6775
+ *
6776
+ * Patch differences are expected and harmless; two copies differing in
6777
+ * minor — 0.85 vs 0.87 — is exactly the split that broke the adapter seam.
6778
+ */
6779
+ function generationOf(version) {
6780
+ const trimmed = version.trim();
6781
+ const parts = /^(\d+)\.(\d+)/u.exec(trimmed);
6782
+ if (parts === null || parts === void 0 || parts[1] === void 0 || parts[2] === void 0) return trimmed;
6783
+ return `${parts[1]}.${parts[2]}`;
6784
+ }
6785
+ /**
6786
+ * Read the version of a resolved pi-ai, starting the lookup from `fromDir`.
6787
+ *
6788
+ * Using `require.resolve` with an explicit anchor is what makes the two sides
6789
+ * distinguishable: resolving plainly would return whichever copy this module
6790
+ * happens to see, the same one for both, and the comparison would always pass.
6791
+ */
6792
+ function piAiGenerationFrom(fromDir) {
6793
+ try {
6794
+ const require_ = createRequire(`${fromDir.replace(/[\\/]+$/u, "")}/`);
6795
+ const pkgPath = require_.resolve(`${PI_AI_PACKAGE}/package.json`);
6796
+ const pkg = require_(pkgPath);
6797
+ if (typeof pkg.version !== "string") return void 0;
6798
+ return {
6799
+ version: pkg.version,
6800
+ resolvedFrom: pkgPath
6801
+ };
6802
+ } catch {
6803
+ return;
6804
+ }
6805
+ }
6806
+ /**
6807
+ * Compare the copy the plugin assembled with against the copy the host adapter
6808
+ * will use.
6809
+ *
6810
+ * `hostDir` should be a directory inside the host install (its own
6811
+ * `node_modules`), so the two resolutions walk different trees.
6812
+ */
6813
+ function checkPiAiGeneration(pluginDir, hostDir) {
6814
+ const plugin = piAiGenerationFrom(pluginDir);
6815
+ const host = piAiGenerationFrom(hostDir);
6816
+ if (plugin === void 0 || host === void 0) return { kind: "unknown" };
6817
+ if (generationOf(plugin.version) === generationOf(host.version)) return {
6818
+ kind: "aligned",
6819
+ generation: generationOf(plugin.version)
6820
+ };
6821
+ return {
6822
+ kind: "mismatched",
6823
+ plugin,
6824
+ host
6825
+ };
6826
+ }
6827
+ /**
6828
+ * A warning message for a mismatched pair, or undefined when there is nothing
6829
+ * to say.
6830
+ */
6831
+ function piAiMismatchMessage(check) {
6832
+ if (check.kind !== "mismatched") return void 0;
6833
+ return `dsh-workbuddy-xdpool: @earendil-works/pi-ai generation mismatch — this plugin assembled its provider with ${check.plugin.version} while the host adapter resolves ${check.host.version}. Two generations on one call chain produce a TypeError at the adapter seam ("Cannot read properties of undefined"), which the host reports as a non-retryable PI_AI_ERROR, so every turn fails with no content. Align the two by pinning pi-ai to the host generation (for example a pnpm \`overrides\` entry in the profile) and restart DSH.`;
6834
+ }
6835
+ //#endregion
6685
6836
  //#region src/index.ts
6686
6837
  /**
6687
6838
  * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
@@ -6690,6 +6841,28 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6690
6841
  *
6691
6842
  * @module dsh-workbuddy-xdpool/index
6692
6843
  */
6844
+ /**
6845
+ * This plugin's install root, the tree its own provider is assembled from.
6846
+ *
6847
+ * Two directories up from `lib/` — the built bundle sits in `lib/`, so the
6848
+ * package root is its parent. Used as one side of the pi-ai generation check.
6849
+ */
6850
+ const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url));
6851
+ /**
6852
+ * The host's own module directory, the tree its `PiAiAdapter` resolves from.
6853
+ *
6854
+ * Reached from the host package this plugin is loaded by: walking up from a
6855
+ * `@deepseek-ai/dsh-llm-pi-ai` import lands in the host's `node_modules`, which
6856
+ * is where the HOST generation actually lives. The plugin's own tree is the
6857
+ * other side; comparing one tree with itself would always agree.
6858
+ */
6859
+ const HOST_MODULE_ROOT = (() => {
6860
+ try {
6861
+ return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
6862
+ } catch {
6863
+ return "";
6864
+ }
6865
+ })();
6693
6866
  /** Stable Cordis plugin name. */
6694
6867
  const name = "llm-workbuddy-xdpool";
6695
6868
  /** The model registry required before the provider can register. */
@@ -6878,12 +7051,30 @@ function createCore(logger) {
6878
7051
  };
6879
7052
  }
6880
7053
  /**
7054
+ * Log a warning when the plugin and the host resolve different pi-ai
7055
+ * generations.
7056
+ *
7057
+ * Never throws and never blocks startup: the check is a filesystem lookup, and
7058
+ * a plugin that refused to load because of a version guess would be worse than
7059
+ * one that merely warns. The two directories are THIS plugin's install root and
7060
+ * the host's own module directory, so the two resolutions walk different trees —
7061
+ * resolving from one place only would compare a copy with itself and always
7062
+ * agree.
7063
+ */
7064
+ function warnOnPiAiGenerationMismatch(ctx) {
7065
+ try {
7066
+ const message = piAiMismatchMessage(checkPiAiGeneration(PLUGIN_ROOT, HOST_MODULE_ROOT));
7067
+ if (message !== void 0) ctx.logger.warn(message);
7068
+ } catch {}
7069
+ }
7070
+ /**
6881
7071
  * Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
6882
7072
  * discover accounts. The provider registers only after `shim.ready` resolves,
6883
7073
  * because its models read the shim origin at construction time.
6884
7074
  */
6885
7075
  function apply(ctx, config = {}) {
6886
7076
  const core = createCore(ctx.logger);
7077
+ warnOnPiAiGenerationMismatch(ctx);
6887
7078
  const ignoredPath = ignoredIdsPath();
6888
7079
  let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6889
7080
  core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-xdpool",
3
3
  "displayName": "DSH WorkBuddy XD Pool",
4
4
  "description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
5
- "version": "1.7.4",
5
+ "version": "1.7.6",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
@@ -103,7 +103,7 @@
103
103
  "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.1-rc.1 <0.2.0",
104
104
  "@deepseek-ai/dsh-settings": ">=0.1.1-rc.1 <0.2.0",
105
105
  "@deepseek-ai/schemastery": "^3.18.1",
106
- "@earendil-works/pi-ai": ">=0.82.1 <0.85.0"
106
+ "@earendil-works/pi-ai": ">=0.82.1"
107
107
  },
108
108
  "peerDependenciesMeta": {
109
109
  "@deepseek-ai/dsh-llm": {