dsh-connect-workbuddy 2.0.11 → 2.0.12

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,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.12 (2026-09-23)
4
+
5
+ ### Features
6
+
7
+ - **支持 DSH 0.1.7 线,且同一个构建同时服务 0.1.5 与 0.1.7**(合并社区 PR #14,作者 [@qikairo7](https://github.com/qikairo7))。这不是「多支持一个版本」的可选增强——**在 0.1.7 上插件此前根本装配不起来**。
8
+
9
+ - **根因:settings 服务换型,`installSection` 消失。** 0.1.5 的 `SettingsProvider` 提供 `installSection(owner, ns, schema, entry, hooks)`;0.1.7 换成 `SettingsForms`,该方法**整个移除**(我在 `dsh-settings` / `dsh-config-editor` / `dsh-client-ui-settings-plugins` 三个包的整份 tarball 里逐一确认无此符号,也未迁移到其他包),取而代之的是 `configure({auto}, owner)`。而插件此前**无保护地裸调用** `ctx.settings.installSection(...)`——`inject = ['llm','settings']` 两个服务在 0.1.7 上都存在,所以 `apply()` 会一路执行到那一行。实测(用一个行为等同 0.1.7 的 settings 服务装配):
10
+
11
+ ```
12
+ apply() THREW -> ctx.settings.installSection is not a function
13
+ ```
14
+
15
+ 即**整个插件装配失败**:两个 provider 都不注册,卡片与 Web 状态路由同样不存在。这与「某个功能坏掉」在用户侧都是「用不了」,但成因完全不同——**也因此「重新登录」「重装插件」一概无效**。现在改为按能力探测:有 `configure` 走 `configure`,否则回落 `installSection`。
16
+ - **易失字段必须显式声明(issue #13 在 0.1.7 线上的真正根因)。** 0.1.7 的 settings 写入门要求插件 schema 把可写字段标记为 volatile,否则**写入被直接拒绝**(`Plugin entry "workbuddy" has no volatile fields`),`scope.set()` 却正常 resolve——于是卡片回读校验抛 `settings field "regions" was not persisted`。新增 `asVolatile()`:运行时探测 `schema.volatile()`,**有则调用、无则退化为 identity no-op**。这一条是兼容易失的关键——`volatile()` 在 3.18.3 才出现,更早的版本上 `asVolatile()` 必须**原样返回**同一个 schema 对象(identity no-op),而不是手写 `meta.volatile = true`:后者会绕过 schemastery 自身的 `validateVolatileSchema` 校验,产出一个它自己都不认的 schema,因此**故意不那样做**。
17
+ - **0.1.7 以「活引用」交付配置值。** 0.1.7 把配置值以 `{get(): T}` 形式交给 `apply()`,不解包则 `config.regions`、`value.accounts[region]`、`config.authFile` 静默变成对象或 `undefined`——表现为「设置明明写了却读不到」。新增 `unwrapVolatile()` 并在所有读取路径上解包,同时监听 `loader/volatile-update` 在每次写入后重读选择。
18
+ - **客户端双线槽位。** 两条线声明的是**互不相交**的槽位集合(0.1.5 只有 `settings.plugin.item`;0.1.7 是插件管理器的 `plugins.bundle.config` / `plugins.row.config`)。卡片现在逐个注册且**各自独立 try/catch**——一个槽位在某一线上不存在时,不能把另一条线的注册一起带走。settings scope 也按能力获取:0.1.7 走 `configForms.get(ns)`(并从镜像里取 Host 实际服务的 namespace,插件可能挂在别的 entry id 下),0.1.5 回落 `settingsScope.bind()`。
19
+ - **折叠箭头改为纯 CSS。** 两线 primitives 的图标名不重叠(`…Outline14` vs `…OutlineRegular`),没有任何一个静态图标 import 能同时服务两边。
20
+
21
+ ### Bug Fixes
22
+
23
+ - **CI 在 windows-latest 上红了 2 例**(2.0.11 引入,本仓库自身的缺陷)。新增的 darwin 候选断言写死了 POSIX 字面量(`expect(mac).toContain('/Applications/WorkBuddy.app/Contents/MacOS/Electron')`)。生产代码没问题——它用 `join` 拼路径;错的是**测试**:它断言的是「跑测试的机器是 POSIX」,而非「darwin 候选列表对不对」。Windows 上 `join` 产出反斜杠,于是必然失败,而 macOS 上恒绿——**这正是它能一路走到 CI 的原因**。改为全部经 `join` 构造期望值,并补一条回归守卫:候选路径不得**混用**分隔符(用字面量 `/` 拼接在 Windows 上会产出 `\Applications/WorkBuddy.app`,任何正确路径都不会长这样)。
24
+
25
+ - **易失标记一生效,0.1.5 线的设置区就整个消失**(合并 PR #14 后由本仓库查出并修复的真缺陷)。`asVolatile()` 给字段打上 volatile 标记后,该字段的值会被包成 `{get(): T}` **活引用**。而 `installSection(owner, ns, schema, entry, hooks)` 的第 4 个参数 `entry` 会被服务**校验并 `structuredClone`**,于是拿到活引用时逐字段抛错:
26
+
27
+ ```
28
+ $.authFile expected string but got [object Object]
29
+ ```
30
+
31
+ 结果是命名空间**注册不上**——卡片设置区静默消失(实测 13 个测试变红)。
32
+
33
+ - **为什么 PR14 与 CI 都没发现**:两边都把 schemastery 锁在 **3.18.2**,那里没有 `volatile()`,`asVolatile()` 退化为 identity no-op,**这条路径从未被执行过**。而 PR14 自己又把 schemastery 钉进 `dependencies: 3.18.4`——npm 用户会解析到嵌套的 3.18.4,`volatile()` 真正生效,**恰好走进没人测过的分支**。开发环境掩盖了用户环境的缺陷,这是本次最值得记取的教训。
34
+ - **修法**:新增 `unwrapVolatileDeep()`,在把 config 交给 settings 服务前**递归**剥掉全部活引用(既有的 `unwrapVolatile()` 只剥调用方读取的那一层,够普通读路径用,不够交给服务校验用)。数组会被重建而非就地改写,调用方的 config 对象不受影响。
35
+ - **顺带把 `asVolatile()` 导出**,让「有 `volatile()` 则打标记 / 没有则恒等 no-op」两条分支**在任何机器上都能被确定性地断言**;否则把钉住的版本一提,no-op 那条分支的覆盖就会静默消失(而这正是手写 `meta.volatile` 会绕过校验的那条防线)。
36
+
37
+ ### Tests
38
+
39
+ 测试总数 300 → 331。
40
+
41
+ - **双 settings 形态兼容(新增 2 例,这是 PR14 缺的那块)**:PR14 只测了 0.1.5 形态——而它修的恰恰是 **0.1.7 分支**,这样的套件在 0.1.7 分支再次坏掉时**不会变红**,正是让本次缺陷穿过 CI 的原因。现在两条路径各有独立用例:0.1.7 形态(有 `configure`、**无** `installSection`)下必须装配成功、两个 provider 均注册、且确实走了 `configure({auto:true})`;0.1.5 形态下必须继续走 `installSection`(否则命名空间不会注册)。**变异验证**:把 `installSection` 改回无保护裸调用,第一条立即变红。
42
+ - **活引用不得喂给 settings 服务(新增 3 例)**:`unwrapVolatileDeep()` 必须递归剥净(含嵌套与数组)、且不就地改写调用方对象;`installSection` 拿到的 entry 必须无活引用(断言注册结果与 `base` 形状,而不是断言内部调用,实现改动无法伪造);两条 volatile 分支各自被确定性断言。**变异验证**:把 `installSection` 的 entry 改回原始 config,**13 个用例立即变红**。
43
+ - **供应商开关的落盘校验(新增 5 例)**:`locked` scope 下必须抛错、错误须标明 `regions`、正常落盘须成功、整槽位其余字段须原样带过、槽位存在但 `enabled` 是错值也须判失败(浅检查会放过「开关没真的关掉」)。
44
+ - **测试读取也走生产 helper**:新增 `readSettings()`——0.1.7 上 `settings.get()` 返回的同样是活引用,直接读 `doc.accounts.cn` 得到 `undefined`,那是**任何真实消费方都不会看到的形状**。断言统一经 `unwrapVolatileDeep()` 解包,避免测试在真实消费方看不见的形状上通过或失败。
45
+ - 其余为 PR14 自带的兼容与槽位隔离用例,以及上一版的凭据修复用例,全部保留。
46
+
47
+ **双版本验证**:同一份代码在 schemastery **3.18.4**(volatile 生效)与 **3.18.2**(no-op 分支)下**均为 326 通过**。
48
+
49
+ - **供应商开关是唯一漏掉「落盘校验」的设置写入**(同批查出的第二处)。关掉某个区域的供应商时,卡片直接调 `settingsScope.set('regions', …)`,**没有**读回校验,也**没接上**插件自己的 Host 兜底端点——而账号选择与模型目录两处早就两者皆有。于是 0.1.7 上那个「`set()` 正常 resolve 但什么都没存」的 scope 会让开关**看起来生效、随后静默复原**,正是 issue #13 的同一类静默失败。现在开关改走 `writeRegionEnabled()`,与其余写入同一条「先 scope、没落盘再走 Host 端点」的路径,要么写进去、要么明确报错。
50
+
51
+ - 整槽位照旧原样带过(只改 `enabled`),用户的模型目录、勾选、图片开关与上下文预算不受影响。
52
+ - 回归测试用 `locked` scope(`set()` 正常 resolve、不落盘)钉住;**变异验证**:把它改回直接调 `scope.set()`,3 个用例立即变红。
53
+
54
+ **同时把开发环境的 schemastery 提到 3.18.4**(`pnpm-workspace.yaml` 的 override)。此前它锁在 3.18.2,于是 CI 只覆盖 no-op 分支——**而用户实际拿到的是 3.18.4**(`dependencies` 钉住的那份嵌套副本)。开发环境与用户环境不一致,正是这个缺陷能同时躲过 PR 评审与 CI 的原因。代价是 no-op 分支不再由「装到的版本恰好很旧」顺带覆盖,因此 `asVolatile()` 改为导出,并用一条不依赖安装版本的用例把它钉死。
55
+
56
+ ### Docs
57
+
58
+ - 本条目记录 0.1.7 的三项差异(settings 换型、易失声明、活引用)与其判定方式,供后续 DSH 升级时对照。
59
+
3
60
  ## 2.0.11 (2026-09-23)
4
61
 
5
62
  ### Bug Fixes
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-BJdDmMSn.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-ChtMu37E.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
@@ -5,7 +5,6 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
- let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
8
  let react_jsx_runtime = require("react/jsx-runtime");
10
9
  //#region src/status-paths.ts
11
10
  /**
@@ -130,16 +129,75 @@ window.__ModuleLoader__.load({
130
129
  var WorkBuddySettingsWriteError = class extends Error {
131
130
  /** The settings field that did not land. */
132
131
  field;
133
- constructor(field) {
134
- super(`workbuddy: settings field "${field}" was not persisted by the settings write`);
132
+ constructor(field, reason) {
133
+ super(`workbuddy: settings field "${field}" was not persisted by the settings write${reason === void 0 ? "" : `: ${reason}`}`);
135
134
  this.name = "WorkBuddySettingsWriteError";
136
135
  this.field = field;
137
136
  }
138
137
  };
139
- /** Read the per-region account selections out of the settings snapshot. */
140
- function configuredAccountsOf(configured) {
141
- const accounts = configured?.accounts;
142
- return typeof accounts === "object" && accounts !== null ? accounts : {};
138
+ /** Read one settings field's current object value from the scope snapshot. */
139
+ function fieldSnapshotOf(scope, field) {
140
+ const value = scope.getSnapshot().value?.[field];
141
+ return typeof value === "object" && value !== null ? value : {};
142
+ }
143
+ /**
144
+ * Fallback write: POST the field to the plugin's own Host endpoint.
145
+ *
146
+ * The endpoint handler runs the settings mutate inside the Host process and
147
+ * reports the raw refusal exception, so a failure here names its cause instead
148
+ * of arriving as a swallowed `ok:false`. It merges the posted region key into
149
+ * the field's live value, preserving the other region.
150
+ */
151
+ async function saveViaHostEndpoint(field, region, value) {
152
+ let response;
153
+ try {
154
+ response = await fetch("/plugins/dsh-connect-workbuddy/__save", {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({
158
+ field,
159
+ value: { [region]: value }
160
+ })
161
+ });
162
+ } catch (error) {
163
+ throw new WorkBuddySettingsWriteError(field, `Host save endpoint unreachable: ${String(error)}`);
164
+ }
165
+ if (!response.ok) {
166
+ const detail = await response.json().catch(() => ({ error: `HTTP ${String(response.status)}` }));
167
+ const reason = `${String(detail.errorName ?? "")} ${String(detail.error ?? "")}`.trim();
168
+ throw new WorkBuddySettingsWriteError(field, `Host save refused: ${reason === "" ? String(detail.error) : reason}`);
169
+ }
170
+ }
171
+ /**
172
+ * Write one volatile field, then confirm the value actually landed.
173
+ *
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.
181
+ *
182
+ * `landed` decides whether a value read back from the scope is the value that
183
+ * was written; it is per-field because `''` (a cleared account slot) and an
184
+ * absent key are different states, and a truncated model catalog must not pass
185
+ * a shallow "is anything there?" check.
186
+ *
187
+ * @throws {WorkBuddySettingsWriteError} when neither path persists the value.
188
+ */
189
+ async function writeField(scope, field, region, value, landed) {
190
+ let scopeDelivered = false;
191
+ try {
192
+ scopeDelivered = await scope.set(field, {
193
+ ...fieldSnapshotOf(scope, field),
194
+ [region]: value
195
+ }) !== false;
196
+ } catch {
197
+ scopeDelivered = false;
198
+ }
199
+ if (scopeDelivered && landed(fieldSnapshotOf(scope, field)[region])) return;
200
+ await saveViaHostEndpoint(field, region, value);
143
201
  }
144
202
  /**
145
203
  * Write one region's account slot, then confirm the value actually landed.
@@ -168,12 +226,7 @@ window.__ModuleLoader__.load({
168
226
  * something else — i.e. the write silently did not persist.
169
227
  */
170
228
  async function writeAccountSlot(scope, region, value) {
171
- const accounts = configuredAccountsOf(scope.getSnapshot().value);
172
- await scope.set("accounts", {
173
- ...accounts,
174
- [region]: value
175
- });
176
- if (configuredAccountsOf(scope.getSnapshot().value)[region] !== value) throw new WorkBuddySettingsWriteError("accounts");
229
+ await writeField(scope, "accounts", region, value, (readBack) => readBack === value);
177
230
  }
178
231
  /**
179
232
  * Write one region's saved model state, then confirm it actually landed.
@@ -194,16 +247,43 @@ window.__ModuleLoader__.load({
194
247
  * @throws {WorkBuddySettingsWriteError} when the catalog does not read back.
195
248
  */
196
249
  async function writeRegionModels(scope, region, payload) {
197
- const configured = scope.getSnapshot().value?.regions;
198
- const regions = typeof configured === "object" && configured !== null ? configured : {};
199
- await scope.set("regions", {
200
- ...regions,
201
- [region]: payload
202
- });
203
- const landed = scope.getSnapshot().value?.regions?.[region];
204
250
  const written = payload.lastCatalog.map((model) => model.id);
205
- const stored = landed?.lastCatalog?.map((model) => model.id);
206
- if (!(stored !== void 0 && stored.length === written.length && stored.every((id, index) => id === written[index]))) throw new WorkBuddySettingsWriteError("regions");
251
+ await writeField(scope, "regions", region, payload, (readBack) => {
252
+ const stored = readBack?.lastCatalog?.map((model) => model.id);
253
+ return stored !== void 0 && stored.length === written.length && stored.every((id, index) => id === written[index]);
254
+ });
255
+ }
256
+ /**
257
+ * Switch one region's provider off or on, with the same landed-check as every
258
+ * other settings write.
259
+ *
260
+ * This exists because the toggle was the ONE write that still called
261
+ * `scope.set()` directly. On the affected 0.1.7 deployments that scope settles
262
+ * without storing anything, so flipping a provider off appeared to work and
263
+ * then silently reverted — the exact silent-failure mode
264
+ * {@link writeAccountSlot} and {@link writeRegionModels} were written to
265
+ * prevent for their own fields. Routing it through {@link writeField} gives it
266
+ * the same scope-first-then-Host-endpoint path, so the switch either lands or
267
+ * reports why it did not.
268
+ *
269
+ * The write carries the region's whole slot through untouched — only `enabled`
270
+ * changes — so the user's directory, model picks, image opt-ins and budgets
271
+ * survive the round trip. The Host withdraws or restores the provider route on
272
+ * the next `onChange`, which is what actually removes it from DSH's model
273
+ * picker.
274
+ *
275
+ * @param scope - the bound settings scope for this plugin's namespace.
276
+ * @param region - the region being switched.
277
+ * @param enabled - the desired state.
278
+ * @param slot - the region's COMPLETE next slot (the caller owns the snapshot
279
+ * read and the whole-slot merge, so this stays a pure write).
280
+ * @throws {WorkBuddySettingsWriteError} when neither writer persists the value.
281
+ */
282
+ async function writeRegionEnabled(scope, region, enabled, slot) {
283
+ await writeField(scope, "regions", region, slot, (readBack) => {
284
+ const stored = readBack;
285
+ return stored !== null && stored !== void 0 && stored["enabled"] === enabled;
286
+ });
207
287
  }
208
288
  //#endregion
209
289
  //#region src/client/icon.ts
@@ -244,7 +324,11 @@ window.__ModuleLoader__.load({
244
324
  .dsm-plugin-card-head{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
245
325
  .dsm-plugin-card-title{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-weight:600;line-height:1.4}
246
326
  .dsm-plugin-card-description{color:var(--dsw-alias-label-tertiary,#999);font-size:13px;line-height:1.5}
247
- .dsm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary,#999);flex:none;display:inline-flex;transition:transform .16s}
327
+ /* Pure-CSS caret: the host primitives' chevron icon names differ per DSH line
328
+ (0.1.5 Outline14 vs 0.1.7 OutlineRegular), so no static import can serve
329
+ both. A border caret in the plugin's own CSS is version-proof. */
330
+ .dsm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary,#999);flex:none;width:16px;height:16px;position:relative;transition:transform .16s}
331
+ .dsm-plugin-card-chevron::before{content:"";display:block;position:absolute;left:4px;top:5px;width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:rotate(45deg)}
248
332
  .dsm-plugin-card-chevron-open{transform:rotate(180deg)}
249
333
  .dsm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2,#36373b);margin:0 16px;padding:0 0 8px}
250
334
  .dsm-plugin-card-icon{width:32px;height:32px;flex:none;border-radius:7px}
@@ -605,9 +689,9 @@ window.__ModuleLoader__.load({
605
689
  return { background: status === "signed-in" ? "var(--dsw-alias-state-success-primary, #22a06b)" : status === "error" ? "var(--dsw-alias-state-error-primary, #d92d20)" : "var(--dsw-alias-label-dimmed, #9aa0a6)" };
606
690
  }
607
691
  /** Render WorkBuddy sign-in state, credits, and model selection as one card. */
608
- function WorkBuddyCard({ t, settingsScope }) {
692
+ function WorkBuddyCard({ t, settingsScope, view }) {
609
693
  if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
610
- const [open, setOpen] = (0, react.useState)(false);
694
+ const [open, setOpen] = (0, react.useState)(view === "page");
611
695
  /** The region whose tab is on screen; each tab is its own provider stack. */
612
696
  const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
613
697
  /**
@@ -776,12 +860,19 @@ window.__ModuleLoader__.load({
776
860
  * directory, model picks, image opt-ins and budgets survive a round trip.
777
861
  * The Host withdraws or restores the provider route on the next `onChange`,
778
862
  * which is what actually removes it from DSH's model picker.
863
+ *
864
+ * Goes through `writeRegionEnabled` rather than calling `scope.set()`
865
+ * directly: on the affected 0.1.7 deployments that scope settles without
866
+ * storing anything, so a direct write appeared to succeed and then silently
867
+ * reverted. The helper adds the landed-check and the Host-endpoint fallback
868
+ * that every other settings write already had.
779
869
  */
780
870
  const toggleRegion = async (item, enabled) => {
781
- if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
871
+ if (settingsScope === void 0) return;
782
872
  setTogglingRegion(item);
783
873
  try {
784
- await settingsScope.set("regions", nextRegionEnabled(settingsScope.getSnapshot().value, item, enabled));
874
+ const slot = nextRegionEnabled(settingsScope.getSnapshot().value, item, enabled)[item];
875
+ await writeRegionEnabled(settingsScope, item, enabled, typeof slot === "object" && slot !== null ? slot : {});
785
876
  } finally {
786
877
  if (mounted.current) setTogglingRegion(void 0);
787
878
  }
@@ -854,13 +945,22 @@ window.__ModuleLoader__.load({
854
945
  const activeEnabledIds = draft?.enabledIds ?? savedEnabledIds;
855
946
  const savedImageIds = status.status === "signed-in" ? new Set(status.imageModelIds) : /* @__PURE__ */ new Set();
856
947
  const activeImageIds = draft?.imageIds ?? savedImageIds;
857
- const configured = settingsScope?.getSnapshot().value;
858
- const configuredRegion = status.status === "signed-in" ? configured?.regions?.[status.region] : void 0;
859
- const legacyContextBudgets = status.status === "signed-in" && status.region === "cn" ? configured?.contextBudgets : void 0;
860
- const savedContextBudgetsSource = configuredRegion?.contextBudgets ?? legacyContextBudgets;
861
- const savedContextBudgets = typeof savedContextBudgetsSource === "object" && savedContextBudgetsSource !== null ? savedContextBudgetsSource : {};
948
+ settingsScope?.getSnapshot().value;
949
+ const savedContextBudgets = status.status === "signed-in" ? status.contextBudgets ?? {} : {};
862
950
  const activeContextBudgets = draft?.contextBudgets ?? savedContextBudgets;
863
951
  const dirty = draft !== void 0;
952
+ /**
953
+ * Whether the card's inputs accept edits.
954
+ *
955
+ * Deliberately NOT the settings scope's own `writable` flag: on the affected
956
+ * DSH 0.1.7 deployment that flag stays false (the scope initialises it false
957
+ * and only raises it once its mirror loads as a Host-backed form), which left
958
+ * every input `disabled` — clicking them did nothing at all. Writes no longer
959
+ * depend on that flag: `writeField` tries the scope and falls back to the
960
+ * plugin's own Host endpoint, which performs the mutate in the Host process.
961
+ * A bound scope is therefore all that is required to accept an edit.
962
+ */
963
+ const canWrite = settingsScope !== void 0;
864
964
  const editDraft = (edit) => {
865
965
  setDrafts((prev) => ({
866
966
  ...prev,
@@ -991,8 +1091,7 @@ window.__ModuleLoader__.load({
991
1091
  }),
992
1092
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
993
1093
  "aria-hidden": "true",
994
- className: `dsm-plugin-card-chevron${open ? " dsm-plugin-card-chevron-open" : ""}`,
995
- children: (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 14 })
1094
+ className: `dsm-plugin-card-chevron${open ? " dsm-plugin-card-chevron-open" : ""}`
996
1095
  })
997
1096
  ]
998
1097
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1030,7 +1129,7 @@ window.__ModuleLoader__.load({
1030
1129
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1031
1130
  type: "checkbox",
1032
1131
  checked: regionOnState,
1033
- disabled: togglingRegion === region || settingsScope?.getSnapshot().writable !== true,
1132
+ disabled: togglingRegion === region || !canWrite,
1034
1133
  "aria-label": t("row.tabSwitchAria", { region: region === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
1035
1134
  onChange: (event) => {
1036
1135
  toggleRegion(region, event.target.checked);
@@ -1094,7 +1193,7 @@ window.__ModuleLoader__.load({
1094
1193
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1095
1194
  className: "dsm-workbuddy-usage-select",
1096
1195
  value: status.accounts.find((account) => account.selected)?.id ?? "",
1097
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1196
+ disabled: switchingAccount || !canWrite,
1098
1197
  onChange: (event) => {
1099
1198
  switchAccount(event.currentTarget.value);
1100
1199
  },
@@ -1203,7 +1302,7 @@ window.__ModuleLoader__.load({
1203
1302
  status.recovery?.usableAccount === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1204
1303
  type: "button",
1205
1304
  className: "dsm-btn dsm-btn-outline",
1206
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
1305
+ disabled: switchingAccount || !canWrite,
1207
1306
  onClick: () => {
1208
1307
  const target = status.recovery?.usableAccount;
1209
1308
  if (target !== void 0) switchAccount(target.accountId);
@@ -1250,7 +1349,7 @@ window.__ModuleLoader__.load({
1250
1349
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1251
1350
  type: "checkbox",
1252
1351
  checked: activeEnabledIds.has(model.id),
1253
- disabled: settingsScope?.getSnapshot().writable !== true || saving,
1352
+ disabled: !canWrite || saving,
1254
1353
  onChange: () => {
1255
1354
  toggleModel(model.id);
1256
1355
  }
@@ -1275,7 +1374,7 @@ window.__ModuleLoader__.load({
1275
1374
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1276
1375
  type: "checkbox",
1277
1376
  checked: activeImageIds.has(model.id),
1278
- disabled: settingsScope?.getSnapshot().writable !== true || saving,
1377
+ disabled: !canWrite || saving,
1279
1378
  onChange: () => {
1280
1379
  toggleImage(model.id);
1281
1380
  }
@@ -1288,7 +1387,7 @@ window.__ModuleLoader__.load({
1288
1387
  type: "radio",
1289
1388
  name: `context-${model.id}`,
1290
1389
  checked: (activeContextBudgets[model.id] ?? 2e5) === 2e5,
1291
- disabled: settingsScope?.getSnapshot().writable !== true || saving,
1390
+ disabled: !canWrite || saving,
1292
1391
  onChange: () => {
1293
1392
  setContextBudget(model.id, 2e5);
1294
1393
  }
@@ -1296,7 +1395,7 @@ window.__ModuleLoader__.load({
1296
1395
  type: "radio",
1297
1396
  name: `context-${model.id}`,
1298
1397
  checked: model.nativeContextWindow <= 2e5 || activeContextBudgets[model.id] === model.nativeContextWindow,
1299
- disabled: model.nativeContextWindow <= 2e5 || settingsScope?.getSnapshot().writable !== true || saving,
1398
+ disabled: model.nativeContextWindow <= 2e5 || !canWrite || saving,
1300
1399
  onChange: () => {
1301
1400
  setContextBudget(model.id, model.nativeContextWindow);
1302
1401
  }
@@ -1544,12 +1643,17 @@ window.__ModuleLoader__.load({
1544
1643
  //#region src/client/index.tsx
1545
1644
  /** Stable browser-plugin name. */
1546
1645
  const name = "dsh-connect-workbuddy-client";
1547
- /** Client services required by the Plugin configuration contribution. */
1548
- const inject = [
1549
- "slots",
1550
- "locale",
1551
- "settingsScope"
1552
- ];
1646
+ /**
1647
+ * Client services required by the Plugin configuration contribution.
1648
+ *
1649
+ * Deliberately only the two services that exist on BOTH host lines. The
1650
+ * settings surface differs by line — 0.1.5 provides `settingsScope`, 0.1.7
1651
+ * replaces it with `configForms` — and Cordis' dependency gate is hard: any
1652
+ * inject entry the running line does not provide keeps `apply` from ever
1653
+ * running. Probing both via `ctx.get()` (which returns undefined, never
1654
+ * throws, for an absent service) is what lets one build serve both lines.
1655
+ */
1656
+ const inject = ["slots", "locale"];
1553
1657
  /** Register card copy and the WorkBuddy card under Plugin configuration. */
1554
1658
  function apply(ctx) {
1555
1659
  try {
@@ -1559,16 +1663,36 @@ window.__ModuleLoader__.load({
1559
1663
  en
1560
1664
  }), "dsh-connect-workbuddy: settings copy");
1561
1665
  const t = ctx.locale.bind(namespace);
1562
- const settingsScope = ctx.settingsScope.bind({ namespace: "workbuddy" });
1563
- ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
1564
- name: "settings.plugin.item",
1565
- key: "workbuddy",
1566
- priority: 30,
1567
- inject: () => ({
1568
- t,
1569
- settingsScope
1570
- })
1571
- }, WorkBuddyCard));
1666
+ const softGet = (name) => ctx.get(name);
1667
+ let settingsScope;
1668
+ const forms = softGet("configForms");
1669
+ const legacy = softGet("settingsScope");
1670
+ if (forms !== void 0) {
1671
+ let ns = "workbuddy";
1672
+ try {
1673
+ const served = (forms.describe().getSnapshot().view?.namespaces ?? []).find((entry) => entry.ns === "workbuddy" || /workbuddy/i.test(entry.ns));
1674
+ if (served !== void 0) ns = served.ns;
1675
+ } catch {}
1676
+ settingsScope = forms.get(ns);
1677
+ } else if (legacy !== void 0) settingsScope = legacy.bind({ namespace: "workbuddy" });
1678
+ const registerCard = (slotName, key) => {
1679
+ try {
1680
+ ctx.slots.inject(slotName, () => ctx.slots.register({
1681
+ name: slotName,
1682
+ key,
1683
+ priority: 30,
1684
+ inject: () => settingsScope === void 0 ? { t } : {
1685
+ t,
1686
+ settingsScope
1687
+ }
1688
+ }, WorkBuddyCard));
1689
+ } catch (error) {
1690
+ console.error(`[dsh-connect-workbuddy] card slot "${slotName}" failed to register (host provider unaffected):`, error);
1691
+ }
1692
+ };
1693
+ registerCard("plugins.bundle.config", "dsh-connect-workbuddy");
1694
+ registerCard("plugins.row.config", "dsh-connect-workbuddy#dsh-connect-workbuddy");
1695
+ registerCard("settings.plugin.item", "workbuddy");
1572
1696
  } catch (error) {
1573
1697
  console.error("[dsh-connect-workbuddy] client card failed to load (host provider unaffected):", error);
1574
1698
  }
@@ -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.11";
2211
+ const WORKBUDDY_CONNECT_VERSION = "2.0.12";
2212
2212
  //#endregion
2213
2213
  //#region src/host-heartbeat.ts
2214
2214
  /**
package/lib/index.d.ts CHANGED
@@ -1345,6 +1345,23 @@ type WorkBuddyWebUsage =
1345
1345
  credentialRejected?: boolean;
1346
1346
  /** Present only alongside {@link credentialRejected}. */
1347
1347
  recovery?: WorkBuddyWebRecovery;
1348
+ /**
1349
+ * Diagnostic only: whether this Host build's Config carries the volatile
1350
+ * markers dsh-settings requires before it will persist any field. Exposed
1351
+ * so a save refusal can be attributed to the settings gate rather than
1352
+ * guessed at.
1353
+ */
1354
+ diagVolatile?: {
1355
+ regions: boolean;
1356
+ accounts: boolean;
1357
+ authFile: boolean;
1358
+ };
1359
+ /**
1360
+ * The persisted per-region context budgets as the Host reads them. The
1361
+ * browser settings mirror can lag a write made through the Host save
1362
+ * endpoint, so the card renders these rather than a possibly-stale snapshot.
1363
+ */
1364
+ contextBudgets?: Record<string, number>;
1348
1365
  } | {
1349
1366
  status: 'error';
1350
1367
  message: string;
@@ -1475,6 +1492,43 @@ interface Config {
1475
1492
  /** @deprecated See {@link Config.lastCatalog}. */
1476
1493
  contextBudgets?: Record<string, WorkBuddyContextBudget>;
1477
1494
  }
1495
+ /**
1496
+ * Mark a schema's field as volatile on the DSH lines that support it.
1497
+ *
1498
+ * `volatile()` exists from schemastery 3.18.3 (the DSH 0.1.7 line, which is the
1499
+ * only line whose settings write gate reads the marker). Older pinning (3.18.2,
1500
+ * the 0.1.5 line) has no such method, and the schema must stay byte-identical to
1501
+ * the unmarked original there: hand-writing `meta.volatile = true` would bypass
1502
+ * schemastery's own validateVolatileSchema checks and produce a schema no 0.1.5
1503
+ * consumer understands — so on that line this degrades to an identity no-op.
1504
+ *
1505
+ * Exported so BOTH arms are testable on any machine. The capability is decided
1506
+ * by whichever schemastery the dependency tree resolves, so without a seam the
1507
+ * no-op arm would silently stop being exercised the moment the pinned version
1508
+ * moved up — which is exactly how the volatile-path defect below went unseen.
1509
+ */
1510
+ declare function asVolatile<T>(schema: z<T>): z<T>;
1511
+ /**
1512
+ * Deep copy of a config value with every `{get(): T}` live reference replaced
1513
+ * by the value it resolves to.
1514
+ *
1515
+ * {@link unwrapVolatile} only peels the ONE level a caller reads, which is all
1516
+ * the ordinary read paths need. Handing a config object to a settings service
1517
+ * is different: the service validates and `structuredClone`s the WHOLE object,
1518
+ * so a reference surviving anywhere inside it fails schema validation with a
1519
+ * message that names the field but not the cause —
1520
+ * `$.authFile expected string but got [object Object]`.
1521
+ *
1522
+ * That is exactly what happened on the 0.1.5 line once volatile marking became
1523
+ * active: `installSection` received the raw config, whose volatile fields were
1524
+ * live references, and every field it validated threw. The namespace never
1525
+ * registered, so the card's settings silently disappeared.
1526
+ *
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.
1530
+ */
1531
+ declare function unwrapVolatileDeep<T>(value: T): T;
1478
1532
  declare const Config: z<Config>;
1479
1533
  /**
1480
1534
  * One region's saved model state. A config written before the region split has
@@ -1545,4 +1599,4 @@ declare function legacyAttributionRegion(value: Config, accountsFor: (region: Wo
1545
1599
  */
1546
1600
  declare function apply(ctx: Context, config: Config): void;
1547
1601
  //#endregion
1548
- 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, 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, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
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 };
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-BJdDmMSn.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-ChtMu37E.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";
@@ -715,6 +715,10 @@ async function searchedPaths(store) {
715
715
  return {};
716
716
  }
717
717
  }
718
+ /** Reads schemastery's internal `meta.volatile` marker for one top-level field. */
719
+ function volatileFlagOf(field) {
720
+ return Config.dict?.[field]?.meta?.volatile === true;
721
+ }
718
722
  /**
719
723
  * Assemble one region's card document. `region` is the tab the card is on;
720
724
  * the region-scoped store already answers with only that region's accounts,
@@ -775,6 +779,12 @@ async function workBuddyWebStatus(deps, region) {
775
779
  });
776
780
  return {
777
781
  status: "signed-in",
782
+ contextBudgets: Object.fromEntries(Object.entries(deps.contextBudgets(region)).filter(([, value]) => typeof value === "number")),
783
+ diagVolatile: {
784
+ regions: volatileFlagOf("regions"),
785
+ accounts: volatileFlagOf("accounts"),
786
+ authFile: volatileFlagOf("authFile")
787
+ },
778
788
  ...account,
779
789
  ...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
780
790
  ...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
@@ -881,11 +891,59 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
881
891
  }
882
892
  }
883
893
  });
894
+ const disposeDiagWrite = ctx.webServer.register({
895
+ kind: "exact",
896
+ path: "/plugins/dsh-connect-workbuddy/__save",
897
+ handler: async (req, res) => {
898
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
899
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
900
+ const settings = ctx.get?.("settings");
901
+ if (settings === void 0) return json(res, 503, { error: "settings service unavailable to this fiber" });
902
+ try {
903
+ const body = await new Promise((resolve, reject) => {
904
+ const chunks = [];
905
+ req.on("data", (chunk) => chunks.push(chunk));
906
+ req.on("end", () => {
907
+ try {
908
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
909
+ } catch (e) {
910
+ reject(e);
911
+ }
912
+ });
913
+ req.on("error", reject);
914
+ });
915
+ const field = body.field;
916
+ if (field !== "regions" && field !== "accounts") return json(res, 400, { error: "field must be regions or accounts" });
917
+ const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
918
+ if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
919
+ const current = row.value?.[field] ?? {};
920
+ const incoming = body.value ?? {};
921
+ const merged = {
922
+ ...current,
923
+ ...incoming
924
+ };
925
+ await settings.mutate(row.ns, [{
926
+ op: "set",
927
+ path: [field],
928
+ value: merged
929
+ }], void 0);
930
+ return json(res, 200, { ok: true });
931
+ } catch (error) {
932
+ const err = error;
933
+ return json(res, 500, {
934
+ ok: false,
935
+ errorName: err?.name ?? "unknown",
936
+ error: err?.message ?? String(error)
937
+ });
938
+ }
939
+ }
940
+ });
884
941
  return () => {
885
942
  disposeRefresh();
886
943
  disposeCheckin();
887
944
  disposeAccounts();
888
945
  disposeUsage();
946
+ disposeDiagWrite();
889
947
  };
890
948
  }, "dsh-connect-workbuddy: Web status route");
891
949
  }
@@ -914,11 +972,63 @@ const accountSelectionConfig = z.object({
914
972
  cn: z.string().description("Selected domestic (CN) account id (never a token)"),
915
973
  global: z.string().description("Selected international account id (never a token)")
916
974
  });
975
+ /**
976
+ * Mark a schema's field as volatile on the DSH lines that support it.
977
+ *
978
+ * `volatile()` exists from schemastery 3.18.3 (the DSH 0.1.7 line, which is the
979
+ * only line whose settings write gate reads the marker). Older pinning (3.18.2,
980
+ * the 0.1.5 line) has no such method, and the schema must stay byte-identical to
981
+ * the unmarked original there: hand-writing `meta.volatile = true` would bypass
982
+ * schemastery's own validateVolatileSchema checks and produce a schema no 0.1.5
983
+ * consumer understands — so on that line this degrades to an identity no-op.
984
+ *
985
+ * Exported so BOTH arms are testable on any machine. The capability is decided
986
+ * by whichever schemastery the dependency tree resolves, so without a seam the
987
+ * no-op arm would silently stop being exercised the moment the pinned version
988
+ * moved up — which is exactly how the volatile-path defect below went unseen.
989
+ */
990
+ function asVolatile(schema) {
991
+ if (typeof schema.volatile === "function") return schema.volatile();
992
+ return schema;
993
+ }
994
+ function unwrapVolatile(value) {
995
+ if (value !== null && typeof value === "object" && "get" in value && typeof value.get === "function") return value.get();
996
+ return value;
997
+ }
998
+ /**
999
+ * Deep copy of a config value with every `{get(): T}` live reference replaced
1000
+ * by the value it resolves to.
1001
+ *
1002
+ * {@link unwrapVolatile} only peels the ONE level a caller reads, which is all
1003
+ * the ordinary read paths need. Handing a config object to a settings service
1004
+ * is different: the service validates and `structuredClone`s the WHOLE object,
1005
+ * so a reference surviving anywhere inside it fails schema validation with a
1006
+ * message that names the field but not the cause —
1007
+ * `$.authFile expected string but got [object Object]`.
1008
+ *
1009
+ * That is exactly what happened on the 0.1.5 line once volatile marking became
1010
+ * active: `installSection` received the raw config, whose volatile fields were
1011
+ * live references, and every field it validated threw. The namespace never
1012
+ * registered, so the card's settings silently disappeared.
1013
+ *
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.
1017
+ */
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
+ }
917
1027
  const Config = z.object({
918
- authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
1028
+ authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)")),
919
1029
  accountId: z.string().description("Deprecated: pre-split account selector, attributed to its own region"),
920
- accounts: accountSelectionConfig.description("Per-region account selections, keyed cn | global"),
921
- regions: z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | global"),
1030
+ accounts: asVolatile(accountSelectionConfig.default({}).description("Per-region account selections, keyed cn | global")),
1031
+ regions: asVolatile(z.dict(regionStateConfig).default({}).description("Per-region model directory and selection, keyed cn | global")),
922
1032
  lastCatalog: z.array(modelConfig).description("Deprecated: pre-region-split CN model directory"),
923
1033
  enabledModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN selection"),
924
1034
  imageModelIds: z.array(z.string()).default([]).description("Deprecated: pre-region-split CN image opt-in"),
@@ -934,14 +1044,18 @@ const Config = z.object({
934
1044
  * picks.
935
1045
  */
936
1046
  function regionStateOf(config, region) {
937
- const stored = config.regions?.[region];
1047
+ const stored = unwrapVolatile(config.regions)?.[region];
938
1048
  if (stored !== void 0) return stored;
939
1049
  if (region !== "cn") return {};
1050
+ const lastCatalog = unwrapVolatile(config.lastCatalog);
1051
+ const enabledModelIds = unwrapVolatile(config.enabledModelIds);
1052
+ const imageModelIds = unwrapVolatile(config.imageModelIds);
1053
+ const contextBudgets = unwrapVolatile(config.contextBudgets);
940
1054
  return {
941
- ...config.lastCatalog === void 0 ? {} : { lastCatalog: config.lastCatalog },
942
- ...config.enabledModelIds === void 0 ? {} : { enabledModelIds: config.enabledModelIds },
943
- ...config.imageModelIds === void 0 ? {} : { imageModelIds: config.imageModelIds },
944
- ...config.contextBudgets === void 0 ? {} : { contextBudgets: config.contextBudgets }
1055
+ ...lastCatalog === void 0 ? {} : { lastCatalog },
1056
+ ...enabledModelIds === void 0 ? {} : { enabledModelIds },
1057
+ ...imageModelIds === void 0 ? {} : { imageModelIds },
1058
+ ...contextBudgets === void 0 ? {} : { contextBudgets }
945
1059
  };
946
1060
  }
947
1061
  /**
@@ -978,14 +1092,15 @@ const REGION_KEYS = ["cn", "global"];
978
1092
  * attribution yet) and every later one.
979
1093
  */
980
1094
  function selectAccountFor(region, value, legacyAccountRegion) {
981
- const configured = value.accounts?.[region];
1095
+ const configured = unwrapVolatile(value.accounts)?.[region];
982
1096
  if (configured === "") return void 0;
983
1097
  if (configured !== void 0) return configured;
984
- return legacyAccountRegion === region ? value.accountId : void 0;
1098
+ const accountId = unwrapVolatile(value.accountId);
1099
+ return legacyAccountRegion === region ? accountId : void 0;
985
1100
  }
986
1101
  /** Whether a region carries the Clear sentinel rather than a saved choice. */
987
1102
  function regionCleared(value, region) {
988
- return value.accounts?.[region] === "";
1103
+ return unwrapVolatile(value.accounts)?.[region] === "";
989
1104
  }
990
1105
  /**
991
1106
  * Which region owns the pre-split `accountId`, or `undefined` when it cannot
@@ -1021,9 +1136,10 @@ function apply(ctx, config) {
1021
1136
  const client = new WorkBuddyUpstreamClient();
1022
1137
  const stacks = {};
1023
1138
  for (const region of REGION_KEYS) {
1139
+ const authFile = unwrapVolatile(config.authFile);
1024
1140
  const store = new WorkBuddyCredentialStore({
1025
1141
  region,
1026
- ...config.authFile === void 0 ? {} : { desktopPath: config.authFile },
1142
+ ...authFile === void 0 ? {} : { desktopPath: authFile },
1027
1143
  refresh: (credential) => client.refreshToken(credential)
1028
1144
  });
1029
1145
  const catalog = new WorkBuddyCatalog(region);
@@ -1071,8 +1187,9 @@ function apply(ctx, config) {
1071
1187
  };
1072
1188
  /** Push the current config into every region's store selection and catalog. */
1073
1189
  const applySelection = (value) => {
1190
+ const authFile = unwrapVolatile(value.authFile);
1074
1191
  for (const region of REGION_KEYS) {
1075
- stacks[region].store.setDesktopPath(value.authFile);
1192
+ stacks[region].store.setDesktopPath(authFile);
1076
1193
  stacks[region].store.selectAccount(effectiveAccountFor(region, value));
1077
1194
  stacks[region].catalog.set(configuredModels(value, region));
1078
1195
  }
@@ -1153,13 +1270,26 @@ function apply(ctx, config) {
1153
1270
  if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
1154
1271
  }
1155
1272
  }));
1156
- ctx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
1157
- setSource(source) {
1158
- current = source;
1159
- },
1160
- onChange() {
1161
- applySelection(current());
1273
+ ctx.inject(["settings"], (sctx) => {
1274
+ const settings = sctx.settings;
1275
+ if (typeof settings.configure === "function") {
1276
+ settings.configure({ auto: true }, ctx.fiber);
1277
+ return;
1162
1278
  }
1279
+ if (typeof settings.installSection === "function") {
1280
+ const entry = unwrapVolatileDeep(config);
1281
+ settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, entry, {
1282
+ setSource(source) {
1283
+ current = source;
1284
+ },
1285
+ onChange() {
1286
+ applySelection(current());
1287
+ }
1288
+ });
1289
+ }
1290
+ });
1291
+ ctx.on("loader/volatile-update", () => {
1292
+ applySelection(current());
1163
1293
  });
1164
1294
  applySelection(config);
1165
1295
  (async () => {
@@ -1270,4 +1400,4 @@ function apply(ctx, config) {
1270
1400
  });
1271
1401
  }
1272
1402
  //#endregion
1273
- export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, 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, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, 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, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1403
+ export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, 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, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, 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/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.11",
5
+ "version": "2.0.12",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
@@ -32,6 +32,9 @@
32
32
  "./cordis.patch.yml": "./cordis.patch.yml",
33
33
  "./package.json": "./package.json"
34
34
  },
35
+ "dependencies": {
36
+ "@deepseek-ai/schemastery": "3.18.4"
37
+ },
35
38
  "files": [
36
39
  "lib",
37
40
  "docs/assets",