dsh-connect-workbuddy 5.5.0 → 5.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,47 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 5.5.2 (2026-10-23)
4
+
5
+ > 补丁版本:两条修复都来自 issue 报告——#118(`WORKBUDDY_BODY_REPORT` 报告文件无界增长、回填整文件读写导致宿主 OOM)与 #122(界面文案审计)。**请求路径、provider 路由、模型目录、计费与凭据一律未变**;改的只有诊断文件的 I/O 形状、若干句用户可见文案与两处内部注释,外加三张 README 截图换成合成数据。
6
+
7
+ ### Fixes
8
+
9
+ - **`WORKBUDDY_BODY_REPORT` 的报告文件不再无界增长,回填不再整文件读写(#118)**
10
+ - **报告**:报告者开着该环境变量长跑,`global.jsonl` 长到 **508 MB**;每次上游回 `usage` 后的回填都会 `readFile` 整文件 → `split('\n')` → `writeFile` 整文件,峰值约为文件大小的 2–3 倍(读串 + 行数组 + 重组串),把宿主进程顶到 OOM。`USAGE_ATTACH_WINDOW = 32` 只裁了**搜索**范围,没裁读写。
11
+ - **修法一:写侧有上限**。`appendBodyReport()` 追加前 `stat` 当前文件,追加会越过 `BODY_REPORT_MAX_BYTES = 32 MB` 时先滚成 `<region>.jsonl.1`(`rm` 旧滚档再 `rename`,只保留一份旧档)再写新行。尺寸检查与追加在**同一个文件锁**里,两个并发请求不会同时判「不用滚」再一起写超。滚档后活文件里是**新**行——报告者对比「最后一行」时不会拿到被滚走的旧捕获。32 MB 的取值:真实会话一行几 KB,32 MB 是几千次请求,远超还有人读它的时点。
12
+ - **修法二:回填只碰尾部窗口**。`attachUsage()` 改成 `open(file, 'r+')` + 读最后 `USAGE_ATTACH_TAIL_BYTES = 1 MB` + 窗口内定位目标行 + 从该行偏移写回 + `truncate` 到新长度;窗口前一个字节都不读不写,500 MB 文件在这里只花 1 MB 堆。窗口起点用**一字节回看**判定行边界:回看字节是 `\n` 说明窗口正好起在行边界、首行完整(必须保留——丢掉等于从文件里删一行);否则首行是上一条的残尾,从搜索与写回里都排除。窗口外的目标行**不再回填**:标注只是 #59 对比的便利,不是正确性要求(发送侧计数早已在盘上)。
13
+ - **README 补警告**(中英各一句):会持续追加、32 MB 滚档、排查完请关闭环境变量并删除报告文件。
14
+ - **测试**:新增 `tests/issue118-body-report-bounded.spec.ts`(6 条)——滚档与「只留一份旧档」、滚档后活文件是新行、不滚空文件与未超上限、大于一个窗口的文件里近期行仍被标注且窗口前逐字节不变、**窗口恰好起在行边界**(用迭代法把追加行尺寸钉到正好 1 MB:`model` 字段被原样抄进行里是唯一的尺寸旋钮,而 `bodyBytes` 的位数会随填充自增,一步算不准;断言三行都在、前两行未被标注也未被删)、目标行被推出窗口后文件不被截断或切碎。原有 `tests/issue59-body-report.spec.ts` 26 条全绿(含「标注只加在原有行上、不新增行」)。
15
+ - **四处用户可见文案与两处内部注释按 #122 审计改正**
16
+ - `row.showCreditsCnHint` / `row.showCreditsGlobalHint`(中英):删掉**不存在**的「手动刷新」——面板头部唯一的按钮是「一键测试」(会真跑一轮测试、真实消耗积分);改成「点击可看各账号余额并一键测试勾选的账号(测试会真实消耗积分)。读数每 5 分钟自动刷新;各账号明细在面板打开时每 60 秒刷新」,把两个周期分开写清(此前只写了 5 分钟那个)。
17
+ - `row.poolEnabledHint`(中英):「失败就换下一个」改成「只有可换号的失败才换下一个」,并点名四类例外(请求本身不合法、模型繁忙、平台排队、渠道校验)与理由(换号只会为同一个拒绝白烧一次真实配额)——这四类在 `isFailoverWorthy` 里被显式排除,旧文案说反了。
18
+ - `row.cliBridgeHint`(中英):`injectResult` **只影响 `/codebuddy` 指令**(结果回注会话、会话空闲时多花一次 DSH 回合),`codebuddy_ask` 工具不读它;并补上三条此前完全没提的限流(每会话每小时 6 回合、每进程每小时 20 回合、同时只跑一个回合)。
19
+ - `row.tabFreeQuotaWarn`(中英):不再声称「时间段内定额 Token 上限」这个插件**读不到**的机制(上游没有这个字段),改成「免费模型并非无限量:上游按单次请求体积与负载限流」;内部注释同步改写——6004 实测是**单次请求体积**触发(`src/probe.ts:71-89`:~25k 通过 / ~30k 被拒),14003 是模型侧负载,两者都不是累计定额。
20
+ - `src/index.ts` 的 `WorkBuddyPoolPreferences.enabled` 注释:旧句「刻意不改变下一个由谁服务」与 `applyPoolSelection()` 每次请求都按排名设服务账号的事实相反;改成「从不改变的是用户**保存的选择**」,并留一句旧注释错在哪的追溯(#122 的审计者指出「用户文案反而比这处注释更准」)。
21
+ - ①④ 三条审计结论无需改动:三条 tab 提醒在 5.5.0 的「能力」分区里已分开表述;5.71 仍是 2026-10-21 的实测常量(无配置项)、生图开关仍 CN-only 且关闭时根本不注册;测试默认 25000 与 1/2/3/5/10 万五档未变。
22
+
23
+ ### Docs
24
+
25
+ - **README 产品截图换成真机图并做隐私处理**。旧图停在 5.5.0 之前(「一键签到所有账号」「刷新积分」等已改名的按钮),且带着真实令牌过期日 / 积分包到期日;本版换成维护者实机截的四张:设置卡「模型」页(`dsh-connect-workbuddy-usage-card.png`,含倍率、图片与可关闭思考开关、`拒绝 off` 标记、活动标签)、账号池(`dsh-connect-workbuddy-account-pool.png`,含「只有可换号的失败才换」的新文案与八行成员表)、输入框积分面板(`dsh-connect-workbuddy-composer-credits.png`,一键测试按钮)与模型选择器(`dsh-connect-workbuddy-model-picker.png`,同一枚活动徽标画在模型名之后)。
26
+ - **隐私**:账号列每一行的名字格用不透明色块覆盖(不是可复原的马赛克),并逐行用「色块覆盖区内深色像素数 = 0」的脚本复核;积分、签到状态、排序位次与时间戳保留——它们不含身份。
27
+ - 模型选择器那张只含模型名、倍率与活动文案,没有账号信息。`screenshots.json` 与中英两份 README 的 alt 文本同步。
28
+
29
+ ## 5.5.1 (2026-10-23)
30
+
31
+ > 补丁版本:修「账号池状态重启就失效」的根因,以及同一症状的另外三处(手动排序箭头、`regionsMapOf` 跳层读旧副本、卡片保存把脏数据带回去)。**没有新增任何功能**:请求路径、provider 路由、模型目录、计费与凭据一律未变,5.5.0 的全部能力原样保留。
32
+ > **本版会主动改写一个设置字段的形状**:被 5.4.0–5.5.0 写脏的 `regions` 字段会在**开机时**被清洗一次(只动形状,不动任何区域内容,干净 profile 不写)。如果你正是「账号池重启就失效」的受害者,升级后重启一次即可恢复,**不需要手动改 profile**;国际版整槽沉到第 4 层的情况也会被捞回来(本机实测 3 个成员与 27 个模型目录)。
4
33
 
5
34
  ### Fixes
6
35
 
7
36
  - **排序箭头在「账号池还没保存开启」时不出现 —— 先勾选再排序这条自然路径被堵死。** `AccountPool.tsx` 的 `moveEnabled` 读的是**已保存**的 `pool.enabled`,而同一行旁边的 `isMember` 读的却是**草稿** `active.memberAccountIds`。于是:勾选账号入池(草稿变了)→ 想去排序 → **箭头根本不存在**,因为开关还没保存;用户既看不到箭头,也就没有理由去按保存,下一次保存自然「什么都没写」——这正是「手动排序没保存」的实际形态。改成读草稿的 `active.enabled`:箭头描述的是草稿,就该跟着草稿走(报告运行中的池仍然是 `poolRunning`,那个值没有动)。变异验证:改回 `pool.enabled`,新增用例立刻红(期望 4 个箭头、实际 0 个)。
8
37
  - **`regionsMapOf()` 会跳到更深一层,读到同名区域的过期副本。** 旧规则是「只要有 `regions` 键就往下跳」,理由是可以同时接受「整份 settings」和「regions map」两种入参。但 regions **map 自己也可能有 `regions` 键** —— 本机真实 profile 里 `config.regions` 下就躺着 `accounts` / `regions` / `cliBridge` 三个旧写入留下的兄弟键。一旦跳下去,读到的就是**另一份更旧的同名区域**:实测同一份文档,「按 section 读」得到 `['c','a','b']`,「按 map 读」得到 `[]`(正是「排序明明存了却读不出来」)。现在改为**按形状判定**:外层对象只要含有区域 id(`cn` / `global`)就是 map,直接用它;否则才是 section,才往里跳。变异验证:把判定换回无条件跳,新增用例立刻红。
38
+ - **「账号池状态重启就失效」的根因:目录修复把整份 settings 写进了 `regions` 字段。** 上一条修的是**读**,这一条修的是**造出那份脏数据的写**。`writeRegionCatalog()` 用 `{ op: 'set', path: ['regions'] }` 写回目录,所以它的值必须是 `regions` **字段**;但它把 `describe().value`(**整份 section**:`{authFile, accounts, regions, cliBridge, …}`)交给了 `mergeRegionCatalog()`,而后者把入参当字段读、又原样展开写回。结果每次开机目录修复都把 section 写进字段:真正的区域表被推到下一层 `regions` 键里,`accounts` / `cliBridge` 成了两个假区域槽,于是宿主 `regionStateOf(config, 'cn')`(只读一层)读到的是**空** —— 账号池开关、成员、手动排序、目标模型、模型选择全部「看起来没保存」,下次保存再把这份空写回去。每次开机多一层:本机实测 5 层、字段 103 KB(其中 3 层是纯拷贝)。修法:`mergeRegionCatalog()` 统一走 `regionsMapOf()`(接受 section 或字段),返回值恒为字段本身;`regionStateOf()`、设置保存端点 `__save`、卡片各写路径的合并基点、以及 `readOrderAccountIds()` 全部改走同一个窄化函数。
39
+ - **脏 profile 不靠人手修**:`regionsMapOf()` 现在同时做三件事 —— 丢掉 section/槽位的兄弟键名拷贝;把只活在下层的区域**捞回来**(本机 `global` 就是这种情况:外层 `cn` 还在、国际版整槽只在下一层,不捞就等于让第一次写入删掉用户的国际版池);「在表里的区域」仍然以外层为准,且当多个深层副本都有同一区域时按**用户配置量**取舍而不是按深浅(本机就有 `{lastCatalog}` 的空壳副本躺在一个仍有 3 个成员的真副本上面 —— 按深浅会捞回空壳)。修复写仍只在目录指纹变化时触发,但**字段脏时也写一次**,否则「目录恰好一致」的 profile 永远等不到打扫。
40
+ - **修复写不再「只在目录变了才写」**:目录指纹相同时,只要 `Config['regions']` 还带兄弟键名就照写一次(`regionsFieldIsCorrupt()`),否则「目录恰好与上游一致」的 profile 永远等不到打扫 —— 那种 profile 的脏数据会活得比产生它的 bug 还久。写一次即净,之后这条分支不再触发。
41
+ - **开机再补一次独立清洗**(`healRegionsField()`):上面那条挂在「拿到实时模型目录」之后,于是关掉的区域、断网启动、上游空回答这三种情况都不会清洗。新增一次纯形状写入 —— 不需要上游、不碰任何区域内容、干净 profile 直接返回不写(只扫一遍键名),失败只记 warn(读侧本来就已是对的)。
42
+ - **代价控制在请求路径上**:`regionStateOf()` 现在是每次请求都会走的读(池开关、模型选择、额度门控),而 heal 会深拷贝整份字段(每区一份多模型目录)。实测 28 模型槽位:不做快路径是 **11 µs/次**(原来裸读 0.05 µs),加「干净字段原样返回」的快路径后回到 **0.09 µs**;脏字段 0.86 µs 且只到 profile 被写入修复为止。快路径的成立条件写在代码注释里(只有**字段本身**是 volatile,槽内是普通数据),并用同引用断言(`toBe`)钉住,防止以后被顺手删掉。
43
+ - **实测(本机真实 profile)**:修复前该字段 5 层嵌套、103 KB;用新 `mergeRegionCatalog()` 对同一份 section 重算,输出恰为 `['cn','global']`,13 KB;`cn` 保住 `pool.enabled=true` / `step-5-preview` / **8 个成员**,`global` 从下层捞回 `pool.enabled=true` / `deepseek-v4.1-flash` / **3 个成员**与 27 个模型目录。另有「主动清空」的保护:外层已显式写成 `null`(schemastery 把 `{}` 简化成 `null`)时不再从下层捞,否则会把用户清掉的池子复活。
44
+ - **变异验证(六处,逐个改回旧行为都必红)**:①`mergeRegionCatalog()` 改回「把入参当字段」→ `catalog-write.spec.ts` 红 4 条;②`regionStateOf()` 改回单层裸读 → `region.spec.ts` 红(`global` 读成空);③`__save` 改回对原始字段合并 → `web-status.spec.ts` 红(写回的值里出现 `regions` 兄弟键、`global` 消失);④卡片写路径改回直接展开原始快照 → `client-account-selection.spec.ts` 红;⑤删掉快路径 → `region.spec.ts` 的同引用断言红;⑥删掉开机清洗的脏判断 → `catalog-write.spec.ts` 的「干净字段不写」红。新增 24 条测试(`catalog-write.spec.ts` 13 条、`region.spec.ts` 8 条、`web-status.spec.ts` / `client-account-selection.spec.ts` / `manual-order.spec.ts` 各 1 条)。
9
45
 
10
46
  ### Docs
11
47
 
package/README.en.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/dsh-connect-workbuddy-usage-card.png" width="640" alt="dsh-connect-workbuddy settings panel" />
2
+ <img src="docs/assets/dsh-connect-workbuddy-usage-card.png" width="640" alt="dsh-connect-workbuddy settings card: model directory with multipliers, image and off-thinking switches, promo badges (account names redacted)" />
3
3
  </p>
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
@@ -33,6 +33,8 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
33
33
  - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account pool and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
34
34
  - **Switch either provider off on its own** — each tab carries an on/off checkbox (both checked by default). Unchecking one **withdraws that provider from DSH's model picker entirely** (it is not merely a hidden tab): its adapter route and its "Settings → Models" directory entry are both pulled, and startup no longer sends pointless requests for it. Account, credits, directory, and selection are all kept, and re-checking restores everything. The typical use is "I have no use for the international side" — switch it off and keep the picker clean. Note: a session that had already selected a model from the switched-off provider will report `NO_ADAPTER` on its next call; the card states this plainly on the switched-off tab instead of failing silently.
35
35
  - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; **image input is decided by a vendor-verified table**: a refresh pre-checks only models the vendor's own documentation calls natively multimodal, leaves documented text-only models unchecked, and treats anything without a vendor source as unverified and unchecked rather than guessing. Upstream's `supportsImages` is a *platform* declaration, not the model's capability, so it does not drive that decision at all. You can still tick any box by hand, and your selection is kept locally. **Every model row also carries a “Can disable” (`off`) checkbox**: it decides whether that model gets the “turn thinking off entirely” level. The default comes from a reviewed built-in table (the 9 models that claim the capability but answer HTTP 400 for it start unticked), and you can change it per model — **untick** for a model you do not need the level on. **The 9 tabled models cannot be ticked at all** (their row is marked "refuses off" with an explanatory tooltip): the level is not merely an extra option — pi-ai uses it as the DEFAULT for every request that names no tier, and a compaction summary is exactly such a request, so one tick made every `/compact` for that model fail with HTTP 400 / code 11133 (issue #83). Models outside the table are still decided entirely by your selection and their own declaration; your ticks are saved on first save and recomputed no further.
36
+
37
+ <img src="docs/assets/dsh-connect-workbuddy-model-picker.png" width="420" alt="DSH model picker: the same campaign badge (night discount / Free now) drawn after the model name, per region (no account data)" />
36
38
  - **Campaign badges on model rows** — the upstream model catalog carries campaign information (free trials, night discounts, limited-time free use, subscription-only tiers, and the like). The plugin adds **no extra request** (it reuses the catalog configuration it already fetches when refreshing models) and draws a pill badge right on the model row: the upstream supplies both the colour and the wording, and hovering shows the upstream's campaign text, its start/end dates, and its daily window (e.g. “night 23:00–7:50”). **A badge is a function of the current moment**: it shows only inside the window the upstream declares and disappears on its own when that window closes; a window that crosses midnight is judged in the timezone the upstream declared; when several campaigns hit the same model, only the highest-priority one is drawn. A benefit marker without an end date (such as “subscription only”) stays until upstream withdraws it. **A badge is a hint, not behaviour** — it reports the campaign and discount upstream currently advertises, and makes no choice for you. **The same badge now appears in the DSH model picker**: every menu row draws the identical pill after the model name and before the load icon — menu rows use the upstream's short wording ("night", "free"), the composer trigger beside the input uses the full one ("night discount", "exclusive free"); judged from the same data at the same clock, because the screen where the model is chosen is exactly where "is it free right now" matters most. (Both regions' catalogs ship the campaign fields: read as the app, the CN `/v3/config` carries 8 promotions + 1 tier badge and the global one carries 5 + 1; the evidence and the decision rules are written up in [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md).)
37
39
  - **Model load on model rows (CN only)** — the upstream answers "how busy is this model right now" from a separate read-only probe (`POST /v2/chat/busy`, up to 50 ids per call, free, no account state touched). The plugin asks it once per catalog refresh and draws the level as a small figure beside the model name: **the number of people = how many are queueing** (healthy `normal` = one yellow figure, `busy` = two orange ones, `congested` = three red ones), while **idle is a green rocket** (nobody is queueing = full speed ahead) — idle is deliberately *not* drawn as "one green person", because at 12px "nobody is waiting" and "one person is waiting" have to be distinguishable at a glance; **unavailable** is a crossed-out figure. The probe's own `unknown` **draws nothing**: **"we were told nothing" is never reported as "all good"**. Colours follow the theme and the `title` tooltip ("极度拥堵") is still there. **The level is not persisted**: the catalog is a fact that survives a restart, a load level is a fact about that minute, so it is stamped onto the runtime catalog only and dies with the process; a failed probe logs one warning and never blocks the refresh. Measured on the CN gateway: the probe answers byte-identically under the CLI and the app identity, the 50-id ceiling is real (62 ids → HTTP 400), and 19 models come back in 186 ms. **The international region shows no load badge, because the international gateway simply does not have this endpoint** — measured, `www.workbuddy.ai` answers **404** for `/v2/chat/busy` (and for the neighbouring `/v3/chat/busy`, `/v2/models/busy` and friends), while the very same credential reads its `/v3/config` fine (28 models) and that document carries **no load or queue field at all**. This is not something the plugin left unimplemented, and it is not a configuration or permission problem: **upstream has never published this data for the international region**. So the international region shows the model name and campaign badges only; the failed probe degrades quietly to "no badge", never blocking the model list, and the plugin never guesses a load level from latency or credits.
38
40
  - **Queueing tells you your position** — upstream expresses a queue with three business codes (`6020` waiting, `6021` queue full, `6022` this user already at the maximum). Instead of passing along a raw upstream error, the plugin says "模型排队中:你排在第 8777 位,队列共 9021 个请求;约 7 秒后自动重试". **A queue does not trigger failover**: the line is shared platform-wide and bound to the model, so another account is handed the same position — no account-pool rotation, no extra requests burned. (The upstream's `/v2/chat/queue/status` returns an i18n KEY rather than a sentence, so the plugin never relays its `message` and always uses its own wording.) **The "resend once it is your turn" half is not implemented** — that needs the queue answer's real envelope on the OpenAI-compatible channel, and writing it unmeasured would mean code that never runs.
@@ -44,7 +46,7 @@ It depends on the WorkBuddy client's own endpoints (not a public API), so the pl
44
46
  - **Local account switching** — discovers the multiple sign-in credentials WorkBuddy's desktop app leaves behind and lets you switch per region. Tokens are never written to DSH settings.
45
47
  - **Credit readout in the composer toolbar (follows the selected model, per region)** — a clickable readout sits at the left of the composer toolbar (e.g. `WB CN · 1,072`) and **switches region automatically with the model this session is using**: pick a WB CN model and it shows the domestic balance, pick a WB AI model and it shows the international one; with any other provider's model it does not appear at all. It refreshes every 5 minutes and can also be refreshed by hand. Clicking it opens a panel: a **table of balances per account**, where clicking a row switches to that account (the same setting the card's account picker writes), and an account that cannot serve right now is **labelled in its row** (e.g. rate-limited, out of credits) using the same wording as the account-pool table. The per-account figures are fetched only while the panel is open, so the 5-minute background refresh never multiplies by the account count. To turn a side off, untick “Show credits in the composer toolbar” under that region's tab on the card; while a region's provider is switched off, the readout disappears and its switch is disabled. The same block carries a “Position” line: by default the readout sits in the composer toolbar (the row with the model picker); choosing “below the input box” moves it to the turn/step/token stats row instead — useful when another plugin has taken the toolbar seat.
46
48
 
47
- <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a refresh button and a last-refreshed time" />
49
+ <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="Composer credit panel: a per-account balance table for the domestic region, with a one-click test button and a last-refreshed time (account names redacted)" />
48
50
  - **Account pool: batch actions across several accounts, plus automatic failover** — tick the accounts you want into a pool and you get **one-click check-in for every account**, **one-click testing of every account against a model you choose** (defaulting to the region's zero-multiplier free model), and with **Enable the account pool** switched on, **automatic failover**: a request that fails upstream is retried against the pool's other usable accounts, in order, until one serves it or none is left. Membership starts **empty and is an explicit opt-in**: "signed in on this machine" is not the same as "spend this account's credits and claim its rewards". The pool's **ranking decides who serves every request** (usable first, then expiring-credits-first, higher credits, freshest credential), and on a failure the next usable member is tried. It **never writes back your saved selection** — switch the pool off and your own account applies again immediately. **The switch only decides whether the PLUGIN chooses** — with it off, the plugin stops picking and switching accounts for you, but check-in and testing stay available (they are manual actions). Testing reuses the model rows' real-volume probe, so it **does cost real credits**; **automatic check-in is deliberately not offered** — a check-in claims real rewards, so it only ever runs when you press the button. See [Account pool](#account-pool).
49
51
  - **Benefits: a marker for what is unclaimed, plus a per-account Claim button** — the card marks, account by account, anything in the upstream's benefit campaigns (compensation / gifts) that **has not been claimed yet**. The data comes from the upstream's own two status endpoints; in the marking stage the plugin is **read-only** and never calls the two write endpoints. Three display rules all follow the upstream's state: `active && !claimed` shows the row, `claimed` removes it entirely, and an expired campaign (upstream flips `active` to `false`) disappears by itself — the plugin **never compares `end_time` against the local clock**, so a skewed machine cannot argue with the server; `end_time` only drives the "three days left" highlight. `active` alone is not enough to decide: an already-claimed account still reports `active: true`, and reading it alone would leave a claimed campaign hanging there forever. The read reuses the check-in pipeline (once per account, cached for 30 minutes, no request while the account pool is off), and a failing endpoint counts as "unread" rather than caching "nobody has benefits". Each row ends in a solid amber **Claim pill**, and one click claims **one campaign of one account**: there is no "claim everything", because a click spends one irrevocable real entitlement — and the write route **requires `accountId`**, so the request itself cannot batch. Before writing it **re-reads** the status and decides again; anything already claimed or expired returns 409 without touching upstream. After a successful claim it answers with the upstream's **fresh reading** and refreshes the card, with no optimistic update — even a silent upstream failure cannot leave a false "claimed" behind — and it also drops that account's cached benefit state, so the pill cannot linger on a stale status.
50
52
  - **Actionable advice when a credential is refused** — when the upstream rejects the selected account's token, the plugin actually **probes** the other local sign-ins: if one still answers, it tells you to switch to it (with a one-click switch) instead of vaguely asking you to sign in again — re-authenticating fixes nothing while you are pinned to a revoked backup. Only when there is genuinely no other local account does it ask you to sign in again.
@@ -78,7 +80,7 @@ Credentials are read (read-only) from the WorkBuddy desktop app's own auth file.
78
80
  ## Account pool
79
81
 
80
82
  <p align="center">
81
- <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy account pool: check-in all, test all, membership selection and automatic failover" />
83
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy account pool: check in the checked accounts, test the checked accounts, manual ordering and check-in state (account names redacted)" />
82
84
  </p>
83
85
 
84
86
  A machine usually carries more than one WorkBuddy sign-in. The **account pool** turns that into a single click: tick the accounts you want into the pool, then press **Check in selected** or **Test selected**; switch on **Enable the account pool** and the plugin will carry on through a failure by itself.
@@ -476,7 +478,7 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
476
478
 
477
479
  - **Switching region makes the context "grow", and can hit `code 11115` immediately — the gateway is not over-charging you.** The same history is counted under different buckets in the two regions, and the root cause is how the host's bundled `@earendil-works/pi-ai` replays history. It evaluates `isSameModel` for every assistant message (`transform-messages.js:68`, keyed on provider **and** api **and** model id all being equal): for a message that fails, its thinking is **downgraded to ordinary content** (`:88–96` — not dropped), and content is what the gateways count, while thinking goes out in a different field. This plugin registers the domestic and international editions as **two provider ids** (`workbuddy` / `workbuddy-global`, see [How it works](#how-it-works)) that carry the **same model id** — so one region switch moves **all the other side's thinking into the content bucket at once**. That is the cause of issue #59's "the same conversation history is charged 1.374M by the international gateway and 708K by the domestic one (1.94×)"; switching back is the same formula read the other way, 0.696×.
478
480
  - Three consequences you can see: a region switch can move the token count by ±30%–94%, and **how far depends only on the other side's thinking character count, not on total history length**; **switching models inside one region** triggers the same downgrade (the model leg of `isSameModel`), so the count can move without a region switch at all; and that jump lands on top of the host's current compaction threshold (`min(0.8 × window, window − 128000 − 65536)`), so a session that was comfortably under the limit can hit `code 11115 prompt is too long` the moment you switch region.
479
- - **This is a mismatch between the host and pi-ai, not a bug in this plugin**: not replaying thinking across providers/models is right in itself (another model's train of thought is not context this model should be fed), and the mistake is treating `provider` as a necessary condition for "the same model" when the two provider ids here are **two access regions of one model**. The fix belongs upstream (same api + same model id, providers as an equivalence group); on the plugin side the backstop is `src/context-rescue.ts` since 4.8.0 — **once the upstream confirms an overflow, it folds history in chunks and resends**, instead of replaying the entire range the host meant to compact (which is exactly what makes a compaction request overflow itself and deadlock). To see where it happened: set `WORKBUDDY_BODY_REPORT` to capture a body-composition report — it records counts only, never text.
481
+ - **This is a mismatch between the host and pi-ai, not a bug in this plugin**: not replaying thinking across providers/models is right in itself (another model's train of thought is not context this model should be fed), and the mistake is treating `provider` as a necessary condition for "the same model" when the two provider ids here are **two access regions of one model**. The fix belongs upstream (same api + same model id, providers as an equivalence group); on the plugin side the backstop is `src/context-rescue.ts` since 4.8.0 — **once the upstream confirms an overflow, it folds history in chunks and resends**, instead of replaying the entire range the host meant to compact (which is exactly what makes a compaction request overflow itself and deadlock). To see where it happened: set `WORKBUDDY_BODY_REPORT` to capture a body-composition report — it records counts only, never text. **It appends continuously**: one `<region>.jsonl` per region, rolled to `.1` past 32 MB (one old copy kept); unset the variable and delete the files when you are done — do not leave it on for long sessions.
480
482
 
481
483
  ## Disclaimer
482
484
 
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/dsh-connect-workbuddy-usage-card.png" width="640" alt="dsh-connect-workbuddy settings panel" />
2
+ <img src="docs/assets/dsh-connect-workbuddy-usage-card.png" width="640" alt="dsh-connect-workbuddy 设置卡:模型目录与倍率、图片与可关闭思考开关、活动标签(账号名已打码)" />
3
3
  </p>
4
4
 
5
5
  <h1 align="center">dsh-connect-workbuddy</h1>
@@ -41,6 +41,8 @@
41
41
  - **可单独关闭任一版本供应商** —— 每个 tab 右侧有一个勾选框(默认都勾选)。**取消勾选即把该供应商从 DSH 模型选择器里彻底撤掉**(不是只藏起 tab):它的路由与「设置 → 模型」页的目录条目一并摘除,启动时也不再为它发无用请求。账号、积分、目录与勾选**全部保留**,重新勾选即完整恢复。典型用法是「我根本用不到国际版」——关掉它,让选择器干净。注意:若某会话此前选中的正是被关闭供应商的模型,该会话再调用会报 `NO_ADAPTER`,卡片会在关闭态的 tab 上明确提示这一点,不会静默失败。
42
42
  - **设置卡片分四栏:账号 / 模型 / 能力 / 诊断** —— 原来这张卡片是一列 3000 多像素的长页,把两类互不相干的事情混排在一起:**运维账号**(谁在服务、还剩多少、切换顺序、签到日志)与**编辑目录**(勾模型、上下文预算、推理档、关闭思考)。它们连提交语义都不同——账号改动点一下即生效,目录改动是草稿、必须点保存——所以「保存」按钮常常离它要保存的东西隔着三屏。现在分栏,一栏只做一件事;**切栏既不会重新拉取,也不会丢掉未保存的目录草稿**(非活动栏留在页面里,只是通过 `hidden` 不可见,因此也不进入无障碍树)。第三栏「能力」放的是**授权类开关**——允许调用本机 CodeBuddy CLI、允许模型自主生图、积分读数显示在哪——它们点一下即生效、从不经过保存,而且每一个都留着长自己子选项的位置(CLI 的工作目录、生图的尺寸或价格上限),所以不挤在账号表格上方那一小条里。第四栏「诊断」是只读的事实表:当前区域、上游域名、真正在服务的账号名、登录有效期、目录里共几个模型、启用了几个、账号池开关,以及上游最近一次错误的**原文**(原样引用,不翻译、不概括)——这一栏存在的唯一理由就是能被整段贴进问题反馈,所以里面没有任何会改设置的控件。
43
43
  - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;**图片输入按厂商核验表判定**:刷新时只有厂商文档明确写为原生多模态的模型才自动勾选,文档写明纯文本的不勾,**没有厂商资料的一律按「未核实」不猜**(上游的 `supportsImages` 是平台侧声明,不等于模型能看图,不参与判定)。用户仍可随时手动勾选,勾选状态保存在本地。**每个模型行还有「可关闭思考」(`off`)勾选框**:决定要不要为该模型提供「完全关闭思考」这一档位。默认由内置复核名单给出(上游声称可关闭、实测却对它返回 HTTP 400 的 9 个模型默认不勾),你可以按模型改勾/取消——**取消**用于当前不需要该档位的模型;**名单内那 9 个模型不可勾选**(行上标「拒绝 off」,悬停有说明):该档位被勾上后不只是多一个选项,pi-ai 会把它当作**所有未指定档位请求的默认值**,压缩摘要请求正属于这一类,勾一次等于让该模型的每一次 `/compact` 都 400 / 11133(issue #83)。名单外的模型仍完全由你的勾选与自身声明决定;首次保存即写入你的勾选,之后不再重算。
44
+
45
+ <img src="docs/assets/dsh-connect-workbuddy-model-picker.png" width="420" alt="DSH 模型选择器:同一枚活动徽标(夜间折扣 / Free now)画在模型名之后(无账号信息)" />
44
46
  - **模型行上的活动徽标** —— 上游的模型目录里带着活动信息(免费试用、夜间折扣、限时免费、订阅优先等),插件**不新增任何请求**(复用刷新模型时本来就会拉的那份目录配置),直接在模型行上画一枚胶囊徽标:颜色与文案都由上游给出,悬停能看到上游的活动说明、活动的起止日期与每日生效时段(例如「夜间 23:00–7:50」)。**徽标是「此刻」的函数**:活动只在它自己声明的时间窗内显示,窗口一关徽标就自己消失;跨零点的时段按上游声明的时区判断;同一个模型命中多条活动时只画优先级最高的那条。上游没给结束时间的权益标记(如「订阅优先」)则常驻,直到上游把它下架。**徽标只是提示,不改变模型行为**——它标注的是上游此刻给出的活动与折扣,不替你做选择。**同一枚徽标也出现在 DSH 的模型选择器里**:菜单每一行都在模型名之后、队列图标之前画同样的胶囊 —— 菜单行用上游的短词(「夜间」「限免」),输入框旁那个触发器用完整词(「夜间折扣」「独家限免」);判定用的是同一份数据、同一个时钟,因为选模型的那一屏正好是最该看到「现在免费吗」的地方。(两个区域的上游目录都下发活动字段:国内版按 App 身份读 `/v3/config` 时带 8 条促销 + 1 条等级标记,国际版带 5 条 + 1 条;取证与判定规则见 [docs/PROMO_BADGE.md](docs/PROMO_BADGE.md)。)
45
47
  - **模型行上的繁忙程度(仅国内版)** —— 上游另有一条只读探针(`POST /v2/chat/busy`,一次最多 50 个 id,免费、不动账号状态)回答「这个模型此刻多忙」,插件在每次刷新目录时顺带问一次,把等级画成模型名旁边的一枚小人图标:排队人数 = 小人个数(正常 1 个黄人 / 轻微拥堵 2 个橙人 / 极度拥堵 3 个红人),**空闲**则是一枚**绿色小火箭**(没人排队 = 可以全速跑)——空闲不画成「绿色的 1 个人」,因为 12px 下「没人等」与「有 1 个人在等」必须一眼分开;**不可用**是一个划掉的小人。探针没回答的 `unknown` **一律不画**,**「没被告知」不会被当成「一切正常」**;颜色跟随主题,`title` 悬停文字(「极度拥堵」四个字)仍在。**等级不落盘**:目录是能活过重启的事实,负载只是那一分钟的事实,所以等级只盖在运行时目录上、随进程消失,探针失败只记一条 warn,绝不挡住目录刷新。国内版实测这条探针在 CLI 与 App 两种身份下答案逐字节相同,50 个 id 的上限也真实存在(62 个 → HTTP 400),19 个模型 186 ms 返回。**国际版没有这个徽标,因为国际网关根本没有这条接口** —— 实测 `www.workbuddy.ai` 对 `/v2/chat/busy` 回 **404**(`/v3/chat/busy`、`/v2/models/busy` 等同族路径也全是 404),而同一份凭据读它的 `/v3/config` 正常返回 28 个模型、**没有任何负载/排队字段**。这不是本插件没实现,也不是配置或权限问题:**上游国际版从未提供这个数据**。所以国际版只显示模型名与活动徽标,探针那条请求失败后安静退化成「没有徽标」,不会挡住模型列表,也不会用延迟或积分去猜一个「忙不忙」。
46
48
  - **排队时说清楚你排在第几位** —— 上游用三个业务码表达排队(`6020` 等待、`6021` 队列满、`6022` 已达上限)。撞上它们时,插件不再给一句上游报错,而是「模型排队中:你排在第 8777 位,队列共 9021 个请求。请稍后重新发送这条消息,或换一个模型」。**排队不会触发换号**:队列跟随的是模型与平台级的队伍,换个账号拿到的还是同一个位次,所以这里既不走账号池也不额外烧请求(上游 `/v2/chat/queue/status` 回的 `message` 是 i18n key 而不是人话,插件因此从不转述它,只用自己的措辞)。**排队不会自动重发,也不会被自动重试**——插件没有排队轮询循环(它需要排队答案在 OpenAI 兼容通道上的真实信封,没测到就不写),并且明确告诉宿主不要重试(`x-should-retry: false`):一次排队在几千位之后,重试十几次也轮不到,只会把真实请求白白花掉。位次报出来,由你决定是重发还是换模型。
@@ -51,7 +53,7 @@
51
53
  - **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
52
54
  - **对话输入框积分读数(国内版 / 国际版自动切换)** —— 对话输入框左侧工具栏常驻一行可点击的读数(如 `WB CN · 1,072`),**按本会话当前选中的模型自动切换区域**:选中 WB CN 的模型就显示国内版余额,选中 WB AI 的模型就显示国际版余额;用其它 provider 的模型时该读数完全不出现。每 5 分钟自动刷新,也可点开手动刷新。点击读数弹出面板:**按账号列出的余额表格**,点某一行即可切换到该账号(与卡片上的账号选择是同一个设置);**当前不能用的账号会在行内标出原因**(如「被限流」「积分耗尽」),标注口径与账号池表格一致。面板里的余额只在打开时才去查,因此不会让 5 分钟的后台刷新按账号数翻倍。要关掉某一边,就在卡片对应区域的 tab 下取消勾选「在输入框工具栏显示积分读数」;该区域的供应商被关闭时,读数不显示、开关也置灰。同一个开关下面还有「显示位置」这一行:默认在输入框工具栏(与模型选择同一行),改成「输入框下方」后读数落到轮次/步数/token 统计那一行——装了别的插件把工具栏座位占掉时用得上。
53
55
 
54
- <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、刷新积分按钮与最近刷新时间" />
56
+ <img src="docs/assets/dsh-connect-workbuddy-composer-credits.png" width="360" alt="对话输入框积分面板:国内版账号余额表格、一键测试按钮与最近刷新时间(账号名已打码)" />
55
57
  - **账号池:多账号批量操作与自动换号** —— 把要用的账号勾进池子,即可**签到勾选的账号**、**测试勾选的账号 × 你指定的模型**(默认挑该区域倍率为 0 的免费模型);开启**启用账号池**即同时打开**自动换号**——某个请求在上游失败时,插件会依次用池内其他可用账号重试,直到有一个接手、或全部不可用为止,然后才把错误报给你。成员**默认一个都不含**、必须显式勾选(「本机有登录」不等于「要拿它去签到和花积分」);**开启后由池子的排序决定每一次请求用谁**(可用优先,其次快过期先花、积分高、凭据更新的);失败时依次用池内其他可用账号重试。排序**从不写回你保存的选择**——关掉开关,你选定的账号立刻完全生效。只有请求本身不合法(HTTP 400)不换号:同一个请求体换谁都是 400,重试只会白等。**这个开关只管「插件要不要自己决定」**:关掉它,插件不再替你选账号、也不再替你换号,但**签到与测试仍然可用**(它们是手动动作)。测试走的是与模型行同一套真实体积探测,因此**有真实成本**;**不提供自动签到**——签到会真实领取奖励,只在你点按钮时执行。详见[账号池](#账号池)。
56
58
  - **福利活动:待领取标记 + 单账号「领取」按钮** —— 卡片会逐账号标出上游活动(补偿 / 赠礼)里**还没领的那一份**,数据来自上游自己的两个状态接口,标记阶段**只读**、两个写接口一次都不调用。显示规则完全由上游状态决定:`active && !claimed` 才显示,`claimed` 整行消失,活动过期(`active` 翻 `false`)自动消失——**不用本地时钟去比 `end_time`**,本机时间偏了也不会与服务端打架;`end_time` 只用于 3 天内临期高亮。`active` 单独看不行:已领取的账号 `active` 仍为 `true`,只看它会让领过的账号永远挂着待领取。读取复用签到的管线(每账号一次、30 分钟缓存、账号池关闭时不发请求),任一接口失败按「未读」处理,不会把一次失败缓存成「全都没福利」。行尾是一个实心琥珀色的**胶囊按钮「领取」**,一次只领**一个账号的一个活动**:没有「全部领取」,因为一次点击花掉的是一份不可撤销的真实权益,写路由也**强制要求 `accountId`**,请求本身无法批量领取。领取前**重新读取**状态再判定,已领取或已过期一律 409 且不调用上游;领取后用上游的**新读数**作答并刷新卡片,不做乐观更新——即使上游静默失败也不会留下「已领取」的假象,成功后同时清掉该账号的福利缓存,按钮不会顶着旧状态赖着不走。
57
59
  - **凭据失效时给出可执行的建议** —— 上游拒绝当前账号的 token 时,插件会**实际探测**本机其他账号:若有账号仍然可用,就直接告诉你「切换到它即可」(并附一键切换),而不是笼统地让你重新登录——被钉在一个作废的旧备份上时,重新登录并不能解决问题。只有本机确实没有别的账号可切换时,才会提示重新登录。
@@ -85,7 +87,7 @@ DSH PiAiAdapter(每个 provider 一套)
85
87
  ## 账号池
86
88
 
87
89
  <p align="center">
88
- <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池区块:签到勾选的账号、测试勾选的账号、成员勾选与自动换号" />
90
+ <img src="docs/assets/dsh-connect-workbuddy-account-pool.png" width="900" alt="dsh-connect-workbuddy 账号池:签到勾选的账号、测试勾选的账号、成员排序与签到状态(账号名已打码)" />
89
91
  </p>
90
92
 
91
93
  一台机器上往往登录过多个 WorkBuddy 账号。**账号池**把这件事收成一次点击:把要用的账号勾进池子,然后「签到勾选的账号」或「测试勾选的账号」;再打开**启用账号池**,插件就会在请求失败时自动换号接着跑。
@@ -489,7 +491,7 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
489
491
  - **这是宿主与 pi-ai 之间的一处错配,不是本插件的 bug**:不跨 provider/模型重放 thinking 这件事本身是对的(别家模型的思考过程对当前模型没有上下文价值),错在把 `provider` 当作「是不是同一个模型」的必要条件,而这里两个 provider 字符串指的是**同一个模型的两个接入区域**。
490
492
  - **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。**4.9.0/4.9.1 的这版修复漏了一半,国际版整区不可用(issue #75)**:上面的说法只对 `getModel(provider, id)` 这一条按**参数**查表的路径成立;真正跑聊天的 `models.streamSimple(model, …)` 第一步是 `requireProvider(model)` = `providers.get(model.provider)`(`models.js:351–355`),查的是**模型上盖的那个字段**。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在只注册了 `workbuddy-global` 的注册表里查不到,**每一个请求都在发出前就抛 `Unknown provider: workbuddy`**——一次也会失败,且报错里不带任何 WorkBuddy 字样,看起来像宿主坏了。**4.9.2 起改为:每条路由把自己的注册表同时登记上「路由 id」和「共享身份」两个键**(`src/adapter.ts` 的 `modelIdentityKeysOf`),模型上盖的身份仍然两边相同(#59 不回退),而 `requireProvider` 在国际版自己的注册表里也能查到、并拿到**国际版自己的** provider 对象(含它自己的 shim 地址与凭据)。pi-ai 的注册表本来就是 `Map`(`models.js:37` 的 `providers.set(provider.id, provider)`,键取的是 **provider 对象的 `id`**,不是 profiles 映射的键),所以这是注册表原生支持的事;**路由集合、模型选择器、目录与凭据都没有变化**,多出来的那个键不出现在任何 UI 里。回归测试 `tests/issue75-global-provider-dispatch.spec.ts` 走的是宿主的真实构造(`createModels` + `setProvider` + `streamSimple`),并保留一个反证用例固定住旧的失败文案。详见 `docs/audit/ISSUE59-OPTIONS.md` 的「#75 CORRECTION」一节。
491
493
  - 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的);**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
492
- - 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。
494
+ - 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。**注意它会持续追加**:每个区一份 `<region>.jsonl`,超过 32 MB 自动滚成 `.1`(只保留一份旧档);排查完请关闭该环境变量并删除报告文件,不要开着它长期跑。
493
495
 
494
496
  ## 免责声明
495
497
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-CVnH8S3J.js";
2
+ import { C as defaultDesktopAuthCandidates, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as hostPeersInfo, a as readHostHeartbeat, b as WorkBuddyCredentialStore, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as legacyWorkbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, wt as findWorkbuddyAppExecutableWithSource, y as WORKBUDDY_AUTH_FILE_ENV } from "./host-heartbeat-hcH37MkG.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
@@ -204,15 +204,166 @@ window.__ModuleLoader__.load({
204
204
  * counts as the whole section only when it shows a key that is neither a region
205
205
  * id nor a known sibling — i.e. it looks like a settings document. A plain (or
206
206
  * dirty) `cn` / `global` map is returned as-is.
207
+ *
208
+ * THE RESULT IS HEALED, not merely narrowed — see {@link healedRegionsMap}.
209
+ * Returning the map "as-is" is what let a corrupted document keep reading as
210
+ * half-empty: the field held `cn` at level 0 and `global` only one level down,
211
+ * so every `global` question answered "never configured" even though the file
212
+ * still had the answer. Reads and writes both come through here, so healing
213
+ * here fixes both halves at once (the Host's `regionStateOf`, the card's
214
+ * `nextRegion*` write bases, and the catalog repair's write).
207
215
  */
208
216
  function regionsMapOf(value) {
217
+ const peeled = peelLiveReference(value);
218
+ if (typeof peeled === "object" && peeled !== null && !Array.isArray(peeled)) {
219
+ const candidate = peeled;
220
+ if (!isRecord(candidate["regions"]) && !carriesForeignKey(candidate)) return candidate;
221
+ }
209
222
  const unwrapped = unwrapVolatileDeep(value);
210
223
  if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
211
224
  const record = unwrapped;
212
225
  const nested = record["regions"];
213
- if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return record;
214
- const regionIds = WORKBUDDY_REGIONS;
215
- return Object.keys(record).some((key) => regionIds.includes(key)) ? record : nested;
226
+ if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return healedRegionsMap(record);
227
+ return healedRegionsMap(hasRegionKey(record) ? record : nested);
228
+ }
229
+ /** `value` with ONE `{get(): T}` live reference peeled, exactly like a read. */
230
+ function peelLiveReference(value) {
231
+ if (value === null || typeof value !== "object") return value;
232
+ const get = value.get;
233
+ return typeof get === "function" ? get() : value;
234
+ }
235
+ /** Whether the object carries a sibling-field name where a region id belongs. */
236
+ function carriesForeignKey(record) {
237
+ return Object.keys(record).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
238
+ }
239
+ /**
240
+ * Names that belong to the settings SECTION (`Config` in `src/index.ts`) or to
241
+ * one region SLOT (`regionStateConfig`) — never to the `regions` map.
242
+ *
243
+ * They are the signature of a data-loss bug and the list that undoes it. From
244
+ * 5.4.0 to 5.5.0 `mergeRegionCatalog` was handed the WHOLE settings section but
245
+ * read it as the `regions` field, so every boot's directory repair wrote
246
+ * `{ accounts, regions, cliBridge }` INTO the field — replacing the region map
247
+ * and burying the real one under a `regions` key. A live profile ended up five
248
+ * levels deep (one per startup) and 258 KB of duplicated slots.
249
+ *
250
+ * Neither a region id (`cn` / `global`) nor any future one can collide with
251
+ * these names, so removing them from the map is safe, idempotent, and does not
252
+ * discard a region that a newer build might add.
253
+ */
254
+ const REGION_MAP_FOREIGN_KEYS = /* @__PURE__ */ new Set([
255
+ "authFile",
256
+ "accountId",
257
+ "accounts",
258
+ "regions",
259
+ "cliBridge",
260
+ "enabled",
261
+ "showCreditsInMainUi",
262
+ "creditPosition",
263
+ "lastCatalog",
264
+ "enabledModelIds",
265
+ "imageModelIds",
266
+ "imageToolEnabled",
267
+ "offModelIds",
268
+ "contextBudgets",
269
+ "pool"
270
+ ]);
271
+ /** A non-null, non-array object: the only shape a slot or a level can have. */
272
+ function isRecord(value) {
273
+ return typeof value === "object" && value !== null && !Array.isArray(value);
274
+ }
275
+ /** Whether a map carries at least one real region id. */
276
+ function hasRegionKey(map) {
277
+ return Object.keys(map).some((key) => WORKBUDDY_REGIONS.includes(key));
278
+ }
279
+ /**
280
+ * How much USER CONFIG one candidate slot actually carries.
281
+ *
282
+ * Used only when the map lost a region and the copies left at different depths
283
+ * disagree — and they do: the bug's own directory repair wrote a slot that held
284
+ * nothing but `lastCatalog` (it started from a map where the region was already
285
+ * missing), while the copy further down still holds the pool the user had
286
+ * checked in. "Shallowest wins" would therefore resurrect the EMPTY one and
287
+ * throw away the real configuration. Depth is not age in this wreckage; CONTENT
288
+ * is the only usable signal.
289
+ *
290
+ * Deliberately counts only non-default, user-authored values: resolved slots
291
+ * carry every schema default, so a slot's key count says nothing.
292
+ */
293
+ function slotConfigWeight(slot) {
294
+ let weight = 0;
295
+ const pool = slot["pool"];
296
+ if (isRecord(pool)) {
297
+ if (pool["enabled"] === true) weight += 4;
298
+ if (Array.isArray(pool["memberAccountIds"]) && pool["memberAccountIds"].length > 0) weight += 4;
299
+ if (Array.isArray(pool["orderAccountIds"]) && pool["orderAccountIds"].length > 0) weight += 2;
300
+ if (typeof pool["targetModelId"] === "string" && pool["targetModelId"] !== "") weight += 2;
301
+ if (typeof pool["probeInputTokens"] === "number" && pool["probeInputTokens"] > 0) weight += 1;
302
+ }
303
+ for (const key of [
304
+ "enabledModelIds",
305
+ "imageModelIds",
306
+ "offModelIds"
307
+ ]) {
308
+ const list = slot[key];
309
+ if (Array.isArray(list) && list.length > 0) weight += 1;
310
+ }
311
+ const budgets = slot["contextBudgets"];
312
+ if (isRecord(budgets) && Object.keys(budgets).length > 0) weight += 1;
313
+ if (slot["enabled"] === false) weight += 1;
314
+ if (slot["showCreditsInMainUi"] === false) weight += 1;
315
+ if (slot["imageToolEnabled"] === true) weight += 1;
316
+ return weight;
317
+ }
318
+ /**
319
+ * The `regions` map with a corrupted document's wreckage removed.
320
+ *
321
+ * Two repairs, both keyed on the bug's signature (a foreign key or a nested
322
+ * `regions` copy) so a HEALTHY map passes through untouched:
323
+ *
324
+ * 1. DROP every key that names a section field or a slot field. Those are the
325
+ * copies the bug wrote; keeping them means the next save carries them
326
+ * forward forever.
327
+ * 2. RECOVER a region this level does not have from the copies buried beneath
328
+ * it. A region that IS here is never replaced — the outer copy is the one
329
+ * the surviving writers have been updating, and re-reading a stale inner
330
+ * copy over it is exactly the defect the shape rule above was fixed for.
331
+ *
332
+ * Recovery exists because `global` in the measured profile lived ONLY one level
333
+ * down: without it, the host reads that region as "never configured" and the
334
+ * first write after the fix would delete the user's international pool outright.
335
+ *
336
+ * Among the buried copies of a MISSING region, the one carrying the most user
337
+ * configuration wins rather than the shallowest — see {@link slotConfigWeight}:
338
+ * the bug's own directory repair left `{ lastCatalog }` slots that look newer
339
+ * but hold nothing, one level above the copy with the pool still in it.
340
+ */
341
+ function healedRegionsMap(map) {
342
+ const healed = {};
343
+ for (const [key, slot] of Object.entries(map)) {
344
+ if (REGION_MAP_FOREIGN_KEYS.has(key)) continue;
345
+ healed[key] = slot;
346
+ }
347
+ const recovered = {};
348
+ let deeper = map;
349
+ for (let depth = 0; depth < 8; depth += 1) {
350
+ const nested = deeper["regions"];
351
+ if (!isRecord(nested)) break;
352
+ for (const region of WORKBUDDY_REGIONS) {
353
+ if (region in map) continue;
354
+ const candidate = nested[region];
355
+ if (!isRecord(candidate)) continue;
356
+ const weight = slotConfigWeight(candidate);
357
+ const best = recovered[region];
358
+ if (best === void 0 || weight > best.weight) recovered[region] = {
359
+ slot: candidate,
360
+ weight
361
+ };
362
+ }
363
+ deeper = nested;
364
+ }
365
+ for (const [region, entry] of Object.entries(recovered)) healed[region] = entry.slot;
366
+ return healed;
216
367
  }
217
368
  /** One region's stored slot as a plain object; any other shape reads as empty. */
218
369
  function regionSlotOf(value, region) {
@@ -537,7 +688,9 @@ window.__ModuleLoader__.load({
537
688
  * value, so reading it wrong silently deleted the untouched region.
538
689
  */
539
690
  function fieldSnapshotOf(scope, field) {
540
- const value = unwrapVolatileDeep(scope.getSnapshot().value?.[field]);
691
+ const stored = scope.getSnapshot().value?.[field];
692
+ if (field === "regions") return regionsMapOf(stored);
693
+ const value = unwrapVolatileDeep(stored);
541
694
  return typeof value === "object" && value !== null ? value : {};
542
695
  }
543
696
  /**
@@ -884,12 +1037,18 @@ window.__ModuleLoader__.load({
884
1037
  * `orderAccountIds` may be anything at all. Every shape that is not a list of
885
1038
  * strings means "no manual order", which is also an older profile's spelling —
886
1039
  * never a crash.
1040
+ *
1041
+ * The map comes from `regionsMapOf` rather than a bare `value.regions` lookup:
1042
+ * it accepts the section OR the field and heals a field an older build
1043
+ * corrupted (the "restart reset" report). That is not cosmetic here — on the
1044
+ * measured profile the INTERNATIONAL slot survived only one level down, so a
1045
+ * direct lookup reported "no manual order" for the region whose order the user
1046
+ * had actually saved, and the credit panel listed those accounts in the Host's
1047
+ * order instead.
887
1048
  */
888
1049
  function readOrderAccountIds(value, region) {
889
1050
  if (region === void 0) return [];
890
- const regions = value?.regions;
891
- if (typeof regions !== "object" || regions === null) return [];
892
- const slot = regions[region];
1051
+ const slot = regionsMapOf(value)[region];
893
1052
  if (typeof slot !== "object" || slot === null) return [];
894
1053
  const order = slot.pool;
895
1054
  if (typeof order !== "object" || order === null) return [];
@@ -8989,7 +9148,7 @@ window.__ModuleLoader__.load({
8989
9148
  "row.tabCn": "Domestic",
8990
9149
  "row.tabGlobal": "Global",
8991
9150
  "row.tabHint": "Each tab is a separate provider (workbuddy / workbuddy-global) with its own account, credits, and models. Both sides are live at once: different sessions can pick from either side, and changes on one tab never touch the other.",
8992
- "row.tabFreeQuotaWarn": "Free models have a per-window token quota — they are not unlimited.",
9151
+ "row.tabFreeQuotaWarn": "Free models are not unlimited — the upstream limits them by request size and load.",
8993
9152
  "row.tabSwitchHint": "Uncheck to switch this provider off. Its models disappear from the DSH model picker; your account, credits, and model settings are kept and return when you check it again.",
8994
9153
  "row.tabSwitchAria": "Offer the {region} provider to DSH",
8995
9154
  "row.tabOffNotice": "This provider is switched off: none of its models are offered to DSH. Your account, credits, and model settings are kept — check the box above the tab to bring it back.",
@@ -9229,7 +9388,7 @@ window.__ModuleLoader__.load({
9229
9388
  "row.poolSettingsTitle": "More settings",
9230
9389
  "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.",
9231
9390
  "row.poolEnabled": "Enable the account pool",
9232
- "row.poolEnabledHint": "On: the pool picks the account for every request and moves to the next one when it fails. Off: the account you select serves every request, and a failure is reported as it is.",
9391
+ "row.poolEnabledHint": "On: the pool picks the account for every request, and only a failover-worthy failure moves to the next account — a malformed request, a busy model, a platform queue, or a channel-gate refusal does not (switching accounts would only burn quota for the same refusal). Off: the account you select serves every request, and a failure is reported as it is.",
9233
9392
  "row.poolModeAuto": "picking automatically",
9234
9393
  "row.poolModeManual": "you pick",
9235
9394
  "row.poolManualAccountLabel": "Account in use",
@@ -9309,12 +9468,12 @@ window.__ModuleLoader__.load({
9309
9468
  "row.poolRowRemoving": "Removing the account…",
9310
9469
  "row.poolRowRemoveDesktopOnly": "{accountName}: hidden from the list — this account comes from the desktop app's own sign-in, which is left untouched. 「Detect accounts again」 re-adds it.",
9311
9470
  "row.cliBridgeLabel": "Let the local CodeBuddy CLI do the work",
9312
- "row.cliBridgeHint": "Adds the codebuddy_ask tool and the /codebuddy command. Each call spends CodeBuddy quota (and, with injectResult on, one DSH turn). Off by default.",
9471
+ "row.cliBridgeHint": "Adds the codebuddy_ask tool and the /codebuddy command. Each call spends CodeBuddy quota. injectResult only affects /codebuddy — its result is injected back into the session, costing one DSH turn when the session is idle; tool calls never read it. Rate-limited: 6 turns per session per hour, 20 per process per hour, one turn at a time. Off by default.",
9313
9472
  "row.imageToolLabel": "Let the model draw images",
9314
9473
  "row.imageToolHint": "Adds the workbuddy_draw_image tool, which the MODEL may call on its own — about 5.71 credits per image, with no per-call confirmation. Off means the tool is not registered at all.",
9315
9474
  "row.showCreditsCn": "Show credits in the composer toolbar",
9316
9475
  "row.showCreditsGlobal": "Show credits in the composer toolbar",
9317
- "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
9476
+ "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a one-click test of the checked accounts (the test spends real credits). The readout refreshes by itself every 5 minutes; the per-account table every 60 seconds while it is open.",
9318
9477
  "row.creditPositionLabel": "Position",
9319
9478
  "row.creditPositionComposer": "composer toolbar",
9320
9479
  "row.creditPositionStats": "below the input box",
@@ -9325,7 +9484,7 @@ window.__ModuleLoader__.load({
9325
9484
  "quotaNotice.fallbackHead": "The quota for this request is exhausted",
9326
9485
  "quotaNotice.supplement": " (some models also have a daily limit that is unrelated to your credits)",
9327
9486
  "row.creditPositionHint": "Where the credit readout sits. \"composer toolbar\" is the default (the row with the model picker). Choose \"below the input box\" — the row carrying the round / step / token statistics — if another plugin occupies the composer toolbar and the readout does not appear.",
9328
- "row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
9487
+ "row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a one-click test of the checked accounts (the test spends real credits). The readout refreshes by itself every 5 minutes; the per-account table every 60 seconds while it is open.",
9329
9488
  "composer.pointsCn": "WB CN",
9330
9489
  "composer.pointsGlobal": "WB AI",
9331
9490
  "composer.panelTitleCn": "WB CN credits",
@@ -9367,7 +9526,7 @@ window.__ModuleLoader__.load({
9367
9526
  "row.tabCn": "国内版",
9368
9527
  "row.tabGlobal": "国际版",
9369
9528
  "row.tabHint": "每个 tab 是一个独立供应商(workbuddy / workbuddy-global),各有自己的账号、积分与模型。两边同时生效:不同会话可各选一边,一侧的改动不影响另一侧。",
9370
- "row.tabFreeQuotaWarn": "免费模型有时间段内定额 Token 上限,并非无限制使用。",
9529
+ "row.tabFreeQuotaWarn": "免费模型并非无限量:上游按单次请求体积与负载限流。",
9371
9530
  "row.tabSwitchHint": "取消勾选即关闭该供应商:它的模型不再出现在 DSH 模型选择器里;账号、积分与模型设置都会保留,重新勾选即恢复。",
9372
9531
  "row.tabSwitchAria": "是否向 DSH 提供{region}供应商",
9373
9532
  "row.tabOffNotice": "该供应商已关闭:它的模型不会提供给 DSH。账号、积分与模型设置均已保留——勾选上方复选框即可恢复。",
@@ -9607,7 +9766,7 @@ window.__ModuleLoader__.load({
9607
9766
  "row.poolSettingsTitle": "更多设置",
9608
9767
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
9609
9768
  "row.poolEnabled": "启用账号池",
9610
- "row.poolEnabledHint": "开启:由账号池为每次请求挑号,失败就换下一个。关闭:你选的账号服务每一次请求,失败如实报错。",
9769
+ "row.poolEnabledHint": "开启:由账号池为每次请求挑号;只有可换号的失败才换下一个——请求本身不合法、模型繁忙、平台排队、渠道校验这四类不换(换号只会为同一个拒绝白烧一次配额)。关闭:你选的账号服务每一次请求,失败如实报错。",
9611
9770
  "row.poolModeAuto": "自动挑号",
9612
9771
  "row.poolModeManual": "手动选号",
9613
9772
  "row.poolManualAccountLabel": "使用哪个账号",
@@ -9687,12 +9846,12 @@ window.__ModuleLoader__.load({
9687
9846
  "row.poolRowRemoving": "正在删除账号…",
9688
9847
  "row.poolRowRemoveDesktopOnly": "{accountName}:已从列表中隐藏——这个账号来自桌面 App 自己的登录,该登录未被改动。点「重新检测账号」会重新加入。",
9689
9848
  "row.cliBridgeLabel": "允许调用本机 CodeBuddy CLI 干活",
9690
- "row.cliBridgeHint": "开启后多出 codebuddy_ask 工具与 /codebuddy 指令;每次调用花 CodeBuddy 配额(injectResult 开着时还会再花一次 DSH token)。默认关闭。",
9849
+ "row.cliBridgeHint": "开启后多出 codebuddy_ask 工具与 /codebuddy 指令;每次调用花 CodeBuddy 配额。injectResult 只影响 /codebuddy:其结果回注会话,会话空闲时多花一次 DSH 回合;工具调用不读它。限流:每会话每小时 6 回合、每进程每小时 20 回合、同时只跑一个回合。默认关闭。",
9691
9850
  "row.imageToolLabel": "允许模型自主生图",
9692
9851
  "row.imageToolHint": "开启后模型可以自己调用 workbuddy_draw_image 画图——约 5.71 积分/张,没有逐次确认。关闭时该工具根本不会注册。",
9693
9852
  "row.showCreditsCn": "在输入框工具栏显示积分读数",
9694
9853
  "row.showCreditsGlobal": "在输入框工具栏显示积分读数",
9695
- "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
9854
+ "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并一键测试勾选的账号(测试会真实消耗积分)。读数每 5 分钟自动刷新;各账号明细在面板打开时每 60 秒刷新。",
9696
9855
  "row.creditPositionLabel": "显示位置",
9697
9856
  "row.creditPositionComposer": "输入框工具栏",
9698
9857
  "row.creditPositionStats": "输入框下方",
@@ -9703,7 +9862,7 @@ window.__ModuleLoader__.load({
9703
9862
  "quotaNotice.fallbackHead": "当前请求的额度已用尽",
9704
9863
  "quotaNotice.supplement": "(部分模型另有每日额度,与积分无关)",
9705
9864
  "row.creditPositionHint": "积分显示在哪里。「输入框工具栏」是默认位置(与模型选择同一行)。如果装了别的插件后读数不显示,改成「输入框下方」—— 那是「轮次 / 步数 / token」统计所在的那一行。",
9706
- "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
9865
+ "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并一键测试勾选的账号(测试会真实消耗积分)。读数每 5 分钟自动刷新;各账号明细在面板打开时每 60 秒刷新。",
9707
9866
  "composer.pointsCn": "WB CN",
9708
9867
  "composer.pointsGlobal": "WB AI",
9709
9868
  "composer.panelTitleCn": "WB CN 积分",
@@ -5966,7 +5966,7 @@ function generationOf(version) {
5966
5966
  //#endregion
5967
5967
  //#region src/version.ts
5968
5968
  /** The npm package version this build was produced from. */
5969
- const WORKBUDDY_CONNECT_VERSION = "5.5.0";
5969
+ const WORKBUDDY_CONNECT_VERSION = "5.5.2";
5970
5970
  //#endregion
5971
5971
  //#region src/host-heartbeat.ts
5972
5972
  /**
package/lib/index.d.ts CHANGED
@@ -2911,6 +2911,57 @@ declare function withWorkBuddyRegion(path: string, region: WorkBuddyWebRegion):
2911
2911
  * answer 400 instead of guessing.
2912
2912
  */
2913
2913
  declare function regionOfStatusUrl(url: string): WorkBuddyWebRegion | undefined;
2914
+ /**
2915
+ * Narrow a settings value to the `regions` map. Accepts EITHER the whole
2916
+ * settings section (the Host's resolved `Config`) OR the `regions` map itself,
2917
+ * and unwraps the former. This tolerance is deliberate: passing the whole
2918
+ * section where the map was expected was a real shipped bug in the sibling
2919
+ * project — the lookup then read `section['cn']` (absent), so the card's
2920
+ * checkbox reported `true` forever and clicking it appeared to do nothing even
2921
+ * though the write succeeded.
2922
+ *
2923
+ * The resolved section delivers `regions` as a `{get(): T}` LIVE reference, and
2924
+ * a live reference is `typeof === 'object'` and not an array — so it passes a
2925
+ * naive object check and gets returned as if it were the map. Every lookup on
2926
+ * it is then `undefined`: the enabled flag reads back as "on" forever (the very
2927
+ * symptom described above), and a merge that spreads this map silently drops
2928
+ * the region it was not editing. Unwrap before narrowing.
2929
+ *
2930
+ * THE HOP IS CONDITIONAL, and that is the whole point (a saved manual pool
2931
+ * order "disappearing"). "Has a `regions` key" is NOT sufficient evidence that
2932
+ * the caller passed the whole section: the `regions` MAP can legitimately carry
2933
+ * a `regions` key of its own, because an earlier build could write a sibling
2934
+ * field name into it — a live profile was found with `accounts`, `regions` and
2935
+ * `cliBridge` sitting beside `cn` / `global`. Hopping there means reading a
2936
+ * DIFFERENT, older copy of the same region: measured, the same document returned
2937
+ * `['c','a','b']` when read as a section and `[]` when read as a map. So a value
2938
+ * counts as the whole section only when it shows a key that is neither a region
2939
+ * id nor a known sibling — i.e. it looks like a settings document. A plain (or
2940
+ * dirty) `cn` / `global` map is returned as-is.
2941
+ *
2942
+ * THE RESULT IS HEALED, not merely narrowed — see {@link healedRegionsMap}.
2943
+ * Returning the map "as-is" is what let a corrupted document keep reading as
2944
+ * half-empty: the field held `cn` at level 0 and `global` only one level down,
2945
+ * so every `global` question answered "never configured" even though the file
2946
+ * still had the answer. Reads and writes both come through here, so healing
2947
+ * here fixes both halves at once (the Host's `regionStateOf`, the card's
2948
+ * `nextRegion*` write bases, and the catalog repair's write).
2949
+ */
2950
+ declare function regionsMapOf(value: unknown): Record<string, unknown>;
2951
+ /**
2952
+ * Whether the stored `regions` FIELD still carries the debris described at
2953
+ * {@link REGION_MAP_FOREIGN_KEYS}.
2954
+ *
2955
+ * Pass the field itself (`Config['regions']`), not the whole section: the
2956
+ * section legitimately has `accounts` / `cliBridge` beside `regions`, so asking
2957
+ * that question of a section would always answer "corrupt".
2958
+ *
2959
+ * The catalog repair uses it to write even when the model directory already
2960
+ * matches: without that, a profile whose directory is in agreement would never
2961
+ * be cleaned — the very shape that made the pool look unsaved would outlive the
2962
+ * fix that stops producing it.
2963
+ */
2964
+ declare function regionsFieldIsCorrupt(value: unknown): boolean;
2914
2965
  /**
2915
2966
  * Whether one region's provider is switched on. Opt-out semantics: only an
2916
2967
  * explicit `false` disables it, so a config written before this switch existed
@@ -4255,9 +4306,14 @@ interface WorkBuddyPoolPreferences {
4255
4306
  * plugin behaves exactly as it did before the pool existed: the account the
4256
4307
  * user selected serves every request, and a failure is reported as-is.
4257
4308
  *
4258
- * It deliberately does NOT change which account serves NEXT: failover is
4259
- * per-request borrowing, so the user's selection stays in effect and the
4260
- * card can keep reporting who really served.
4309
+ * It deliberately does NOT change the SAVED selection: while on, the pool
4310
+ * picks the serving account for every request (`applyPoolSelection` ranks
4311
+ * the usable members and sets a per-request override), but that override is
4312
+ * per-request borrowing — turning the pool off hands every request back to
4313
+ * the account the user chose, and the card can keep reporting who really
4314
+ * served. (An older version of this comment said the pool "does NOT change
4315
+ * which account serves NEXT", which was wrong the moment `applyPoolSelection`
4316
+ * started ranking on every request; what never changes is the saved choice.)
4261
4317
  */
4262
4318
  enabled?: boolean;
4263
4319
  /** Model id to test; `''` means auto-pick a free model from this region. */
@@ -4446,4 +4502,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
4446
4502
  */
4447
4503
  declare function apply(ctx: Context, config: Config): void;
4448
4504
  //#endregion
4449
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, WorkBuddyEncryptedCredentialError, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyNativeModality, WorkBuddyPoolPreferences, type WorkBuddyProbeClient, type WorkBuddyProbeOutcome, type WorkBuddyProbeResult, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
4505
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, type WorkBuddyAccountChoice, type WorkBuddyAdapter, type WorkBuddyAuthStatus, type WorkBuddyCandidateFailure, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, type WorkBuddyCredentialRecovery, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, WorkBuddyEncryptedCredentialError, type WorkBuddyEncryptedField, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyNativeModality, WorkBuddyPoolPreferences, type WorkBuddyProbeClient, type WorkBuddyProbeOutcome, type WorkBuddyProbeResult, type WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, regionsFieldIsCorrupt, regionsMapOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-CVnH8S3J.js";
1
+ import { $ as prepareChatBody, A as parseWorkBuddyAuth, At as readAtRestKey, B as declaredTools, C as defaultDesktopAuthCandidates, Ct as findWorkbuddyAppExecutable, D as isEncryptedCredentialError, Dt as macosBundleExecutable, E as hasEncryptedCredentialFields, Et as isWorkbuddyBundle, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, Ft as writeFileAtomic, G as isFirstMessageGateBody, H as isContextTooLongBody, I as WorkBuddyCredentialRejectedError, J as openingEvidenceOfBody, K as knownRegionOf, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, N as CONTEXT_OVERFLOW_MARKER, Nt as resolveDshHome, O as isVaultAccountId, Ot as macosNestedAppBundles, P as CREDENTIAL_REJECTED_CODE, Pt as withFileLock, Q as perturbChannelFingerprints, R as channelGateMemberOf, S as authFileName, St as fetchAtRestKeyPayload, T as defaultDesktopAuthPath, Tt as isEncryptedFieldWrapper, U as isConversationIdGateBody, V as forceNeutralHead, W as isCredentialRejectedError, X as parseReasoning, Y as parseCreditMultiplier, Z as parseUpstreamModel, _ as ENCRYPTED_CREDENTIAL_CODE, _t as nativeModalityOf, a as readHostHeartbeat, at as lastRefusalFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKey, c as WORKBUDDY_CONNECT_VERSION, ct as recordRefusal, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as WorkBuddyImageError, et as regionOf, f as WorkBuddyCatalog, ft as pickImageModel, g as withoutRouters, gt as imageDefaultFor, h as fallbackModelsFor, ht as NATIVE_MODALITY_BY_MODEL_ID, i as processStartTimeMs, it as defaultOffModelIds, j as workbuddyAccountId, jt as workbuddyAppExecutableCandidates, k as legacyWorkbuddyOwnAuthPath, kt as openEncryptedField, l as piAiRuntimeInfo, lt as withEffectiveOff, m as deriveCatalog, mt as queueNoticeText, n as clearHostHeartbeat, nt as attachBusyLevels, o as workbuddyHostHeartbeatPath, ot as offDefaultFor, p as applyContextBudgets, pt as parseQueueAnswer, q as modelIdOfBody, r as isHeartbeatProcessAlive, rt as REFUSES_OFF_MODEL_IDS, s as writeHostHeartbeat, st as offRefused, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as requestEvidenceOf, u as FALLBACK_WORKBUDDY_MODELS, ut as DEFAULT_IMAGE_SIZE, v as WORKBUDDY_AUTH_FILENAME, vt as WORKBUDDY_APP_EXECUTABLE_ENV, w as defaultDesktopAuthDirs, x as WorkBuddyEncryptedCredentialError, xt as deriveAtRestKeyId, y as WORKBUDDY_AUTH_FILE_ENV, yt as clearAtRestKeyCache, z as classifyUpstreamError } from "./host-heartbeat-hcH37MkG.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { isAbsolute, join, resolve, sep } from "node:path";
@@ -451,15 +451,184 @@ function regionOfStatusUrl(url) {
451
451
  * counts as the whole section only when it shows a key that is neither a region
452
452
  * id nor a known sibling — i.e. it looks like a settings document. A plain (or
453
453
  * dirty) `cn` / `global` map is returned as-is.
454
+ *
455
+ * THE RESULT IS HEALED, not merely narrowed — see {@link healedRegionsMap}.
456
+ * Returning the map "as-is" is what let a corrupted document keep reading as
457
+ * half-empty: the field held `cn` at level 0 and `global` only one level down,
458
+ * so every `global` question answered "never configured" even though the file
459
+ * still had the answer. Reads and writes both come through here, so healing
460
+ * here fixes both halves at once (the Host's `regionStateOf`, the card's
461
+ * `nextRegion*` write bases, and the catalog repair's write).
454
462
  */
455
463
  function regionsMapOf(value) {
464
+ const peeled = peelLiveReference(value);
465
+ if (typeof peeled === "object" && peeled !== null && !Array.isArray(peeled)) {
466
+ const candidate = peeled;
467
+ if (!isRecord$1(candidate["regions"]) && !carriesForeignKey(candidate)) return candidate;
468
+ }
456
469
  const unwrapped = unwrapVolatileDeep(value);
457
470
  if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
458
471
  const record = unwrapped;
459
472
  const nested = record["regions"];
460
- if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return record;
461
- const regionIds = WORKBUDDY_REGIONS;
462
- return Object.keys(record).some((key) => regionIds.includes(key)) ? record : nested;
473
+ if (typeof nested !== "object" || nested === null || Array.isArray(nested)) return healedRegionsMap(record);
474
+ return healedRegionsMap(hasRegionKey(record) ? record : nested);
475
+ }
476
+ /** `value` with ONE `{get(): T}` live reference peeled, exactly like a read. */
477
+ function peelLiveReference(value) {
478
+ if (value === null || typeof value !== "object") return value;
479
+ const get = value.get;
480
+ return typeof get === "function" ? get() : value;
481
+ }
482
+ /** Whether the object carries a sibling-field name where a region id belongs. */
483
+ function carriesForeignKey(record) {
484
+ return Object.keys(record).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
485
+ }
486
+ /**
487
+ * Names that belong to the settings SECTION (`Config` in `src/index.ts`) or to
488
+ * one region SLOT (`regionStateConfig`) — never to the `regions` map.
489
+ *
490
+ * They are the signature of a data-loss bug and the list that undoes it. From
491
+ * 5.4.0 to 5.5.0 `mergeRegionCatalog` was handed the WHOLE settings section but
492
+ * read it as the `regions` field, so every boot's directory repair wrote
493
+ * `{ accounts, regions, cliBridge }` INTO the field — replacing the region map
494
+ * and burying the real one under a `regions` key. A live profile ended up five
495
+ * levels deep (one per startup) and 258 KB of duplicated slots.
496
+ *
497
+ * Neither a region id (`cn` / `global`) nor any future one can collide with
498
+ * these names, so removing them from the map is safe, idempotent, and does not
499
+ * discard a region that a newer build might add.
500
+ */
501
+ const REGION_MAP_FOREIGN_KEYS = /* @__PURE__ */ new Set([
502
+ "authFile",
503
+ "accountId",
504
+ "accounts",
505
+ "regions",
506
+ "cliBridge",
507
+ "enabled",
508
+ "showCreditsInMainUi",
509
+ "creditPosition",
510
+ "lastCatalog",
511
+ "enabledModelIds",
512
+ "imageModelIds",
513
+ "imageToolEnabled",
514
+ "offModelIds",
515
+ "contextBudgets",
516
+ "pool"
517
+ ]);
518
+ /** A non-null, non-array object: the only shape a slot or a level can have. */
519
+ function isRecord$1(value) {
520
+ return typeof value === "object" && value !== null && !Array.isArray(value);
521
+ }
522
+ /** Whether a map carries at least one real region id. */
523
+ function hasRegionKey(map) {
524
+ return Object.keys(map).some((key) => WORKBUDDY_REGIONS.includes(key));
525
+ }
526
+ /**
527
+ * How much USER CONFIG one candidate slot actually carries.
528
+ *
529
+ * Used only when the map lost a region and the copies left at different depths
530
+ * disagree — and they do: the bug's own directory repair wrote a slot that held
531
+ * nothing but `lastCatalog` (it started from a map where the region was already
532
+ * missing), while the copy further down still holds the pool the user had
533
+ * checked in. "Shallowest wins" would therefore resurrect the EMPTY one and
534
+ * throw away the real configuration. Depth is not age in this wreckage; CONTENT
535
+ * is the only usable signal.
536
+ *
537
+ * Deliberately counts only non-default, user-authored values: resolved slots
538
+ * carry every schema default, so a slot's key count says nothing.
539
+ */
540
+ function slotConfigWeight(slot) {
541
+ let weight = 0;
542
+ const pool = slot["pool"];
543
+ if (isRecord$1(pool)) {
544
+ if (pool["enabled"] === true) weight += 4;
545
+ if (Array.isArray(pool["memberAccountIds"]) && pool["memberAccountIds"].length > 0) weight += 4;
546
+ if (Array.isArray(pool["orderAccountIds"]) && pool["orderAccountIds"].length > 0) weight += 2;
547
+ if (typeof pool["targetModelId"] === "string" && pool["targetModelId"] !== "") weight += 2;
548
+ if (typeof pool["probeInputTokens"] === "number" && pool["probeInputTokens"] > 0) weight += 1;
549
+ }
550
+ for (const key of [
551
+ "enabledModelIds",
552
+ "imageModelIds",
553
+ "offModelIds"
554
+ ]) {
555
+ const list = slot[key];
556
+ if (Array.isArray(list) && list.length > 0) weight += 1;
557
+ }
558
+ const budgets = slot["contextBudgets"];
559
+ if (isRecord$1(budgets) && Object.keys(budgets).length > 0) weight += 1;
560
+ if (slot["enabled"] === false) weight += 1;
561
+ if (slot["showCreditsInMainUi"] === false) weight += 1;
562
+ if (slot["imageToolEnabled"] === true) weight += 1;
563
+ return weight;
564
+ }
565
+ /**
566
+ * The `regions` map with a corrupted document's wreckage removed.
567
+ *
568
+ * Two repairs, both keyed on the bug's signature (a foreign key or a nested
569
+ * `regions` copy) so a HEALTHY map passes through untouched:
570
+ *
571
+ * 1. DROP every key that names a section field or a slot field. Those are the
572
+ * copies the bug wrote; keeping them means the next save carries them
573
+ * forward forever.
574
+ * 2. RECOVER a region this level does not have from the copies buried beneath
575
+ * it. A region that IS here is never replaced — the outer copy is the one
576
+ * the surviving writers have been updating, and re-reading a stale inner
577
+ * copy over it is exactly the defect the shape rule above was fixed for.
578
+ *
579
+ * Recovery exists because `global` in the measured profile lived ONLY one level
580
+ * down: without it, the host reads that region as "never configured" and the
581
+ * first write after the fix would delete the user's international pool outright.
582
+ *
583
+ * Among the buried copies of a MISSING region, the one carrying the most user
584
+ * configuration wins rather than the shallowest — see {@link slotConfigWeight}:
585
+ * the bug's own directory repair left `{ lastCatalog }` slots that look newer
586
+ * but hold nothing, one level above the copy with the pool still in it.
587
+ */
588
+ function healedRegionsMap(map) {
589
+ const healed = {};
590
+ for (const [key, slot] of Object.entries(map)) {
591
+ if (REGION_MAP_FOREIGN_KEYS.has(key)) continue;
592
+ healed[key] = slot;
593
+ }
594
+ const recovered = {};
595
+ let deeper = map;
596
+ for (let depth = 0; depth < 8; depth += 1) {
597
+ const nested = deeper["regions"];
598
+ if (!isRecord$1(nested)) break;
599
+ for (const region of WORKBUDDY_REGIONS) {
600
+ if (region in map) continue;
601
+ const candidate = nested[region];
602
+ if (!isRecord$1(candidate)) continue;
603
+ const weight = slotConfigWeight(candidate);
604
+ const best = recovered[region];
605
+ if (best === void 0 || weight > best.weight) recovered[region] = {
606
+ slot: candidate,
607
+ weight
608
+ };
609
+ }
610
+ deeper = nested;
611
+ }
612
+ for (const [region, entry] of Object.entries(recovered)) healed[region] = entry.slot;
613
+ return healed;
614
+ }
615
+ /**
616
+ * Whether the stored `regions` FIELD still carries the debris described at
617
+ * {@link REGION_MAP_FOREIGN_KEYS}.
618
+ *
619
+ * Pass the field itself (`Config['regions']`), not the whole section: the
620
+ * section legitimately has `accounts` / `cliBridge` beside `regions`, so asking
621
+ * that question of a section would always answer "corrupt".
622
+ *
623
+ * The catalog repair uses it to write even when the model directory already
624
+ * matches: without that, a profile whose directory is in agreement would never
625
+ * be cleaned — the very shape that made the pool look unsaved would outlive the
626
+ * fix that stops producing it.
627
+ */
628
+ function regionsFieldIsCorrupt(value) {
629
+ const unwrapped = unwrapVolatileDeep(value);
630
+ if (!isRecord$1(unwrapped)) return false;
631
+ return Object.keys(unwrapped).some((key) => REGION_MAP_FOREIGN_KEYS.has(key));
463
632
  }
464
633
  /** One region's stored slot as a plain object; any other shape reads as empty. */
465
634
  function regionSlotOf(value, region) {
@@ -560,8 +729,8 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
560
729
  /**
561
730
  * Writing the model's saved directory back to the Host's settings document.
562
731
  *
563
- * THE HOST-SIDE HALF OF ISSUES #114/#115. The card's own save path lives in
564
- * the browser half (`src/client/`), but the repair that heals a STALE saved
732
+ * THE HOST-SIDE HALF OF ISSUES #114/#115. The card's own save path lives in the
733
+ * browser half (`src/client/`), but the repair that heals a STALE saved
565
734
  * directory has to happen where the LIVE roster lands — inside the Host
566
735
  * process, right after the upstream answered. So this module writes through
567
736
  * the Host's own settings service, using the same merge discipline the
@@ -574,6 +743,19 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
574
743
  * replaced, every other key of the slot is preserved, and the sibling region
575
744
  * is untouched.
576
745
  *
746
+ * THE VALUE THIS MODULE WRITES IS THE `regions` FIELD, NOT THE SETTINGS
747
+ * SECTION. That distinction is the whole of this bug — reported as
748
+ * "账号池状态重启就失效". `describe()` hands back the resolved SECTION
749
+ * (`{authFile, accounts, regions, cliBridge, …}`), and
750
+ * `mutate(ns, [{ op: 'set', path: ['regions'], … }])` stores whatever it is
751
+ * given AT that path. Handing it the section therefore wrote the section INTO
752
+ * the field: the real region map was pushed down under a nested `regions` key,
753
+ * its own siblings (`accounts`, `cliBridge`) became fake region slots, and the
754
+ * Host's `regionStateOf(config, 'cn')` — which reads exactly one level — then
755
+ * answered "no slot". Every account-pool preference, model selection and
756
+ * directory looked unsaved after the next restart, and each startup added
757
+ * another level (measured on a live profile: five levels, 258 KB).
758
+ *
577
759
  * WHY THE HOST SERVICE RATHER THAN THE SCOPE. The plugin's browser half
578
760
  * writes through the Host's settings endpoint because the browser-side
579
761
  * `scope.set()` replaces the whole field with the browser's own (possibly
@@ -584,25 +766,27 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
584
766
  * @module dsh-connect-workbuddy/catalog-write
585
767
  */
586
768
  /**
587
- * Merge one region's repaired directory into the `regions` field, preserving
588
- * every other key of that region's slot and the whole sibling region.
769
+ * Merge one region's repaired directory into the `regions` FIELD.
770
+ *
771
+ * `current` may be EITHER the resolved settings section (what `describe()`
772
+ * answers, and what {@link writeRegionCatalog} passes) OR the bare `regions`
773
+ * field; `regionsMapOf` narrows both and heals a document the bug above already
774
+ * corrupted. The RETURN value is always the field itself — the exact shape
775
+ * `path: ['regions']` expects — never the section around it.
589
776
  *
590
777
  * The merge is done on the AUTHORITATIVE value the settings service holds —
591
778
  * not on a snapshot the caller brought — because a snapshot can be stale and
592
779
  * a stale merge is how the other region disappears.
593
780
  */
594
781
  function mergeRegionCatalog(current, region, catalog) {
595
- const field = unwrapVolatileDeep(current) ?? {};
596
- const regions = field["regions"] ?? {};
597
- const slot = regions[region] ?? {};
782
+ const regions = regionsMapOf(current);
783
+ const stored = regions[region];
784
+ const slot = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? stored : {};
598
785
  return {
599
- ...field,
600
- regions: {
601
- ...regions,
602
- [region]: {
603
- ...slot,
604
- lastCatalog: catalog
605
- }
786
+ ...regions,
787
+ [region]: {
788
+ ...slot,
789
+ lastCatalog: catalog
606
790
  }
607
791
  };
608
792
  }
@@ -627,6 +811,39 @@ async function writeRegionCatalog(service, ns, region, catalog) {
627
811
  }], void 0);
628
812
  return true;
629
813
  }
814
+ /**
815
+ * Clean a `regions` field an older build corrupted, WITHOUT touching any
816
+ * region's content.
817
+ *
818
+ * WHY THIS IS SEPARATE FROM {@link writeRegionCatalog}. The catalog repair is
819
+ * the writer that produced the wreckage, so it is also a natural place to clean
820
+ * it — but it only runs when the live roster arrived (an enabled region, a
821
+ * reachable upstream, a non-empty answer). A profile would therefore stay dirty
822
+ * for as long as the network is down, and a region the user has switched OFF
823
+ * would never be cleaned at all. This write needs neither: it is one settings
824
+ * round-trip at boot, and it is a no-op for every healthy profile (a single key
825
+ * scan, no mutation).
826
+ *
827
+ * It is a pure shape fix, not a merge: `regionsMapOf` already answers the
828
+ * healed map, and the field is replaced with exactly that. Reads are correct
829
+ * without this write (they narrow through the same helper), so failing to write
830
+ * costs tidiness only — the caller logs and carries on.
831
+ *
832
+ * @returns whether the write happened.
833
+ */
834
+ async function healRegionsField(service, ns) {
835
+ if (service === void 0) return false;
836
+ const row = service.describe().find((entry) => entry.ns === ns);
837
+ if (row === void 0) return false;
838
+ const section = row.value ?? {};
839
+ if (!regionsFieldIsCorrupt(section["regions"])) return false;
840
+ await service.mutate(ns, [{
841
+ op: "set",
842
+ path: ["regions"],
843
+ value: regionsMapOf(section)
844
+ }], void 0);
845
+ return true;
846
+ }
630
847
  //#endregion
631
848
  //#region src/credential-recovery.ts
632
849
  /** How long a verdict stays fresh by default. */
@@ -1925,6 +2142,23 @@ function bodyReportDirectory(env = process.env) {
1925
2142
  return trimmed;
1926
2143
  }
1927
2144
  /**
2145
+ * How many bytes from the END of the file {@link attachUsage} reads when it
2146
+ * looks for the line it wrote (issue #118).
2147
+ *
2148
+ * The old implementation read the WHOLE file, split it, rewrote the WHOLE file —
2149
+ * so every streamed answer cost ~3× the file size in heap (the string, the line
2150
+ * array, the re-joined string), which is what OOMed the host at ~500 MB. The
2151
+ * usage callback fires seconds after its own append, so its line is always
2152
+ * near the tail; a bounded tail read makes the cost O(window) instead of
2153
+ * O(file). A line older than the window is simply left un-annotated — the
2154
+ * annotation is best-effort by design (see the header of {@link attachUsage}).
2155
+ *
2156
+ * Exported because the boundary behaviour is exactly what a regression would
2157
+ * break first, and a test that pins it must be able to size a file against it
2158
+ * without reaching into the module.
2159
+ */
2160
+ const USAGE_ATTACH_TAIL_BYTES = 1048576;
2161
+ /**
1928
2162
  * Append one composition to `<dir>/<region>.jsonl`.
1929
2163
  *
1930
2164
  * One file per region is the whole point: the reporter captures a session, then
@@ -1934,13 +2168,23 @@ function bodyReportDirectory(env = process.env) {
1934
2168
  *
1935
2169
  * Read-modify-write is avoided on purpose — a long session would rewrite a
1936
2170
  * growing file on every step. The lock keeps two concurrent requests from
1937
- * interleaving a line.
2171
+ * interleaving a line, and the size check inside the SAME lock keeps two
2172
+ * concurrent appends from both deciding "no roll needed" and then writing past
2173
+ * the cap together.
1938
2174
  */
1939
2175
  async function appendBodyReport(directory, report) {
1940
2176
  const file = join(directory, `${report.region}.jsonl`);
1941
2177
  await mkdir(directory, { recursive: true });
1942
2178
  const line = `${JSON.stringify(report)}\n`;
1943
- await withFileLock(file, () => appendFile(file, line, "utf8"));
2179
+ await withFileLock(file, async () => {
2180
+ const size = await stat(file).then((entry) => entry.size).catch(() => 0);
2181
+ if (size > 0 && size + Buffer.byteLength(line, "utf8") > 33554432) {
2182
+ const rolled = `${file}.1`;
2183
+ await rm(rolled, { force: true });
2184
+ await rename(file, rolled);
2185
+ }
2186
+ await appendFile(file, line, "utf8");
2187
+ });
1944
2188
  }
1945
2189
  /**
1946
2190
  * Record one outgoing body, if the report is enabled, and return a handle that
@@ -1971,16 +2215,6 @@ async function recordBodyReport(region, bodyJson, options = {}) {
1971
2215
  }
1972
2216
  }
1973
2217
  /**
1974
- * How many trailing lines are searched for the line this handle wrote.
1975
- *
1976
- * One would be enough for a single-threaded request, but a turn can have several
1977
- * requests in flight (parallel tool steps) and their lines interleave, so the
1978
- * target is not necessarily last. A small bounded window keeps the read cheap on
1979
- * a file that grows to tens of megabytes, while still finding a line that landed
1980
- * a couple of appends ago.
1981
- */
1982
- const USAGE_ATTACH_WINDOW = 32;
1983
- /**
1984
2218
  * Return a callback that rewrites its own line with the upstream's counts.
1985
2219
  *
1986
2220
  * Matched by `at` + `region` + `bodyBytes` + `messageCount`, all four being
@@ -1988,6 +2222,24 @@ const USAGE_ATTACH_WINDOW = 32;
1988
2222
  * timestamped in the same millisecond, and `bodyBytes` tells apart the two
1989
2223
  * different bodies a failover or a retry can produce. A line that is already
1990
2224
  * annotated is left alone, so a second call cannot double-apply.
2225
+ *
2226
+ * The rewrite touches ONLY the tail window (issue #118): the file is opened
2227
+ * `r+`, the last {@link USAGE_ATTACH_TAIL_BYTES} are read, the window is split
2228
+ * into lines, the target line is replaced, and the new window is written back
2229
+ * at the SAME offset with the file truncated to the new end. Nothing before the
2230
+ * window is ever read or written, so a 500 MB file costs one megabyte of heap
2231
+ * here instead of three copies of itself.
2232
+ *
2233
+ * Two consequences of the bounded window, both deliberate:
2234
+ *
2235
+ * - a line pushed out of the window by later appends is left un-annotated.
2236
+ * The annotation is a convenience for the #59 diff, never a correctness
2237
+ * requirement — the sent side is already on disk;
2238
+ * - the window must start on a line boundary, so the read takes ONE byte of
2239
+ * lookback before it: when that byte is a newline the window already starts
2240
+ * on a boundary and its first line is complete (kept in search and rewrite);
2241
+ * otherwise the first partial line is dropped from the SEARCH and preserved
2242
+ * byte-for-byte by starting the write at the first complete line's offset.
1991
2243
  */
1992
2244
  function attachUsage(directory, report, onError) {
1993
2245
  /**
@@ -2008,28 +2260,58 @@ function attachUsage(directory, report, onError) {
2008
2260
  (async () => {
2009
2261
  try {
2010
2262
  await withFileLock(file, async () => {
2011
- const lines = (await readFile(file, "utf8")).split("\n");
2012
- for (let index = lines.length - 1; index >= 0 && index >= lines.length - USAGE_ATTACH_WINDOW; index -= 1) {
2013
- const line = lines[index];
2014
- if (line === void 0 || line === "") continue;
2015
- let parsed;
2016
- try {
2017
- parsed = JSON.parse(line);
2018
- } catch {
2019
- continue;
2263
+ const handle = await open(file, "r+");
2264
+ try {
2265
+ const { size } = await handle.stat();
2266
+ if (size === 0) return;
2267
+ const windowStart = size - Math.min(size, USAGE_ATTACH_TAIL_BYTES);
2268
+ const readStart = windowStart === 0 ? 0 : windowStart - 1;
2269
+ const readLength = size - readStart;
2270
+ const buffer = Buffer.allocUnsafe(readLength);
2271
+ const { bytesRead } = await handle.read(buffer, 0, readLength, readStart);
2272
+ if (bytesRead === 0) return;
2273
+ const raw = buffer.toString("utf8", 0, bytesRead);
2274
+ let contentStart;
2275
+ let text;
2276
+ if (readStart === 0) {
2277
+ contentStart = 0;
2278
+ text = raw;
2279
+ } else if (raw.startsWith("\n")) {
2280
+ contentStart = readStart + 1;
2281
+ text = raw.slice(1);
2282
+ } else {
2283
+ const firstNewline = raw.indexOf("\n");
2284
+ if (firstNewline === -1) return;
2285
+ contentStart = readStart + firstNewline + 1;
2286
+ text = raw.slice(firstNewline + 1);
2020
2287
  }
2021
- if (!isRecord(parsed)) continue;
2022
- if (parsed["at"] !== report.at) continue;
2023
- if (parsed["region"] !== report.region) continue;
2024
- if (parsed["bodyBytes"] !== report.bodyBytes) continue;
2025
- if (parsed["messageCount"] !== report.messageCount) continue;
2026
- if (parsed["promptTokens"] !== void 0) return;
2027
- lines[index] = JSON.stringify({
2028
- ...parsed,
2029
- ...usage
2030
- });
2031
- await writeFile(file, lines.join("\n"), "utf8");
2032
- return;
2288
+ const lines = text.split("\n");
2289
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2290
+ const line = lines[index];
2291
+ if (line === void 0 || line === "") continue;
2292
+ let parsed;
2293
+ try {
2294
+ parsed = JSON.parse(line);
2295
+ } catch {
2296
+ continue;
2297
+ }
2298
+ if (!isRecord(parsed)) continue;
2299
+ if (parsed["at"] !== report.at) continue;
2300
+ if (parsed["region"] !== report.region) continue;
2301
+ if (parsed["bodyBytes"] !== report.bodyBytes) continue;
2302
+ if (parsed["messageCount"] !== report.messageCount) continue;
2303
+ if (parsed["promptTokens"] !== void 0) return;
2304
+ lines[index] = JSON.stringify({
2305
+ ...parsed,
2306
+ ...usage
2307
+ });
2308
+ const rewritten = lines.join("\n");
2309
+ await handle.write(rewritten, contentStart, "utf8");
2310
+ await handle.truncate(contentStart + Buffer.byteLength(rewritten, "utf8"));
2311
+ return;
2312
+ }
2313
+ } finally {
2314
+ await handle.close();
2033
2315
  }
2034
2316
  });
2035
2317
  } catch (error) {
@@ -9033,8 +9315,8 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9033
9315
  if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or cliBridge" });
9034
9316
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
9035
9317
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
9036
- const current = unwrapVolatileDeep(row.value?.[field] ?? {});
9037
- const incoming = body.value ?? {};
9318
+ const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
9319
+ const incoming = field === "regions" ? regionsMapOf(body.value) : body.value ?? {};
9038
9320
  if (field === "cliBridge") {
9039
9321
  if (!isPlainRecord(incoming)) return json(res, 400, { error: "cliBridge value must be an object" });
9040
9322
  const mergedBridge = nextCliBridgeSettings(current, incoming);
@@ -9408,7 +9690,7 @@ const Config = z.object({
9408
9690
  * picks.
9409
9691
  */
9410
9692
  function regionStateOf(config, region) {
9411
- const stored = unwrapVolatile(config.regions)?.[region];
9693
+ const stored = regionsMapOf(config.regions)[region];
9412
9694
  if (stored !== void 0 && stored !== null) return stored;
9413
9695
  if (region !== "cn") return {};
9414
9696
  const lastCatalog = unwrapVolatile(config.lastCatalog);
@@ -9685,7 +9967,7 @@ function apply(ctx, config) {
9685
9967
  return rest;
9686
9968
  });
9687
9969
  if (repaired.length === 0) return;
9688
- if (catalogFingerprint(repaired) === catalogFingerprint(saved)) return;
9970
+ if (!(catalogFingerprint(repaired) !== catalogFingerprint(saved)) && !regionsFieldIsCorrupt(current().regions)) return;
9689
9971
  try {
9690
9972
  await writeRegionCatalog(hostSettings(), settingsNs, region, repaired);
9691
9973
  } catch (error) {
@@ -9701,6 +9983,27 @@ function apply(ctx, config) {
9701
9983
  */
9702
9984
  const hostSettings = () => ctx.get?.("settings");
9703
9985
  /**
9986
+ * Clean a `regions` field a previous version corrupted, once, at boot.
9987
+ *
9988
+ * The corruption itself is fixed at the source (`src/catalog-write.ts`) and the
9989
+ * read paths heal in memory, so a dirty profile is CORRECT long before this
9990
+ * runs — but it stays dirty in the file, and the next save would carry the
9991
+ * debris forward. The catalog repair writes the same clean shape, yet it only
9992
+ * runs when a live roster arrives: a disabled region, an unreachable upstream
9993
+ * or an empty answer would leave the wreckage in place for as long as those
9994
+ * conditions hold. This needs no upstream at all.
9995
+ *
9996
+ * FAIL-SOFT, exactly like the repair: the read side is already right, so a
9997
+ * write that cannot land costs tidiness and nothing else.
9998
+ */
9999
+ const healRegionsFieldIfCorrupt = async () => {
10000
+ try {
10001
+ await healRegionsField(hostSettings(), settingsNs);
10002
+ } catch (error) {
10003
+ ctx.logger.warn("dsh-connect-workbuddy: could not clean the shape of the regions setting", error);
10004
+ }
10005
+ };
10006
+ /**
9704
10007
  * Legacy migration for the pre-split single `accountId`: its region is
9705
10008
  * resolved once from the local account scan and the selection is then
9706
10009
  * attributed to that region ONLY — the other region keeps its documented
@@ -10939,6 +11242,7 @@ function apply(ctx, config) {
10939
11242
  return;
10940
11243
  }
10941
11244
  if (stopped) return;
11245
+ const regionsFieldHealed = healRegionsFieldIfCorrupt();
10942
11246
  for (const region of REGION_KEYS) {
10943
11247
  if (!regionEnabled(current(), region)) continue;
10944
11248
  (async () => {
@@ -10950,6 +11254,7 @@ function apply(ctx, config) {
10950
11254
  const state = regionStateOf(current(), region);
10951
11255
  stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? [])), new Set(savedOffModelIds(state, models))));
10952
11256
  adapters[region].invalidate();
11257
+ await regionsFieldHealed;
10953
11258
  await repairRegionCatalog(region, models);
10954
11259
  } catch (error) {
10955
11260
  ctx.logger.warn(`dsh-connect-workbuddy: dynamic ${region} model catalog unavailable; serving the static fallback list`, error);
@@ -10961,4 +11266,4 @@ function apply(ctx, config) {
10961
11266
  });
10962
11267
  }
10963
11268
  //#endregion
10964
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
11269
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_TPS_PATH, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, regionsFieldIsCorrupt, regionsMapOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
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": "5.5.0",
5
+ "version": "5.5.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
package/screenshots.json CHANGED
@@ -1,5 +1,6 @@
1
1
  [
2
2
  "docs/assets/dsh-connect-workbuddy-usage-card.png",
3
3
  "docs/assets/dsh-connect-workbuddy-account-pool.png",
4
- "docs/assets/dsh-connect-workbuddy-composer-credits.png"
4
+ "docs/assets/dsh-connect-workbuddy-composer-credits.png",
5
+ "docs/assets/dsh-connect-workbuddy-model-picker.png"
5
6
  ]