dsh-workbuddy-xdpool 1.7.5 → 1.7.7

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,127 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.7.7 — 别再让你为了一个「等一会儿」去重新登录
8
+
9
+ > 报错说「API 密钥无效」,于是你跑去重新登录 —— 但密钥根本没问题,只是那个模型暂时限流了。
10
+
11
+ ### 问题一:限流被说成了「密钥无效」
12
+
13
+ **你会看到**:国际版用 hy4 时,偶尔弹出「API 密钥无效」。你重新登录、换账号,都不一定管用。
14
+
15
+ **为什么**:看日志就清楚了(时间连着三行):
16
+
17
+ ```
18
+ 14:50:22 qingche526 rate-limited on hy4-preview-f → cooling + rotating
19
+ 14:50:23 aosi526 rate-limited on hy4-preview-f → cooling + rotating
20
+ 14:50:25 401: "no WorkBuddy credential found; sign in on the desktop app"
21
+ ```
22
+
23
+ 两个账号的 hy4-preview-f 都被限流**冷却**了(一小时),于是池子里暂时没有可用账号。可插件判断「有没有被限流」用的是**本次请求内**的临时记录 —— 上次请求造成的冷却,它看不见。于是它以为「一个账号都没有 → 你没登录」→ 报 **401**。
24
+
25
+ DSH 把 401 显示成「API 密钥无效」。**你被派去修一个根本没坏的东西。**
26
+
27
+ **怎么修的**:不再靠本次请求的临时记录猜,而是**直接问账号池「为什么现在没有可用账号」**,然后按真实原因报对应的错:
28
+
29
+ | 真实情况 | 以前 | 现在 |
30
+ |---|---|---|
31
+ | 模型限流中 | 401 密钥无效 | **429 该模型限流中**(等一会儿即可)|
32
+ | 账号全被关/移出 | 401 密钥无效 | **403 账号全部已关闭**(去卡片打开)|
33
+ | 积分低于保留线 | 401 密钥无效 | **402 积分不足** |
34
+ | 确实没登录 | 401 | 401(这个才是对的)|
35
+
36
+ 四种情况**需要完全相反的处理**,以前却都推给你「重新登录」。现在只有真的该重新登录时才这么说。
37
+
38
+ ### 问题二:上下文「压缩」把目标设得比原文还大
39
+
40
+ 日志里抓到的:
41
+
42
+ ```
43
+ context overrun on deepseek-v4.1-flash (~791793 tokens); compacting to ~797952
44
+ ↑ 原文 79.1 万 ↑ 目标 79.7 万
45
+ ```
46
+
47
+ **压缩目标比要压缩的东西还大** —— 等于什么都没压,重试当然又超限。而日志看起来还挺像"我处理过了"。
48
+
49
+ **为什么**:目标值是按**目录里写的窗口**算的(`deepseek-v4.1-flash` 标称 1M),取 80% → `797952`。但**标称 1M 是宣传值,不是真实生效上限** —— 否则它就不会被拒了。
50
+
51
+ **怎么修的**:**正在超限的那个请求本身就是证据**。既然它太大了,目标就必须明显小于它。现在取「按窗口算」和「按原文一半」的较小值:
52
+
53
+ ```
54
+ 原文 791793 → 目标 395896 (真正压掉一半)
55
+ ```
56
+
57
+ ### 顺便:这类报错终于有线索了
58
+
59
+ 上游的 `model_param_invalid` 里 **`param` 字段是空的** —— 它只说「参数不符合要求」,不说是哪个参数。你之前贴的那条 11133 就是这样,等于没有线索。
60
+
61
+ 现在遇到 400 时,日志会记下**请求的形状**(顶层参数名、各角色的消息条数、工具数量、内容字符数):
62
+
63
+ ```
64
+ rejected the request as invalid (model deepseek-v4.1-flash) —
65
+ keys=[max_tokens messages model reasoning_effort stream temperature tool_choice tools top_k top_p]
66
+ messages=122 (system:1 user:61 assistant:30 tool:30) tools=77 contentChars=481233
67
+ ```
68
+
69
+ **只有形状,没有内容**(对话是你的,不该进日志)。下次再遇到,日志里就能看出是哪个参数/哪个维度不对。
70
+
71
+ ### 我排查时的事
72
+
73
+ 我一开始想直接复现 11133,试了:采样参数(`top_k`/`repetition_penalty`/`temperature`/`top_p`)、推理档位(`minimal` 到 `max`,还试了无效档位)、各种消息形状(回放 `reasoning_content`、工具调用链)、消息数量(最多 362 条)、77 个工具 + 长会话、超长 prompt ——**国内版和国际版全部返回 200**。
74
+
75
+ **我没能复现它**,所以没有针对 11133 写"修复"(那会是猜测)。改的都是**从日志里坐实的**问题:那两个是确凿的。这个 11133 加上刚加的形状日志,下次出现时就能定位了。
76
+
77
+ ### 验证
78
+
79
+ - 类型检查全绿,测试 **325 项全绿**(+12)。
80
+ - 新增 `tests/pool-availability.test.ts`(7 项):池能正确区分「无人登录 / 模型限流 / 账号全关 / 积分保留 / 可用」、**限流时返回 429 且文案不含「sign in」**、真没登录时仍是 401、**不把另一个 region 的账号算进来**、过期冷却自动视为可用。
81
+ - 新增 `tests/context-budget-budget.test.ts`(5 项):**目录窗口远大于原文时目标必须更小**(用日志里那组真实数字)、窗口未知时减半、窗口小于原文时也不放大、目标不为零。
82
+ - **反向验证**:把 429 分支禁用、退回原来的 401 → 测试变红。
83
+
84
+ ## 1.7.6 — 一个读不出来的凭据文件,不再带走整个账号池
85
+
86
+ > 你切了 4 个账号登录,界面显示 4 个;重启之后只剩 2 个 —— 不是账号没了,是它们被一个文件拖累了。
87
+
88
+ **你会看到**:用 WorkBuddy Switch 切换登录几个国内版账号后,卡片上账号数对不上——重启少了两个。
89
+
90
+ **为什么**:认证目录里**同时存在好几种文件**:明文的老格式、5.6.0+ 起的加密格式、写到一半的、还有插件不认识的版本。这些**混在一起是常态**,不是错误。
91
+
92
+ 但旧代码里,只要**碰到一个加密又打不开的文件**(WorkBuddy 没运行、或者取不到它的密钥),就会**抛异常终止整个扫描** —— 于是那些**明明读得好好的账号也被一起丢掉**。一个坏文件,带走整个池。
93
+
94
+ 我复现了这个行为:3 个正常文件 + 1 个打不开的,结果**不是返回 3 个,而是整个扫描抛错、0 个账号**。
95
+
96
+ **怎么修的**:
97
+
98
+ - **单个文件失败只跳过它自己**,不会连累其他账号。这是核心修复;
99
+ - **跳过的文件会被记录并显示在卡片上**:现在账号区会明确写「有 N 个凭据文件读不出来」,并**列出文件名和原因**。其中「已加密 — 启动一次 WorkBuddy 才能取到密钥」是可操作的;而"不是凭据文件"就只是个无关文件。区分开很重要,不然你会去修一个压根修不好的东西。
100
+
101
+ 这样账号数就**可信了**:不会再出现「磁盘上 4 个文件,界面显示 2 个账号」这种说不清的落差。
102
+
103
+ ### 顺带修的第二个问题:pi-ai 跨代误用
104
+
105
+ 另一份反馈报的是「每轮对话必崩,报 `Cannot read properties of undefined` 且不可重试」。根因是**同一条调用链上混用了两代 pi-ai** —— 插件用 0.85.x 组装 provider,宿主适配器用 0.87.x 消费事件流,两代对终态消息的形状约定不同,接缝处直接抛异常。而宿主把它归为不可重试的 `PI_AI_ERROR`,栈还被丢弃,所以极难排查。
106
+
107
+ **为什么 `package.json` 的范围拦不住**:旧的 peer 声明是 `>=0.82.1 <0.85.0`,**上界把宿主的 0.87 代际整个排除在外** —— 等于把「跨代混用」变成了一种**合法的安装组合**。版本范围只能描述"我能接受什么",描述不了"机器上实际装的是哪一份"。
108
+
109
+ **所以改成两条**:
110
+
111
+ - **放开上界**(`>=0.82.1`),不再把宿主代际排除成非法组合;
112
+ - **加载时实测并告警**:比对插件这份与宿主那份 pi-ai 的代际,**不一致就在日志里点名两个版本并给出修法**(用 pnpm `overrides` 把代际对齐宿主)。
113
+
114
+ 只告警、**不阻止启动** —— 版本不一致也可能是无害的,因为一个猜测就让插件拒绝加载会更糟。
115
+
116
+ ### 验证
117
+
118
+ - 类型检查全绿,测试 **313 项全绿**(+9),**连续两次全量运行稳定**。
119
+ - 新增 `tests/account-discovery.test.ts`(7 项):单个坏文件不拖垮扫描、**全部文件都坏时也不抛错**、跳过的文件带原因上报、坏文件不会被算成账号、非 JSON / 结构不对的文件同样只跳过。
120
+ - 新增 `tests/pi-ai-generation.test.ts`(9 项):代际归约(补丁差异不算跨代)、**0.85 / 0.87 分裂能被识别**、任一侧解析不到时判为未知、告警文案包含两个版本与修法。
121
+ - **反向验证**:把「加密错误重新抛出」放回 → 4 项变红,确认测试确实能抓住这个缺陷。
122
+ - 构建后校验产物**已内联**代际检测模块(无外部 `.ts` 引用),并直接加载 `lib/index.js` 确认可运行。
123
+
124
+ ### 已知未覆盖
125
+
126
+ 「每轮必崩」的那份报告里,还建议了**宿主侧**两处加固(`mapStopReason` 对空 `content` 加守卫、`classifyPiAiError` 保留原始 `error.stack`)。那是 DSH 宿主的代码,不在这个插件仓库内,需要另开 issue 到 DSH。
127
+
7
128
  ## 1.7.5 — 模型列表会告诉你「现在用哪个不花钱」
8
129
 
9
130
  > Hy4 preview 晚上 23 点到早上 8 点免费,Hy3 一直在免费期 —— 现在卡片上直接看得出来。
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) {
@@ -2789,6 +2846,70 @@ var WorkBuddyAccountPool = class {
2789
2846
  return true;
2790
2847
  });
2791
2848
  }
2849
+ /**
2850
+ * Why no account is available right now, for an accurate error.
2851
+ *
2852
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
2853
+ * signed in (the user must sign in), every account is rate-limited (the user
2854
+ * must wait, and retrying later works), or every account was switched off /
2855
+ * ignored (the user must re-enable one). Reporting all of them as "no
2856
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
2857
+ * observed as an "API key invalid" panel for a model that was merely cooling.
2858
+ *
2859
+ * Counts are over the region's accounts, since a provider only ever sees its
2860
+ * own gateway.
2861
+ */
2862
+ unavailableReason(modelId, region) {
2863
+ const now = Date.now();
2864
+ const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
2865
+ if (inRegion.length === 0) return {
2866
+ total: 0,
2867
+ cooling: 0,
2868
+ disabled: 0,
2869
+ reason: "empty"
2870
+ };
2871
+ let cooling = 0;
2872
+ let disabled = 0;
2873
+ for (const account of inRegion) {
2874
+ if (this.disabledIds.has(account.id)) {
2875
+ disabled += 1;
2876
+ continue;
2877
+ }
2878
+ const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
2879
+ if (account.cooldownUntilMs > now || modelCooling) {
2880
+ cooling += 1;
2881
+ continue;
2882
+ }
2883
+ const reserve = this.creditReserves.get(account.id);
2884
+ if (reserve !== void 0 && reserve > 0) {
2885
+ const balance = this.creditBalances.get(account.id);
2886
+ if (balance !== void 0 && balance <= reserve) return {
2887
+ total: inRegion.length,
2888
+ cooling,
2889
+ disabled,
2890
+ reason: "reserve"
2891
+ };
2892
+ }
2893
+ }
2894
+ if (cooling > 0) return {
2895
+ total: inRegion.length,
2896
+ cooling,
2897
+ disabled,
2898
+ reason: "cooling"
2899
+ };
2900
+ if (disabled > 0) return {
2901
+ total: inRegion.length,
2902
+ cooling,
2903
+ disabled,
2904
+ reason: "disabled"
2905
+ };
2906
+ return {
2907
+ total: inRegion.length,
2908
+ cooling,
2909
+ disabled,
2910
+ reason: "none"
2911
+ };
2912
+ }
2792
2913
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
2793
2914
  pickRoundRobin(pool) {
2794
2915
  const index = this.cursor % pool.length;
@@ -4899,8 +5020,14 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
4899
5020
  if (next.length !== current.length) await writeIgnoredAccounts(next, path);
4900
5021
  return next;
4901
5022
  }
4902
- //#endregion
4903
- //#region src/index.ts
5023
+ fileURLToPath(new URL("..", import.meta.url));
5024
+ (() => {
5025
+ try {
5026
+ return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
5027
+ } catch {
5028
+ return "";
5029
+ }
5030
+ })();
4904
5031
  /**
4905
5032
  * One region's model-selection schema.
4906
5033
  *
package/lib/client.js CHANGED
@@ -279,6 +279,13 @@ window.__ModuleLoader__.load({
279
279
  /* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
280
280
  not a claim about the current price. */
281
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)}
282
289
  .dsm-workbuddy-xdpool-model-cap{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
283
290
  /* Model row: checkbox + image toggle + context-budget radios. */
284
291
  .dsm-workbuddy-xdpool-model-off{opacity:.55}
@@ -1504,6 +1511,22 @@ window.__ModuleLoader__.load({
1504
1511
  }) ?? `${accountCount} account(s) · ${cooling} cooling`
1505
1512
  })]
1506
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
+ }),
1507
1530
  (() => {
1508
1531
  const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
1509
1532
  if (active === void 0) return null;
@@ -2244,6 +2267,10 @@ window.__ModuleLoader__.load({
2244
2267
  "row.nightFreeNow": "free until {time}",
2245
2268
  "row.nightFreeLater": "free from {time}",
2246
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",
2247
2274
  "row.imageCapable": "image input",
2248
2275
  "row.rate": "{rate}x credits",
2249
2276
  "row.accountsRescan": "Detect accounts again",
@@ -2392,6 +2419,10 @@ window.__ModuleLoader__.load({
2392
2419
  "row.nightFreeNow": "免费至 {time}",
2393
2420
  "row.nightFreeLater": "{time} 起免费",
2394
2421
  "row.promoUntil": "活动至 {date}",
2422
+ "row.skippedFiles": "有 {count} 个凭据文件读不出来",
2423
+ "row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
2424
+ "row.skippedUnreadable": "文件读不出来",
2425
+ "row.skippedMalformed": "不是凭据文件",
2395
2426
  "row.imageCapable": "图片输入",
2396
2427
  "row.rate": "{rate}x 积分",
2397
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. */
@@ -962,6 +989,25 @@ export declare class WorkBuddyAccountPool {
962
989
  * model, e.g. CLI diagnostics).
963
990
  */
964
991
  private available;
992
+ /**
993
+ * Why no account is available right now, for an accurate error.
994
+ *
995
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
996
+ * signed in (the user must sign in), every account is rate-limited (the user
997
+ * must wait, and retrying later works), or every account was switched off /
998
+ * ignored (the user must re-enable one). Reporting all of them as "no
999
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
1000
+ * observed as an "API key invalid" panel for a model that was merely cooling.
1001
+ *
1002
+ * Counts are over the region's accounts, since a provider only ever sees its
1003
+ * own gateway.
1004
+ */
1005
+ unavailableReason(modelId?: string, region?: WorkBuddyRegion): {
1006
+ total: number;
1007
+ cooling: number;
1008
+ disabled: number;
1009
+ reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'none';
1010
+ };
965
1011
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
966
1012
  private pickRoundRobin;
967
1013
  /**
@@ -1345,6 +1391,21 @@ interface PoolWebStatus {
1345
1391
  catalogUpdatedAt?: string;
1346
1392
  /** Why the last fetch failed, when it did. Redacted and length-capped. */
1347
1393
  catalogError?: string;
1394
+ /**
1395
+ * Credential files on disk that did NOT become accounts.
1396
+ *
1397
+ * Reported so the account count can be trusted: without this, a directory
1398
+ * holding four files that yields two accounts looks like two accounts were
1399
+ * deleted, when really two files could not be opened (most often an encrypted
1400
+ * credential the desktop app was not running to unlock).
1401
+ */
1402
+ skippedFiles?: readonly PoolWebSkippedFile[];
1403
+ }
1404
+ /** A credential file the pool could not read. */
1405
+ interface PoolWebSkippedFile {
1406
+ /** Basename only; the full path lives in the desktop app's auth directory. */
1407
+ file: string;
1408
+ reason: 'encrypted' | 'unreadable' | 'malformed';
1348
1409
  }
1349
1410
  /** How a region's model list was obtained. */
1350
1411
  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) {
@@ -2896,6 +2954,70 @@ var WorkBuddyAccountPool = class {
2896
2954
  return true;
2897
2955
  });
2898
2956
  }
2957
+ /**
2958
+ * Why no account is available right now, for an accurate error.
2959
+ *
2960
+ * The pool can be empty for reasons that need OPPOSITE remedies: nobody is
2961
+ * signed in (the user must sign in), every account is rate-limited (the user
2962
+ * must wait, and retrying later works), or every account was switched off /
2963
+ * ignored (the user must re-enable one). Reporting all of them as "no
2964
+ * credential, sign in" sent users to re-authenticate over a temporary 429 —
2965
+ * observed as an "API key invalid" panel for a model that was merely cooling.
2966
+ *
2967
+ * Counts are over the region's accounts, since a provider only ever sees its
2968
+ * own gateway.
2969
+ */
2970
+ unavailableReason(modelId, region) {
2971
+ const now = Date.now();
2972
+ const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
2973
+ if (inRegion.length === 0) return {
2974
+ total: 0,
2975
+ cooling: 0,
2976
+ disabled: 0,
2977
+ reason: "empty"
2978
+ };
2979
+ let cooling = 0;
2980
+ let disabled = 0;
2981
+ for (const account of inRegion) {
2982
+ if (this.disabledIds.has(account.id)) {
2983
+ disabled += 1;
2984
+ continue;
2985
+ }
2986
+ const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
2987
+ if (account.cooldownUntilMs > now || modelCooling) {
2988
+ cooling += 1;
2989
+ continue;
2990
+ }
2991
+ const reserve = this.creditReserves.get(account.id);
2992
+ if (reserve !== void 0 && reserve > 0) {
2993
+ const balance = this.creditBalances.get(account.id);
2994
+ if (balance !== void 0 && balance <= reserve) return {
2995
+ total: inRegion.length,
2996
+ cooling,
2997
+ disabled,
2998
+ reason: "reserve"
2999
+ };
3000
+ }
3001
+ }
3002
+ if (cooling > 0) return {
3003
+ total: inRegion.length,
3004
+ cooling,
3005
+ disabled,
3006
+ reason: "cooling"
3007
+ };
3008
+ if (disabled > 0) return {
3009
+ total: inRegion.length,
3010
+ cooling,
3011
+ disabled,
3012
+ reason: "disabled"
3013
+ };
3014
+ return {
3015
+ total: inRegion.length,
3016
+ cooling,
3017
+ disabled,
3018
+ reason: "none"
3019
+ };
3020
+ }
2899
3021
  /** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
2900
3022
  pickRoundRobin(pool) {
2901
3023
  const index = this.cursor % pool.length;
@@ -5490,6 +5612,40 @@ function isContextTooLong(body) {
5490
5612
  if (/exceeds?\s+(the\s+)?(model\s+)?context\s+(window|limit)/iu.test(body)) return true;
5491
5613
  return false;
5492
5614
  }
5615
+ /**
5616
+ * A content-free description of a request, for the log when the upstream
5617
+ * rejects it.
5618
+ *
5619
+ * `model_param_invalid` arrives with an EMPTY `param` field, so the error alone
5620
+ * says nothing about which parameter was rejected — the user is left with "the
5621
+ * request parameters do not meet the current model requirements" and no way to
5622
+ * act. Recording the shape (top-level keys, message count by role, tool count,
5623
+ * approximate prompt size) makes such a rejection diagnosable from the log.
5624
+ *
5625
+ * Deliberately SHAPE ONLY: no message content, no tool schemas, no tokens. It
5626
+ * ends up in a log file, and the conversation it describes is the user's.
5627
+ */
5628
+ function requestShape(raw) {
5629
+ try {
5630
+ const parsed = JSON.parse(raw);
5631
+ const keys = Object.keys(parsed).sort();
5632
+ const messages = Array.isArray(parsed["messages"]) ? parsed["messages"] : [];
5633
+ const byRole = /* @__PURE__ */ new Map();
5634
+ let chars = 0;
5635
+ for (const entry of messages) {
5636
+ if (typeof entry !== "object" || entry === null) continue;
5637
+ const message = entry;
5638
+ const role = typeof message["role"] === "string" ? message["role"] : "?";
5639
+ byRole.set(role, (byRole.get(role) ?? 0) + 1);
5640
+ chars += JSON.stringify(message["content"] ?? "").length;
5641
+ }
5642
+ const roles = [...byRole.entries()].map(([role, n]) => `${role}:${n}`).join(" ");
5643
+ const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].length : 0;
5644
+ return `keys=[${keys.join(" ")}] messages=${messages.length}${roles === "" ? "" : ` (${roles})`} tools=${tools} contentChars=${chars}`;
5645
+ } catch {
5646
+ return "body was not parseable JSON";
5647
+ }
5648
+ }
5493
5649
  function readBody(req) {
5494
5650
  return new Promise((resolve, reject) => {
5495
5651
  const chunks = [];
@@ -5628,17 +5784,26 @@ function createWorkBuddyShim(options) {
5628
5784
  }
5629
5785
  const tried = [];
5630
5786
  let last;
5631
- let exhaustedByRateLimit = false;
5632
5787
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
5633
5788
  if (controller.signal.aborted) return;
5634
5789
  const account = await pool.acquire(modelId, region);
5635
5790
  if (account === void 0) {
5636
- if (exhaustedByRateLimit && last !== void 0) {
5791
+ const why = pool.unavailableReason(modelId, region);
5792
+ if (why.reason === "cooling") {
5637
5793
  const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
5638
- writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `${subject} (tried ${tried.length}: ${tried.join(", ")}); resets at the upstream window — ${last.message.slice(0, 200)}`);
5794
+ const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
5795
+ writeOpenAIError(res, 429, "soft_rate", `${subject} (${why.cooling}/${why.total} cooling, tried ${tried.length})${tried.length === 0 ? "" : `: ${tried.join(" → ")}`}${upstream}`);
5639
5796
  return;
5640
5797
  }
5641
- writeOpenAIError(res, 401, "not_signed_in", "no WorkBuddy credential found; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)");
5798
+ if (why.reason === "disabled") {
5799
+ writeOpenAIError(res, 403, "no_enabled_account", `all ${why.total} WorkBuddy account(s) for this gateway are switched off or ignored; re-enable one on the pool card`);
5800
+ return;
5801
+ }
5802
+ if (why.reason === "reserve") {
5803
+ writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
5804
+ return;
5805
+ }
5806
+ writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
5642
5807
  return;
5643
5808
  }
5644
5809
  tried.push(account.label);
@@ -5662,8 +5827,8 @@ function createWorkBuddyShim(options) {
5662
5827
  logger?.warn(`dsh-workbuddy-xdpool: ${account.label} has no credits left (attempt ${attempt + 1}/${maxAttempts}); rotating`);
5663
5828
  continue;
5664
5829
  }
5830
+ if (result.kind === "client") logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rejected the request as invalid (model ${modelId ?? "(none)"}) — ${requestShape(raw)}; upstream: ${result.message.slice(0, 300)}`);
5665
5831
  if (result.kind !== "soft_rate") break;
5666
- exhaustedByRateLimit = true;
5667
5832
  pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
5668
5833
  logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on ${modelId ?? "(no model)"} (attempt ${attempt + 1}/${maxAttempts}); rotating`);
5669
5834
  }
@@ -5786,8 +5951,10 @@ async function recoverFromContextOverrun(options) {
5786
5951
  };
5787
5952
  const overrunTokens = estimateMessagesTokens(messages);
5788
5953
  const realWindow = options.contextWindow;
5789
- const budget = realWindow !== void 0 && realWindow > 0 ? Math.max(512, Math.floor(realWindow * .8) - 2048) : Math.max(512, Math.floor(overrunTokens / 2));
5790
- logger?.warn(`dsh-workbuddy-xdpool: context overrun on ${modelId ?? "(no model)"} (~${overrunTokens} tokens); compacting to ~${budget} and retrying once`);
5954
+ const fromWindow = realWindow !== void 0 && realWindow > 0 ? Math.max(512, Math.floor(realWindow * .8) - 2048) : Number.POSITIVE_INFINITY;
5955
+ const fromOverrun = Math.max(512, Math.floor(overrunTokens / 2));
5956
+ const budget = Math.min(fromWindow, fromOverrun);
5957
+ logger?.warn(`dsh-workbuddy-xdpool: context overrun on ${modelId ?? "(no model)"} (~${overrunTokens} tokens, catalog window ${realWindow ?? "unknown"}); compacting to ~${budget} and retrying once`);
5791
5958
  let summary;
5792
5959
  let compacted = messages;
5793
5960
  let compactionDetail = "";
@@ -6275,7 +6442,11 @@ async function poolWebStatus(deps, region = "cn") {
6275
6442
  ignored: deps.ignoredAccounts?.() ?? [],
6276
6443
  catalogSource: deps.catalogs[region].currentSource(),
6277
6444
  ...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
6278
- ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) }
6445
+ ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
6446
+ ...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
6447
+ file: basename(entry.path),
6448
+ reason: entry.reason
6449
+ })) }
6279
6450
  };
6280
6451
  }
6281
6452
  /**
@@ -6682,6 +6853,95 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6682
6853
  return next;
6683
6854
  }
6684
6855
  //#endregion
6856
+ //#region pi-ai-generation.ts
6857
+ /**
6858
+ * Cross-generation guard for `@earendil-works/pi-ai`.
6859
+ *
6860
+ * The failure this exists to catch: the plugin assembles its provider with ONE
6861
+ * copy of pi-ai while the host's `PiAiAdapter` consumes the event stream with
6862
+ * ANOTHER. The two generations disagree on the shape of the terminal message,
6863
+ * and the seam between them throws `Cannot read properties of undefined
6864
+ * (reading 'length')` inside the host adapter — which the host then classifies
6865
+ * as a non-retryable `PI_AI_ERROR`. From the user's side it is "every turn
6866
+ * fails, immediately, with no content and no useful error".
6867
+ *
6868
+ * A `package.json` range cannot prevent this. The old peer range was
6869
+ * `>=0.82.1 <0.85.0` while the host shipped 0.87.x: the upper bound excluded the
6870
+ * host's generation outright, which turned a cross-generation mix into a
6871
+ * legitimate install. Ranges describe what a package tolerates; they cannot
6872
+ * describe what the OTHER resolved copy on the same machine happens to be.
6873
+ *
6874
+ * So this checks the resolved reality at load time and says so, loudly, in the
6875
+ * log. It does NOT refuse to start: a version mismatch might well be benign, and
6876
+ * a plugin that hard-fails on a guess would be worse than one that warns.
6877
+ *
6878
+ * @module dsh-workbuddy-xdpool/pi-ai-generation
6879
+ */
6880
+ /** The package whose generation must agree between plugin and host. */
6881
+ const PI_AI_PACKAGE = "@earendil-works/pi-ai";
6882
+ /**
6883
+ * The major.minor of a version string, which is what "generation" means here.
6884
+ *
6885
+ * Patch differences are expected and harmless; two copies differing in
6886
+ * minor — 0.85 vs 0.87 — is exactly the split that broke the adapter seam.
6887
+ */
6888
+ function generationOf(version) {
6889
+ const trimmed = version.trim();
6890
+ const parts = /^(\d+)\.(\d+)/u.exec(trimmed);
6891
+ if (parts === null || parts === void 0 || parts[1] === void 0 || parts[2] === void 0) return trimmed;
6892
+ return `${parts[1]}.${parts[2]}`;
6893
+ }
6894
+ /**
6895
+ * Read the version of a resolved pi-ai, starting the lookup from `fromDir`.
6896
+ *
6897
+ * Using `require.resolve` with an explicit anchor is what makes the two sides
6898
+ * distinguishable: resolving plainly would return whichever copy this module
6899
+ * happens to see, the same one for both, and the comparison would always pass.
6900
+ */
6901
+ function piAiGenerationFrom(fromDir) {
6902
+ try {
6903
+ const require_ = createRequire(`${fromDir.replace(/[\\/]+$/u, "")}/`);
6904
+ const pkgPath = require_.resolve(`${PI_AI_PACKAGE}/package.json`);
6905
+ const pkg = require_(pkgPath);
6906
+ if (typeof pkg.version !== "string") return void 0;
6907
+ return {
6908
+ version: pkg.version,
6909
+ resolvedFrom: pkgPath
6910
+ };
6911
+ } catch {
6912
+ return;
6913
+ }
6914
+ }
6915
+ /**
6916
+ * Compare the copy the plugin assembled with against the copy the host adapter
6917
+ * will use.
6918
+ *
6919
+ * `hostDir` should be a directory inside the host install (its own
6920
+ * `node_modules`), so the two resolutions walk different trees.
6921
+ */
6922
+ function checkPiAiGeneration(pluginDir, hostDir) {
6923
+ const plugin = piAiGenerationFrom(pluginDir);
6924
+ const host = piAiGenerationFrom(hostDir);
6925
+ if (plugin === void 0 || host === void 0) return { kind: "unknown" };
6926
+ if (generationOf(plugin.version) === generationOf(host.version)) return {
6927
+ kind: "aligned",
6928
+ generation: generationOf(plugin.version)
6929
+ };
6930
+ return {
6931
+ kind: "mismatched",
6932
+ plugin,
6933
+ host
6934
+ };
6935
+ }
6936
+ /**
6937
+ * A warning message for a mismatched pair, or undefined when there is nothing
6938
+ * to say.
6939
+ */
6940
+ function piAiMismatchMessage(check) {
6941
+ if (check.kind !== "mismatched") return void 0;
6942
+ 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.`;
6943
+ }
6944
+ //#endregion
6685
6945
  //#region src/index.ts
6686
6946
  /**
6687
6947
  * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
@@ -6690,6 +6950,28 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6690
6950
  *
6691
6951
  * @module dsh-workbuddy-xdpool/index
6692
6952
  */
6953
+ /**
6954
+ * This plugin's install root, the tree its own provider is assembled from.
6955
+ *
6956
+ * Two directories up from `lib/` — the built bundle sits in `lib/`, so the
6957
+ * package root is its parent. Used as one side of the pi-ai generation check.
6958
+ */
6959
+ const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url));
6960
+ /**
6961
+ * The host's own module directory, the tree its `PiAiAdapter` resolves from.
6962
+ *
6963
+ * Reached from the host package this plugin is loaded by: walking up from a
6964
+ * `@deepseek-ai/dsh-llm-pi-ai` import lands in the host's `node_modules`, which
6965
+ * is where the HOST generation actually lives. The plugin's own tree is the
6966
+ * other side; comparing one tree with itself would always agree.
6967
+ */
6968
+ const HOST_MODULE_ROOT = (() => {
6969
+ try {
6970
+ return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
6971
+ } catch {
6972
+ return "";
6973
+ }
6974
+ })();
6693
6975
  /** Stable Cordis plugin name. */
6694
6976
  const name = "llm-workbuddy-xdpool";
6695
6977
  /** The model registry required before the provider can register. */
@@ -6878,12 +7160,30 @@ function createCore(logger) {
6878
7160
  };
6879
7161
  }
6880
7162
  /**
7163
+ * Log a warning when the plugin and the host resolve different pi-ai
7164
+ * generations.
7165
+ *
7166
+ * Never throws and never blocks startup: the check is a filesystem lookup, and
7167
+ * a plugin that refused to load because of a version guess would be worse than
7168
+ * one that merely warns. The two directories are THIS plugin's install root and
7169
+ * the host's own module directory, so the two resolutions walk different trees —
7170
+ * resolving from one place only would compare a copy with itself and always
7171
+ * agree.
7172
+ */
7173
+ function warnOnPiAiGenerationMismatch(ctx) {
7174
+ try {
7175
+ const message = piAiMismatchMessage(checkPiAiGeneration(PLUGIN_ROOT, HOST_MODULE_ROOT));
7176
+ if (message !== void 0) ctx.logger.warn(message);
7177
+ } catch {}
7178
+ }
7179
+ /**
6881
7180
  * Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
6882
7181
  * discover accounts. The provider registers only after `shim.ready` resolves,
6883
7182
  * because its models read the shim origin at construction time.
6884
7183
  */
6885
7184
  function apply(ctx, config = {}) {
6886
7185
  const core = createCore(ctx.logger);
7186
+ warnOnPiAiGenerationMismatch(ctx);
6887
7187
  const ignoredPath = ignoredIdsPath();
6888
7188
  let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6889
7189
  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.5",
5
+ "version": "1.7.7",
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": {