dsh-connect-workbuddy 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.1 (2026-09-30)
4
+
5
+ ### Bug Fixes
6
+
7
+ #### 保存账号池会把该区域的模型目录整段删掉,于是「保存后目标模型就下架了」
8
+
9
+ **用户报告**:在账号池里点保存后,目标模型立刻显示「已下架」,并且「一键测试所有账号」被停用;**macOS 上不出现**。
10
+
11
+ **根因(真机取证)**:`__save` 的路由此前做的是 `{ ...current, ...incoming }` —— **区域级整体替换**。而客户端只能按自己的 settings 镜像拼出槽位,这个镜像**本仓库自己注释过对本命名空间不可靠**(经本端点写入不会刷新它,在 0.1.7 线上可长期陈旧)。于是:
12
+
13
+ 1. 保存账号池时客户端 POST 的是 `{ cn: { pool } }`;
14
+ 2. Host 用这一个键**替换掉整个 cn 槽** → `lastCatalog`、`enabledModelIds`、`imageModelIds`、`contextBudgets`、`enabled` **全被删除**;
15
+ 3. `displayModels` 见 `lastCatalog` 为空即回落到**静态 CN 目录**(`src/catalog.ts`),而它只有 `deepseek-v4-flash`(第 39 行),**没有 `deepseek-v4.1-flash`**(那在第 59 行,属国际版);
16
+ 4. 于是判「已下架」,`resolveTargetModel` 返回 `stale`,测试随之停用。
17
+
18
+ **证据**:同一 profile 下留有 1:58 的配置备份(20711 B,`cn.lastCatalog` 含 `auto`/`hy4-preview`/… 完整清单),而当前配置(14873 B)的 `cn` 槽**只剩 `pool`**——差出的约 6 KB 正是被删掉的目录。本项目审计 `docs/audit/D-verification.md` L-4 记录过同一机制(当时是 `toggleRegion` 的过期整槽写入),但只修了写入交错,没有修「整体替换」这一步。
19
+
20
+ **修法**:`__save` 的合并**下沉一层**——字段的键做一层(保兄弟区域),被触及区域槽内的键再做一层。**只保留客户端未提及的键**;客户端显式发来的字段(含空数组)依旧覆盖,因此「主动清空」不受影响。刻意不再更深:`contextBudgets` 这类嵌套 map 由其所有者整体发送,继续递归会让单个条目无法删除。
21
+
22
+ **为什么不动 macOS**:该合并只在「客户端漏发了某个键」时才有差别;macOS 上的槽位是完整的,因此对它是恒等操作。
23
+
24
+ 守卫:`tests/web-status.spec.ts` 新增 4 例——① 只发 `{cn:{pool}}` 时 `contextBudgets`/`enabled` 必须存活(**这条直接复现本次数据丢失**);② 国际版同理(`{global:{pool}}` 不得删掉 global 的目录与预算);③ 显式发空数组仍能清空;④ `accounts` 的账号 id 是字符串,不得被对象化。**变异验证**:把合并改回一层展开 → ① 与 ② 同时变红(已实测)。
25
+
26
+ **即时补救**:卡片上的「切回自动」会把目标换成该区域的免费模型(`hy3`,静态目录里也有),立即可用;被删掉的模型勾选与上下文预算需在「模型管理」里重新保存一次以重建 `lastCatalog`。
27
+
28
+ **国际版(global)同样已覆盖,而且它更需要这个修复**:
29
+
30
+ - **为什么覆盖**:合并遍历的是**字段自己的键**,不认区域名——`{cn:{…}}` 与 `{global:{…}}` 走的是同一段代码,没有区域分支。有专门用例钉住(`preserves omitted fields in the INTERNATIONAL region too`)。
31
+ - **为什么它更需要**:同一个删除在国际版**不表现出任何症状**。`displayModels` 在 `lastCatalog` 为空时回落到静态目录,而**国际版静态目录里有 `deepseek-v4.1-flash`(`src/catalog.ts:59`,倍率 0)**,国内版静态目录没有(只有 `deepseek-v4-flash`,L39)。所以国际版用户的目标模型照旧解析成功——目录、勾选、上下文预算**被默默删掉却毫无提示**。国内版至少还会喊「已下架」。
32
+ - 这条区域差异已用 `tests/catalog.spec.ts` 的断言钉住(`cn.has('deepseek-v4.1-flash') === false` 且 `cn.has('deepseek-v4-flash') === true`),避免日后改动任一份目录时悄悄改变「哪些故障可见」。
33
+
34
+ **另说明**:本次两处修复都不区分区域。`isFileContentionWriteError` 是**平台**维度的(只在 Windows 上会命中 `EPERM/EBUSY/EACCES`),两条区域共用同一段客户端代码,因此国际版的保存失败提示也一并改善。
35
+
36
+ - **Windows 上文件被占用时,两条保存路径只甩原始 EPERM、不给做法**(模型列表与账号池;账号路径本来就有)。真机实测(3.0.0 宿主 + 锁住 profile 的 `cordis.patch.yml`):写入返回 HTTP 500,报文是
37
+ `EPERM: operation not permitted, rename 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'`
38
+ ——只报了一个临时文件路径,**没有说明原因、也没告诉用户怎么办**。而 `row.accountsWriteFailed` 早就把「杀毒软件/同步盘占用 → 关闭占用者后重试」写进了文案。
39
+ - **修法**:新增 `isFileContentionWriteError()`(匹配 `EPERM`/`EBUSY`/`EACCES` 三个**词边界**标记——正是 `dsh-atomic-write` 在 win32 上重试的那三个),命中时在原因后追加 `row.saveContentionHint`;模型列表与账号池两条路径接上。
40
+ - **只对文件占用生效,不做无条件提示**:校验类失败走的是同一个 catch,对那种失败说「去关闭占用文件的程序」会把用户引向不存在的原因。有专门用例钉住「无关失败不得出现该提示」。
41
+ - **macOS 行为不变**:POSIX 的 `rename` 无论目标是否被打开都直接替换,所以这三个错误码在 macOS/Linux 上基本不会出现——检测器天然只在 Windows 上生效。
42
+ - **顺带记录的核验结论:Windows 保存逻辑本身没有「静默成功」**。锁住文件时 `regions` 与 `accounts` 两条写入都返回 500 并带 `EPERM`,客户端随即抛 `WorkBuddySettingsWriteError`,卡片明确报错并**保留草稿/原选择**(三条保存路径都不会丢弃用户改动)。回退路径的回读校验读的是「被拒后重新加载的 Host 状态」,不会把乐观写入误判为已落盘。因此本次修的是**报错文案的可操作性**,不是落盘判定。
43
+
44
+ ### Docs
45
+
46
+ - **改正 3.0.0 里一处过期的免费模型举例**(README 两份 + 上方 3.0.0 条目):原文写「该区域倍率为 0 的免费模型(**国内版即 `deepseek-v4.1-flash`**)」。2026-09-30 在真机上核对 live catalog(16 个模型)后,`deepseek-v4.1-flash` 的倍率是 **0.11**,国内版**唯一**倍率为 0 的是 **`hy3`**(ctx 192000)。
47
+ - **机制本来是对的,错的只是举例**:`pickFreeModel` 严格按 `creditMultiplier === 0` 现挑、多个候选取上下文窗口最大者,实测输入真实目录时返回 `hy3`,规则成立;`docs/ACCOUNT_POOL_PLAN.md` §4.4 早就写明「不写死 `deepseek-v4.1-flash`」并解释了原因。所以这条只是文档里的名字腐坏了,不是行为缺陷。
48
+ - **为什么不改成另一个写死的名字**:上游会改倍率(本次观察到的就是一次改动),任何名字都会再次过期。现改为陈述「按 `creditMultiplier === 0` 现挑」并附本次实测值,同时说明为何不写死。
49
+ - **顺带印证的一条正确设计**:`auto` 在上游目录里**没有** `creditMultiplier` 字段,`pickFreeModel` 不把它当免费——这正是该模块注释警告过的陷阱(缺失 ≠ 0),实测确认未踩。
50
+
3
51
  ## 3.0.0 (2026-09-30)
4
52
 
5
53
  > **主版本号升级的理由**:账号池是第一个会改变「哪个账号被计费」的功能。在此之前插件只有一条硬规则——**绝不为了找积分而重排账号**(`src/auth.ts` 的原注释:*This is NOT credit-seeking*)。账号池把这条规则变成**用户显式授权的例外**:打开「按积分自动轮换」后,被计费的账号可能不是你手动选的那个。这是语义级的变化,因此走主版本号。
@@ -9,7 +57,7 @@
9
57
  #### 账号池:多账号批量操作与自动轮换
10
58
 
11
59
  - **一键签到所有账号**:逐个账号签到,**签到前先查当天状态**,已签到的直接标记、**不发写入请求**(幂等,不重复领取);单个账号失败只标记该行,**不中断整批**。
12
- - **一键测试所有账号**:对每个成员发一次**真实体积**的探测(约 25k 输入 token,复用模型行的 `probeModel`),得出该账号此刻能否用这个模型。默认目标是**该区域倍率为 0 的免费模型**(国内版即 `deepseek-v4.1-flash`),也可指定具体模型;指定的模型若离开目录,手动动作**明确拒绝**(`target-model-stale`)而不是偷换一个。
60
+ - **一键测试所有账号**:对每个成员发一次**真实体积**的探测(约 25k 输入 token,复用模型行的 `probeModel`),得出该账号此刻能否用这个模型。默认目标是**该区域倍率为 0 的免费模型**(**不写死名字**:实测国内版当前只有 `hy3` 一个免费模型,而 `deepseek-v4.1-flash` 已从 0 变为 0.11——上游会改倍率,所以按目录里的 `creditMultiplier === 0` 现挑),也可指定具体模型;指定的模型若离开目录,手动动作**明确拒绝**(`target-model-stale`)而不是偷换一个。
13
61
  - **按积分自动轮换**:在可用成员中按 **① 积分余额高者优先 → ② 快过期的积分优先用完 → ③ 平手时凭据更新鲜者优先** 选出计费账号。**与手动选择互斥**:开启后手动下拉立即置灰并就地给出原因与关闭按钮(否则会出现「下拉写着 A、实际计费 B」)。**轮换只在运行时决定用谁,绝不写回 `config.accounts[region]`**,关掉开关即原样恢复你上次的手动选择;每次换号都在卡片上留一条记录。
14
62
  - **成员是显式勾选、默认空**:「本机有登录」不等于「要拿它去签到和花积分」。**空池不会退化成「全部账号」**——未勾选时两个批量按钮直接拒绝。
15
63
  - **定时自动测试**:间隔 **5 ~ 1440 分钟(默认 30)**,定时器每 60 秒走一次。下限 5 分钟是刻意的:更密只会把整池打成限流。
package/README.en.md CHANGED
@@ -71,7 +71,7 @@ A machine usually carries more than one WorkBuddy sign-in. The **account pool**
71
71
  - **Check in all accounts** — signs in account by account. It **reads today's status first**, so an account that is already checked in is marked as such and receives **no write request at all** (idempotent — no duplicate claims); a single account failing marks only that row and **never aborts the batch**.
72
72
  - **Test all accounts** — sends one **real-volume** probe per member (about 25k input tokens; the same `probeModel` the model rows use, so it inherits both measured facts: throttling is triggered by request **size**, and the reset time is written into the response **body** rather than a header), reporting whether that account can serve that model right now.
73
73
 
74
- **Which model is tested:** by default the **zero-multiplier free model in that region's catalog** (`deepseek-v4.1-flash` on the domestic side), or a specific model you pick in the dropdown. If the model you named has left the catalog, the **manual action refuses outright** (`target-model-stale`) instead of quietly substituting another one — you asked to test *that* model, and swapping it answers a question you did not ask.
74
+ **Which model is tested:** by default the **zero-multiplier free model in that region's catalog** (measured: the domestic catalog currently has exactly one, `hy3`; the name is deliberately **not** hard-coded, because the upstream changes multipliers — `deepseek-v4.1-flash` has already moved from 0 to 0.11), or a specific model you pick in the dropdown. If the model you named has left the catalog, the **manual action refuses outright** (`target-model-stale`) instead of quietly substituting another one — you asked to test *that* model, and swapping it answers a question you did not ask.
75
75
 
76
76
  **Cost and boundaries:** testing is a real request. Pointed at the free model (multiplier 0) it costs virtually nothing, but it is **not a free no-op**; check-in claims that day's real reward. Both actions run **only when you press the button** — the plugin **does not offer automatic check-in**, and the card does not show a switch that can never be flipped.
77
77
 
package/README.md CHANGED
@@ -78,7 +78,7 @@ DSH PiAiAdapter(每个 provider 一套)
78
78
  - **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
79
79
  - **一键测试所有账号** —— 对每个成员发一次**真实体积**的探测请求(约 25k 输入 token;与模型行的「测试」是同一套 `probeModel`,因而同样继承两个实测结论:限流**按请求体积**触发、恢复时间写在**响应体**里而不是响应头),得出该账号此刻能否用这个模型。
80
80
 
81
- **测试目标是哪个模型**:默认是**该区域目录里倍率为 0 的免费模型**(国内版即 `deepseek-v4.1-flash`),也可以在下拉里指定一个具体模型。指定的模型若已离开目录,**手动动作会明确拒绝**(`target-model-stale`),而不是偷偷换一个——你点的是「测这个模型」,换掉就是答非所问。
81
+ **测试目标是哪个模型**:默认是**该区域目录里倍率为 0 的免费模型**(实测国内版当前只有 `hy3` 一个;**不写死名字**,因为上游会改倍率——`deepseek-v4.1-flash` 就已从 0 变成 0.11),也可以在下拉里指定一个具体模型。指定的模型若已离开目录,**手动动作会明确拒绝**(`target-model-stale`),而不是偷偷换一个——你点的是「测这个模型」,换掉就是答非所问。
82
82
 
83
83
  **成本与边界**:测试是真实请求,指向免费模型时(倍率 0)几乎不花钱,但它**不是零成本的空转**;签到则会真实领取当日奖励。两个动作都**只在你点按钮时执行**——插件**不提供自动签到**,卡片上也不放一个永远点不动的开关。
84
84
 
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-JODdwFRx.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-CfXJht7R.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
@@ -264,6 +264,31 @@ window.__ModuleLoader__.load({
264
264
  }
265
265
  };
266
266
  /**
267
+ * Whether a save failure is a FILE-CONTENTION refusal.
268
+ *
269
+ * On Windows, replacing a file that another process holds open fails with
270
+ * `EPERM` / `EBUSY` / `EACCES` — an antivirus scanner, a sync client (OneDrive),
271
+ * or an editor with the profile's patch file open. POSIX `rename` replaces
272
+ * outright regardless of open handles, so this branch effectively never fires
273
+ * on macOS/Linux: the diagnosis is Windows-shaped because the failure is.
274
+ *
275
+ * It exists so a save failure can say WHAT TO DO. Without it the card pasted the
276
+ * raw error at the user — `EPERM: operation not permitted, rename
277
+ * 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'` — which
278
+ * names a temp path and no remedy. Measured live against the 3.0.0 host with the
279
+ * file held open: both the `regions` and `accounts` writes came back HTTP 500
280
+ * with exactly that text.
281
+ *
282
+ * Deliberately matched on the message rather than on `WorkBuddySettingsWriteError`
283
+ * alone: a validation refusal is also that class but has nothing to do with file
284
+ * contention, and telling such a user to close their editor would send them
285
+ * chasing a cause that is not there.
286
+ */
287
+ function isFileContentionWriteError(error) {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ return /\b(?:EPERM|EBUSY|EACCES)\b/u.test(message);
290
+ }
291
+ /**
267
292
  * Read one settings field's current object value from the scope snapshot.
268
293
  *
269
294
  * The snapshot holds the RESOLVED section, where a volatile field
@@ -1113,7 +1138,10 @@ window.__ModuleLoader__.load({
1113
1138
  }
1114
1139
  committed = true;
1115
1140
  } catch (error) {
1116
- if (mounted.current) setSaveError(error instanceof Error ? error.message : t("row.requestFailed"));
1141
+ if (mounted.current) {
1142
+ const reason = error instanceof Error ? error.message : t("row.requestFailed");
1143
+ setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
1144
+ }
1117
1145
  } finally {
1118
1146
  if (committed) discard();
1119
1147
  if (mounted.current) setSaving(false);
@@ -2542,7 +2570,10 @@ window.__ModuleLoader__.load({
2542
2570
  discardModels();
2543
2571
  await refreshUsage(activeRegion);
2544
2572
  } catch (error) {
2545
- if (mounted.current) setSaveError(error instanceof Error ? error.message : t("row.requestFailed"));
2573
+ if (mounted.current) {
2574
+ const reason = error instanceof Error ? error.message : t("row.requestFailed");
2575
+ setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
2576
+ }
2546
2577
  } finally {
2547
2578
  if (mounted.current) setSaving(false);
2548
2579
  }
@@ -3132,6 +3163,7 @@ window.__ModuleLoader__.load({
3132
3163
  "row.save": "Save",
3133
3164
  "row.saving": "Saving…",
3134
3165
  "row.saveError": "Save failed: {message}",
3166
+ "row.saveContentionHint": " (The file was locked, so the write was refused — on Windows this is usually an antivirus scanner, a sync client such as OneDrive, or an editor with that file open. Close whatever holds it and save again; your edits are still here.)",
3135
3167
  "row.modelContext": "Maximum context {context}",
3136
3168
  "row.contextBudget": "DSH context budget",
3137
3169
  "row.modelOutput": "Output {output}",
@@ -3324,6 +3356,7 @@ window.__ModuleLoader__.load({
3324
3356
  "row.save": "保存",
3325
3357
  "row.saving": "保存中…",
3326
3358
  "row.saveError": "保存失败:{message}",
3359
+ "row.saveContentionHint": "(该文件被占用,写入被拒——Windows 上通常是杀毒软件、OneDrive 这类同步盘,或正打开该文件的编辑器。请先关闭占用者再保存一次;你的改动仍然还在。)",
3327
3360
  "row.modelContext": "最大上下文 {context}",
3328
3361
  "row.contextBudget": "DSH 上下文预算",
3329
3362
  "row.modelOutput": "最大输出 {output}",
@@ -2286,7 +2286,7 @@ var WorkBuddyCatalog = class {
2286
2286
  //#endregion
2287
2287
  //#region src/version.ts
2288
2288
  /** The npm package version this build was produced from. */
2289
- const WORKBUDDY_CONNECT_VERSION = "3.0.0";
2289
+ const WORKBUDDY_CONNECT_VERSION = "3.0.1";
2290
2290
  //#endregion
2291
2291
  //#region src/host-heartbeat.ts
2292
2292
  /**
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-JODdwFRx.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-CfXJht7R.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, timingSafeEqual } from "node:crypto";
@@ -1540,6 +1540,10 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
1540
1540
  function safeMessage(error) {
1541
1541
  return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
1542
1542
  }
1543
+ /** A non-null, non-array object: the only shape the slot merge applies to. */
1544
+ function isPlainRecord(value) {
1545
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1546
+ }
1543
1547
  function json(res, status, body) {
1544
1548
  const payload = JSON.stringify(body);
1545
1549
  res.writeHead(status, {
@@ -2044,10 +2048,14 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
2044
2048
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
2045
2049
  const current = unwrapVolatileDeep(row.value?.[field] ?? {});
2046
2050
  const incoming = body.value ?? {};
2047
- const merged = {
2048
- ...current,
2049
- ...incoming
2050
- };
2051
+ const merged = { ...current };
2052
+ for (const [key, slot] of Object.entries(incoming)) {
2053
+ const prior = current[key];
2054
+ merged[key] = isPlainRecord(prior) && isPlainRecord(slot) ? {
2055
+ ...prior,
2056
+ ...slot
2057
+ } : slot;
2058
+ }
2051
2059
  await settings.mutate(row.ns, [{
2052
2060
  op: "set",
2053
2061
  path: [field],
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "3.0.0",
5
+ "version": "3.0.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",