dsh-connect-workbuddy 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.1 (2026-10-02)
4
+
5
+ ### Fixes
6
+
7
+ - **「要等几小时才恢复」的配额错误不再被宿主当成「等两秒就好」的瞬时抖动(issue #28)。**
8
+ - **现象**:上游 429 / code 6004 的原文是「您的使用量已超出频率限制,**将在 13:37:03 重置**」——要等几小时。本插件把它正确分类成 `soft_rate`(`src/upstream.ts`),但写回宿主时 `type` 与 `code` 都用了 `soft_rate` 这个内部名字。
9
+ - **根因**:宿主决定「这个失败要不要在原地重试」靠的是从 `type + code + message` 里匹配**几个英文措辞**(`@deepseek-ai/dsh-llm` 的 `isQuotaExceededError`)。`soft_rate` 不在那份名单里,上游原话又是中文,于是宿主只能按 `429` 的默认含义理解——进入 `RATE_LIMIT`,而 `RATE_LIMIT` **在**宿主的重试集合里(`DEFAULT_RETRYABLE_CODES`),`QUOTA` **不在**。宿主于是原地重试 5 次、指数退避,把预算烧光;而真正能救场的跨供应商故障转移(`dsh-subagent-default-model` 的 `failoverEnabled`)根本没被叫到——实测一次会话 1228 个 step、只切换过 1 次供应商、以 429 收场,`models` 里其它供应商全程闲置。
10
+ - **修法**:只翻译写回宿主的 `code`——`soft_rate` → `quota_exceeded`(宿主认得这个词,且它符合上游实际在说的事)。**HTTP 状态码仍是 429**(那才是上游返回的东西,改成 402 是谎报),上游原话仍原样留在 `message` 里给人看。`type` 保持插件自己的内部名字,便于读日志与报错。
11
+ - **只翻译这一处**:其余 kind 本来就落在重试集合之外(`hard_credit` 402 → 按状态就是 QUOTA,`session_dead` 401 → AUTH,502/400 → OTHER),所以**不做多余翻译**——没有人需要的翻译只是等着漂移的谎言。插件**自己的**错误(`unauthorized` / `not_found` / `host_not_allowed` / `internal`)也一律不翻译:把自身的 401/404 说成「配额用尽」,会让宿主去为一个换供应商也治不好的错误做故障转移。
12
+ - 测试直接调用**宿主自己的** `isQuotaExceededError`(`@deepseek-ai/dsh-llm` 导出)来钉住这件事,而不是在测试里抄一份正则——抄一份只能证明两份副本今天一致。三次变异验证:撤回翻译 / 把翻译扩大到所有 kind / 让本地错误也翻译,均会变红。
13
+
14
+ ## 3.4.0 (2026-10-02)
15
+
16
+ ### Fixes
17
+
18
+ - **账号池不再被一次瞬时故障永久打死:`unavailable` / `failed` 现在有 30 分钟冷却。**
19
+ - **原来的形状**:`exclusionOf` 只给 `rate-limited` / `out-of-credit` 判冷却到期,`unavailable` / `failed` 一律无条件出池且**不带恢复时间**。而自动复测定时器已在 3.2.0 删除,能刷新它的只剩手动「测试」——**一次 502 或一次网络抖动就能把一个健康账号永久踢出池子**。
20
+ - **症状**:池内每个成员都被这样剔除后,池子没有任何候选,`failoverAccountFor` 第一轮即返回 `undefined`,自动换号无处可去,上游原始 429/502 直接报给用户——界面上「账号池已启用」与「请求失败」同时成立,且报错没有 `(after trying N accounts)`(因为只试了 1 个账号)。
21
+ - **现在的形状**:`unavailable` / `failed` 与限流同形,走 `retryDue()`;上游没给时间就按 `POOL_UNKNOWN_COOLDOWN_MS`(30 分钟)兜底。冷却在**排序时求值**,所以不需要定时器——到点的成员在下一次请求自动变回候选。`credential-rejected` 与 `not-found` 保持永不冷却:一个要重新登录,一个是本区域不再提供的模型,等待都修不好。
22
+ - 反转了 `tests/account-pool.spec.ts` 里一条**明确钉住旧行为**的测试(其原注释即写明「这是留给未来主动决定的反转」),并在测试内记录反转理由。
23
+
24
+ - **自己中止的请求不再被记成账号故障。** 客户端断开时 `res.on('close')` 会 abort 上游调用,`fetch` 抛出的 `AbortError` 经 `chatStream` 统一成 `status: 0, kind: 'server'`,读起来与「网络真的断了」完全一样——于是**关一次面板就可能把正在服务的账号记成 `unavailable`**。换号循环本来就有 `controller.signal.aborted` 守卫,但那只管「要不要继续换号」,不管「要不要把这次失败记成测量」;`reportFailures()` 现在同样短路。
25
+
26
+ - **换号路径不再抓积分。** `failoverAccountFor` 用的是 `poolMembersOf`(逐成员 `fetchCredits`),而它自己上方的注释写着「不得再花请求去决定谁服务」——一次失败的对话因此先变成 N 个额外上游请求才发出重试。改用 `localPoolMembers`,与 `applyPoolSelection` 对齐;有新鲜快照时积分仍然参与排序。
27
+
28
+ - **探测请求补上超时(10 秒)。** `probeChat` 在没有 signal 时完全没有上限,而池的批量测试是**串行**且不传 signal——一个挂住的连接会阻塞整批,且界面上看不出是哪一个成员。现在给「等待响应」设 10 秒上限(远高于正常网关的首字节,远低于用户的耐心),并与调用方传入的 signal 用 `AbortSignal.any` 合并,单模型路由的取消行为不变。
29
+ - **上限只覆盖等待响应**:`fetch` 一 resolve 就 `clearTimeout`,绝不在读取 body 期间继续计时——否则一个健康但偏慢的模型会和死端点共用同一块表,这正是此处不能用裸 `AbortSignal.timeout` 的原因。
30
+
31
+ - **两句会误导人的文案改掉了。** `row.probeUnavailable` 去掉了「这是网络问题,**不是模型问题**」——`unavailable` 这个分类里同时装着网关 5xx、DNS 失败与请求超时,对后两者这个断言并不成立(5xx 恰恰是上游侧的问题)。新文案只陈述可核实的事实:「连不上上游——上游没有给出可用答复」,具体原因由旁边记录的 `message` 给出。
32
+ - `row.poolNoCandidateHint` 不再叫人「重新检测账号,或在桌面端重新登录」。池内全不可用时那个建议是错的:限流、积分耗尽与瞬时故障都会**自动回池**(上游给的时间,没给就 30 分钟兜底),只有「凭据被拒」才需要重新登录。原话会让用户去做一件没有任何用处的事。
33
+ - 顺带修掉 3 处本地化文案里的字面量 Markdown(`**真实体积**`、`**不会**`、`**手动动作**`)。React 不解析 Markdown,它们此前是原样把星号画在界面上的——与本轮改掉的两处属于同一类缺陷。
34
+ - **池表与模型行不再把上游报文原样贴一遍。** 限流账号的记录文本就是 `{"code":6004,"msg":"…将在 2026-10-02 06:53:43 重置…","requestId":"…"}`——与旁边刚显示的绝对时刻完全重复;加上新的相对说法后会变成同一时间印三次,后面还跟一个没人看的 requestId。规则现在只在 `src/client/probe-reason.ts` 里写一次、两处共用:
35
+ - **标签本身已说清的结果**(`rate-limited` / `out-of-credit`,它们都带恢复时刻)**不内联任何正文**;
36
+ - 其余结果内联**短**形式:JSON 信封里取 `msg` 那句人话,`transport error: ` 这个英文前缀也去掉(中文卡片上它只是噪声),整段封顶 72 字符——一行的病态正文不该把整张表挤出屏幕;
37
+ - 完整原文挂在 `title` 上,**缩短不等于丢掉**(含 requestId),悬停即可看到。
38
+ - 例外是有意的:`credential-rejected` / `not-found` 不在「已说清」之列——它们的标签只给结论(「重新登录」「没有这个模型」),上游原话才是让结论可核实的东西。
39
+
40
+ ### Features
41
+
42
+ - **池表能说清「为什么出池」了。** `WorkBuddyPoolProbe` 新增 `message`:落盘前经 `redactUpstreamText` 脱敏(与探测路径共用同一实现),卡片在结果后追加 `· <上游原话>`。此前 DNS 失败、网关 502、被中止的请求在池表里渲染成**逐字相同**的一句话,用户拿到的是无法行动的提示。字段可选,旧记录照常读取而不被当成损坏。
43
+
44
+ - **冷却时间现在同时给出「还要等多久」。** 卡片一直只印上游给出的绝对时刻(`05:23:27`)——它回答「何时」,但要读者自己拿当前时间去减,这一步正是让冷却难以行动的地方。现在它旁边多一个相对说法:`05:23:27 之后可再用 · 约 1 小时后`。
45
+ - 粒度按距离切换:不足 1 分钟 → 「即将恢复」;不足 1 小时 → 「约 N 分钟后」;不足 2 天 → 「约 N 小时后」;再远 → 「约 N 天后」。**小时档从整点开始**(不是 90 分钟)——因为用户自己描述那个 69 分钟后的时刻时写的就是「约 1 小时后」,阈值放高反而会把触发这个改动的那个例子渲染成「约 69 分钟」。天档切在 **2 天**而不是 1 天,否则 36 小时会印成「约 1 天后」,少说 12 小时。
46
+ - **只对上游给过的时间生效**:`cooldownOf` 返回 `{}`(上游没说时间)时仍然只说「上游未给出何时恢复」,绝不编造倒计时——这是本功能一贯的红线,本次没有新增任何时间来源,只是把已有的那个换个说法。已经到点的时刻返回空串而不是「0 分钟后」。
47
+ - 模型行与池表两处都接上了;绝对时刻仍是主文案,相对说法是附加的便利。
48
+
49
+ ### Docs
50
+
51
+ - README 的「账号为什么出池」表按新规则改写:区分「限流/欠费/瞬时故障会自动回池」与「凭据被拒/模型不存在只能手动重测」,并删掉已不存在的「定时器」表述。
52
+ - 清理死代码:`POOL_TICK_MS`、`POOL_MIN_INTERVAL_MINUTES`、`poolDueAt`、`duePoolRegions`、`PoolScheduleInput` / `PoolScheduleResult` 及其单元测试全部删除。它们自 3.2.0 起已无任何生产调用(构建时已被 tree-shake),却仍被测试覆盖——于是「测试全绿」对「账号会不会回来」这个问题什么也没说明。
53
+
3
54
  ## 3.3.0 (2026-10-01)
4
55
 
5
56
  ### Features
package/README.md CHANGED
@@ -135,17 +135,20 @@ DSH PiAiAdapter(每个 provider 一套)
135
135
  | 探测结果 | 对轮换的意义 |
136
136
  | --- | --- |
137
137
  | 可用 | 可入选 |
138
- | 被限流 | **暂时**出池,按上游给出的重置时间自动回池(上游没说时间就如实说没说,**不编造倒计时**) |
138
+ | 被限流 | **暂时**出池,按上游给出的重置时间自动回池(上游没说时间就按 30 分钟兜底,**不编造倒计时**) |
139
139
  | 积分耗尽 | 出池,按月周期包的刷新时间回池 |
140
- | 凭据被拒 / 模型不存在 / 连不上上游 / 失败 | 出池,直到下一次探测刷新结果 |
140
+ | 连不上上游 / 失败 | **暂时**出池,30 分钟后自动回池——这类结果说的往往不是账号,而是一次网络抖动或上游 5xx |
141
+ | 凭据被拒 / 模型不存在 | 出池,**只能靠手动「测试」回池**:一个需要重新登录,一个是本区域不再提供该模型,等待都修不好 |
141
142
 
142
- 关键一条:**「被限流」不等于不可用**。它恰恰是最值得等的那一类状态——把它当成永久失效剔除,正好丢掉池子里本来最能用的账号。
143
+ 关键一条:**「被限流」「连不上上游」都不等于不可用**。它们恰恰是最值得等的那两类状态——当成永久失效剔除,正好丢掉池子里本来最能用的账号,而且**当池内每个成员都被这样剔除时,池子就没有任何候选,自动换号会无处可去**,你只会看到上游原始报错。
144
+
145
+ **为什么只有限流/欠费/瞬时故障会自己回池**:这三类要么由上游写明了恢复时间,要么由插件按 30 分钟兜底;而「凭据被拒」「模型不存在」等待都无意义,只能由你手动重测或重新登录来刷新。插件**不再有自动复测定时器**(见下),所以后两类的唯一出路就是手动动作。
143
146
 
144
147
  ### 保存与草稿:为什么卡片上有两个「保存」
145
148
 
146
149
  模型管理一个、账号池一个,这是**刻意保留**而非漏合并:两者的草稿域不同,合并后一次写入失败会连累另一边(模型目录写不进去,就连池偏好也存不了);一个共享的「放弃」还会在一次误点里同时丢掉两份草稿。两边都是**验证写入**——**写不成功不丢弃草稿**,否则用户的编辑就永久丢了。
147
150
 
148
- 池里还有一类状态**不走草稿**:探测结果、冷却时间、当前轮换到的账号。它们是**观测事实**,由插件自己高频写入。原因很实际——草稿是覆盖式的,若观测结果也进草稿,你手工点一次「保存」就可能把定时器刚写进去的结果**回退成几分钟前的旧值**。
151
+ 池里还有一类状态**不走草稿**:探测结果、冷却时间、当前轮换到的账号。它们是**观测事实**,由插件自己高频写入。原因很实际——草稿是覆盖式的,若观测结果也进草稿,你手工点一次「保存」就可能把插件刚写进去的结果**回退成几分钟前的旧值**。
149
152
 
150
153
  ## 安装
151
154
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-t93cLCoW.js";
2
+ import { E as legacyWorkbuddyOwnAuthPath, H as WORKBUDDY_APP_EXECUTABLE_ENV, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-DSO-07lc.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -525,6 +525,133 @@ window.__ModuleLoader__.load({
525
525
  });
526
526
  }
527
527
  //#endregion
528
+ //#region src/client/ndjson.ts
529
+ /**
530
+ * Read a newline-delimited JSON body, one object at a time.
531
+ *
532
+ * NDJSON rather than SSE or a hand-rolled frame format because the pool's batch
533
+ * is a list of INDEPENDENT results: one line per result needs no state machine,
534
+ * survives a partially-delivered batch, and lets the reader act on account N
535
+ * while account N+1 is still being tested.
536
+ *
537
+ * A malformed line is SKIPPED, not fatal. The stream carries live progress, so
538
+ * discarding the rest of a batch because one frame did not parse would throw away
539
+ * rows the user already watched arrive — and a truncated final line is a normal
540
+ * way for a stream to end when something upstream misbehaves.
541
+ *
542
+ * @module dsh-connect-workbuddy/client/ndjson
543
+ */
544
+ /** Hand each parsed object to `onLine`, in order, as it arrives. */
545
+ async function readNdjson(body, onLine) {
546
+ const reader = body?.getReader();
547
+ if (reader === void 0) return;
548
+ const decoder = new TextDecoder();
549
+ const emit = (line) => {
550
+ const trimmed = line.trim();
551
+ if (trimmed === "") return;
552
+ try {
553
+ const parsed = JSON.parse(trimmed);
554
+ if (typeof parsed === "object" && parsed !== null) onLine(parsed);
555
+ } catch {}
556
+ };
557
+ let buffer = "";
558
+ try {
559
+ for (;;) {
560
+ const { done, value } = await reader.read();
561
+ if (done) break;
562
+ buffer += decoder.decode(value, { stream: true });
563
+ const lines = buffer.split("\n");
564
+ buffer = lines.pop() ?? "";
565
+ for (const line of lines) emit(line);
566
+ }
567
+ buffer += decoder.decode();
568
+ emit(buffer);
569
+ } finally {
570
+ reader.releaseLock();
571
+ }
572
+ }
573
+ //#endregion
574
+ //#region src/client/probe-reason.ts
575
+ /**
576
+ * Outcomes whose label + stated recovery time ARE the whole story.
577
+ *
578
+ * Both carry a reset instant, which the row prints right beside them; their
579
+ * bodies restate that instant and add a `code`/`requestId` pair nobody reads.
580
+ *
581
+ * `credential-rejected` and `not-found` are deliberately NOT here: their labels
582
+ * state a conclusion ("sign in again", "no such model"), and the upstream's own
583
+ * words are what make that conclusion checkable rather than asserted.
584
+ */
585
+ const SELF_EXPLANATORY = /* @__PURE__ */ new Set(["rate-limited", "out-of-credit"]);
586
+ /**
587
+ * Longest inline reason kept.
588
+ *
589
+ * A cap rather than a wrapper: one pathological body must not push the rest of
590
+ * the table off screen. 72 characters holds a full transport error and a full
591
+ * upstream sentence, while stopping well short of a raw JSON document.
592
+ */
593
+ const REASON_LIMIT = 72;
594
+ /** The inline reason for one measurement, or `undefined` when it adds nothing. */
595
+ function inlineProbeReason(outcome, message) {
596
+ if (message === void 0 || message === "") return void 0;
597
+ if (SELF_EXPLANATORY.has(outcome)) return void 0;
598
+ const text = (humanSentenceOf(message) ?? stripTransportPrefix(message)).trim();
599
+ return text === "" ? void 0 : truncate(text);
600
+ }
601
+ function truncate(text) {
602
+ return text.length <= REASON_LIMIT ? text : `${text.slice(0, 71)}…`;
603
+ }
604
+ /** `{"code":6004,"msg":"…","requestId":"…"}` → the `msg` sentence, if it has one. */
605
+ function humanSentenceOf(message) {
606
+ if (!message.startsWith("{")) return void 0;
607
+ let parsed;
608
+ try {
609
+ parsed = JSON.parse(message);
610
+ } catch {
611
+ return;
612
+ }
613
+ if (typeof parsed !== "object" || parsed === null) return void 0;
614
+ const msg = parsed["msg"];
615
+ return typeof msg === "string" && msg !== "" ? msg : void 0;
616
+ }
617
+ /** `transport error: TimeoutError: …` → `TimeoutError: …`. */
618
+ function stripTransportPrefix(message) {
619
+ return message.replace(/^transport error:\s*/u, "");
620
+ }
621
+ //#endregion
622
+ //#region src/client/remaining.ts
623
+ const MINUTE_MS = 6e4;
624
+ const HOUR_MS = 60 * MINUTE_MS;
625
+ const DAY_MS = 24 * HOUR_MS;
626
+ /**
627
+ * How far out hours stop being the useful unit.
628
+ *
629
+ * Below this, hours; at or above it, days. The cut is at TWO days rather than
630
+ * one so that 30 hours reads as "约 30 小时后" instead of rounding to "约 1 天后",
631
+ * which would understate it by six hours.
632
+ */
633
+ const DAYS_AT_MS = 2 * DAY_MS;
634
+ /**
635
+ * A short "约 N 分钟后 / 约 N 小时后 / 约 N 天后", or `''` once the moment passed.
636
+ *
637
+ * The empty string is the contract callers depend on: an elapsed cooldown means
638
+ * the account is already back, so appending "in 0 minutes" would assert the
639
+ * opposite of the state the card is showing. Callers append this only when it is
640
+ * non-empty.
641
+ *
642
+ * Rounding is deliberately coarse — "约 1 小时后" for 69 minutes is what a reader
643
+ * wants, and a precision that changes every minute would be noise rather than
644
+ * information. The absolute instant sits right beside it for anyone who needs it.
645
+ */
646
+ function remainingText(t, untilMs, nowMs) {
647
+ const left = untilMs - nowMs;
648
+ if (left <= 0) return "";
649
+ if (left < MINUTE_MS) return t("row.remainingImminent");
650
+ if (left < HOUR_MS) return t("row.remainingMinutes", { count: Math.round(left / MINUTE_MS) });
651
+ if (left < DAYS_AT_MS) return t("row.remainingHours", { count: Math.round(left / HOUR_MS) });
652
+ return t("row.remainingDays", { count: Math.round(left / DAY_MS) });
653
+ }
654
+ //#endregion
528
655
  //#region src/account-pool.ts
529
656
  /**
530
657
  * The checked ids that still resolve to a local sign-in, in the caller's order.
@@ -1022,44 +1149,83 @@ window.__ModuleLoader__.load({
1022
1149
  count: memberCount,
1023
1150
  model: pool?.targetModelId ?? ""
1024
1151
  }), "info");
1152
+ /**
1153
+ * One test row into the activity log.
1154
+ *
1155
+ * Shared by the STREAMED shape (a line per account) and the whole-batch
1156
+ * shape (an older Host's single document), so the two can never report the
1157
+ * same measurement differently — the failure mode this file's history is
1158
+ * mostly made of.
1159
+ */
1160
+ const logTestRow = (row) => {
1161
+ const outcome = row?.result?.outcome;
1162
+ if (outcome === void 0) {
1163
+ appendLog(t("row.poolLogTestRowMalformed", { accountName: row?.accountName === "" || row?.accountName === void 0 ? t("row.accountUnnamed") : row.accountName }), "warn");
1164
+ return;
1165
+ }
1166
+ appendLog(t("row.poolLogTestRow", {
1167
+ accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1168
+ outcome: outcomeText(t, outcome)
1169
+ }), outcomeOk(outcome) ? "ok" : "warn");
1170
+ };
1025
1171
  try {
1026
1172
  const response = await fetch(withWorkBuddyRegionAndAction(WORKBUDDY_POOL_PATH, region, action), {
1027
1173
  method: "POST",
1028
1174
  headers: { accept: "application/json" },
1029
1175
  credentials: "same-origin"
1030
1176
  });
1031
- const body = await response.json().catch(() => void 0);
1032
- if (!response.ok) throw new Error(poolFailureText(t, {
1033
- status: response.status,
1034
- reason: body?.reason,
1035
- error: body?.error
1036
- }));
1037
- if (!mounted.current) return;
1038
- if (action === "checkin") {
1039
- for (const row of body?.rows ?? []) if (row.status === "claimed") appendLog(t("row.poolLogCheckinClaimed", {
1040
- accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1041
- credit: String(row.credit ?? 0)
1042
- }), "ok");
1043
- else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
1044
- else appendLog(t("row.poolLogCheckinFailed", {
1045
- accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1046
- message: row.message ?? t("row.requestFailed")
1047
- }), "error");
1048
- appendLog(t("row.poolLogCheckinDone"), "ok");
1049
- } else {
1050
- for (const row of body?.rows ?? []) {
1051
- const outcome = row?.result?.outcome;
1052
- if (outcome === void 0) {
1053
- appendLog(t("row.poolLogTestRowMalformed", { accountName: row?.accountName === "" || row?.accountName === void 0 ? t("row.accountUnnamed") : row.accountName }), "warn");
1054
- continue;
1177
+ let failed = false;
1178
+ if ((response.headers.get("content-type") ?? "").includes("ndjson")) {
1179
+ if (!response.ok) throw new Error(poolFailureText(t, { status: response.status }));
1180
+ let sawRow = false;
1181
+ await readNdjson(response.body, (line) => {
1182
+ const row = line["row"];
1183
+ if (row !== void 0) {
1184
+ sawRow = true;
1185
+ if (mounted.current) logTestRow(row);
1186
+ return;
1055
1187
  }
1056
- appendLog(t("row.poolLogTestRow", {
1188
+ const reason = line["reason"];
1189
+ if (typeof reason === "string") {
1190
+ failed = true;
1191
+ const message = poolFailureText(t, {
1192
+ status: 200,
1193
+ reason,
1194
+ error: typeof line["error"] === "string" ? line["error"] : void 0
1195
+ });
1196
+ if (mounted.current) {
1197
+ setActionError(message);
1198
+ appendLog(t("row.poolLogBatchFailed", { message }), "error");
1199
+ }
1200
+ }
1201
+ });
1202
+ if (!mounted.current) return;
1203
+ if (!failed && sawRow) appendLog(t("row.poolLogTestDone"), "ok");
1204
+ } else {
1205
+ const body = await response.json().catch(() => void 0);
1206
+ if (!response.ok) throw new Error(poolFailureText(t, {
1207
+ status: response.status,
1208
+ reason: body?.reason,
1209
+ error: body?.error
1210
+ }));
1211
+ if (!mounted.current) return;
1212
+ if (action === "checkin") {
1213
+ for (const row of body?.rows ?? []) if (row.status === "claimed") appendLog(t("row.poolLogCheckinClaimed", {
1214
+ accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1215
+ credit: String(row.credit ?? 0)
1216
+ }), "ok");
1217
+ else if (row.status === "already") appendLog(t("row.poolLogCheckinAlready", { accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName }), "info");
1218
+ else appendLog(t("row.poolLogCheckinFailed", {
1057
1219
  accountName: row.accountName === "" ? t("row.accountUnnamed") : row.accountName,
1058
- outcome: outcomeText(t, outcome)
1059
- }), outcomeOk(outcome) ? "ok" : "warn");
1220
+ message: row.message ?? t("row.requestFailed")
1221
+ }), "error");
1222
+ appendLog(t("row.poolLogCheckinDone"), "ok");
1223
+ } else {
1224
+ for (const row of body?.rows ?? []) logTestRow(row);
1225
+ appendLog(t("row.poolLogTestDone"), "ok");
1060
1226
  }
1061
- appendLog(t("row.poolLogTestDone"), "ok");
1062
1227
  }
1228
+ if (failed) return;
1063
1229
  onRefresh?.();
1064
1230
  } catch (error) {
1065
1231
  const message = error instanceof Error ? error.message : t("row.requestFailed");
@@ -1210,7 +1376,11 @@ window.__ModuleLoader__.load({
1210
1376
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1211
1377
  const excluded = exclusionText(t, account);
1212
1378
  const probe = account.probe;
1213
- const probeLine = probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome);
1379
+ const probeLine = [
1380
+ probe === void 0 ? t("row.poolNeverTested") : probe.retryAtMs !== void 0 ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryAt", { at: formatShort(probe.retryAtMs) })}` : probe.outcome === "rate-limited" || probe.outcome === "out-of-credit" ? `${outcomeText(t, probe.outcome)} · ${t("row.poolRetryUnknown")}` : outcomeText(t, probe.outcome),
1381
+ probe?.retryAtMs === void 0 ? "" : remainingText(t, probe.retryAtMs, Date.now()),
1382
+ probe === void 0 ? void 0 : inlineProbeReason(probe.outcome, probe.message)
1383
+ ].filter((part) => part !== void 0 && part !== "").join(" · ");
1214
1384
  return (0, react.createElement)("div", {
1215
1385
  className: `dsm-workbuddy-pool-row${account.current ? " dsm-workbuddy-pool-row-current" : ""}`,
1216
1386
  key: account.accountId
@@ -1223,7 +1393,10 @@ window.__ModuleLoader__.load({
1223
1393
  })), (0, react.createElement)("span", {
1224
1394
  className: "dsm-workbuddy-usage-dot",
1225
1395
  style: { background: stateColor(account) }
1226
- }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe" }, probeLine), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
1396
+ }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
1397
+ className: "dsm-workbuddy-pool-probe",
1398
+ ...probe?.message === void 0 ? {} : { title: probe.message }
1399
+ }, probeLine), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
1227
1400
  className: "dsm-workbuddy-pool-unknown",
1228
1401
  title: t("row.poolNeverTested")
1229
1402
  }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null);
@@ -1866,7 +2039,20 @@ window.__ModuleLoader__.load({
1866
2039
  * would look like an upstream answer while being a guess, and the user would
1867
2040
  * wait for a moment that means nothing.
1868
2041
  */
1869
- function probeResultView(result, t) {
2042
+ /**
2043
+ * ` · 约 1 小时后` for a future instant, or `''` when there is none.
2044
+ *
2045
+ * Kept beside {@link probeResultView} rather than inlined three times so the
2046
+ * "append only when non-empty" rule cannot drift between the two limited
2047
+ * outcomes. The absolute instant stays the primary text: it is the upstream's
2048
+ * own answer, and the relative form is a convenience derived from it.
2049
+ */
2050
+ function retrySuffix(t, untilMs, nowMs) {
2051
+ if (untilMs === void 0) return "";
2052
+ const remaining = remainingText(t, untilMs, nowMs);
2053
+ return remaining === "" ? "" : ` · ${remaining}`;
2054
+ }
2055
+ function probeResultView(result, t, nowMs = Date.now()) {
1870
2056
  switch (result.outcome) {
1871
2057
  case "ok": {
1872
2058
  const elapsed = result.elapsedMs === void 0 ? void 0 : `${String(result.elapsedMs / 1e3)}s`;
@@ -1876,11 +2062,11 @@ window.__ModuleLoader__.load({
1876
2062
  };
1877
2063
  }
1878
2064
  case "rate-limited": return {
1879
- text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }),
2065
+ text: result.retryAtMs === void 0 ? t("row.probeRateLimitedUnknown") : t("row.probeRateLimitedAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
1880
2066
  tone: "warn"
1881
2067
  };
1882
2068
  case "out-of-credit": return {
1883
- text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }),
2069
+ text: result.retryAtMs === void 0 ? t("row.probeOutOfCreditUnknown") : t("row.probeOutOfCreditAt", { at: formatDate(result.retryAtMs) }) + retrySuffix(t, result.retryAtMs, nowMs),
1884
2070
  tone: "bad"
1885
2071
  };
1886
2072
  case "credential-rejected": return {
@@ -2655,10 +2841,12 @@ window.__ModuleLoader__.load({
2655
2841
  const result = probes[activeRegion]?.[model.id];
2656
2842
  if (result === void 0) return null;
2657
2843
  const view = probeResultView(result, t);
2844
+ const reason = inlineProbeReason(result.outcome, result.message);
2658
2845
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2659
2846
  className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
2660
2847
  role: "status",
2661
- children: [view.text, result.message === void 0 ? null : " · " + result.message]
2848
+ ...result.message === void 0 ? {} : { title: result.message },
2849
+ children: [view.text, reason === void 0 ? null : " · " + reason]
2662
2850
  });
2663
2851
  })()
2664
2852
  ]
@@ -2837,14 +3025,14 @@ window.__ModuleLoader__.load({
2837
3025
  "row.probeOutOfCreditAt": "✗ Not enough credits · quota refreshes {at}",
2838
3026
  "row.probeCredentialRejected": "✗ WorkBuddy refused this account's token — sign in again or pick another account",
2839
3027
  "row.probeNotFound": "✗ The upstream does not know this model",
2840
- "row.probeUnavailable": "✗ The upstream could not be reached — this is a network problem, not the model",
3028
+ "row.probeUnavailable": "✗ The upstream could not be reached — no usable answer came back",
2841
3029
  "row.probeFailed": "✗ Test failed",
2842
3030
  "row.probeFailedStatus": "✗ Test failed (HTTP {status})",
2843
3031
  "row.probeError": "Could not run the test: {message}",
2844
3032
  "row.cheer": "Star on GitHub",
2845
3033
  "row.poolTitle": "Account pool",
2846
3034
  "row.poolNoCandidate": "No account in this pool can be used right now.",
2847
- "row.poolNoCandidateHint": "Every account is rate-limited, out of credits, rejected, unreachable, or in a state this build cannot use. Re-check the accounts, or sign in again in the desktop app.",
3035
+ "row.poolNoCandidateHint": "Members are rate-limited, out of credits, rejected, or unreachable. Rate limits, drained quotas and transient failures return to the pool on their own — at the time the upstream states, or after a 30-minute fallback. Only a rejected credential needs a new sign-in.",
2848
3036
  "row.poolCheckinAll": "Check in all accounts",
2849
3037
  "row.poolCheckingIn": "Checking in…",
2850
3038
  "row.poolTestAll": "Test all accounts",
@@ -2880,6 +3068,10 @@ window.__ModuleLoader__.load({
2880
3068
  "row.poolExcludedUnusable": "Unusable",
2881
3069
  "row.poolRetryAt": "Back after {at}",
2882
3070
  "row.poolRetryUnknown": "The upstream gave no time for when this frees up",
3071
+ "row.remainingImminent": "any moment now",
3072
+ "row.remainingMinutes": "in about {count} min",
3073
+ "row.remainingHours": "in about {count} h",
3074
+ "row.remainingDays": "in about {count} d",
2883
3075
  "row.poolSettingsTitle": "Account pool settings",
2884
3076
  "row.poolSettingsHint": "These are preferences: they take effect when you save (same as the model list). Test results are written automatically and are not affected by this button.",
2885
3077
  "row.poolEnabled": "Enable the account pool",
@@ -3000,7 +3192,7 @@ window.__ModuleLoader__.load({
3000
3192
  "row.modelCapabilityPending": "仅展示 WorkBuddy 接口明确公布的模型能力。",
3001
3193
  "row.probe": "测试",
3002
3194
  "row.probing": "测试中…",
3003
- "row.probeHint": "对该模型发一条**真实体积**的请求(约 25k 输入 token),报告它现在能不能用。上游的限流按**请求体积**触发——实测约 20k 能过、约 30k 被拒——所以小请求看着正常、长对话里却可能每次都被拒。这也是为什么每次测试都会消耗真实积分(约 0.7),且一次只测一个模型。",
3195
+ "row.probeHint": "对该模型发一条真实体积的请求(约 25k 输入 token),报告它现在能不能用。上游的限流按请求体积触发——实测约 20k 能过、约 30k 被拒——所以小请求看着正常、长对话里却可能每次都被拒。这也是为什么每次测试都会消耗真实积分(约 0.7),且一次只测一个模型。",
3004
3196
  "row.probeOk": "✓ 可用",
3005
3197
  "row.probeOkMs": "✓ 可用({ms})",
3006
3198
  "row.probeRateLimitedUnknown": "✗ 被限流 · 上游未给出何时恢复",
@@ -3009,14 +3201,14 @@ window.__ModuleLoader__.load({
3009
3201
  "row.probeOutOfCreditAt": "✗ 积分不足 · 积分包 {at} 刷新",
3010
3202
  "row.probeCredentialRejected": "✗ WorkBuddy 拒绝了这个账号的 token——请重新登录或换一个账号",
3011
3203
  "row.probeNotFound": "✗ 上游不认识这个模型",
3012
- "row.probeUnavailable": "✗ 连不上上游——这是网络问题,不是模型问题",
3204
+ "row.probeUnavailable": "✗ 连不上上游——上游没有给出可用答复",
3013
3205
  "row.probeFailed": "✗ 测试失败",
3014
3206
  "row.probeFailedStatus": "✗ 测试失败(HTTP {status})",
3015
3207
  "row.probeError": "无法执行测试:{message}",
3016
3208
  "row.cheer": "鼓励一下",
3017
3209
  "row.poolTitle": "账号池",
3018
3210
  "row.poolNoCandidate": "账号池里目前没有可用的账号。",
3019
- "row.poolNoCandidateHint": "每个账号要么被限流、要么积分耗尽、要么被上游拒绝、要么连不上上游,或处于本版本无法使用的状态。请重新检测账号,或在桌面端重新登录。",
3211
+ "row.poolNoCandidateHint": "成员要么在限流冷却中、要么积分耗尽、要么凭据被拒、要么上游无响应。限流、积分耗尽与瞬时故障都会自动回池——按上游给出的恢复时间,没给时间就按 30 分钟兜底,不需要你操作;只有「凭据被拒」需要重新登录。",
3020
3212
  "row.poolCheckinAll": "一键签到所有账号",
3021
3213
  "row.poolCheckingIn": "签到中…",
3022
3214
  "row.poolTestAll": "一键测试所有账号",
@@ -3034,7 +3226,7 @@ window.__ModuleLoader__.load({
3034
3226
  "row.poolTargetStaleHint": "本区域的模型目录已变化,不再包含这个模型,因此测试被停用——而不是拿一个上游可能不接受的模型去跑。请改选一个目标模型,或切回自动。",
3035
3227
  "row.poolTargetStaleClear": "切回自动",
3036
3228
  "row.poolTargetStaleOption": "{model}(已下架)",
3037
- "row.poolTargetNoneHint": "国内版的倍率要等模型目录刷新后才会出现。为了不让你在不知情的情况下花掉积分,插件**不会**自动改用收费模型——请先刷新模型目录,或自己指定一个目标模型。",
3229
+ "row.poolTargetNoneHint": "国内版的倍率要等模型目录刷新后才会出现。为了不让你在不知情的情况下花掉积分,插件不会自动改用收费模型——请先刷新模型目录,或自己指定一个目标模型。",
3038
3230
  "row.poolColumnAccount": "账号",
3039
3231
  "row.poolColumnCredits": "积分",
3040
3232
  "row.poolColumnProbe": "目标模型",
@@ -3052,10 +3244,14 @@ window.__ModuleLoader__.load({
3052
3244
  "row.poolExcludedUnusable": "不可用",
3053
3245
  "row.poolRetryAt": "{at} 之后可再用",
3054
3246
  "row.poolRetryUnknown": "上游未给出何时恢复",
3247
+ "row.remainingImminent": "即将恢复",
3248
+ "row.remainingMinutes": "约 {count} 分钟后",
3249
+ "row.remainingHours": "约 {count} 小时后",
3250
+ "row.remainingDays": "约 {count} 天后",
3055
3251
  "row.poolSettingsTitle": "账号池设置",
3056
3252
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
3057
3253
  "row.poolEnabled": "启用账号池",
3058
- "row.poolEnabledHint": "开启后由本区域的账号池自动决定每一次请求用谁(可用优先,其次积分高、快过期的积分包、凭据更新的),失败时自动换下一个可用账号。关闭后插件不替你选、也不替你换:你选的账号服务每一次请求,失败就如实报错。签到与测试是**手动动作**,两种状态下都可用 —— 这个开关只管「插件要不要自己决定」。",
3254
+ "row.poolEnabledHint": "开启后由本区域的账号池自动决定每一次请求用谁(可用优先,其次积分高、快过期的积分包、凭据更新的),失败时自动换下一个可用账号。关闭后插件不替你选、也不替你换:你选的账号服务每一次请求,失败就如实报错。签到与测试是手动动作,两种状态下都可用 —— 这个开关只管「插件要不要自己决定」。",
3059
3255
  "row.poolManualAccountLabel": "使用哪个账号",
3060
3256
  "row.poolManualAccountHint": "账号池关闭时,由你在这里选定服务本区域的账号。开启账号池后改由排序自动决定。",
3061
3257
  "row.poolManualAccountNone": "当前没有生效的账号",
@@ -416,6 +416,19 @@ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
416
416
  const DESKTOP_UA = "WorkBuddy/5.5.2";
417
417
  const JSON_TIMEOUT_MS = 3e4;
418
418
  const ERROR_BODY_LIMIT = 4096;
419
+ /**
420
+ * Ceiling for one real-volume probe to get a RESPONSE, which is not a metadata
421
+ * call.
422
+ *
423
+ * Much larger than {@link JSON_TIMEOUT_MS} on purpose: a probe posts ~25k input
424
+ * tokens, so prompt processing legitimately takes a while before the first byte
425
+ * comes back. It exists only to stop a connection that will NEVER answer — the
426
+ * pool's batch is serial, so one hung member would otherwise block all the rest
427
+ * with nothing in the UI to say which one it was. Ten seconds is comfortably
428
+ * above a working gateway's first byte and far below a user's patience; the
429
+ * budget covers the response only, never the one-line body that follows.
430
+ */
431
+ const PROBE_TIMEOUT_MS = 1e4;
419
432
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
420
433
  const HARD_CREDIT_MARKERS = [
421
434
  "insufficient credit",
@@ -946,9 +959,26 @@ var WorkBuddyUpstreamClient = class {
946
959
  *
947
960
  * The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
948
961
  * holds the connection open.
962
+ *
963
+ * ALWAYS bounded in time. The pool's batch runner is serial and passes no
964
+ * signal, so without a ceiling here one stalled connection blocks every account
965
+ * behind it — the batch looks hung with no way to tell which member did it.
966
+ * A supplied signal is COMBINED with the timeout rather than replacing it, so
967
+ * the single-model route keeps its cancellation behaviour and still cannot
968
+ * hang forever.
969
+ *
970
+ * The ceiling covers ONLY the wait for a response — it is cleared the moment
971
+ * `fetch` resolves. Holding a timer over the body would put a healthy but slow
972
+ * model on the same clock as a dead endpoint, and the whole point of the
973
+ * period is to answer "did the upstream answer at all". A probe asks for a
974
+ * single token, so the body that follows is one SSE line.
949
975
  */
950
976
  async probeChat(credential, bodyJson, signal) {
951
977
  let response;
978
+ const ceiling = new AbortController();
979
+ const timer = setTimeout(() => {
980
+ ceiling.abort(/* @__PURE__ */ new Error(`no response within ${PROBE_TIMEOUT_MS}ms`));
981
+ }, PROBE_TIMEOUT_MS);
952
982
  try {
953
983
  response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
954
984
  method: "POST",
@@ -957,7 +987,7 @@ var WorkBuddyUpstreamClient = class {
957
987
  "Authorization": `Bearer ${credential.accessToken}`
958
988
  },
959
989
  body: bodyJson,
960
- ...signal === void 0 ? {} : { signal }
990
+ signal: signal === void 0 ? ceiling.signal : AbortSignal.any([signal, ceiling.signal])
961
991
  });
962
992
  } catch (error) {
963
993
  return {
@@ -966,6 +996,8 @@ var WorkBuddyUpstreamClient = class {
966
996
  retryAfter: null,
967
997
  body: `transport error: ${String(error)}`
968
998
  };
999
+ } finally {
1000
+ clearTimeout(timer);
969
1001
  }
970
1002
  const retryAfter = response.headers.get("retry-after");
971
1003
  if (response.ok) return {
@@ -2520,7 +2552,7 @@ function generationOf(version) {
2520
2552
  //#endregion
2521
2553
  //#region src/version.ts
2522
2554
  /** The npm package version this build was produced from. */
2523
- const WORKBUDDY_CONNECT_VERSION = "3.3.0";
2555
+ const WORKBUDDY_CONNECT_VERSION = "3.4.1";
2524
2556
  //#endregion
2525
2557
  //#region src/host-heartbeat.ts
2526
2558
  /**
package/lib/index.d.ts CHANGED
@@ -631,6 +631,19 @@ declare class WorkBuddyUpstreamClient {
631
631
  *
632
632
  * The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
633
633
  * holds the connection open.
634
+ *
635
+ * ALWAYS bounded in time. The pool's batch runner is serial and passes no
636
+ * signal, so without a ceiling here one stalled connection blocks every account
637
+ * behind it — the batch looks hung with no way to tell which member did it.
638
+ * A supplied signal is COMBINED with the timeout rather than replacing it, so
639
+ * the single-model route keeps its cancellation behaviour and still cannot
640
+ * hang forever.
641
+ *
642
+ * The ceiling covers ONLY the wait for a response — it is cleared the moment
643
+ * `fetch` resolves. Holding a timer over the body would put a healthy but slow
644
+ * model on the same clock as a dead endpoint, and the whole point of the
645
+ * period is to answer "did the upstream answer at all". A probe asks for a
646
+ * single token, so the body that follows is one SSE line.
634
647
  */
635
648
  probeChat(credential: WorkBuddyCredential, bodyJson: string, signal?: AbortSignal): Promise<WorkBuddyProbeAnswer>;
636
649
  /** POST the token-refresh endpoint; the caller merges the outcome. */
@@ -1518,6 +1531,20 @@ interface WorkBuddyPoolProbe {
1518
1531
  * must never be turned into a locally invented countdown.
1519
1532
  */
1520
1533
  retryAtMs?: number;
1534
+ /**
1535
+ * The upstream's own words on the failure, already redacted.
1536
+ *
1537
+ * Kept so the card can say WHY an account left the pool instead of repeating
1538
+ * one generic sentence for a DNS failure, a gateway 502 and a request the user
1539
+ * cancelled alike. Those three used to render as the SAME line, which left the
1540
+ * user with a message they could not act on and no way to tell a broken
1541
+ * account from a broken network.
1542
+ *
1543
+ * ABSENT for a success, and for any measurement written before this field
1544
+ * existed: an `outcome` without a `message` is a complete, valid record, so
1545
+ * old probe files stay readable rather than being discarded.
1546
+ */
1547
+ message?: string;
1521
1548
  }
1522
1549
  /** One account's live credit situation, as the pool ranks it. */
1523
1550
  interface WorkBuddyPoolCredits {
@@ -1924,6 +1951,15 @@ interface WorkBuddyWebPoolAccount {
1924
1951
  atMs: number;
1925
1952
  /** Only when the upstream named a time; never invented locally. */
1926
1953
  retryAtMs?: number;
1954
+ /**
1955
+ * Why it failed, in the upstream's own redacted words.
1956
+ *
1957
+ * Carried so the pool table can distinguish a DNS failure from a gateway
1958
+ * 502 from a request the user cancelled, instead of repeating one generic
1959
+ * sentence for all three. ABSENT on success and on measurements written
1960
+ * before this field existed, so the card must render the bare outcome then.
1961
+ */
1962
+ message?: string;
1927
1963
  };
1928
1964
  /** Set when this account cannot serve right now. */
1929
1965
  excludedBy?: WorkBuddyWebPoolExclusion;
@@ -2193,8 +2229,15 @@ interface WorkBuddyPoolDeps {
2193
2229
  * reports "unavailable" instead of appearing to succeed.
2194
2230
  */
2195
2231
  checkin?(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolCheckinRow[]>;
2196
- /** Test every account of one region against the resolved target model. */
2197
- test?(region: WorkBuddyRegion, modelId: string): Promise<readonly WorkBuddyPoolTestRow[]>;
2232
+ /**
2233
+ * Test every account of one region against the resolved target model.
2234
+ *
2235
+ * `onRow` is optional and exists so the route can STREAM each account as it
2236
+ * finishes. An older Host ignores it and answers with the whole batch at once;
2237
+ * the route handles both, because a missing reporter must degrade to the
2238
+ * previous behaviour rather than to no rows at all.
2239
+ */
2240
+ test?(region: WorkBuddyRegion, modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
2198
2241
  /** The region's live catalog, used to resolve the free target model. */
2199
2242
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
2200
2243
  }
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-t93cLCoW.js";
1
+ import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-DSO-07lc.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1171,6 +1171,46 @@ const KIND_STATUS = {
1171
1171
  server: 502,
1172
1172
  client: 400
1173
1173
  };
1174
+ /**
1175
+ * The `code` spelling the HOST is expected to classify each upstream failure by.
1176
+ *
1177
+ * The host decides whether a failed request may be retried IN PLACE (5 attempts
1178
+ * with backoff) or must be handed to cross-provider failover by reading English
1179
+ * phrases out of `type + " " + code + " " + message` — see
1180
+ * `isQuotaExceededError` in `@deepseek-ai/dsh-llm`. Only `RATE_LIMIT`, `SERVER`,
1181
+ * `TIMEOUT`, `TRANSPORT` and `EMPTY_RESPONSE` are in its retry set, so the
1182
+ * spelling decides which of two very different things happens next.
1183
+ *
1184
+ * `soft_rate` is the one kind that spelling got wrong. The upstream's 429 says
1185
+ * "usage exceeds the frequency limit, and it resets at 13:37" — hours away, not
1186
+ * two seconds. Sent as `soft_rate` it reads as an ordinary rate limit, enters
1187
+ * the in-place retry set, and spends the whole retry budget on an endpoint that
1188
+ * cannot recover within it; by the time cross-provider failover is offered, the
1189
+ * turn has already failed. The upstream's own words are Chinese, so the
1190
+ * phrase-based classifier cannot see the distinction either — the label is the
1191
+ * only channel left to carry it.
1192
+ *
1193
+ * `quota_exceeded` is the minimal honest translation: it is the WORDS the host
1194
+ * recognises, and it matches what the upstream is actually saying. The HTTP
1195
+ * status stays 429 (that is what the upstream returned — relabelling it 402
1196
+ * would misreport the upstream), and the upstream's own message is still passed
1197
+ * through verbatim for a human to read.
1198
+ *
1199
+ * Every other kind already lands outside the retry set by its status alone
1200
+ * (`hard_credit` 402 → quota, `session_dead` 401 → auth, 502/400 → other), so
1201
+ * none of them is listed: a translation nobody needs is a lie waiting to drift.
1202
+ */
1203
+ const KIND_HOST_CODE = { soft_rate: "quota_exceeded" };
1204
+ /**
1205
+ * The `code` to write for an upstream failure, given its classified kind.
1206
+ *
1207
+ * `type` deliberately stays the plugin's own kind: it is what a human reads in
1208
+ * a log or a bug report, and it keeps the two fields individually meaningful.
1209
+ * Only `code` carries the host-facing translation.
1210
+ */
1211
+ function hostErrorCode(kind) {
1212
+ return KIND_HOST_CODE[kind] ?? kind;
1213
+ }
1174
1214
  function writeJson(res, status, body) {
1175
1215
  const payload = JSON.stringify(body);
1176
1216
  res.writeHead(status, {
@@ -1179,11 +1219,19 @@ function writeJson(res, status, body) {
1179
1219
  });
1180
1220
  res.end(payload);
1181
1221
  }
1182
- function writeOpenAIError(res, status, kind, message) {
1222
+ /**
1223
+ * Write a JSON error the way an OpenAI-compatible client expects.
1224
+ *
1225
+ * `code` defaults to `type`, which is right for every error THIS shim raises on
1226
+ * its own (`unauthorized`, `not_found`, …): those are not upstream failures and
1227
+ * must not be disguised as one. The upstream failure path passes an explicit
1228
+ * `code` so the host classifies it correctly — see {@link KIND_HOST_CODE}.
1229
+ */
1230
+ function writeOpenAIError(res, status, kind, message, code) {
1183
1231
  writeJson(res, status, { error: {
1184
1232
  message,
1185
1233
  type: kind,
1186
- code: kind
1234
+ code: code ?? kind
1187
1235
  } });
1188
1236
  }
1189
1237
  /** Read a request body with a size cap; over-limit bodies fail the request. */
@@ -1674,11 +1722,10 @@ function createWorkBuddyShim(options) {
1674
1722
  });
1675
1723
  };
1676
1724
  const reportFailures = () => {
1677
- if (onAccountFailure === void 0) {
1678
- failures.length = 0;
1679
- return;
1680
- }
1681
- for (const entry of failures.splice(0, failures.length)) {
1725
+ const batch = failures.splice(0, failures.length);
1726
+ if (onAccountFailure === void 0) return;
1727
+ if (controller.signal.aborted) return;
1728
+ for (const entry of batch) {
1682
1729
  if (entry.failure.ok) continue;
1683
1730
  try {
1684
1731
  onAccountFailure(entry.accountId, {
@@ -1707,7 +1754,7 @@ function createWorkBuddyShim(options) {
1707
1754
  if (!result.ok) {
1708
1755
  const attempts = triedAccountIds.length;
1709
1756
  const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
1710
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`);
1757
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`, hostErrorCode(result.kind));
1711
1758
  return;
1712
1759
  }
1713
1760
  res.writeHead(200, {
@@ -1993,12 +2040,18 @@ const PROBE_MESSAGE_LIMIT = 300;
1993
2040
  /**
1994
2041
  * Redact token-shaped content out of upstream text before it is stored or sent.
1995
2042
  *
1996
- * Duplicated from the route layer on purpose rather than imported: this module
1997
- * is the one that reads raw failure bodies, so the redaction belongs at the
1998
- * point of capture. A probe failure body is the one place a raw upstream string
1999
- * from an arbitrary endpoint enters the plugin's data flow.
2043
+ * Lives here rather than in the route layer because this module is the one that
2044
+ * reads raw failure bodies, so the redaction belongs at the point of capture. A
2045
+ * probe failure body is the one place a raw upstream string from an arbitrary
2046
+ * endpoint enters the plugin's data flow.
2047
+ *
2048
+ * EXPORTED because a failed CHAT request now persists its reason into the pool
2049
+ * store too (`recordAccountFailure` in `src/index.ts`), and that text is a raw
2050
+ * upstream body as well. One implementation, so the two writers cannot drift
2051
+ * into redacting differently — the same "one definition" rule this codebase
2052
+ * applies to `effectiveMembersOf`.
2000
2053
  */
2001
- function redact(text) {
2054
+ function redactUpstreamText(text) {
2002
2055
  return text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, PROBE_MESSAGE_LIMIT);
2003
2056
  }
2004
2057
  /**
@@ -2021,7 +2074,7 @@ async function probeModel(input) {
2021
2074
  return {
2022
2075
  modelId: input.modelId,
2023
2076
  outcome: "unavailable",
2024
- message: redact(error instanceof Error ? error.message : String(error))
2077
+ message: redactUpstreamText(error instanceof Error ? error.message : String(error))
2025
2078
  };
2026
2079
  }
2027
2080
  const elapsedMs = Date.now() - startedAt;
@@ -2051,7 +2104,7 @@ async function probeModel(input) {
2051
2104
  outcome,
2052
2105
  elapsedMs,
2053
2106
  status: answer.status,
2054
- ...body === "" ? {} : { message: redact(body) },
2107
+ ...body === "" ? {} : { message: redactUpstreamText(body) },
2055
2108
  ...cooldownOf({
2056
2109
  outcome,
2057
2110
  retryAfter: answer.retryAfter,
@@ -2135,12 +2188,21 @@ async function checkinOne(target, deps) {
2135
2188
  * exactly the silent spend the plan forbids.
2136
2189
  *
2137
2190
  * Never throws: a failed probe is a ROW, not an aborted batch.
2191
+ *
2192
+ * `onRow` fires the moment one account's row exists, which is what lets a caller
2193
+ * report progress. The loop is SERIAL by design (one account at a time — hitting
2194
+ * the whole pool at once is the fastest way to trip the upstream's volume limit),
2195
+ * so without this a caller cannot say anything until the SLOWEST member answers:
2196
+ * one stuck account made a working batch look like a dead button. The return
2197
+ * value is unchanged, so every existing caller keeps all rows at the end.
2138
2198
  */
2139
- async function testAllAccounts(targets, modelId, deps) {
2199
+ async function testAllAccounts(targets, modelId, deps, onRow) {
2140
2200
  const rows = [];
2141
2201
  for (const [index, target] of targets.entries()) {
2142
2202
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2143
- rows.push(await testOne(target, modelId, deps));
2203
+ const row = await testOne(target, modelId, deps);
2204
+ rows.push(row);
2205
+ onRow?.(row);
2144
2206
  }
2145
2207
  return rows;
2146
2208
  }
@@ -2186,6 +2248,11 @@ async function testOne(target, modelId, deps) {
2186
2248
  *
2187
2249
  * `atMs` is stamped here, once for the batch, so the card's "tested N minutes
2188
2250
  * ago" reflects when the batch finished rather than drifting per row.
2251
+ *
2252
+ * `message` is carried through as well. `probeModel` already redacted it before
2253
+ * returning, so persisting it cannot leak a token, and without it the pool table
2254
+ * could only repeat one generic sentence for a DNS failure, a gateway 502 and a
2255
+ * cancelled request alike.
2189
2256
  */
2190
2257
  function probeUpdatesOf(rows) {
2191
2258
  const atMs = Date.now();
@@ -2195,7 +2262,8 @@ function probeUpdatesOf(rows) {
2195
2262
  updates[row.accountId] = {
2196
2263
  outcome: row.result.outcome,
2197
2264
  atMs,
2198
- ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs }
2265
+ ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
2266
+ ...row.result.message === void 0 ? {} : { message: row.result.message }
2199
2267
  };
2200
2268
  }
2201
2269
  return updates;
@@ -2227,18 +2295,37 @@ function messageOf(error) {
2227
2295
  *
2228
2296
  * - A `credential-rejected` account is excluded regardless of any cooldown: its
2229
2297
  * token is not accepted, so waiting cannot help and the user must re-auth.
2230
- * - A limited outcome is checked against its stated (or fallback) cooldown, so
2231
- * an account whose limit has already reset is back in the pool without
2232
- * needing another test first.
2298
+ * - A limited, unreachable or failed outcome is checked against its stated (or
2299
+ * fallback) cooldown, so an account whose limit has already reset — or whose
2300
+ * gateway blipped once — is back in the pool without needing another test
2301
+ * first.
2233
2302
  * - An `ok` measurement, and no measurement at all, are both candidates. The
2234
2303
  * latter matters: a freshly discovered account has never been tested and must
2235
2304
  * not be invisible.
2236
2305
  *
2306
+ * WHY TRANSIENT OUTCOMES GET A COOLDOWN. They are not statements about the
2307
+ * ACCOUNT. A transport failure can be a DNS blip, and — before the shim learned
2308
+ * to skip reporting an aborted request — it was routinely the user closing the
2309
+ * panel mid-flight. A 5xx is the gateway's afternoon, not this account's health.
2310
+ * Excluding them permanently meant one transient event idled a perfectly good
2311
+ * account until the user happened to press "test" again, and with every member
2312
+ * so idled the pool had no candidate at all: failover then had nowhere to go and
2313
+ * the raw upstream error was reported as-is. The cooldown is what makes the pool
2314
+ * RECOVER instead of staying broken.
2315
+ *
2316
+ * `not-found` and `credential-rejected` deliberately get none: one describes a
2317
+ * model the region no longer offers (a catalog fact, not a transient one) and
2318
+ * the other needs the user to sign in again. Neither is fixed by waiting, and
2319
+ * pretending otherwise would re-bill a token the upstream has already refused.
2320
+ *
2237
2321
  * Each unusable outcome keeps its OWN value rather than being folded into
2238
2322
  * `credential-rejected`. The fold made one row state two contradictory things —
2239
2323
  * the name column said "被拒绝" (sign in again) while the probe column, reading
2240
2324
  * the same measurement, said "连不上上游——这是网络问题,不是模型问题" — and it
2241
2325
  * told a user whose upstream had merely blipped that their sign-in was bad.
2326
+ * (That label has since been made cause-NEUTRAL: it used to assert "not the
2327
+ * model" for a bucket that also holds gateway 5xx and request timeouts, where
2328
+ * the claim is simply unfounded. The recorded `message` now carries the cause.)
2242
2329
  * The distinctions are already computed upstream in `outcomeOfFailure`
2243
2330
  * (`src/probe.ts:290-299`); this only stops discarding them.
2244
2331
  */
@@ -2248,14 +2335,22 @@ function exclusionOf(probe, nowMs) {
2248
2335
  case "ok": return;
2249
2336
  case "credential-rejected": return "credential-rejected";
2250
2337
  case "not-found": return "not-found";
2251
- case "unavailable": return "unavailable";
2252
- case "failed": return "failed";
2338
+ case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
2339
+ case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
2253
2340
  case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
2254
2341
  case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
2255
2342
  default: return "unusable";
2256
2343
  }
2257
2344
  }
2258
- /** Whether a limited account's cooldown has elapsed (or was never stated). */
2345
+ /**
2346
+ * Whether an account's cooldown has elapsed (or was never stated).
2347
+ *
2348
+ * The fallback is the ONLY thing a transient outcome relies on: `cooldownOf`
2349
+ * attaches `retryAtMs` for a rate limit or a drained quota, never for a
2350
+ * transport failure, so `atMs + POOL_UNKNOWN_COOLDOWN_MS` is what brings those
2351
+ * back. That is deliberate — the alternative is a permanent exclusion decided by
2352
+ * an event that said nothing about the account.
2353
+ */
2259
2354
  function retryDue(probe, nowMs) {
2260
2355
  return nowMs >= (probe.retryAtMs ?? probe.atMs + 18e5);
2261
2356
  }
@@ -2430,7 +2525,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
2430
2525
  function isProbe(value) {
2431
2526
  if (typeof value !== "object" || value === null) return false;
2432
2527
  const candidate = value;
2433
- return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number");
2528
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string");
2434
2529
  }
2435
2530
  /**
2436
2531
  * One region's path for measured pool facts.
@@ -2444,9 +2539,11 @@ function workbuddyPoolStorePath(region) {
2444
2539
  /**
2445
2540
  * Read one region's measured facts.
2446
2541
  *
2447
- * Contains NO credentials — only outcome words and timestamps — so it is not
2448
- * written with the credential store's stricter secrecy requirements, though it
2449
- * still uses the same atomic-write helpers.
2542
+ * Contains NO credentials. The one free-text field is `message`, and it is
2543
+ * REDACTED BY THE WRITER before it ever reaches this file (`probe.ts`'s
2544
+ * `redactUpstreamText`), so a token-shaped string cannot survive into storage.
2545
+ * Because of that the file is not written with the credential store's stricter
2546
+ * secrecy requirements, though it still uses the same atomic-write helpers.
2450
2547
  *
2451
2548
  * Returns an empty map for a missing, unreadable, malformed, or
2452
2549
  * version-mismatched file: all four mean "nothing measured yet", which is a
@@ -2475,7 +2572,8 @@ async function readPoolProbes(region) {
2475
2572
  probes[accountId] = {
2476
2573
  outcome: probe.outcome,
2477
2574
  atMs: probe.atMs,
2478
- ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs }
2575
+ ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
2576
+ ...probe.message === void 0 ? {} : { message: probe.message }
2479
2577
  };
2480
2578
  }
2481
2579
  return probes;
@@ -2980,7 +3078,8 @@ async function workBuddyWebPool(deps, region) {
2980
3078
  ...measured?.probe === void 0 ? {} : { probe: {
2981
3079
  outcome: measured.probe.outcome,
2982
3080
  atMs: measured.probe.atMs,
2983
- ...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs }
3081
+ ...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs },
3082
+ ...measured.probe.message === void 0 ? {} : { message: measured.probe.message }
2984
3083
  } },
2985
3084
  ...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
2986
3085
  current: account.id === currentAccountId,
@@ -3175,12 +3274,32 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3175
3274
  reason: target.source === "stale" ? "target-model-stale" : "no-free-model",
3176
3275
  error: target.source === "stale" ? "the saved target model is no longer offered by this region; pick another or switch back to automatic" : "no zero-multiplier model in this region; refresh the catalog or set a target model"
3177
3276
  });
3178
- const rows = await pool.test(region, target.modelId);
3179
- return json(res, 200, {
3277
+ res.writeHead(200, {
3278
+ "Content-Type": "application/x-ndjson",
3279
+ "Cache-Control": "no-cache",
3280
+ "Connection": "keep-alive",
3281
+ "X-Accel-Buffering": "no"
3282
+ });
3283
+ const writeLine = (payload) => {
3284
+ res.write(`${JSON.stringify(payload)}\n`);
3285
+ };
3286
+ writeLine({
3180
3287
  action: "test",
3181
- modelId: target.modelId,
3182
- rows
3288
+ modelId: target.modelId
3183
3289
  });
3290
+ try {
3291
+ await pool.test(region, target.modelId, (row) => {
3292
+ writeLine({ row });
3293
+ });
3294
+ writeLine({ done: true });
3295
+ } catch (error) {
3296
+ writeLine({
3297
+ reason: "pool-failed",
3298
+ error: safeMessage(error)
3299
+ });
3300
+ }
3301
+ res.end();
3302
+ return;
3184
3303
  } catch (error) {
3185
3304
  json(res, 500, {
3186
3305
  reason: "pool-failed",
@@ -3825,8 +3944,8 @@ function apply(ctx, config) {
3825
3944
  }
3826
3945
  return rows;
3827
3946
  },
3828
- test: async (region, modelId) => {
3829
- const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region));
3947
+ test: async (region, modelId, onRow) => {
3948
+ const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region), onRow);
3830
3949
  await writePoolProbes(region, probeUpdatesOf(rows));
3831
3950
  return rows;
3832
3951
  },
@@ -3868,11 +3987,19 @@ function apply(ctx, config) {
3868
3987
  * Reads the candidate through `credentialFor()`, never `resolve()`: the saved
3869
3988
  * selection and the store's runtime state stay untouched, so this is
3870
3989
  * per-request borrowing rather than a silent change of who pays.
3990
+ *
3991
+ * Uses `localPoolMembers`, NOT `poolMembersOf`. This runs on the failure path
3992
+ * of a request the user is already waiting on, and `poolMembersOf` fetches
3993
+ * credits per member — so the expensive version turned one failed request into
3994
+ * N extra upstream calls before the retry even left. The doc above always said
3995
+ * "must not spend further requests"; the code used to contradict it. Credits
3996
+ * still steer the ranking when a recent snapshot exists, because
3997
+ * `localPoolMembers` reads the one the card's poll already paid for.
3871
3998
  */
3872
3999
  const failoverAccountFor = async (region, triedAccountIds) => {
3873
4000
  if (!poolPreferencesOf(current(), region).enabled) return void 0;
3874
4001
  const tried = new Set(triedAccountIds);
3875
- for (const row of rankPool(await poolMembersOf(region), Date.now())) {
4002
+ for (const row of rankPool(await localPoolMembers(region), Date.now())) {
3876
4003
  if (row.excludedBy !== void 0) continue;
3877
4004
  if (tried.has(row.account.id)) continue;
3878
4005
  const credential = await stacks[region].store.credentialFor(row.account.id);
@@ -3940,6 +4067,11 @@ function apply(ctx, config) {
3940
4067
  * The reset time comes from the upstream's own words — the 429 body carries
3941
4068
  * 「将在 … 重置」 — which `cooldownOf` parses. When it states no time, the store's
3942
4069
  * own short window applies rather than a guess at a long one.
4070
+ *
4071
+ * The upstream's text is persisted alongside the outcome so the card can name
4072
+ * the reason. It is REDACTED first because this message is a raw upstream body:
4073
+ * without that, a failure containing a token-shaped string would write it into
4074
+ * a file on disk.
3943
4075
  */
3944
4076
  const recordAccountFailure = async (region, accountId, failure) => {
3945
4077
  if (!poolPreferencesOf(current(), region).enabled) return;
@@ -3953,10 +4085,12 @@ function apply(ctx, config) {
3953
4085
  nowMs: Date.now(),
3954
4086
  body: failure.message
3955
4087
  });
4088
+ const message = failure.message === "" ? "" : redactUpstreamText(failure.message);
3956
4089
  await writePoolProbes(region, { [accountId]: {
3957
4090
  outcome,
3958
4091
  atMs: Date.now(),
3959
- ...retryAtMs === void 0 ? {} : { retryAtMs }
4092
+ ...retryAtMs === void 0 ? {} : { retryAtMs },
4093
+ ...message === "" ? {} : { message }
3960
4094
  } });
3961
4095
  };
3962
4096
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "3.3.0",
5
+ "version": "3.4.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",