dsh-connect-workbuddy 2.0.4 → 2.0.5

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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.5 (2026-09-22)
4
+
5
+ ### Fixes
6
+
7
+ - **「跟随 App 当前登录」清除后不再被旧版 `accountId` 顶掉,且清除结果在卡片上可见**(issue #11 的缺陷 1;其症状属实,但所声称的机制与严重性不准确——见下方「核查」)。
8
+
9
+ - **空字符串哨兵现在显式终止旧值回退**。`selectAccountFor()`(由 `applySelection` 内联逻辑提升为导出纯函数)按顺序判定:`accounts.<region> === ""` → 无选择(跟随 App);具体 id → 该区域的显式选择;键不存在 → 回退到所属区域的旧版 `accountId`。此前 `""` 走的是「值已存在」分支并被 `selectAccount('')` 归一化为 `undefined`,结果碰巧正确,但语义靠下游兜底;任何一处偏离(下游不再归一化、或增加新的读取方)都会让旧账号复活。现在哨兵在源头终止回退,并有测试锁定。
10
+ - **启动时的旧值归属(attribution)跳过被显式清除的区域**(`legacyAttributionRegion()`)。该 pass 在启动后异步运行:跳过被清除的区域,既避免把用户刚丢弃的选择重新绑回,也保证**另一个区域仍有机会认领同一个 id**(用 `continue` 而非 `break`,否则国际版的迁移会被国内版的清除顺手打断)。
11
+ - **卡片新增区域状态行**:显示该区域当前是「使用保存的账号」还是「跟随 App 当前登录」,清除后先显示确认文案。这是 issue #11 里真正属于产品缺陷的那一半——清除后回退到的默认账号**通常正是刚被清掉的那个账号**(用户当初就是在 App 登录状态下选它的),因此清除成功与清除无效在界面上完全同形。此前 `usage` 文档也没有任何字段能区分二者。
12
+ - **设置写入改为「写入后回读校验」,失败时明确报错**(Windows 上尤其重要,见下)。`settingsScope.set()` 的 promise resolve **并不代表值已落盘**:设置文档 `settings.yaml` 靠「写临时文件 + rename 覆盖」替换,而 `@deepseek-ai/dsh-atomic-write` 对 `EPERM`/`EBUSY`/`EACCES` 的重试**只在 `win32` 生效**——Windows 上杀毒软件、OneDrive 等同步盘或编辑器会短暂锁住该文件。重试耗尽后,失败以「不成功的响应」回到客户端,而设置 scope 的 `mutate()` 对此**只是重新加载 Host 状态然后正常返回**,所以调用方的 `await` 会成功、文档却没变。新增 `writeAccountSlot()` 与 `writeRegionModels()`:写入后把值读回,读不回即抛 `WorkBuddySettingsWriteError`。对三处写入的影响:
13
+ - **清除账号**:只有确认落盘才显示「已清除」,失败则显示错误并说明原选择仍生效——否则会给出一个被旧选择默默反驳的假确认,比不提示更糟;
14
+ - **切换账号**:同样校验。静默丢弃会让下拉框显示一个账号、实际计费另一个;
15
+ - **保存模型**:尤其要紧——保存成功会**丢弃草稿**,若写入没落盘就丢,用户唯一的副本就没了,却还被告知「已保存」。校验比对回读的 `lastCatalog` id 序列(不能只看「槽位存在」,截断/残缺的写入同样要检出)。
16
+
17
+ **核查**:报告的根因链在 HEAD 上不成立,逐条复现如下。**注意措辞更正**:2.0.5 初稿曾断言「清除本来就生效」,该结论只在 Host 侧成立;在 Windows 上写入可能被静默丢弃,因此报告的「点了没反应」在那里是真实存在的,只是机制与报告所述不同(不是 `accountId` 顶掉,而是写入没落盘)。回读校验修复的正是后者。
18
+
19
+ | 报告的主张 | 实测结果 |
20
+ |---|---|
21
+ | `clearAccount()` 只写区域槽位,旧 `accountId` 会持续顶掉清除结果 | 清除后该区域立即跟随 App 默认;三种变体(旧账号≠当前登录、旧账号=当前登录、启动归属与清除竞争)均通过 |
22
+ | `effectiveAccountFor()` 在哨兵值上不提前返回,因此旧值继续生效 | 该分支返回 `""`,经 `selectAccount('')` 归一化为 `undefined`,**不会**回退到 `accountId`——返回值虽然「不提前」,但结果正确 |
23
+ | 启动迁移块会把区域重新绑回旧账号 | 迁移块只在启动时运行一次,且 `applySelection` 走同一个 `effectiveAccountFor`,同样被哨兵挡住 |
24
+ | `""` 与「未设置」在 schema 层不可区分 | `""` 与键缺失**是可区分的**:`z.object({cn: z.string()})` 对 `{cn: ''}` 保留空串、对 `{}` 保留缺键;`Object.hasOwn` 亦为真。不可区分的只有**语义**,不是 schema |
25
+ | (报告未提及)写入可能根本不生效 | **成立,且与平台相关**:见上一条。空串本身在 YAML 里能正确往返(写成 `cn: ""`),所以哨兵语义没问题;丢的是**整次写入** |
26
+
27
+ 因此本版本不按报告的建议去「清除旧字段」——旧字段是**两个区域共享的**迁移来源,清掉它会让另一侧失去迁移依据;正确的做法是让哨兵赢(已实现),并把无法观测的状态暴露给界面(已实现),再加上对「写入没生效」的显式检测。
28
+
29
+ ### Tests
30
+
31
+ 测试总数 142 → 173(新增 31 例,**全套 173 通过**)。
32
+
33
+ > 关于 `host-heartbeat.spec.ts` 的 `processStartTimeMs`:在受限沙箱下该例会失败
34
+ > (`processStartTimeMs` 需要执行 `ps`,被沙箱以 `EPERM` 拒绝 → 返回 `undefined`)。
35
+ > 放开文件策略后 `ps` 可用,**5/5 通过**。该例与本次改动无关——两次运行用同一份
36
+ > 源码,差异只来自沙箱能否执行 `ps`。
37
+
38
+ - `settings-integration.spec.ts` 新增 15 例:`selectAccountFor` 的 6 例(显式选择优先、旧值只归属本区域、哨兵终止回退、清除与未配置可区分、两区域同时清除、清除一侧不影响另一侧);`legacyAttributionRegion` 的 6 例(归属、跳过已清除区域、跳过但继续扫描另一区域、账号已消失、无旧字段、跨区域共享 id);另有 3 例走真实 settings 链路 —— 清除后旧字段仍在但选不中、**重启后**已清除区域不被重新归属、以及清除前后经 usage 路由可见 `selectionExplicit` 翻转(账号名不变,这正是报告里「看起来没反应」的场景)。
39
+ - `client-account-selection.spec.ts` 新增 13 例(新文件):`configuredAccountsOf` 的容错读取;`writeAccountSlot` 的保存与「保留另一区域」、哨兵写成 `""` 而非删键、**锁文件导致的静默未落盘必须抛错**、错误携带字段名、**不把「键缺失」误判为清除成功**、正常落盘不误报、回读与卡片读取同源(scope 的 flush 默认 `sync`,落盘后同步可见,故不会误失败);`writeRegionModels` 的保存与区域保留、**静默未落盘必须抛错**、目录被截断也要检出(不能只看「槽位存在」)、错误字段名、正常落盘不误报。
40
+ - `auth.spec.ts` 新增 1 例:`hasExplicitSelection()` 在未选择/已选择/选择失效/哨兵清除四种状态下的取值。
41
+ - `web-status.spec.ts` 新增 2 例:signed-in 与两个 signed-out 分支都携带 `selectionExplicit`。
42
+
43
+ 每条新增守卫都用「删掉对应实现即失败」逐一验证过:移除哨兵判断会挂 5 例,移除归属跳过会挂 2 例,把 `selectionExplicit` 写死为 `true` 会挂 3 例,把账号回读校验换成「相信 resolve」会挂 3 例,去掉模型保存的回读校验会挂 3 例。
44
+
45
+ ### Docs
46
+
47
+ - `README.md` / `README.en.md` 的「已知限制 / Known limitations」补充:旧版 `accountId` 只作用于所属区域、且该区域被显式清除后不再生效;卡片状态行用于分辨清除是否生效;**Windows 上设置文件被占用会导致账号改动无法保存**,此时卡片报错且原选择保持生效。
48
+
3
49
  ## 2.0.4 (2026-09-21)
4
50
 
5
51
  ### Fixes
package/README.en.md CHANGED
@@ -116,6 +116,8 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
116
116
  - The plugin depends on WorkBuddy client endpoints (not an official public API), so a WorkBuddy update may require adjustments.
117
117
  - **Account switching** relies on the historical auth files the WorkBuddy desktop app leaves behind (the app's own backups), not an official multi-account API. Following the app's current sign-in remains the default; switching is an explicit opt-in, and historical credentials can be invalidated by the app's cleanup or sign-out.
118
118
  - If an explicitly selected account disappears locally (the app replaced its login or cleaned up its backups), the plugin does **not** silently switch to another account — that would bill a different account. The card says the saved account no longer exists; pick an account again, or use "Follow the app's sign-in" to clear the saved choice. **Signing in again in the desktop app will not fix this**: the credentials are fine, it is the saved account id that no longer matches.
119
+ - Users upgrading from before 2.0.0 may still carry the old top-level `accountId` (the migration source). It applies only to its own region and **stops applying once that region is explicitly cleared (`accounts.<region> = ""`)** — otherwise the account you cleared would be silently restored at the next startup. The card shows whether a region is "using the saved account" or "following the app's current sign-in": the restored default is usually the very account you just cleared, so without that line there is no way to tell a successful clear from a no-op.
120
+ - **On Windows, an account change may fail to save while `settings.yaml` is held open.** DSH updates that file by writing a temp file and replacing the target; an antivirus scanner, a sync client (OneDrive), or an open editor can lock it briefly, and once the replacement retries are exhausted the failed write does **not** make `set()` reject. The plugin therefore reads the value back after writing: it shows "cleared" **only once the write is confirmed on disk**, and on failure it reports the error and keeps your previous choice (never a false confirmation that the old selection silently contradicts). Close whatever holds the file and retry.
119
121
  - Windows and Linux credential paths are derived from platform conventions and are not verified on real hardware; set `WORKBUDDY_AUTH_FILE` if yours lives elsewhere.
120
122
 
121
123
  ## Disclaimer
package/README.md CHANGED
@@ -124,6 +124,8 @@ dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
124
124
  - 依赖 WorkBuddy 客户端接口(非官方开放 API),WorkBuddy 更新后插件可能需要随之调整。
125
125
  - **账号切换**基于 WorkBuddy 桌面端留下的历史 auth 文件(App 自身的备份产物),并非官方多账号 API。默认行为仍是跟随 App 当前登录;切换账号是显式选项,且历史凭据可能因 App 清理或退出登录而失效。
126
126
  - 显式选择的账号若在本机消失(App 更换登录或清理备份文件),插件**不会**静默改选其他账号——那样会让账单落到另一个账号上。卡片会明确说明「保存的账号已不存在」,此时重新选择一个账号,或点「跟随 App 当前登录」清除该选择即可恢复。**重新登录桌面端 App 无法修复这种状态**:凭据本身是好的,失效的只是保存下来的账号标识。
127
+ - 从 2.0.0 之前的版本升级而来的用户,设置里可能仍留有旧版的顶层 `accountId`(迁移来源)。它只作用于其所属区域,且**该区域被显式清除(`accounts.<区域> = ""`)后即不再生效**——否则清除掉的账号会在下次启动时被静默恢复。卡片会显示每个区域当前是「使用保存的账号」还是「跟随 App 当前登录」:清除后回到的默认账号往往与刚清掉的账号是同一个,没有这行提示就无法分辨清除是否生效。
128
+ - **Windows:设置文件 `settings.yaml` 被占用时,账号改动可能无法保存**。DSH 用「写临时文件 + 覆盖替换」的方式更新该文件,而杀毒软件、OneDrive 等同步盘或正在打开该文件的编辑器会短暂锁住它;重试耗尽后,写入失败**不会**让 `set()` 报错。插件因此会在写入后回读校验:**确认落盘才显示「已清除」**,失败则给出明确错误并保留原来的选择(不会显示一个旧选择默默反驳的假确认)。遇到该提示时,关闭占用该文件的程序后重试即可。
127
129
  - Windows / Linux 的凭据默认路径为按平台约定推导,未经真机验证;必要时可通过环境变量 `WORKBUDDY_AUTH_FILE` 指定实际位置。
128
130
 
129
131
  ## 免责声明
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CPIHUxKL.js";
2
+ import { T as WorkBuddyUpstreamClient, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-BOrNj3pY.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
@@ -57,6 +57,92 @@ window.__ModuleLoader__.load({
57
57
  };
58
58
  }
59
59
  //#endregion
60
+ //#region src/client/account-selection.ts
61
+ /**
62
+ * A settings write that did not take effect.
63
+ *
64
+ * Distinct from a rejected `set()`: this is thrown when the write reported
65
+ * success and the value is nonetheless absent from the document.
66
+ */
67
+ var WorkBuddySettingsWriteError = class extends Error {
68
+ /** The settings field that did not land. */
69
+ field;
70
+ constructor(field) {
71
+ super(`workbuddy: settings field "${field}" was not persisted by the settings write`);
72
+ this.name = "WorkBuddySettingsWriteError";
73
+ this.field = field;
74
+ }
75
+ };
76
+ /** Read the per-region account selections out of the settings snapshot. */
77
+ function configuredAccountsOf(configured) {
78
+ const accounts = configured?.accounts;
79
+ return typeof accounts === "object" && accounts !== null ? accounts : {};
80
+ }
81
+ /**
82
+ * Write one region's account slot, then confirm the value actually landed.
83
+ *
84
+ * `settingsScope.set()` resolving is NOT proof that anything was stored. The
85
+ * Host's document is `settings.yaml`, replaced by writing a temp file and
86
+ * renaming it over the target; on Windows an antivirus scanner, a sync client
87
+ * (OneDrive, Dropbox), or an open editor can hold the file briefly.
88
+ * `@deepseek-ai/dsh-atomic-write` retries `EPERM` / `EBUSY` / `EACCES` a
89
+ * bounded number of times and those retries exist only for `win32` — and once
90
+ * they are exhausted the failure reaches the client as an unsuccessful
91
+ * response, which the settings scope handles by reloading Host state and
92
+ * merely RETURNING. The caller's `await` therefore succeeds while the document
93
+ * is unchanged.
94
+ *
95
+ * Reading the field back is the only reliable check, and it is what the
96
+ * official plugin cards do (`CardForm.store()` compares the user layer against
97
+ * the value it wrote). Failing loudly matters most on Clear: a false success
98
+ * would flip the card to "following the app's sign-in" while the old selection
99
+ * keeps running — the exact confusion that state line exists to remove.
100
+ *
101
+ * @param scope - the bound settings scope for this plugin's namespace.
102
+ * @param region - the region whose slot is written; the other is preserved.
103
+ * @param value - the account id to save, or `''` to clear the region.
104
+ * @throws {WorkBuddySettingsWriteError} when the value is readable back as
105
+ * something else — i.e. the write silently did not persist.
106
+ */
107
+ async function writeAccountSlot(scope, region, value) {
108
+ const accounts = configuredAccountsOf(scope.getSnapshot().value);
109
+ await scope.set("accounts", {
110
+ ...accounts,
111
+ [region]: value
112
+ });
113
+ if (configuredAccountsOf(scope.getSnapshot().value)[region] !== value) throw new WorkBuddySettingsWriteError("accounts");
114
+ }
115
+ /**
116
+ * Write one region's saved model state, then confirm it actually landed.
117
+ *
118
+ * The same silent-failure mode as {@link writeAccountSlot} applies to EVERY
119
+ * settings write, not just the account slot. It matters here for a different
120
+ * reason: a successful save discards the user's draft, so a write that did not
121
+ * persist makes the card throw away unsaved edits while claiming they were
122
+ * saved — unrecoverable, since the drafts are the only copy.
123
+ *
124
+ * Verification compares the round-tripped `lastCatalog` ids, which is the part
125
+ * of the payload that is both user-visible and cheap to compare; the selection
126
+ * fields ride along in the same object and cannot land separately.
127
+ *
128
+ * @param scope - the bound settings scope for this plugin's namespace.
129
+ * @param region - the region whose model slot is written.
130
+ * @param payload - the complete next state for that region's slot.
131
+ * @throws {WorkBuddySettingsWriteError} when the catalog does not read back.
132
+ */
133
+ async function writeRegionModels(scope, region, payload) {
134
+ const configured = scope.getSnapshot().value?.regions;
135
+ const regions = typeof configured === "object" && configured !== null ? configured : {};
136
+ await scope.set("regions", {
137
+ ...regions,
138
+ [region]: payload
139
+ });
140
+ const landed = scope.getSnapshot().value?.regions?.[region];
141
+ const written = payload.lastCatalog.map((model) => model.id);
142
+ const stored = landed?.lastCatalog?.map((model) => model.id);
143
+ if (!(stored !== void 0 && stored.length === written.length && stored.every((id, index) => id === written[index]))) throw new WorkBuddySettingsWriteError("regions");
144
+ }
145
+ //#endregion
60
146
  //#region src/client/icon.ts
61
147
  /**
62
148
  * Plugin icon (LD brand logo, 64px) shared across the LaoDing plugin family.
@@ -117,7 +203,10 @@ window.__ModuleLoader__.load({
117
203
  .dsm-workbuddy-usage-account-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
118
204
  .dsm-workbuddy-usage-expiry{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
119
205
  .dsm-workbuddy-usage-hint{padding-left:19px;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
120
- .dsm-workbuddy-account-picker{display:block}
206
+ .dsm-workbuddy-account-picker{display:flex;flex-direction:column;gap:8px}
207
+ .dsm-workbuddy-account-state{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
208
+ .dsm-workbuddy-account-state:empty{display:none}
209
+ .dsm-workbuddy-account-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;white-space:pre-line}
121
210
  .dsm-workbuddy-usage-select-wrap{position:relative}
122
211
  .dsm-workbuddy-usage-select{appearance:none;width:100%;font:inherit;padding:10px 34px 10px 12px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;color:var(--dsw-alias-label-primary,#e6e6e6);background:var(--dsw-alias-bg-layer-3,#2a2c33);cursor:pointer;transition:border-color .15s,box-shadow .15s}
123
212
  .dsm-workbuddy-usage-select:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary,#5686fe);box-shadow:0 0 0 3px rgba(86,134,254,.22)}
@@ -255,26 +344,30 @@ window.__ModuleLoader__.load({
255
344
  function dotStyle(status) {
256
345
  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)" };
257
346
  }
258
- /** Read the per-region account selections out of the settings snapshot. */
259
- function configuredAccountsOf(configured) {
260
- const accounts = configured?.accounts;
261
- return typeof accounts === "object" && accounts !== null ? accounts : {};
262
- }
263
347
  /** Render WorkBuddy sign-in state, credits, and model selection as one card. */
264
348
  function WorkBuddyCard({ t, settingsScope }) {
265
349
  if (t === void 0) throw new Error("WorkBuddy plugin card requires its translation function");
266
350
  const [open, setOpen] = (0, react.useState)(false);
267
351
  /** The region whose tab is on screen; each tab is its own provider stack. */
268
352
  const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
269
- /** Last-known usage per region, so tab dots survive tab switches. */
353
+ /**
354
+ * Last-known usage per region, so tab dots survive tab switches.
355
+ *
356
+ * The placeholder carries `selectionExplicit: false` because that is the
357
+ * state the plugin documents as the default before any choice is saved. It
358
+ * is never rendered: the account picker and its state line only appear once
359
+ * a real fetch has populated `accounts`.
360
+ */
270
361
  const [statusByRegion, setStatusByRegion] = (0, react.useState)({
271
362
  cn: {
272
363
  status: "signed-out",
273
- accounts: []
364
+ accounts: [],
365
+ selectionExplicit: false
274
366
  },
275
367
  global: {
276
368
  status: "signed-out",
277
- accounts: []
369
+ accounts: [],
370
+ selectionExplicit: false
278
371
  }
279
372
  });
280
373
  const [busy, setBusy] = (0, react.useState)(false);
@@ -286,6 +379,10 @@ window.__ModuleLoader__.load({
286
379
  /** Save failure surfaced next to the buttons; cleared by the next attempt. */
287
380
  const [saveError, setSaveError] = (0, react.useState)(void 0);
288
381
  const [switchingAccount, setSwitchingAccount] = (0, react.useState)(false);
382
+ /** Set right after a CONFIRMED Clear so the dropped choice is stated. */
383
+ const [accountNote, setAccountNote] = (0, react.useState)(void 0);
384
+ /** A refused account write (silently unpersisted settings on a locked file). */
385
+ const [accountError, setAccountError] = (0, react.useState)(void 0);
289
386
  const [checkingIn, setCheckingIn] = (0, react.useState)(false);
290
387
  const [checkinActionError, setCheckinActionError] = (0, react.useState)(void 0);
291
388
  const mounted = (0, react.useRef)(true);
@@ -338,7 +435,8 @@ window.__ModuleLoader__.load({
338
435
  ]);
339
436
  const status = statusByRegion[activeRegion] ?? {
340
437
  status: "signed-out",
341
- accounts: []
438
+ accounts: [],
439
+ selectionExplicit: false
342
440
  };
343
441
  (0, react.useEffect)(() => {
344
442
  if (!open || status.status !== "signed-in") return;
@@ -387,13 +485,13 @@ window.__ModuleLoader__.load({
387
485
  const switchAccount = async (accountId) => {
388
486
  if (settingsScope === void 0) return;
389
487
  setSwitchingAccount(true);
488
+ setAccountNote(void 0);
489
+ setAccountError(void 0);
390
490
  try {
391
- const configuredAccounts = configuredAccountsOf(settingsScope.getSnapshot().value);
392
- await settingsScope.set("accounts", {
393
- ...configuredAccounts,
394
- [activeRegion]: accountId
395
- });
491
+ await writeAccountSlot(settingsScope, activeRegion, accountId);
396
492
  await refreshUsage(activeRegion);
493
+ } catch (error) {
494
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
397
495
  } finally {
398
496
  if (mounted.current) setSwitchingAccount(false);
399
497
  }
@@ -402,18 +500,29 @@ window.__ModuleLoader__.load({
402
500
  * Drop the explicit choice so the region returns to its documented default:
403
501
  * follow whatever the WorkBuddy app is currently signed in as. The empty
404
502
  * string is the settings-level sentinel for "no explicit selection" (the
405
- * store normalizes it away); the account id itself is never written here.
503
+ * store normalizes it away, and the host's legacy attribution treats it as a
504
+ * deliberate clear rather than an unset key); the account id itself is never
505
+ * written here.
506
+ *
507
+ * The confirmation is only shown once the write is CONFIRMED. That matters
508
+ * twice over: the restored default is usually the very account that was
509
+ * saved, so the state line is the only feedback the user gets — and on
510
+ * Windows the atomic replace of `settings.yaml` can fail outright (locked by
511
+ * an antivirus scanner or a sync client), in which case claiming "cleared"
512
+ * would be a lie that the old selection silently contradicts.
406
513
  */
407
514
  const clearAccount = async () => {
408
515
  if (settingsScope === void 0) return;
409
516
  setSwitchingAccount(true);
517
+ setAccountError(void 0);
410
518
  try {
411
- const configuredAccounts = configuredAccountsOf(settingsScope.getSnapshot().value);
412
- await settingsScope.set("accounts", {
413
- ...configuredAccounts,
414
- [activeRegion]: ""
415
- });
519
+ await writeAccountSlot(settingsScope, activeRegion, "");
416
520
  await refreshUsage(activeRegion);
521
+ if (mounted.current) setAccountNote("cleared");
522
+ } catch (error) {
523
+ if (!mounted.current) return;
524
+ setAccountNote(void 0);
525
+ setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
417
526
  } finally {
418
527
  if (mounted.current) setSwitchingAccount(false);
419
528
  }
@@ -542,15 +651,11 @@ window.__ModuleLoader__.load({
542
651
  setSaving(true);
543
652
  setSaveError(void 0);
544
653
  try {
545
- const configuredRegions = configured?.regions;
546
- await settingsScope.set("regions", {
547
- ...typeof configuredRegions === "object" && configuredRegions !== null ? configuredRegions : {},
548
- [status.region]: {
549
- lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
550
- enabledModelIds: [...activeEnabledIds],
551
- imageModelIds: [...activeImageIds],
552
- contextBudgets: activeContextBudgets
553
- }
654
+ await writeRegionModels(settingsScope, status.region, {
655
+ lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
656
+ enabledModelIds: [...activeEnabledIds],
657
+ imageModelIds: [...activeImageIds],
658
+ contextBudgets: activeContextBudgets
554
659
  });
555
660
  discardModels();
556
661
  await refreshUsage(activeRegion);
@@ -564,6 +669,11 @@ window.__ModuleLoader__.load({
564
669
  const label = status.status === "signed-in" ? t("row.signedIn", { accountName: status.accountName }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
565
670
  /** The saved choice no longer matches a local sign-in (tokens are fine). */
566
671
  const selectionLost = status.status === "signed-out" && status.selectionLost === true;
672
+ /**
673
+ * Whether this region runs a saved choice, per the Host. `undefined` only on
674
+ * the error branch, which renders no picker and therefore no state line.
675
+ */
676
+ const selectionExplicit = status.status === "error" ? void 0 : status.selectionExplicit;
567
677
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
568
678
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
569
679
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -615,6 +725,8 @@ window.__ModuleLoader__.load({
615
725
  className: `dsm-workbuddy-tab${region === activeRegion ? " dsm-workbuddy-tab-active" : ""}`,
616
726
  onClick: () => {
617
727
  setActiveRegion(region);
728
+ setAccountNote(void 0);
729
+ setAccountError(void 0);
618
730
  },
619
731
  children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
620
732
  "aria-hidden": "true",
@@ -668,33 +780,46 @@ window.__ModuleLoader__.load({
668
780
  status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
669
781
  className: "dsm-workbuddy-account-picker",
670
782
  "aria-label": t("row.accountsTitle"),
671
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
672
- className: "dsm-workbuddy-usage-select-wrap",
673
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
674
- className: "dsm-workbuddy-usage-select",
675
- value: status.accounts.find((account) => account.selected)?.id ?? "",
783
+ children: [
784
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
785
+ className: "dsm-workbuddy-usage-select-wrap",
786
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
787
+ className: "dsm-workbuddy-usage-select",
788
+ value: status.accounts.find((account) => account.selected)?.id ?? "",
789
+ disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
790
+ onChange: (event) => {
791
+ switchAccount(event.currentTarget.value);
792
+ },
793
+ children: [status.accounts.some((account) => account.selected) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
794
+ value: "",
795
+ disabled: true,
796
+ children: t("row.accountNoneInEffect")
797
+ }), status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
798
+ value: account.id,
799
+ children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
800
+ }, account.id))]
801
+ })
802
+ }),
803
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
804
+ type: "button",
805
+ className: "dsm-btn dsm-btn-outline",
676
806
  disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
677
- onChange: (event) => {
678
- switchAccount(event.currentTarget.value);
807
+ onClick: () => {
808
+ clearAccount();
679
809
  },
680
- children: [status.accounts.some((account) => account.selected) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
681
- value: "",
682
- disabled: true,
683
- children: t("row.accountNoneInEffect")
684
- }), status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
685
- value: account.id,
686
- children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
687
- }, account.id))]
810
+ children: t("row.accountsFollowApp")
811
+ }),
812
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
813
+ className: "dsm-workbuddy-account-state",
814
+ role: "status",
815
+ children: accountNote === "cleared" ? t("row.accountsCleared") : selectionExplicit === false ? t("row.accountsFollowingApp") : selectionExplicit === true ? t("row.accountsSavedChoice") : ""
816
+ }),
817
+ accountError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
818
+ className: "dsm-workbuddy-account-error",
819
+ role: "alert",
820
+ children: t("row.accountsWriteFailed", { message: accountError })
688
821
  })
689
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
690
- type: "button",
691
- className: "dsm-btn dsm-btn-outline",
692
- disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
693
- onClick: () => {
694
- clearAccount();
695
- },
696
- children: t("row.accountsFollowApp")
697
- })]
822
+ ]
698
823
  }) : null,
699
824
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
700
825
  status.credits === void 0 ? null : (() => {
@@ -975,6 +1100,10 @@ window.__ModuleLoader__.load({
975
1100
  "row.accountsScanning": "Detecting…",
976
1101
  "row.accountsFollowApp": "Follow the app's sign-in",
977
1102
  "row.accountNoneInEffect": "No account in effect",
1103
+ "row.accountsSavedChoice": "Using the account saved for this region.",
1104
+ "row.accountsFollowingApp": "Following the app's current sign-in — no account is saved for this region.",
1105
+ "row.accountsCleared": "Saved choice cleared. This region follows the app's current sign-in again.",
1106
+ "row.accountsWriteFailed": "The account change was not saved: {message} — the settings file could not be updated (on Windows it may be locked by an antivirus scanner or a sync client). Your previous choice is still in effect; close whatever holds settings.yaml and try again.",
978
1107
  "row.selectionLostHint": "The saved account no longer exists locally.",
979
1108
  "row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick one above, or choose \"Follow the app's sign-in\" to clear the saved choice. Signing in again in the desktop app will not fix this.",
980
1109
  "row.modelsTitle": "Models",
@@ -1027,6 +1156,10 @@ window.__ModuleLoader__.load({
1027
1156
  "row.accountsScanning": "正在检测…",
1028
1157
  "row.accountsFollowApp": "跟随 App 当前登录",
1029
1158
  "row.accountNoneInEffect": "当前没有生效的账号",
1159
+ "row.accountsSavedChoice": "正在使用该区域保存的账号。",
1160
+ "row.accountsFollowingApp": "正在跟随 App 当前登录——该区域没有保存账号。",
1161
+ "row.accountsCleared": "已清除保存的选择,该区域重新跟随 App 当前登录。",
1162
+ "row.accountsWriteFailed": "账号改动未保存:{message} —— 设置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用 settings.yaml 的程序再重试。",
1030
1163
  "row.selectionLostHint": "已保存的账号在本机已不存在。",
1031
1164
  "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方选择一个,或点「跟随 App 当前登录」清除已保存的选择。重新登录桌面端 App 无法修复此问题。",
1032
1165
  "row.modelsTitle": "模型",
@@ -931,6 +931,19 @@ var WorkBuddyCredentialStore = class {
931
931
  selectedAccountId() {
932
932
  return this.accountId;
933
933
  }
934
+ /**
935
+ * Whether the region runs a SAVED choice rather than the documented default.
936
+ *
937
+ * `false` means "follow the app's current sign-in", which is also what the
938
+ * card's Clear action restores. The two states can resolve to the very same
939
+ * account — an upgraded user whose pre-split `accountId` happens to be the
940
+ * app's current sign-in clears the choice and sees no change at all — so the
941
+ * card cannot infer this from the account list alone. It reports the state
942
+ * here so clearing is observable instead of looking like a dead button.
943
+ */
944
+ hasExplicitSelection() {
945
+ return this.accountId !== void 0;
946
+ }
934
947
  /** The auth-file path candidates, in probe order. */
935
948
  resolveDesktopCandidates() {
936
949
  const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
@@ -1451,7 +1464,7 @@ var WorkBuddyCatalog = class {
1451
1464
  //#endregion
1452
1465
  //#region src/version.ts
1453
1466
  /** The npm package version this build was produced from. */
1454
- const WORKBUDDY_CONNECT_VERSION = "2.0.4";
1467
+ const WORKBUDDY_CONNECT_VERSION = "2.0.5";
1455
1468
  //#endregion
1456
1469
  //#region src/host-heartbeat.ts
1457
1470
  /**
package/lib/index.d.ts CHANGED
@@ -187,6 +187,17 @@ declare class WorkBuddyCredentialStore {
187
187
  selectAccount(accountId: string | undefined): void;
188
188
  /** Selected account id, for diagnostics and route assembly. */
189
189
  selectedAccountId(): string | undefined;
190
+ /**
191
+ * Whether the region runs a SAVED choice rather than the documented default.
192
+ *
193
+ * `false` means "follow the app's current sign-in", which is also what the
194
+ * card's Clear action restores. The two states can resolve to the very same
195
+ * account — an upgraded user whose pre-split `accountId` happens to be the
196
+ * app's current sign-in clears the choice and sees no change at all — so the
197
+ * card cannot infer this from the account list alone. It reports the state
198
+ * here so clearing is observable instead of looking like a dead button.
199
+ */
200
+ hasExplicitSelection(): boolean;
190
201
  /** The auth-file path candidates, in probe order. */
191
202
  private resolveDesktopCandidates;
192
203
  /** The resolved desktop auth-file path, for diagnostics. */
@@ -766,6 +777,11 @@ type WorkBuddyWebUsage =
766
777
  * sign-in again), versus nothing usable was found at all (sign in in the
767
778
  * desktop app). Only the latter makes the "sign in again" hint truthful —
768
779
  * re-signing in does not repair an orphaned id.
780
+ *
781
+ * `selectionExplicit` answers a question the account list cannot: whether
782
+ * the region runs a saved choice at all. Clearing restores the default, and
783
+ * when the default resolves to the same account the card would otherwise
784
+ * look unchanged — the "clear did nothing" report.
769
785
  */
770
786
  {
771
787
  status: 'signed-out';
@@ -773,6 +789,8 @@ type WorkBuddyWebUsage =
773
789
  message?: string;
774
790
  /** The persisted account id matches no local account. */
775
791
  selectionLost?: boolean;
792
+ /** A saved per-region choice is in effect (false = following the app). */
793
+ selectionExplicit: boolean;
776
794
  } | {
777
795
  status: 'signed-in';
778
796
  accountId: string;
@@ -783,6 +801,8 @@ type WorkBuddyWebUsage =
783
801
  region: WorkBuddyWebRegion;
784
802
  source?: 'desktop' | 'dsh';
785
803
  tokenExpiresAtMs: number;
804
+ /** A saved per-region choice is in effect (false = following the app). */
805
+ selectionExplicit: boolean;
786
806
  accounts: readonly WorkBuddyWebAccount[];
787
807
  models: readonly WorkBuddyWebModel[];
788
808
  enabledModelIds: readonly string[];
@@ -898,6 +918,47 @@ declare const Config: z<Config>;
898
918
  * picks.
899
919
  */
900
920
  declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBuddyRegionState;
921
+ /**
922
+ * The account id a region should select, or `undefined` for the documented
923
+ * default: follow whatever the WorkBuddy app is currently signed in as.
924
+ *
925
+ * Three inputs decide it, and the ORDER is the whole point:
926
+ *
927
+ * 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
928
+ * dropped this region's choice", which is the opposite of the key being
929
+ * absent ("never configured"). It therefore terminates the lookup: falling
930
+ * through to the legacy `accountId` here would re-bind the very account the
931
+ * user just dropped, and would do it after clearing appeared to succeed.
932
+ * 2. `accounts[region]` set to a real id — an explicit per-region choice.
933
+ * 3. absent key, with the pre-split `accountId` belonging to this region —
934
+ * the legacy migration, attributed once at startup from the local scan.
935
+ *
936
+ * `legacyAccountRegion` is resolved asynchronously after the first
937
+ * `applySelection` pass, so this is a pure function of it rather than a
938
+ * closure over the stores: the same call answers both the early pass (no
939
+ * attribution yet) and every later one.
940
+ */
941
+ declare function selectAccountFor(region: WorkBuddyRegion, value: Config, legacyAccountRegion: WorkBuddyRegion | undefined): string | undefined;
942
+ /** Whether a region carries the Clear sentinel rather than a saved choice. */
943
+ declare function regionCleared(value: Config, region: WorkBuddyRegion): boolean;
944
+ /**
945
+ * Which region owns the pre-split `accountId`, or `undefined` when it cannot
946
+ * be attributed (the field is absent, or the account is gone from every local
947
+ * sign-in list).
948
+ *
949
+ * Regions the user explicitly cleared are skipped, even when the saved account
950
+ * IS among their local sign-ins. `selectAccountFor` already refuses to fall
951
+ * back for a cleared region, so this cannot change what runs — but it keeps the
952
+ * attribution honest about its own decision: the region that owns this id is
953
+ * not the one the user just told the plugin to stop pinning, so the other
954
+ * region must get the chance to claim it.
955
+ *
956
+ * `accountsFor` is injected so the sequential, stop-at-first-match scan is
957
+ * testable without a filesystem.
958
+ */
959
+ declare function legacyAttributionRegion(value: Config, accountsFor: (region: WorkBuddyRegion) => Promise<readonly {
960
+ id: string;
961
+ }[]>): Promise<WorkBuddyRegion | undefined>;
901
962
  /**
902
963
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
903
964
  * `workbuddy-global` (international) providers, and refresh each region's
@@ -907,4 +968,4 @@ declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBud
907
968
  */
908
969
  declare function apply(ctx: Context, config: Config): void;
909
970
  //#endregion
910
- export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, 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, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyReasoning, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
971
+ export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, type UpstreamErrorKind, WORKBUDDY_ACCOUNTS_REFRESH_PATH, 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, WorkBuddyCatalog, type WorkBuddyChatResult, type WorkBuddyCredential, WorkBuddyCredentialStore, type WorkBuddyCreditPackage, type WorkBuddyCredits, type WorkBuddyHostHeartbeat, type WorkBuddyModelInfo, type WorkBuddyReasoning, type WorkBuddyRefreshOutcome, WorkBuddyRegionState, type WorkBuddyShim, type WorkBuddyStatusRouteOptions, type WorkBuddyStoreOptions, WorkBuddyUpstreamClient, type WorkBuddyUpstreamModel, type WorkBuddyWebAccount, type WorkBuddyWebCheckin, type WorkBuddyWebCredits, type WorkBuddyWebModel, type WorkBuddyWebPackage, type WorkBuddyWebRegion, type WorkBuddyWebUsage, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionCleared, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-CPIHUxKL.js";
1
+ import { A as prepareChatBody, C as workbuddyAccountId, D as parseCreditMultiplier, E as classifyUpstreamError, O as parseReasoning, S as parseWorkBuddyAuth, T as WorkBuddyUpstreamClient, _ as authFileName, a as readHostHeartbeat, b as defaultDesktopAuthPath, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WorkBuddyCredentialStore, h as WORKBUDDY_AUTH_FILE_ENV, i as processStartTimeMs, j as regionOf, k as parseUpstreamModel, l as FALLBACK_WORKBUDDY_MODELS, m as WORKBUDDY_AUTH_FILENAME, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as defaultDesktopAuthCandidates, w as workbuddyOwnAuthPath, x as legacyWorkbuddyOwnAuthPath, y as defaultDesktopAuthDirs } from "./host-heartbeat-BOrNj3pY.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";
@@ -561,9 +561,12 @@ function toWebAccount(account) {
561
561
  async function workBuddyWebStatus(deps, region) {
562
562
  const store = deps.store(region);
563
563
  const accounts = await store.accounts();
564
- if ((await store.status()).state !== "signed-out" && accounts.length === 0) return {
564
+ const authStatus = await store.status();
565
+ const selectionExplicit = store.hasExplicitSelection();
566
+ if (authStatus.state !== "signed-out" && accounts.length === 0) return {
565
567
  status: "signed-out",
566
- accounts: []
568
+ accounts: [],
569
+ selectionExplicit
567
570
  };
568
571
  let credential;
569
572
  try {
@@ -573,6 +576,7 @@ async function workBuddyWebStatus(deps, region) {
573
576
  status: "signed-out",
574
577
  accounts: accounts.map(toWebAccount),
575
578
  message: safeMessage(error),
579
+ selectionExplicit,
576
580
  ...await store.selectionLost() ? { selectionLost: true } : {}
577
581
  };
578
582
  }
@@ -584,6 +588,7 @@ async function workBuddyWebStatus(deps, region) {
584
588
  region,
585
589
  source: credential.source,
586
590
  tokenExpiresAtMs: credential.expiresAtMs,
591
+ selectionExplicit,
587
592
  accounts: accounts.map(toWebAccount),
588
593
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
589
594
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -759,6 +764,59 @@ function regionStateOf(config, region) {
759
764
  /** Every region, in card tab order. */
760
765
  const REGION_KEYS = ["cn", "global"];
761
766
  /**
767
+ * The account id a region should select, or `undefined` for the documented
768
+ * default: follow whatever the WorkBuddy app is currently signed in as.
769
+ *
770
+ * Three inputs decide it, and the ORDER is the whole point:
771
+ *
772
+ * 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
773
+ * dropped this region's choice", which is the opposite of the key being
774
+ * absent ("never configured"). It therefore terminates the lookup: falling
775
+ * through to the legacy `accountId` here would re-bind the very account the
776
+ * user just dropped, and would do it after clearing appeared to succeed.
777
+ * 2. `accounts[region]` set to a real id — an explicit per-region choice.
778
+ * 3. absent key, with the pre-split `accountId` belonging to this region —
779
+ * the legacy migration, attributed once at startup from the local scan.
780
+ *
781
+ * `legacyAccountRegion` is resolved asynchronously after the first
782
+ * `applySelection` pass, so this is a pure function of it rather than a
783
+ * closure over the stores: the same call answers both the early pass (no
784
+ * attribution yet) and every later one.
785
+ */
786
+ function selectAccountFor(region, value, legacyAccountRegion) {
787
+ const configured = value.accounts?.[region];
788
+ if (configured === "") return void 0;
789
+ if (configured !== void 0) return configured;
790
+ return legacyAccountRegion === region ? value.accountId : void 0;
791
+ }
792
+ /** Whether a region carries the Clear sentinel rather than a saved choice. */
793
+ function regionCleared(value, region) {
794
+ return value.accounts?.[region] === "";
795
+ }
796
+ /**
797
+ * Which region owns the pre-split `accountId`, or `undefined` when it cannot
798
+ * be attributed (the field is absent, or the account is gone from every local
799
+ * sign-in list).
800
+ *
801
+ * Regions the user explicitly cleared are skipped, even when the saved account
802
+ * IS among their local sign-ins. `selectAccountFor` already refuses to fall
803
+ * back for a cleared region, so this cannot change what runs — but it keeps the
804
+ * attribution honest about its own decision: the region that owns this id is
805
+ * not the one the user just told the plugin to stop pinning, so the other
806
+ * region must get the chance to claim it.
807
+ *
808
+ * `accountsFor` is injected so the sequential, stop-at-first-match scan is
809
+ * testable without a filesystem.
810
+ */
811
+ async function legacyAttributionRegion(value, accountsFor) {
812
+ const id = value.accountId;
813
+ if (id === void 0) return void 0;
814
+ for (const region of REGION_KEYS) {
815
+ if (regionCleared(value, region)) continue;
816
+ if ((await accountsFor(region)).some((account) => account.id === id)) return region;
817
+ }
818
+ }
819
+ /**
762
820
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
763
821
  * `workbuddy-global` (international) providers, and refresh each region's
764
822
  * model catalog from the upstream once that region's credentials allow it.
@@ -808,11 +866,7 @@ function apply(ctx, config) {
808
866
  * a selection that belongs to the other side of the split.
809
867
  */
810
868
  let legacyAccountRegion;
811
- const effectiveAccountFor = (region, value) => {
812
- const explicit = value.accounts?.[region];
813
- if (explicit !== void 0) return explicit;
814
- return legacyAccountRegion === region ? value.accountId : void 0;
815
- };
869
+ const effectiveAccountFor = (region, value) => selectAccountFor(region, value, legacyAccountRegion);
816
870
  const discoverModels = async (region, signal) => {
817
871
  const credential = await stacks[region].store.resolve();
818
872
  return client.fetchModels(credential, signal);
@@ -845,14 +899,11 @@ function apply(ctx, config) {
845
899
  });
846
900
  applySelection(config);
847
901
  (async () => {
848
- const id = current().accountId;
849
- if (id === void 0) return;
850
902
  try {
851
- for (const region of REGION_KEYS) if ((await stacks[region].store.accounts()).some((account) => account.id === id)) {
852
- legacyAccountRegion = region;
853
- applySelection(current());
854
- return;
855
- }
903
+ const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
904
+ if (region === void 0) return;
905
+ legacyAccountRegion = region;
906
+ applySelection(current());
856
907
  } catch {}
857
908
  })();
858
909
  let stopped = false;
@@ -950,4 +1001,4 @@ function apply(ctx, config) {
950
1001
  });
951
1002
  }
952
1003
  //#endregion
953
- export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, WORKBUDDY_ACCOUNTS_REFRESH_PATH, 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, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1004
+ export { Config, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, WORKBUDDY_ACCOUNTS_REFRESH_PATH, 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, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, authFileName, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveCatalog, fallbackModelsFor, inject, isHeartbeatProcessAlive, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, name, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionCleared, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, selectAccountFor, toPersistedWorkBuddyModel, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, 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.4",
5
+ "version": "2.0.5",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",