dsh-connect-workbuddy 5.5.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 5.5.1 (2026-10-23)
4
+
5
+ > 补丁版本:修「账号池状态重启就失效」的根因,以及同一症状的另外三处(手动排序箭头、`regionsMapOf` 跳层读旧副本、卡片保存把脏数据带回去)。**没有新增任何功能**:请求路径、provider 路由、模型目录、计费与凭据一律未变,5.5.0 的全部能力原样保留。
6
+ > **本版会主动改写一个设置字段的形状**:被 5.4.0–5.5.0 写脏的 `regions` 字段会在**开机时**被清洗一次(只动形状,不动任何区域内容,干净 profile 不写)。如果你正是「账号池重启就失效」的受害者,升级后重启一次即可恢复,**不需要手动改 profile**;国际版整槽沉到第 4 层的情况也会被捞回来(本机实测 3 个成员与 27 个模型目录)。
4
7
 
5
8
  ### Fixes
6
9
 
7
10
  - **排序箭头在「账号池还没保存开启」时不出现 —— 先勾选再排序这条自然路径被堵死。** `AccountPool.tsx` 的 `moveEnabled` 读的是**已保存**的 `pool.enabled`,而同一行旁边的 `isMember` 读的却是**草稿** `active.memberAccountIds`。于是:勾选账号入池(草稿变了)→ 想去排序 → **箭头根本不存在**,因为开关还没保存;用户既看不到箭头,也就没有理由去按保存,下一次保存自然「什么都没写」——这正是「手动排序没保存」的实际形态。改成读草稿的 `active.enabled`:箭头描述的是草稿,就该跟着草稿走(报告运行中的池仍然是 `poolRunning`,那个值没有动)。变异验证:改回 `pool.enabled`,新增用例立刻红(期望 4 个箭头、实际 0 个)。
8
11
  - **`regionsMapOf()` 会跳到更深一层,读到同名区域的过期副本。** 旧规则是「只要有 `regions` 键就往下跳」,理由是可以同时接受「整份 settings」和「regions map」两种入参。但 regions **map 自己也可能有 `regions` 键** —— 本机真实 profile 里 `config.regions` 下就躺着 `accounts` / `regions` / `cliBridge` 三个旧写入留下的兄弟键。一旦跳下去,读到的就是**另一份更旧的同名区域**:实测同一份文档,「按 section 读」得到 `['c','a','b']`,「按 map 读」得到 `[]`(正是「排序明明存了却读不出来」)。现在改为**按形状判定**:外层对象只要含有区域 id(`cn` / `global`)就是 map,直接用它;否则才是 section,才往里跳。变异验证:把判定换回无条件跳,新增用例立刻红。
12
+ - **「账号池状态重启就失效」的根因:目录修复把整份 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()` 全部改走同一个窄化函数。
13
+ - **脏 profile 不靠人手修**:`regionsMapOf()` 现在同时做三件事 —— 丢掉 section/槽位的兄弟键名拷贝;把只活在下层的区域**捞回来**(本机 `global` 就是这种情况:外层 `cn` 还在、国际版整槽只在下一层,不捞就等于让第一次写入删掉用户的国际版池);「在表里的区域」仍然以外层为准,且当多个深层副本都有同一区域时按**用户配置量**取舍而不是按深浅(本机就有 `{lastCatalog}` 的空壳副本躺在一个仍有 3 个成员的真副本上面 —— 按深浅会捞回空壳)。修复写仍只在目录指纹变化时触发,但**字段脏时也写一次**,否则「目录恰好一致」的 profile 永远等不到打扫。
14
+ - **修复写不再「只在目录变了才写」**:目录指纹相同时,只要 `Config['regions']` 还带兄弟键名就照写一次(`regionsFieldIsCorrupt()`),否则「目录恰好与上游一致」的 profile 永远等不到打扫 —— 那种 profile 的脏数据会活得比产生它的 bug 还久。写一次即净,之后这条分支不再触发。
15
+ - **开机再补一次独立清洗**(`healRegionsField()`):上面那条挂在「拿到实时模型目录」之后,于是关掉的区域、断网启动、上游空回答这三种情况都不会清洗。新增一次纯形状写入 —— 不需要上游、不碰任何区域内容、干净 profile 直接返回不写(只扫一遍键名),失败只记 warn(读侧本来就已是对的)。
16
+ - **代价控制在请求路径上**:`regionStateOf()` 现在是每次请求都会走的读(池开关、模型选择、额度门控),而 heal 会深拷贝整份字段(每区一份多模型目录)。实测 28 模型槽位:不做快路径是 **11 µs/次**(原来裸读 0.05 µs),加「干净字段原样返回」的快路径后回到 **0.09 µs**;脏字段 0.86 µs 且只到 profile 被写入修复为止。快路径的成立条件写在代码注释里(只有**字段本身**是 volatile,槽内是普通数据),并用同引用断言(`toBe`)钉住,防止以后被顺手删掉。
17
+ - **实测(本机真实 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`)时不再从下层捞,否则会把用户清掉的池子复活。
18
+ - **变异验证(六处,逐个改回旧行为都必红)**:①`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
19
 
10
20
  ### Docs
11
21
 
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-BhgyeZWV.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 [];
@@ -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.1";
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
@@ -4446,4 +4497,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
4446
4497
  */
4447
4498
  declare function apply(ctx: Context, config: Config): void;
4448
4499
  //#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 };
4500
+ 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,4 +1,4 @@
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-BhgyeZWV.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -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. */
@@ -9033,8 +9250,8 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
9033
9250
  if (field !== "regions" && field !== "accounts" && field !== "cliBridge") return json(res, 400, { error: "field must be regions, accounts or cliBridge" });
9034
9251
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
9035
9252
  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 ?? {};
9253
+ const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
9254
+ const incoming = field === "regions" ? regionsMapOf(body.value) : body.value ?? {};
9038
9255
  if (field === "cliBridge") {
9039
9256
  if (!isPlainRecord(incoming)) return json(res, 400, { error: "cliBridge value must be an object" });
9040
9257
  const mergedBridge = nextCliBridgeSettings(current, incoming);
@@ -9408,7 +9625,7 @@ const Config = z.object({
9408
9625
  * picks.
9409
9626
  */
9410
9627
  function regionStateOf(config, region) {
9411
- const stored = unwrapVolatile(config.regions)?.[region];
9628
+ const stored = regionsMapOf(config.regions)[region];
9412
9629
  if (stored !== void 0 && stored !== null) return stored;
9413
9630
  if (region !== "cn") return {};
9414
9631
  const lastCatalog = unwrapVolatile(config.lastCatalog);
@@ -9685,7 +9902,7 @@ function apply(ctx, config) {
9685
9902
  return rest;
9686
9903
  });
9687
9904
  if (repaired.length === 0) return;
9688
- if (catalogFingerprint(repaired) === catalogFingerprint(saved)) return;
9905
+ if (!(catalogFingerprint(repaired) !== catalogFingerprint(saved)) && !regionsFieldIsCorrupt(current().regions)) return;
9689
9906
  try {
9690
9907
  await writeRegionCatalog(hostSettings(), settingsNs, region, repaired);
9691
9908
  } catch (error) {
@@ -9701,6 +9918,27 @@ function apply(ctx, config) {
9701
9918
  */
9702
9919
  const hostSettings = () => ctx.get?.("settings");
9703
9920
  /**
9921
+ * Clean a `regions` field a previous version corrupted, once, at boot.
9922
+ *
9923
+ * The corruption itself is fixed at the source (`src/catalog-write.ts`) and the
9924
+ * read paths heal in memory, so a dirty profile is CORRECT long before this
9925
+ * runs — but it stays dirty in the file, and the next save would carry the
9926
+ * debris forward. The catalog repair writes the same clean shape, yet it only
9927
+ * runs when a live roster arrives: a disabled region, an unreachable upstream
9928
+ * or an empty answer would leave the wreckage in place for as long as those
9929
+ * conditions hold. This needs no upstream at all.
9930
+ *
9931
+ * FAIL-SOFT, exactly like the repair: the read side is already right, so a
9932
+ * write that cannot land costs tidiness and nothing else.
9933
+ */
9934
+ const healRegionsFieldIfCorrupt = async () => {
9935
+ try {
9936
+ await healRegionsField(hostSettings(), settingsNs);
9937
+ } catch (error) {
9938
+ ctx.logger.warn("dsh-connect-workbuddy: could not clean the shape of the regions setting", error);
9939
+ }
9940
+ };
9941
+ /**
9704
9942
  * Legacy migration for the pre-split single `accountId`: its region is
9705
9943
  * resolved once from the local account scan and the selection is then
9706
9944
  * attributed to that region ONLY — the other region keeps its documented
@@ -10939,6 +11177,7 @@ function apply(ctx, config) {
10939
11177
  return;
10940
11178
  }
10941
11179
  if (stopped) return;
11180
+ const regionsFieldHealed = healRegionsFieldIfCorrupt();
10942
11181
  for (const region of REGION_KEYS) {
10943
11182
  if (!regionEnabled(current(), region)) continue;
10944
11183
  (async () => {
@@ -10950,6 +11189,7 @@ function apply(ctx, config) {
10950
11189
  const state = regionStateOf(current(), region);
10951
11190
  stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? [])), new Set(savedOffModelIds(state, models))));
10952
11191
  adapters[region].invalidate();
11192
+ await regionsFieldHealed;
10953
11193
  await repairRegionCatalog(region, models);
10954
11194
  } catch (error) {
10955
11195
  ctx.logger.warn(`dsh-connect-workbuddy: dynamic ${region} model catalog unavailable; serving the static fallback list`, error);
@@ -10961,4 +11201,4 @@ function apply(ctx, config) {
10961
11201
  });
10962
11202
  }
10963
11203
  //#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 };
11204
+ 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.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",