dsh-connect-workbuddy 2.0.3 → 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,83 @@
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
+
49
+ ## 2.0.4 (2026-09-21)
50
+
51
+ ### Fixes
52
+
53
+ - **修复「保存的账号在本机消失后,界面自相矛盾」**(issue #10 的缺陷 3;其症状属实,但其声称的机制与严重性不准确)。显式选择的账号若在本机不再存在(WorkBuddy 更换登录或清理备份文件——`current()` 的注释早已点名这个成因),同一个状态下四条读取路径给出两种说法:
54
+
55
+ | 路径 | 修复前 | 修复后 |
56
+ |---|---|---|
57
+ | `accounts()` | 靠对象同一性比较**仍标记一个账号为 selected** | 无人生效时不标记任何行 |
58
+ | `current()` | 返回 `undefined`(拒绝计费,刻意不回退) | 不变(账单安全语义保留) |
59
+ | `status()` | `signed-out` | 不变 |
60
+ | 卡片下拉框 | `value` 取 signed-in 分支才有的 `accountId` → `''` → **空白**(选项仍在) | 显示「当前没有生效的账号」占位项 |
61
+
62
+ - **根因**:`accounts()` 与 `current()` 对「id 失配」的口径不一致。卡片渲染前者、计费走后者,于是面板同时出现「下拉框选中 Alpha」「状态栏未登录」「提示去桌面端登录一次」——而用户明明已登录,那句提示还把人指向唯一无法修复该问题的操作。commit `1279234` 移除 `current()` 的回退(`?? this.preferred()`)以避免账单落到别的账号,本修复保留该决定,只消除显示层矛盾。
63
+ - **`accounts()` 不再报告幽灵选中**:显式选择失配时不再回退到对象同一性比较(原写法依赖 `preferred()` 返回同一数组元素,能工作但语义脆弱);未显式选择时仍如实标出正在跟随的默认账号。
64
+ - **新增 `selectionLost`**:usage 文档的 `signed-out` 分支新增该可选字段,区分「保存的选择已失效(凭据是好的,请重选或清除)」与「本机确实没有登录(去桌面端登录)」。仅前者为真时才显示新的说明文案,后者保留原文案。
65
+ - **卡片下拉框改从账号列表取选中值**(原先取 signed-in 文档的 `accountId`)。这是同一类问题的第二处:当保存的账号**仍然有效**、只是 token 过期且刷新失败时,`resolve()` 同样抛错,下拉框也会变空白——用户的完整选择看起来像消失了。现在由账号列表这一个来源决定,并以此区分「没有任何账号生效」与「已登录」。
66
+ - **新增「跟随 App 当前登录」按钮**:清除该区域的显式选择,回到文档承诺的默认行为。清除写入空字符串哨兵值,`selectAccount('')` 归一化为 `undefined`;此前空字符串会像任何失配 id 一样永久卡死(即「没有清除路径」)。
67
+ - **`rescanAccounts()` 不再写入选中的账号**:原先只要 `accounts()` 报告的选中项与保存值不同就回写设置,导致两点危害——无显式选择时把「跟随 App 登录」的默认行为固化成一个永久绑定(此后 App 换登录不再跟随,与 `README`/`DESIGN` 承诺不符),以及保存的 id 失配时静默把区域改绑到**另一个账号**(正是严格不回退规则要防的静默改选)。「重新检测」现在只重读,选择权归下拉框与清除按钮。
68
+
69
+ ### Tests
70
+
71
+ 测试总数 131 → 142(新增 11 例):
72
+
73
+ - `auth.spec.ts` 新增 7 例:失配选择不标记任何账号、未显式选择仍标出默认账号、有效选择在刷新失败时保持可见、`selectionLost` 的三种取值(无选择/有效选择/失配)、无本机登录时不报 `selectionLost`、空字符串哨兵恢复默认、以及区域隔离(一个区域失配不影响另一区域)。
74
+ - `web-status.spec.ts` 新增 3 例:orphaned 选择标记 `selectionLost` 且保留账号列表、真正的未登录不标记、signed-in 文档不含该字段。
75
+ - `settings-integration.spec.ts` 新增 1 例:空字符串哨兵经真实的 `settings.update` 链路写入后仍为 `''`,且两个 provider 继续正常服务。
76
+
77
+ ### Docs
78
+
79
+ - `README.md` / `README.en.md` 的「已知限制 / Known limitations」补充账号失效后的表现与恢复方式,并说明重新登录桌面端无法修复该状态。
80
+
3
81
  ## 2.0.2 (2026-09-15)
4
82
 
5
83
  ### Fixes
package/README.en.md CHANGED
@@ -115,6 +115,9 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
115
115
 
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
+ - 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.
118
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.
119
122
 
120
123
  ## Disclaimer
package/README.md CHANGED
@@ -123,6 +123,9 @@ dsh plugin --profile desktop add /Users/dmh2002/DshProject/dsh-connect-workbuddy
123
123
 
124
124
  - 依赖 WorkBuddy 客户端接口(非官方开放 API),WorkBuddy 更新后插件可能需要随之调整。
125
125
  - **账号切换**基于 WorkBuddy 桌面端留下的历史 auth 文件(App 自身的备份产物),并非官方多账号 API。默认行为仍是跟随 App 当前登录;切换账号是显式选项,且历史凭据可能因 App 清理或退出登录而失效。
126
+ - 显式选择的账号若在本机消失(App 更换登录或清理备份文件),插件**不会**静默改选其他账号——那样会让账单落到另一个账号上。卡片会明确说明「保存的账号已不存在」,此时重新选择一个账号,或点「跟随 App 当前登录」清除该选择即可恢复。**重新登录桌面端 App 无法修复这种状态**:凭据本身是好的,失效的只是保存下来的账号标识。
127
+ - 从 2.0.0 之前的版本升级而来的用户,设置里可能仍留有旧版的顶层 `accountId`(迁移来源)。它只作用于其所属区域,且**该区域被显式清除(`accounts.<区域> = ""`)后即不再生效**——否则清除掉的账号会在下次启动时被静默恢复。卡片会显示每个区域当前是「使用保存的账号」还是「跟随 App 当前登录」:清除后回到的默认账号往往与刚清掉的账号是同一个,没有这行提示就无法分辨清除是否生效。
128
+ - **Windows:设置文件 `settings.yaml` 被占用时,账号改动可能无法保存**。DSH 用「写临时文件 + 覆盖替换」的方式更新该文件,而杀毒软件、OneDrive 等同步盘或正在打开该文件的编辑器会短暂锁住它;重试耗尽后,写入失败**不会**让 `set()` 报错。插件因此会在写入后回读校验:**确认落盘才显示「已清除」**,失败则给出明确错误并保留原来的选择(不会显示一个旧选择默默反驳的假确认)。遇到该提示时,关闭占用该文件的程序后重试即可。
126
129
  - Windows / Linux 的凭据默认路径为按平台约定推导,未经真机验证;必要时可通过环境变量 `WORKBUDDY_AUTH_FILE` 指定实际位置。
127
130
 
128
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-CYeVuoJE.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;
@@ -356,6 +454,19 @@ window.__ModuleLoader__.load({
356
454
  refreshUsage,
357
455
  status.status
358
456
  ]);
457
+ /**
458
+ * Re-detect the local sign-ins and refresh the panel.
459
+ *
460
+ * This deliberately writes NO account selection. It used to persist whatever
461
+ * row the store reported as "selected" whenever that differed from the saved
462
+ * value, which had two harmful effects: with no explicit choice it turned the
463
+ * documented default (follow the app's current sign-in) into a permanent
464
+ * explicit binding — so a later sign-in in the app was no longer followed —
465
+ * and with an orphaned saved id it silently re-bound the region to a
466
+ * different account, which is the silent switch that the strict
467
+ * no-fallback rule exists to prevent. Re-detecting and re-picking are
468
+ * separate actions now; the picker and Clear button own the selection.
469
+ */
359
470
  const rescanAccounts = async () => {
360
471
  setBusy(true);
361
472
  try {
@@ -366,13 +477,6 @@ window.__ModuleLoader__.load({
366
477
  });
367
478
  const body = await response.json();
368
479
  if (!response.ok || !Array.isArray(body.accounts)) throw new Error(`HTTP ${response.status}`);
369
- const selected = body.accounts.find((account) => account.selected)?.id;
370
- const configuredAccounts = configuredAccountsOf(settingsScope?.getSnapshot().value);
371
- const configuredId = configuredAccounts[activeRegion];
372
- if (selected !== void 0 && selected !== configuredId && settingsScope?.getSnapshot().writable === true) await settingsScope.set("accounts", {
373
- ...configuredAccounts,
374
- [activeRegion]: selected
375
- });
376
480
  await refreshUsage(activeRegion);
377
481
  } finally {
378
482
  if (mounted.current) setBusy(false);
@@ -381,13 +485,44 @@ window.__ModuleLoader__.load({
381
485
  const switchAccount = async (accountId) => {
382
486
  if (settingsScope === void 0) return;
383
487
  setSwitchingAccount(true);
488
+ setAccountNote(void 0);
489
+ setAccountError(void 0);
384
490
  try {
385
- const configuredAccounts = configuredAccountsOf(settingsScope.getSnapshot().value);
386
- await settingsScope.set("accounts", {
387
- ...configuredAccounts,
388
- [activeRegion]: accountId
389
- });
491
+ await writeAccountSlot(settingsScope, activeRegion, accountId);
492
+ await refreshUsage(activeRegion);
493
+ } catch (error) {
494
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
495
+ } finally {
496
+ if (mounted.current) setSwitchingAccount(false);
497
+ }
498
+ };
499
+ /**
500
+ * Drop the explicit choice so the region returns to its documented default:
501
+ * follow whatever the WorkBuddy app is currently signed in as. The empty
502
+ * string is the settings-level sentinel for "no explicit selection" (the
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.
513
+ */
514
+ const clearAccount = async () => {
515
+ if (settingsScope === void 0) return;
516
+ setSwitchingAccount(true);
517
+ setAccountError(void 0);
518
+ try {
519
+ await writeAccountSlot(settingsScope, activeRegion, "");
390
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"));
391
526
  } finally {
392
527
  if (mounted.current) setSwitchingAccount(false);
393
528
  }
@@ -516,15 +651,11 @@ window.__ModuleLoader__.load({
516
651
  setSaving(true);
517
652
  setSaveError(void 0);
518
653
  try {
519
- const configuredRegions = configured?.regions;
520
- await settingsScope.set("regions", {
521
- ...typeof configuredRegions === "object" && configuredRegions !== null ? configuredRegions : {},
522
- [status.region]: {
523
- lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
524
- enabledModelIds: [...activeEnabledIds],
525
- imageModelIds: [...activeImageIds],
526
- contextBudgets: activeContextBudgets
527
- }
654
+ await writeRegionModels(settingsScope, status.region, {
655
+ lastCatalog: visibleModels.map(toPersistedWorkBuddyModel),
656
+ enabledModelIds: [...activeEnabledIds],
657
+ imageModelIds: [...activeImageIds],
658
+ contextBudgets: activeContextBudgets
528
659
  });
529
660
  discardModels();
530
661
  await refreshUsage(activeRegion);
@@ -536,6 +667,13 @@ window.__ModuleLoader__.load({
536
667
  };
537
668
  const title = t("row.title");
538
669
  const label = status.status === "signed-in" ? t("row.signedIn", { accountName: status.accountName }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
670
+ /** The saved choice no longer matches a local sign-in (tokens are fine). */
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;
539
677
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
540
678
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
541
679
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -587,6 +725,8 @@ window.__ModuleLoader__.load({
587
725
  className: `dsm-workbuddy-tab${region === activeRegion ? " dsm-workbuddy-tab-active" : ""}`,
588
726
  onClick: () => {
589
727
  setActiveRegion(region);
728
+ setAccountNote(void 0);
729
+ setAccountError(void 0);
590
730
  },
591
731
  children: [regionStatus === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
592
732
  "aria-hidden": "true",
@@ -621,6 +761,10 @@ window.__ModuleLoader__.load({
621
761
  status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
622
762
  className: "dsm-workbuddy-usage-hint",
623
763
  children: t("row.reloginHint")
764
+ }) : null,
765
+ selectionLost ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
766
+ className: "dsm-workbuddy-usage-hint",
767
+ children: t("row.selectionLostHint")
624
768
  }) : null
625
769
  ]
626
770
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -633,24 +777,49 @@ window.__ModuleLoader__.load({
633
777
  children: busy ? t("row.accountsScanning") : t("row.accountsRescan")
634
778
  })]
635
779
  }),
636
- status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
780
+ status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
637
781
  className: "dsm-workbuddy-account-picker",
638
782
  "aria-label": t("row.accountsTitle"),
639
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
640
- className: "dsm-workbuddy-usage-select-wrap",
641
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
642
- className: "dsm-workbuddy-usage-select",
643
- value: status.status === "signed-in" ? status.accountId : "",
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",
644
806
  disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
645
- onChange: (event) => {
646
- switchAccount(event.currentTarget.value);
807
+ onClick: () => {
808
+ clearAccount();
647
809
  },
648
- children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
649
- value: account.id,
650
- children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
651
- }, 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 })
652
821
  })
653
- })
822
+ ]
654
823
  }) : null,
655
824
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
656
825
  status.credits === void 0 ? null : (() => {
@@ -876,7 +1045,7 @@ window.__ModuleLoader__.load({
876
1045
  ] }) : null,
877
1046
  status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
878
1047
  className: "dsm-workbuddy-usage-text",
879
- children: status.message ?? t("row.signedOutHint")
1048
+ children: selectionLost ? t("row.selectionLostMessage") : status.message ?? t("row.signedOutHint")
880
1049
  }) : null,
881
1050
  status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
882
1051
  className: "dsm-workbuddy-usage-error",
@@ -929,6 +1098,14 @@ window.__ModuleLoader__.load({
929
1098
  "row.accountsHint": "Choose from locally detected sign-ins. Tokens are never shown or saved in DSH settings.",
930
1099
  "row.accountsRescan": "Detect accounts again",
931
1100
  "row.accountsScanning": "Detecting…",
1101
+ "row.accountsFollowApp": "Follow the app's sign-in",
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.",
1107
+ "row.selectionLostHint": "The saved account no longer exists locally.",
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.",
932
1109
  "row.modelsTitle": "Models",
933
1110
  "row.modelsSummary": "{count} enabled",
934
1111
  "row.modelsRefresh": "Refresh from WorkBuddy",
@@ -977,6 +1154,14 @@ window.__ModuleLoader__.load({
977
1154
  "row.accountsHint": "选择本机检测到的登录账号;Token 不会显示,也不会保存到 DSH 设置。",
978
1155
  "row.accountsRescan": "重新检测账号",
979
1156
  "row.accountsScanning": "正在检测…",
1157
+ "row.accountsFollowApp": "跟随 App 当前登录",
1158
+ "row.accountNoneInEffect": "当前没有生效的账号",
1159
+ "row.accountsSavedChoice": "正在使用该区域保存的账号。",
1160
+ "row.accountsFollowingApp": "正在跟随 App 当前登录——该区域没有保存账号。",
1161
+ "row.accountsCleared": "已清除保存的选择,该区域重新跟随 App 当前登录。",
1162
+ "row.accountsWriteFailed": "账号改动未保存:{message} —— 设置文件写入失败(Windows 上可能被杀毒软件或同步盘占用)。当前仍是原来保存的选择,请先关闭占用 settings.yaml 的程序再重试。",
1163
+ "row.selectionLostHint": "已保存的账号在本机已不存在。",
1164
+ "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方选择一个,或点「跟随 App 当前登录」清除已保存的选择。重新登录桌面端 App 无法修复此问题。",
980
1165
  "row.modelsTitle": "模型",
981
1166
  "row.modelsSummary": "已启用 {count} 个",
982
1167
  "row.modelsRefresh": "从 WorkBuddy 刷新",
@@ -914,15 +914,36 @@ var WorkBuddyCredentialStore = class {
914
914
  this.desktopPathOverride = path;
915
915
  this.inflight = void 0;
916
916
  }
917
- /** Select an account by id; tokens stay outside settings. */
917
+ /**
918
+ * Select an account by id; tokens stay outside settings.
919
+ *
920
+ * The empty string is the settings-level sentinel for "no explicit
921
+ * selection" (the card's Clear action writes it), so it is normalized here
922
+ * rather than being kept as an id that can never match an account. Every
923
+ * caller therefore gets the documented default — follow the app's current
924
+ * sign-in — instead of a dead selection.
925
+ */
918
926
  selectAccount(accountId) {
919
- this.accountId = accountId;
927
+ this.accountId = accountId === "" ? void 0 : accountId;
920
928
  this.inflight = void 0;
921
929
  }
922
930
  /** Selected account id, for diagnostics and route assembly. */
923
931
  selectedAccountId() {
924
932
  return this.accountId;
925
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
+ }
926
947
  /** The auth-file path candidates, in probe order. */
927
948
  resolveDesktopCandidates() {
928
949
  const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
@@ -1014,11 +1035,25 @@ var WorkBuddyCredentialStore = class {
1014
1035
  if (credentials.length === 0) return void 0;
1015
1036
  return credentials.reduce((best, credential) => isFresher(credential, best) ? credential : best);
1016
1037
  }
1017
- /** Token-free account list for the plugin card. */
1038
+ /**
1039
+ * Token-free account list for the plugin card.
1040
+ *
1041
+ * `selected` answers one question only: which row is the account the plugin
1042
+ * is actually going to use? That is exactly what {@link current} decides, so
1043
+ * the two must never disagree — the card renders this list while the shim
1044
+ * bills through `current()`, and a row marked "selected" that `current()`
1045
+ * refuses to use is what made the dropdown look healthy while every request
1046
+ * failed with 401.
1047
+ *
1048
+ * So an explicit selection that matches NO local account marks nothing as
1049
+ * selected (no silent fallback to a different account — see {@link current}).
1050
+ * The implicit default is marked only when nothing was explicitly chosen.
1051
+ */
1018
1052
  async accounts() {
1019
1053
  const credentials = await this.readAll();
1020
1054
  if (credentials.length === 0) return [];
1021
- const selectedExists = this.accountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
1055
+ const hasExplicitSelection = this.accountId !== void 0;
1056
+ const selectedExists = hasExplicitSelection && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
1022
1057
  const defaultSelected = this.preferred(credentials);
1023
1058
  return credentials.map((credential) => {
1024
1059
  const id = workbuddyAccountId(credential);
@@ -1030,10 +1065,26 @@ var WorkBuddyCredentialStore = class {
1030
1065
  source: credential.source,
1031
1066
  tokenExpiresAtMs: credential.expiresAtMs,
1032
1067
  filePath: credential.filePath,
1033
- selected: selectedExists ? id === this.accountId : credential === defaultSelected
1068
+ selected: selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
1034
1069
  };
1035
1070
  });
1036
1071
  }
1072
+ /**
1073
+ * Whether a persisted selection exists that matches no local account, while
1074
+ * other local sign-ins ARE available to choose from.
1075
+ *
1076
+ * The card uses this to explain the state honestly (the account is signed in,
1077
+ * but the SAVED choice is gone) instead of showing the generic "sign in
1078
+ * again" hint, which misdirects: the token is usually perfectly healthy and
1079
+ * signing in again does not repair an orphaned id. When no local sign-in is
1080
+ * available at all, that hint IS accurate and this returns false.
1081
+ */
1082
+ async selectionLost() {
1083
+ if (this.accountId === void 0) return false;
1084
+ const credentials = await this.readAll();
1085
+ if (credentials.length === 0) return false;
1086
+ return !credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
1087
+ }
1037
1088
  /** The freshest stored credential for the current selection, no refresh. */
1038
1089
  async current() {
1039
1090
  const credentials = await this.readAll();
@@ -1413,7 +1464,7 @@ var WorkBuddyCatalog = class {
1413
1464
  //#endregion
1414
1465
  //#region src/version.ts
1415
1466
  /** The npm package version this build was produced from. */
1416
- const WORKBUDDY_CONNECT_VERSION = "2.0.3";
1467
+ const WORKBUDDY_CONNECT_VERSION = "2.0.5";
1417
1468
  //#endregion
1418
1469
  //#region src/host-heartbeat.ts
1419
1470
  /**
package/lib/index.d.ts CHANGED
@@ -175,10 +175,29 @@ declare class WorkBuddyCredentialStore {
175
175
  private ownCandidates;
176
176
  /** Repoint the desktop file or directory; applies on the next read. */
177
177
  setDesktopPath(path: string | undefined): void;
178
- /** Select an account by id; tokens stay outside settings. */
178
+ /**
179
+ * Select an account by id; tokens stay outside settings.
180
+ *
181
+ * The empty string is the settings-level sentinel for "no explicit
182
+ * selection" (the card's Clear action writes it), so it is normalized here
183
+ * rather than being kept as an id that can never match an account. Every
184
+ * caller therefore gets the documented default — follow the app's current
185
+ * sign-in — instead of a dead selection.
186
+ */
179
187
  selectAccount(accountId: string | undefined): void;
180
188
  /** Selected account id, for diagnostics and route assembly. */
181
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;
182
201
  /** The auth-file path candidates, in probe order. */
183
202
  private resolveDesktopCandidates;
184
203
  /** The resolved desktop auth-file path, for diagnostics. */
@@ -215,8 +234,32 @@ declare class WorkBuddyCredentialStore {
215
234
  * remaining credit.
216
235
  */
217
236
  private preferred;
218
- /** Token-free account list for the plugin card. */
237
+ /**
238
+ * Token-free account list for the plugin card.
239
+ *
240
+ * `selected` answers one question only: which row is the account the plugin
241
+ * is actually going to use? That is exactly what {@link current} decides, so
242
+ * the two must never disagree — the card renders this list while the shim
243
+ * bills through `current()`, and a row marked "selected" that `current()`
244
+ * refuses to use is what made the dropdown look healthy while every request
245
+ * failed with 401.
246
+ *
247
+ * So an explicit selection that matches NO local account marks nothing as
248
+ * selected (no silent fallback to a different account — see {@link current}).
249
+ * The implicit default is marked only when nothing was explicitly chosen.
250
+ */
219
251
  accounts(): Promise<WorkBuddyAccountChoice[]>;
252
+ /**
253
+ * Whether a persisted selection exists that matches no local account, while
254
+ * other local sign-ins ARE available to choose from.
255
+ *
256
+ * The card uses this to explain the state honestly (the account is signed in,
257
+ * but the SAVED choice is gone) instead of showing the generic "sign in
258
+ * again" hint, which misdirects: the token is usually perfectly healthy and
259
+ * signing in again does not repair an orphaned id. When no local sign-in is
260
+ * available at all, that hint IS accurate and this returns false.
261
+ */
262
+ selectionLost(): Promise<boolean>;
220
263
  /** The freshest stored credential for the current selection, no refresh. */
221
264
  current(): Promise<WorkBuddyCredential | undefined>;
222
265
  /** The credential to send upstream: {@link current}, refreshed on demand. */
@@ -725,10 +768,29 @@ type WorkBuddyWebPackage = WorkBuddyWebCreditPackage;
725
768
  */
726
769
  type WorkBuddyWebRegion = 'cn' | 'global';
727
770
  /** The JSON document the plugin card renders. */
728
- type WorkBuddyWebUsage = {
771
+ type WorkBuddyWebUsage =
772
+ /**
773
+ * Not usable right now. `accounts` is still populated so the picker can
774
+ * offer a way out, and `selectionLost` separates the two very different
775
+ * reasons this happens: local sign-ins exist but the SAVED choice no longer
776
+ * matches any of them (re-select it, or clear it to follow the app's current
777
+ * sign-in again), versus nothing usable was found at all (sign in in the
778
+ * desktop app). Only the latter makes the "sign in again" hint truthful —
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.
785
+ */
786
+ {
729
787
  status: 'signed-out';
730
788
  accounts: readonly WorkBuddyWebAccount[];
731
789
  message?: string;
790
+ /** The persisted account id matches no local account. */
791
+ selectionLost?: boolean;
792
+ /** A saved per-region choice is in effect (false = following the app). */
793
+ selectionExplicit: boolean;
732
794
  } | {
733
795
  status: 'signed-in';
734
796
  accountId: string;
@@ -739,6 +801,8 @@ type WorkBuddyWebUsage = {
739
801
  region: WorkBuddyWebRegion;
740
802
  source?: 'desktop' | 'dsh';
741
803
  tokenExpiresAtMs: number;
804
+ /** A saved per-region choice is in effect (false = following the app). */
805
+ selectionExplicit: boolean;
742
806
  accounts: readonly WorkBuddyWebAccount[];
743
807
  models: readonly WorkBuddyWebModel[];
744
808
  enabledModelIds: readonly string[];
@@ -854,6 +918,47 @@ declare const Config: z<Config>;
854
918
  * picks.
855
919
  */
856
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>;
857
962
  /**
858
963
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
859
964
  * `workbuddy-global` (international) providers, and refresh each region's
@@ -863,4 +968,4 @@ declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBud
863
968
  */
864
969
  declare function apply(ctx: Context, config: Config): void;
865
970
  //#endregion
866
- 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-CYeVuoJE.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 {
@@ -572,7 +575,9 @@ async function workBuddyWebStatus(deps, region) {
572
575
  return {
573
576
  status: "signed-out",
574
577
  accounts: accounts.map(toWebAccount),
575
- message: safeMessage(error)
578
+ message: safeMessage(error),
579
+ selectionExplicit,
580
+ ...await store.selectionLost() ? { selectionLost: true } : {}
576
581
  };
577
582
  }
578
583
  const account = {
@@ -583,6 +588,7 @@ async function workBuddyWebStatus(deps, region) {
583
588
  region,
584
589
  source: credential.source,
585
590
  tokenExpiresAtMs: credential.expiresAtMs,
591
+ selectionExplicit,
586
592
  accounts: accounts.map(toWebAccount),
587
593
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
588
594
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -758,6 +764,59 @@ function regionStateOf(config, region) {
758
764
  /** Every region, in card tab order. */
759
765
  const REGION_KEYS = ["cn", "global"];
760
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
+ /**
761
820
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
762
821
  * `workbuddy-global` (international) providers, and refresh each region's
763
822
  * model catalog from the upstream once that region's credentials allow it.
@@ -807,11 +866,7 @@ function apply(ctx, config) {
807
866
  * a selection that belongs to the other side of the split.
808
867
  */
809
868
  let legacyAccountRegion;
810
- const effectiveAccountFor = (region, value) => {
811
- const explicit = value.accounts?.[region];
812
- if (explicit !== void 0) return explicit;
813
- return legacyAccountRegion === region ? value.accountId : void 0;
814
- };
869
+ const effectiveAccountFor = (region, value) => selectAccountFor(region, value, legacyAccountRegion);
815
870
  const discoverModels = async (region, signal) => {
816
871
  const credential = await stacks[region].store.resolve();
817
872
  return client.fetchModels(credential, signal);
@@ -844,14 +899,11 @@ function apply(ctx, config) {
844
899
  });
845
900
  applySelection(config);
846
901
  (async () => {
847
- const id = current().accountId;
848
- if (id === void 0) return;
849
902
  try {
850
- for (const region of REGION_KEYS) if ((await stacks[region].store.accounts()).some((account) => account.id === id)) {
851
- legacyAccountRegion = region;
852
- applySelection(current());
853
- return;
854
- }
903
+ const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
904
+ if (region === void 0) return;
905
+ legacyAccountRegion = region;
906
+ applySelection(current());
855
907
  } catch {}
856
908
  })();
857
909
  let stopped = false;
@@ -949,4 +1001,4 @@ function apply(ctx, config) {
949
1001
  });
950
1002
  }
951
1003
  //#endregion
952
- 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.3",
5
+ "version": "2.0.5",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",