dsh-connect-workbuddy 2.0.13 → 2.0.14

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,6 +1,33 @@
1
1
  # Changelog
2
2
 
3
- ## 2.0.13 (Unreleased)
3
+ ## 2.0.14 (2026-09-23)
4
+
5
+ ### Bug Fixes
6
+
7
+ - **保存国内版 / 国际版信息时,总有一个区域的配置被清掉**(用户实报:「我保存国际版信息和国内版信息,总会丢失一个」)。这是 2.0.13 那条「活引用被展开成函数」缺陷的**客户端侧同源路径**——服务端(`__save`)那次只修了一半,浏览器侧一直在按错误的方式读同一个值。
8
+
9
+ - **根因**:`regions` / `accounts` 被声明为 `asVolatile(...)`,schemastery 会把这类字段解析成 `{ get(): T }` **活引用**——**这一步发生在 schemastery 内部**(由 schema 的 `meta.volatile` 驱动),因此与 DSH 版本无关:0.1.5 与 0.1.7 上,浏览器侧看到的解析值**都是**活引用。
10
+ - 而活引用**仍然是 `typeof === "object"`**。卡片的每一次写入都是「读出当前区域映射 → 展开 → 覆盖自己那一格」,用来保住**另一个**区域。两处读法都栽在这一点上:
11
+ - `src/client/account-selection.ts` 的 `fieldSnapshotOf()`:`{ ...活引用, [region]: slot }` 得到 `{ get: <函数>, [region]: slot }` —— **另一个区域被整个丢掉**,同时还把引用函数写进了文档。
12
+ - `src/status-paths.ts` 的 `regionsMapOf()`:它检查「是对象且不是数组」就把值当成区域映射返回,而活引用**恰好满足**这个判断。于是映射里每一次查表都是 `undefined`——`regionEnabledOf()` 读出的开关状态**永远是「开」**(这正是该函数注释里警告过的那个历史症状),`nextRegionEnabled()` 的合并基座也变成 `{ get: <函数> }`,**开关一次就丢掉另一个区域**。
13
+ - **修法**。三条独立缺陷,缺一条症状就仍在:
14
+ 1. **读法**:`fieldSnapshotOf()` 与 `regionsMapOf()` 都先 `unwrapVolatileDeep()` 再使用。该 helper 原本只存在于 Host 入口,浏览器侧取不到,因此**移入 `src/status-paths.ts`**——那个模块本来就是 host↔client 的无 node 依赖桥梁——并由 `src/index.ts` 继续对外导出,**公共 API 不变**。
15
+ 2. **写入顺序**:`writeField()` 改为 **Host 端点优先**。两个写入方的语义**并不相同**,而这一点恰恰会删数据:
16
+ - `__save` 在 Host 进程内把提交的区域合并进该字段的**权威值**,保留其余区域;
17
+ - `scope.set(field, next)` 是把**整个字段**交给 settings 服务,而这个字段是客户端**从浏览器的镜像**自己合并出来的——Host 在这条路径上**不做任何服务端合并**,因此客户端给什么就**原样成为**什么。
18
+
19
+ 而镜像**不更新**由端点完成的写入(`contextBudgets` 处早已注明这一滞后),在此前受影响的 0.1.7 部署上该命名空间的镜像更是长期陈旧。于是从镜像构造的合并结果是 `{ [本次区域]: slot }`——**兄弟区域全部缺失**——而 `scope.set` 把它原样存下,区域就此消失。这正是「我保存了国内版,国际版的信息就没有了」。也解释了用户机器上的**混合状态**:`accounts` 两个区域都在(写入时镜像恰好是新的),而 `regions` 少了一个。
20
+ 3. **镜像回写**:`__save` 现在**返回它实际存下的那个字段**,客户端据此刷新镜像;此前客户端会用**自己那份陈旧镜像重新合并**再 `scope.set` 回去,**反而把 Host 刚保住兄弟区域的合并结果覆盖掉**。老版本 Host 只回 `{ok:true}`,此时该刷新自动跳过。
21
+ - **回归测试**(新增 `tests/volatile-scope.spec.ts`,9 例):整个套件此前只用**普通对象**模拟 `getSnapshot().value`,而线上真实的形状是活引用——**受影响的路径从未被测过**。新用例按真实形状构造 scope,并**按真实写入路径**建模(Host 端点在服务端合并并回传结果、镜像滞后、`scope.set` 原样存储),断言两个区域在每种交替顺序下都存活、且文档里绝不出现引用函数。另有一例断言客户端镜像的是 **Host 答回的权威值**,而不是它自己重新合并的结果。
22
+ - **变异验证**(分别撤销每一处):`fieldSnapshotOf` 解包 → **5 例红**;`regionsMapOf` 解包 → **2 例红**;镜像回写改回「从自身镜像重合并」→ **仍会删掉兄弟区域**。三处修复各自必需。
23
+
24
+ > 更正两次:① 2.0.13 的说明里我写过「对单 region 写入足够」——**那是错的**,每条写入路径都要靠这次读取来保住另一个区域。② 2.0.14 发布前我判断「读法」是唯一根因——**也不完整**,还有上述第 2、3 条写入路径缺陷。二者均在此订正。
25
+
26
+ ### Tests
27
+
28
+ - 332 → 341(新增 9 例:活引用形状下的双区域写入、开关回归,以及「Host 端点为权威、镜像不得删掉兄弟区域」的写入路径回归)。
29
+
30
+ ## 2.0.13 (2026-09-23)
4
31
 
5
32
  ### Bug Fixes
6
33
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { O as workbuddyOwnAuthPath, T as legacyWorkbuddyOwnAuthPath, W as findWorkbuddyAppExecutable, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, g as WORKBUDDY_AUTH_FILE_ENV, j as WorkBuddyUpstreamClient, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, x as defaultDesktopAuthDirs, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-FxHiaIn-.js";
2
+ import { O as workbuddyOwnAuthPath, T as legacyWorkbuddyOwnAuthPath, W as findWorkbuddyAppExecutable, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, g as WORKBUDDY_AUTH_FILE_ENV, j as WorkBuddyUpstreamClient, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, x as defaultDesktopAuthDirs, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-SQNmYRNj.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
@@ -47,10 +47,18 @@ window.__ModuleLoader__.load({
47
47
  * project — the lookup then read `section['cn']` (absent), so the card's
48
48
  * checkbox reported `true` forever and clicking it appeared to do nothing even
49
49
  * though the write succeeded.
50
+ *
51
+ * The resolved section delivers `regions` as a `{get(): T}` LIVE reference, and
52
+ * a live reference is `typeof === 'object'` and not an array — so it passes a
53
+ * naive object check and gets returned as if it were the map. Every lookup on
54
+ * it is then `undefined`: the enabled flag reads back as "on" forever (the very
55
+ * symptom described above), and a merge that spreads this map silently drops
56
+ * the region it was not editing. Unwrap before narrowing.
50
57
  */
51
58
  function regionsMapOf(value) {
52
- if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
53
- const record = value;
59
+ const unwrapped = unwrapVolatileDeep(value);
60
+ if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
61
+ const record = unwrapped;
54
62
  const nested = record["regions"];
55
63
  if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
56
64
  return record;
@@ -118,9 +126,60 @@ window.__ModuleLoader__.load({
118
126
  contextWindow: nativeContextWindow
119
127
  };
120
128
  }
129
+ /**
130
+ * Deep copy of a settings value with every `{get(): T}` live reference replaced
131
+ * by the value it resolves to.
132
+ *
133
+ * `regions` and `accounts` are declared `asVolatile(...)`, and schemastery
134
+ * resolves a volatile field to a live reference. That resolution happens in
135
+ * schemastery itself, driven by the schema's `meta.volatile`, so it is
136
+ * independent of the DSH line — the resolved section looks like this to the
137
+ * browser half on BOTH 0.1.5 and 0.1.7.
138
+ *
139
+ * A live reference is still `typeof === 'object'`, so `{ ...reference }` does
140
+ * NOT read the field: it produces `{ get: <function> }`. Any caller that
141
+ * spreads the resolved field to preserve its siblings — the card's
142
+ * "write one region, keep the other" merge — would otherwise DROP every
143
+ * sibling and leak a function into the document. Both halves therefore unwrap
144
+ * before touching a resolved field.
145
+ *
146
+ * Lives here, not in the Host entry, because the browser half needs it too and
147
+ * this module is the node-free bridge between the two.
148
+ *
149
+ * Non-reference values are recursed into so a nested volatile field is caught
150
+ * too — arrays and objects are rebuilt rather than mutated, so the caller's
151
+ * value is never touched.
152
+ */
153
+ function unwrapVolatileDeep(value) {
154
+ if (value === null || typeof value !== "object") return value;
155
+ if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
156
+ if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
157
+ const source = value;
158
+ const out = {};
159
+ for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
160
+ return out;
161
+ }
121
162
  //#endregion
122
163
  //#region src/client/account-selection.ts
123
164
  /**
165
+ * Verified writes of the per-region account slot.
166
+ *
167
+ * 参考:DSH 自带插件卡片(`@deepseek-ai/dsh-client-ui-settings-plugins`
168
+ * 的 `CardForm.store()`)— 「写入后回读用户层并与写入值比对」这一校验形态,
169
+ * 是本模块 `writeAccountSlot()` 的出发点。
170
+ * 改动:针对账号槽位重写而非照搬 —— 回读的是**解析后的 section**而非 user 层
171
+ * (Host 的 legacy 归属也读它,两者必须同源),并用精确比较区分 `''`(哨兵清除)
172
+ * 与 `undefined`(键缺失),因为后者会让区域重新回退到旧版 `accountId`。
173
+ *
174
+ * Kept out of `WorkBuddyCard.tsx` — and free of any browser-only import — so
175
+ * the write contract can be unit-tested directly. The card is a `.tsx`
176
+ * component whose module graph pulls DSH's browser packages, which cannot load
177
+ * in the Node test environment; the check below is exactly the part that must
178
+ * not go untested.
179
+ *
180
+ * @module dsh-connect-workbuddy/client/account-selection
181
+ */
182
+ /**
124
183
  * A settings write that did not take effect.
125
184
  *
126
185
  * Distinct from a rejected `set()`: this is thrown when the write reported
@@ -135,9 +194,22 @@ window.__ModuleLoader__.load({
135
194
  this.field = field;
136
195
  }
137
196
  };
138
- /** Read one settings field's current object value from the scope snapshot. */
197
+ /**
198
+ * Read one settings field's current object value from the scope snapshot.
199
+ *
200
+ * The snapshot holds the RESOLVED section, where a volatile field
201
+ * (`regions` / `accounts`) is a `{get(): T}` LIVE reference rather than the
202
+ * plain object it resembles. Unwrapping first is not cosmetic: a live reference
203
+ * is still `typeof === 'object'`, so without this the caller spreads it into
204
+ * `{ get: <function> }` — which DROPS every sibling key (the other region) and
205
+ * leaks a reference function into the document.
206
+ *
207
+ * That was the "saving the international region loses the domestic one" report:
208
+ * every write here preserves the region it is not touching by spreading this
209
+ * value, so reading it wrong silently deleted the untouched region.
210
+ */
139
211
  function fieldSnapshotOf(scope, field) {
140
- const value = scope.getSnapshot().value?.[field];
212
+ const value = unwrapVolatileDeep(scope.getSnapshot().value?.[field]);
141
213
  return typeof value === "object" && value !== null ? value : {};
142
214
  }
143
215
  /**
@@ -167,17 +239,32 @@ window.__ModuleLoader__.load({
167
239
  const reason = `${String(detail.errorName ?? "")} ${String(detail.error ?? "")}`.trim();
168
240
  throw new WorkBuddySettingsWriteError(field, `Host save refused: ${reason === "" ? String(detail.error) : reason}`);
169
241
  }
242
+ return (await response.json().catch(() => void 0))?.value;
170
243
  }
171
244
  /**
172
245
  * Write one volatile field, then confirm the value actually landed.
173
246
  *
174
- * The bound settings scope is the official path and is tried FIRST: it keeps
175
- * the browser mirror in sync, and it is the only writer needed on hosts whose
176
- * ConfigForm is healthy. On the affected DSH 0.1.7 deployment that scope
177
- * settles without delivering anything — its `set()` resolves `false` while the
178
- * Host's own mutate is perfectly healthy (proven by a direct in-Host probe) —
179
- * so a write that does not read back falls through to the plugin's Host
180
- * endpoint, which performs the mutate inside the Host process.
247
+ * ORDER MATTERS, and getting it wrong DELETES DATA. The two writers do not have
248
+ * the same semantics:
249
+ *
250
+ * - The Host save endpoint merges the posted region into the field's
251
+ * AUTHORITATIVE value inside the Host process, preserving every other region.
252
+ * - `scope.set(field, next)` hands the settings service the WHOLE field, merged
253
+ * on the CLIENT out of the browser mirror. The Host performs no server-side
254
+ * merge on this path, so whatever the client built REPLACES the field.
255
+ *
256
+ * The mirror is not a reliable merge base: a write made through the Host
257
+ * endpoint does not update it (that lag is documented on `contextBudgets`
258
+ * below), and on the affected 0.1.7 deployment the mirror for this namespace
259
+ * stays stale outright. Building the merge from it therefore produced
260
+ * `{ [region]: slot }` with every SIBLING region missing — and `scope.set`
261
+ * stores that verbatim, deleting them. That was the report "I saved the
262
+ * domestic region and the international one was gone": `accounts` survived
263
+ * (written while the mirror happened to be fresh) while `regions` lost a slot.
264
+ *
265
+ * So the Host endpoint goes FIRST — it is the only writer that cannot delete a
266
+ * sibling. `scope.set` is then attempted purely as a mirror refresh, and only
267
+ * when it delivers is the write considered done without the endpoint.
181
268
  *
182
269
  * `landed` decides whether a value read back from the scope is the value that
183
270
  * was written; it is per-field because `''` (a cleared account slot) and an
@@ -187,6 +274,16 @@ window.__ModuleLoader__.load({
187
274
  * @throws {WorkBuddySettingsWriteError} when neither path persists the value.
188
275
  */
189
276
  async function writeField(scope, field, region, value, landed) {
277
+ let hostError;
278
+ try {
279
+ const authoritative = await saveViaHostEndpoint(field, region, value);
280
+ if (authoritative !== void 0) try {
281
+ await scope.set(field, authoritative);
282
+ } catch {}
283
+ return;
284
+ } catch (error) {
285
+ hostError = error;
286
+ }
190
287
  let scopeDelivered = false;
191
288
  try {
192
289
  scopeDelivered = await scope.set(field, {
@@ -197,7 +294,7 @@ window.__ModuleLoader__.load({
197
294
  scopeDelivered = false;
198
295
  }
199
296
  if (scopeDelivered && landed(fieldSnapshotOf(scope, field)[region])) return;
200
- await saveViaHostEndpoint(field, region, value);
297
+ throw hostError instanceof Error ? hostError : new WorkBuddySettingsWriteError(field, "neither the Host save endpoint nor the settings scope persisted the value");
201
298
  }
202
299
  /**
203
300
  * Write one region's account slot, then confirm the value actually landed.
@@ -2208,7 +2208,7 @@ var WorkBuddyCatalog = class {
2208
2208
  //#endregion
2209
2209
  //#region src/version.ts
2210
2210
  /** The npm package version this build was produced from. */
2211
- const WORKBUDDY_CONNECT_VERSION = "2.0.13";
2211
+ const WORKBUDDY_CONNECT_VERSION = "2.0.14";
2212
2212
  //#endregion
2213
2213
  //#region src/host-heartbeat.ts
2214
2214
  /**
package/lib/index.d.ts CHANGED
@@ -1247,7 +1247,6 @@ interface WorkBuddyWebAccount {
1247
1247
  tokenExpiresAtMs: number;
1248
1248
  selected: boolean;
1249
1249
  }
1250
- type WorkBuddyWebPackage = WorkBuddyWebCreditPackage;
1251
1250
  /**
1252
1251
  * One probed candidate path and why it yielded no account.
1253
1252
  *
@@ -1366,6 +1365,31 @@ type WorkBuddyWebUsage =
1366
1365
  status: 'error';
1367
1366
  message: string;
1368
1367
  };
1368
+ /**
1369
+ * Deep copy of a settings value with every `{get(): T}` live reference replaced
1370
+ * by the value it resolves to.
1371
+ *
1372
+ * `regions` and `accounts` are declared `asVolatile(...)`, and schemastery
1373
+ * resolves a volatile field to a live reference. That resolution happens in
1374
+ * schemastery itself, driven by the schema's `meta.volatile`, so it is
1375
+ * independent of the DSH line — the resolved section looks like this to the
1376
+ * browser half on BOTH 0.1.5 and 0.1.7.
1377
+ *
1378
+ * A live reference is still `typeof === 'object'`, so `{ ...reference }` does
1379
+ * NOT read the field: it produces `{ get: <function> }`. Any caller that
1380
+ * spreads the resolved field to preserve its siblings — the card's
1381
+ * "write one region, keep the other" merge — would otherwise DROP every
1382
+ * sibling and leak a function into the document. Both halves therefore unwrap
1383
+ * before touching a resolved field.
1384
+ *
1385
+ * Lives here, not in the Host entry, because the browser half needs it too and
1386
+ * this module is the node-free bridge between the two.
1387
+ *
1388
+ * Non-reference values are recursed into so a nested volatile field is caught
1389
+ * too — arrays and objects are rebuilt rather than mutated, so the caller's
1390
+ * value is never touched.
1391
+ */
1392
+ declare function unwrapVolatileDeep<T>(value: T): T;
1369
1393
  //#endregion
1370
1394
  //#region src/web-status.d.ts
1371
1395
  /** Constructor dependencies. */
@@ -1524,11 +1548,12 @@ declare function asVolatile<T>(schema: z<T>): z<T>;
1524
1548
  * live references, and every field it validated threw. The namespace never
1525
1549
  * registered, so the card's settings silently disappeared.
1526
1550
  *
1527
- * Non-reference values are recursed into so a nested volatile field is caught
1528
- * too — arrays are rebuilt rather than mutated, so the caller's config object is
1529
- * never touched.
1551
+ * The implementation now lives in the node-free host<->client bridge, because
1552
+ * the browser half needs the SAME unwrap before it spreads a resolved field —
1553
+ * spreading a live reference is `{get: <function>}`, which drops every sibling
1554
+ * region. It is imported and re-exported from here so the Host entry keeps
1555
+ * exposing it as part of its public API.
1530
1556
  */
1531
- declare function unwrapVolatileDeep<T>(value: T): T;
1532
1557
  declare const Config: z<Config>;
1533
1558
  /**
1534
1559
  * One region's saved model state. A config written before the region split has
@@ -1599,4 +1624,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
1599
1624
  */
1600
1625
  declare function apply(ctx: Context, config: Config): void;
1601
1626
  //#endregion
1602
- export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, 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_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, 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 WorkBuddyProbeStore, type WorkBuddyReasoning, type WorkBuddyRecoveryCandidate, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyUsabilityProbeOptions, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebSearchPath, type WorkBuddyWebUsage, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1627
+ export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, 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_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, 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 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, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-FxHiaIn-.js";
1
+ import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-SQNmYRNj.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { createProvider } from "@earendil-works/pi-ai";
@@ -544,10 +544,18 @@ function regionOfStatusUrl(url) {
544
544
  * project — the lookup then read `section['cn']` (absent), so the card's
545
545
  * checkbox reported `true` forever and clicking it appeared to do nothing even
546
546
  * though the write succeeded.
547
+ *
548
+ * The resolved section delivers `regions` as a `{get(): T}` LIVE reference, and
549
+ * a live reference is `typeof === 'object'` and not an array — so it passes a
550
+ * naive object check and gets returned as if it were the map. Every lookup on
551
+ * it is then `undefined`: the enabled flag reads back as "on" forever (the very
552
+ * symptom described above), and a merge that spreads this map silently drops
553
+ * the region it was not editing. Unwrap before narrowing.
547
554
  */
548
555
  function regionsMapOf(value) {
549
- if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
550
- const record = value;
556
+ const unwrapped = unwrapVolatileDeep(value);
557
+ if (typeof unwrapped !== "object" || unwrapped === null || Array.isArray(unwrapped)) return {};
558
+ const record = unwrapped;
551
559
  const nested = record["regions"];
552
560
  if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) return nested;
553
561
  return record;
@@ -615,6 +623,39 @@ function toPersistedWorkBuddyModel(model) {
615
623
  contextWindow: nativeContextWindow
616
624
  };
617
625
  }
626
+ /**
627
+ * Deep copy of a settings value with every `{get(): T}` live reference replaced
628
+ * by the value it resolves to.
629
+ *
630
+ * `regions` and `accounts` are declared `asVolatile(...)`, and schemastery
631
+ * resolves a volatile field to a live reference. That resolution happens in
632
+ * schemastery itself, driven by the schema's `meta.volatile`, so it is
633
+ * independent of the DSH line — the resolved section looks like this to the
634
+ * browser half on BOTH 0.1.5 and 0.1.7.
635
+ *
636
+ * A live reference is still `typeof === 'object'`, so `{ ...reference }` does
637
+ * NOT read the field: it produces `{ get: <function> }`. Any caller that
638
+ * spreads the resolved field to preserve its siblings — the card's
639
+ * "write one region, keep the other" merge — would otherwise DROP every
640
+ * sibling and leak a function into the document. Both halves therefore unwrap
641
+ * before touching a resolved field.
642
+ *
643
+ * Lives here, not in the Host entry, because the browser half needs it too and
644
+ * this module is the node-free bridge between the two.
645
+ *
646
+ * Non-reference values are recursed into so a nested volatile field is caught
647
+ * too — arrays and objects are rebuilt rather than mutated, so the caller's
648
+ * value is never touched.
649
+ */
650
+ function unwrapVolatileDeep(value) {
651
+ if (value === null || typeof value !== "object") return value;
652
+ if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
653
+ if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
654
+ const source = value;
655
+ const out = {};
656
+ for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
657
+ return out;
658
+ }
618
659
  //#endregion
619
660
  //#region src/web-status.ts
620
661
  /** Redact token-like content before it crosses to the browser. */
@@ -927,7 +968,10 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
927
968
  path: [field],
928
969
  value: merged
929
970
  }], void 0);
930
- return json(res, 200, { ok: true });
971
+ return json(res, 200, {
972
+ ok: true,
973
+ value: merged
974
+ });
931
975
  } catch (error) {
932
976
  const err = error;
933
977
  return json(res, 500, {
@@ -1011,19 +1055,12 @@ function unwrapVolatile(value) {
1011
1055
  * live references, and every field it validated threw. The namespace never
1012
1056
  * registered, so the card's settings silently disappeared.
1013
1057
  *
1014
- * Non-reference values are recursed into so a nested volatile field is caught
1015
- * too — arrays are rebuilt rather than mutated, so the caller's config object is
1016
- * never touched.
1058
+ * The implementation now lives in the node-free host<->client bridge, because
1059
+ * the browser half needs the SAME unwrap before it spreads a resolved field —
1060
+ * spreading a live reference is `{get: <function>}`, which drops every sibling
1061
+ * region. It is imported and re-exported from here so the Host entry keeps
1062
+ * exposing it as part of its public API.
1017
1063
  */
1018
- function unwrapVolatileDeep(value) {
1019
- if (value === null || typeof value !== "object") return value;
1020
- if (typeof value.get === "function") return unwrapVolatileDeep(value.get());
1021
- if (Array.isArray(value)) return value.map((entry) => unwrapVolatileDeep(entry));
1022
- const source = value;
1023
- const out = {};
1024
- for (const key of Object.keys(source)) out[key] = unwrapVolatileDeep(source[key]);
1025
- return out;
1026
- }
1027
1064
  const Config = z.object({
1028
1065
  authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)")),
1029
1066
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "Connect locally signed-in WorkBuddy models to DeepSeek Harness with a read-only credits overview and model management.",
5
- "version": "2.0.13",
5
+ "version": "2.0.14",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",