dsh-connect-workbuddy 2.0.4 → 2.0.6

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,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.6 (2026-09-22)
4
+
5
+ ### Fixes
6
+
7
+ - **没有账号的区域不再向模型选择器列出模型**(issue #12,报告者 `stephenyab`)。此前只有国内版账号的用户,模型选择器里会多出一整组 `WorkBuddy Global`(**恰好 20 个**,与 `FALLBACK_WORKBUDDY_MODELS_GLOBAL` 逐条一致)——这些模型一个都用不了:该区域连凭据都解析不出来,选中后 `store.resolve()` 必抛错,shim 一律回 `401 not_signed_in`。**选择器说可选、实际必失败**,对单区域用户是纯噪音。
8
+
9
+ - **根因**:静态 fallback 的初衷是「上游离线也不让 provider 空着」,但它无条件播种(`new WorkBuddyCatalog(region)` 即装载整份 fallback),而 `deriveCatalog` 的「空勾选 = 全量」又把整份放行;启动时的真实刷新对无账号区域必然失败、只留一条 `logger.warn`,fallback 便永久留存。DSH 宿主对凭据一无所知(`buildModelCatalog` 只过滤 `models.length > 0`),于是原样渲染。
10
+ - **修复**:`WorkBuddyCatalog` 新增「本区域是否有本机登录」状态(`setRegionUsable()` / `isRegionUsable()`),无账号时 `current()` 返回空数组 → 宿主的空分组过滤让该分组**直接消失**。**provider 本身照常注册**:它的设置卡片与账号选择器正是用户登录的入口,隐藏分组是目录层决定,不是注册层决定。
11
+ - **与 `set()` 的非空守卫分开**,没有放宽它:两者回答不同问题——`set()` 承载**上游真实答案**,永不允许为空;「本区域没有账号」是**合法的空状态**,且必须能跨刷新存活。合并二者会要么丢掉守卫、要么丢掉隐藏分组的能力。测试专门锁定这一点:区域不可用期间上游刷新仍会在内部恢复目录,账号一出现即可立即服务。
12
+ - **默认可用(宽松方向)**:`setRegionUsable` 由异步扫描调用,扫描尚未完成或失败时保持原状,因此**一次瞬时文件系统错误不会把正常区域清空**。
13
+ - **两个触发点覆盖登录后的恢复**:① 设置变更时重算(`applySelection` 之后);② 卡片侧——usage 轮询与「重新检测账号」路由本就调用 `store.accounts()`,顺手上报可用性,所以用户登录桌面端后**无需重启**,模型自动出现。`setRegionUsable()` 返回值表明状态是否真的变化,未变化时不做多余的 adapter 失效。
14
+ - **对称覆盖两侧**:报告举的例子是「只有国内版账号 → 国际版多出 20 个」,反之亦然(只有国际版账号时国内版会多出 12 个),修复同时作用于两个区域。
15
+
16
+ **核查**:报告的根因链**逐条属实**,本版本按报告建议的方向(其「建议修复方向」第 1、2、3 条)实现。我们额外确认了它列出的两条实现约束确实成立——`catalog.ts` 的 `set([])` 会抛错(故另开一条状态而非放宽守卫)、空目录会让宿主过滤掉整个分组(故无需注销 provider),并验证了它在第 5 条提到但未展开的点:`routableProviders` 仍包含该 provider id,这与选择器过滤是两个独立概念,本次不改动。
17
+
18
+ **措辞说明**:`README` 此前写「两边模型同时出现在选择器里」——该表述自本版本起变为**有条件**(有账号才出现),中英文 README 与 2.0.0 的 CHANGELOG 条目均已就地更正,而不是留着一个已被推翻的说法。
19
+
20
+ ### Tests
21
+
22
+ 测试总数 173 → 179(新增 6 例):
23
+
24
+ - `tests/issue12-region-hiding.spec.ts` 新增 2 例(新文件,端到端复现报告者的环境):CN-only 机器上 `workbuddy-global` **不再列出任何模型**,而 `workbuddy` 正常服务;同时断言 **provider 与可配置 provider 仍在册**(登录入口不能消失)。第 2 例覆盖恢复路径:登录国际版账号后经设置链路重算,模型**无需重启**即可用。
25
+ - `catalog.spec.ts` 新增 4 例:默认可用(扫描未完成不误清空)、无账号后 `current()` 为空且 `isRegionUsable()` 为假、`setRegionUsable()` 的「无变化」返回值语义、以及**非空守卫与可用性标记互相独立**(区域不可用期间 `set()` 仍保有真实目录,账号出现即恢复)。
26
+
27
+ 每条新增守卫都用「删掉对应实现即失败」验证过:让 `current()` 忽略可用性标记会挂 5 例;移除 `applySelection` 的重算调用会挂 3 例(含一条端到端用例)。
28
+
29
+ ### Docs
30
+
31
+ - `README.md` / `README.en.md`:双供应商一节的「两边模型同时出现」改为「**有账号的区域**其模型出现」,并说明无账号区域不列出、登录后自动恢复、provider 始终注册。
32
+ - `CHANGELOG.md` 2.0.0 条目中同一处表述就地标注了 2.0.6 的收紧。
33
+
34
+ ## 2.0.5 (2026-09-22)
35
+
36
+ ### Fixes
37
+
38
+ - **「跟随 App 当前登录」清除后不再被旧版 `accountId` 顶掉,且清除结果在卡片上可见**(issue #11 的缺陷 1;其症状属实,但所声称的机制与严重性不准确——见下方「核查」)。
39
+
40
+ - **空字符串哨兵现在显式终止旧值回退**。`selectAccountFor()`(由 `applySelection` 内联逻辑提升为导出纯函数)按顺序判定:`accounts.<region> === ""` → 无选择(跟随 App);具体 id → 该区域的显式选择;键不存在 → 回退到所属区域的旧版 `accountId`。此前 `""` 走的是「值已存在」分支并被 `selectAccount('')` 归一化为 `undefined`,结果碰巧正确,但语义靠下游兜底;任何一处偏离(下游不再归一化、或增加新的读取方)都会让旧账号复活。现在哨兵在源头终止回退,并有测试锁定。
41
+ - **启动时的旧值归属(attribution)跳过被显式清除的区域**(`legacyAttributionRegion()`)。该 pass 在启动后异步运行:跳过被清除的区域,既避免把用户刚丢弃的选择重新绑回,也保证**另一个区域仍有机会认领同一个 id**(用 `continue` 而非 `break`,否则国际版的迁移会被国内版的清除顺手打断)。
42
+ - **卡片新增区域状态行**:显示该区域当前是「使用保存的账号」还是「跟随 App 当前登录」,清除后先显示确认文案。这是 issue #11 里真正属于产品缺陷的那一半——清除后回退到的默认账号**通常正是刚被清掉的那个账号**(用户当初就是在 App 登录状态下选它的),因此清除成功与清除无效在界面上完全同形。此前 `usage` 文档也没有任何字段能区分二者。
43
+ - **设置写入改为「写入后回读校验」,失败时明确报错**(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`。对三处写入的影响:
44
+ - **清除账号**:只有确认落盘才显示「已清除」,失败则显示错误并说明原选择仍生效——否则会给出一个被旧选择默默反驳的假确认,比不提示更糟;
45
+ - **切换账号**:同样校验。静默丢弃会让下拉框显示一个账号、实际计费另一个;
46
+ - **保存模型**:尤其要紧——保存成功会**丢弃草稿**,若写入没落盘就丢,用户唯一的副本就没了,却还被告知「已保存」。校验比对回读的 `lastCatalog` id 序列(不能只看「槽位存在」,截断/残缺的写入同样要检出)。
47
+
48
+ **核查**:报告的根因链在 HEAD 上不成立,逐条复现如下。**注意措辞更正**:2.0.5 初稿曾断言「清除本来就生效」,该结论只在 Host 侧成立;在 Windows 上写入可能被静默丢弃,因此报告的「点了没反应」在那里是真实存在的,只是机制与报告所述不同(不是 `accountId` 顶掉,而是写入没落盘)。回读校验修复的正是后者。
49
+
50
+ | 报告的主张 | 实测结果 |
51
+ |---|---|
52
+ | `clearAccount()` 只写区域槽位,旧 `accountId` 会持续顶掉清除结果 | 清除后该区域立即跟随 App 默认;三种变体(旧账号≠当前登录、旧账号=当前登录、启动归属与清除竞争)均通过 |
53
+ | `effectiveAccountFor()` 在哨兵值上不提前返回,因此旧值继续生效 | 该分支返回 `""`,经 `selectAccount('')` 归一化为 `undefined`,**不会**回退到 `accountId`——返回值虽然「不提前」,但结果正确 |
54
+ | 启动迁移块会把区域重新绑回旧账号 | 迁移块只在启动时运行一次,且 `applySelection` 走同一个 `effectiveAccountFor`,同样被哨兵挡住 |
55
+ | `""` 与「未设置」在 schema 层不可区分 | `""` 与键缺失**是可区分的**:`z.object({cn: z.string()})` 对 `{cn: ''}` 保留空串、对 `{}` 保留缺键;`Object.hasOwn` 亦为真。不可区分的只有**语义**,不是 schema |
56
+ | (报告未提及)写入可能根本不生效 | **成立,且与平台相关**:见上一条。空串本身在 YAML 里能正确往返(写成 `cn: ""`),所以哨兵语义没问题;丢的是**整次写入** |
57
+
58
+ 因此本版本不按报告的建议去「清除旧字段」——旧字段是**两个区域共享的**迁移来源,清掉它会让另一侧失去迁移依据;正确的做法是让哨兵赢(已实现),并把无法观测的状态暴露给界面(已实现),再加上对「写入没生效」的显式检测。
59
+
60
+ ### Tests
61
+
62
+ 测试总数 142 → 173(新增 31 例,**全套 173 通过**)。
63
+
64
+ > 关于 `host-heartbeat.spec.ts` 的 `processStartTimeMs`:在受限沙箱下该例会失败
65
+ > (`processStartTimeMs` 需要执行 `ps`,被沙箱以 `EPERM` 拒绝 → 返回 `undefined`)。
66
+ > 放开文件策略后 `ps` 可用,**5/5 通过**。该例与本次改动无关——两次运行用同一份
67
+ > 源码,差异只来自沙箱能否执行 `ps`。
68
+
69
+ - `settings-integration.spec.ts` 新增 15 例:`selectAccountFor` 的 6 例(显式选择优先、旧值只归属本区域、哨兵终止回退、清除与未配置可区分、两区域同时清除、清除一侧不影响另一侧);`legacyAttributionRegion` 的 6 例(归属、跳过已清除区域、跳过但继续扫描另一区域、账号已消失、无旧字段、跨区域共享 id);另有 3 例走真实 settings 链路 —— 清除后旧字段仍在但选不中、**重启后**已清除区域不被重新归属、以及清除前后经 usage 路由可见 `selectionExplicit` 翻转(账号名不变,这正是报告里「看起来没反应」的场景)。
70
+ - `client-account-selection.spec.ts` 新增 13 例(新文件):`configuredAccountsOf` 的容错读取;`writeAccountSlot` 的保存与「保留另一区域」、哨兵写成 `""` 而非删键、**锁文件导致的静默未落盘必须抛错**、错误携带字段名、**不把「键缺失」误判为清除成功**、正常落盘不误报、回读与卡片读取同源(scope 的 flush 默认 `sync`,落盘后同步可见,故不会误失败);`writeRegionModels` 的保存与区域保留、**静默未落盘必须抛错**、目录被截断也要检出(不能只看「槽位存在」)、错误字段名、正常落盘不误报。
71
+ - `auth.spec.ts` 新增 1 例:`hasExplicitSelection()` 在未选择/已选择/选择失效/哨兵清除四种状态下的取值。
72
+ - `web-status.spec.ts` 新增 2 例:signed-in 与两个 signed-out 分支都携带 `selectionExplicit`。
73
+
74
+ 每条新增守卫都用「删掉对应实现即失败」逐一验证过:移除哨兵判断会挂 5 例,移除归属跳过会挂 2 例,把 `selectionExplicit` 写死为 `true` 会挂 3 例,把账号回读校验换成「相信 resolve」会挂 3 例,去掉模型保存的回读校验会挂 3 例。
75
+
76
+ ### Docs
77
+
78
+ - `README.md` / `README.en.md` 的「已知限制 / Known limitations」补充:旧版 `accountId` 只作用于所属区域、且该区域被显式清除后不再生效;卡片状态行用于分辨清除是否生效;**Windows 上设置文件被占用会导致账号改动无法保存**,此时卡片报错且原选择保持生效。
79
+
3
80
  ## 2.0.4 (2026-09-21)
4
81
 
5
82
  ### Fixes
@@ -85,7 +162,7 @@
85
162
 
86
163
  - **🆕 国内版与国际版拆分为两个并行供应商,可同时使用**——此前一个 provider 一次只能活一个区域(选哪个账号整个 `workbuddy` 就服务于哪个区域,切账号 = 翻转整个运行时目录)。现在两侧是两套完全独立的实例,**互不干扰**:
87
164
 
88
- - **双 provider 注册**:国内版保持 `workbuddy`(老 id 不变,存量会话的默认模型与已保存选择全部继续有效),国际版新增 `workbuddy-global`(displayName `WorkBuddy Global`)。**两边模型同时出现在 DSH 模型选择器里**——不同会话 / 子代理可以各选一边,互不干扰。
165
+ - **双 provider 注册**:国内版保持 `workbuddy`(老 id 不变,存量会话的默认模型与已保存选择全部继续有效),国际版新增 `workbuddy-global`(displayName `WorkBuddy Global`)。两边模型同时出现在 DSH 模型选择器里——不同会话 / 子代理可以各选一边,互不干扰。(**2.0.6 起收紧**:没有账号的区域不再列出模型,理由见该版本 CHANGELOG 的 issue #12 一节。)
89
166
  - **每个区域一套完整运行时栈**:凭据 store、模型 catalog、回环 shim、adapter 各一份。store 按凭据域名过滤可见账号(`workbuddy.ai` → 国际版,其余 → 国内版),两个区域的账号可同时在线。
90
167
  - **插件卡片 tab 化**:设置卡片顶部新增「国内版 / 国际版」tab 栏(带各自登录状态圆点)。每个 tab 有独立的账号选择、积分概览、签到与模型管理;**切 tab 不丢另一侧未保存的草稿**(模型勾选 / 图片开关 / 上下文预算的草稿按区域隔离保存)。4 条卡片路由全部按 `?region=cn|global` 参数化。
91
168
  - **「减少刷新变化」**:一个 tab 里切账号、刷新模型、轮询积分,完全不触碰另一边的运行时目录——绑定另一边模型的进行中会话不受任何影响(单 provider 架构做不到这一点)。
package/README.en.md CHANGED
@@ -21,7 +21,7 @@ A [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) bund
21
21
 
22
22
  ## Features
23
23
 
24
- - **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.), and **both rosters appear in the DSH model picker simultaneously**: different sessions can each pick a side without interfering. Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
24
+ - **Dual parallel providers (CN + international)** — the CN side registers as the `workbuddy` provider (`GLM-5.3`, `DeepSeek-V4-Pro`, `Kimi-K3`, `MiniMax-M3`, `Hy3`, etc.), the international side as `workbuddy-global` (`GPT-5.6`, `Gemini-3.5-Flash`, `GLM-5.3`, `Kimi-K3`, etc.). **A region's models appear in the DSH model picker when that region has an account**: with accounts on both sides, both rosters are selectable at once, and different sessions can each pick a side without interfering. **A region with no local sign-in lists no models** — it cannot serve a single request, so listing them would offer nothing but failures. Sign in once for that region and its models come back on "detect accounts again" or after a restart; the provider itself stays registered either way, because its settings card and account picker are how you sign in. Model names carry the upstream credit multiplier (e.g. `GLM-5.3 · x0.79`), matching WorkBuddy's own model menu.
25
25
  - **Tabbed settings card** — the card's top carries a "Domestic / Global" tab bar; each tab holds its own account picker, credits overview, and model management. Account, directory, selection, and unsaved drafts are fully isolated per tab — switching accounts or refreshing models on one tab never touches the other side's runtime catalog or sessions.
26
26
  - **Model management** — refresh the full catalog from upstream and enable or disable each model individually; refresh is a draft operation that only takes effect on save. Upstream also reports credit multiplier, context/output limits, and reasoning efforts; image input is opted in per model manually (off by default).
27
27
  - **Local account switching** — discovers the multiple sign-in credentials WorkBuddy's desktop app leaves behind and lets you switch per region. Tokens are never written to DSH settings.
@@ -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
@@ -27,7 +27,7 @@
27
27
 
28
28
  ## 功能特性
29
29
 
30
- - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等),**两边模型同时出现在 DSH 模型选择器里**:不同会话可以各选一边,互不干扰。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
30
+ - **双供应商并行接入(国内版 + 国际版)** —— 国内版注册为 `workbuddy` provider(`GLM-5.3`、`DeepSeek-V4-Pro`、`Kimi-K3`、`MiniMax-M3`、`Hy3` 等),国际版注册为 `workbuddy-global` provider(`GPT-5.6`、`Gemini-3.5-Flash`、`GLM-5.3`、`Kimi-K3` 等)。**有账号的区域其模型会出现在 DSH 模型选择器里**:两边都有账号时两组同时可选,不同会话可以各选一边,互不干扰。**本机没有账号的区域不列出任何模型**(该区域一个请求也发不出去,列出来只会点到就失败);只要在该区域登录一次,「重新检测账号」或重启后模型就会自动出现——provider 本身始终注册着,因为它的设置卡片与账号选择器正是登录的入口。模型名内嵌上游积分倍率(如 `GLM-5.3 · x0.79`),与 WorkBuddy 自身模型菜单一致。
31
31
  - **插件卡片 tab 切换** —— 设置卡片顶部为「国内版 / 国际版」两个 tab,各含独立的账号选择、积分概览与模型管理;每个 tab 的账号、目录、勾选与未保存草稿完全隔离——在一个 tab 里切账号或刷新模型,不会触碰另一边的运行时目录与会话。
32
32
  - **模型管理** —— 从上游刷新完整模型目录,逐项勾选启用或禁用;刷新是草稿操作,点保存才生效。上游同时给出积分倍率、上下文/输出上限与推理档位;图片输入按模型手动勾选(默认不勾选)。
33
33
  - **本机账号切换** —— 自动发现 WorkBuddy 桌面端留下的多个登录凭据,可按区域切换账号;Token 不写入 DSH 设置。
@@ -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--iKDuwr5.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];
@@ -1427,10 +1440,24 @@ function applyContextBudgets(catalog, budgets = {}) {
1427
1440
  function deriveCatalog(catalog, enabled, budgets = {}) {
1428
1441
  return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
1429
1442
  }
1430
- /** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
1443
+ /**
1444
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
1445
+ *
1446
+ * The static fallback exists so an OFFLINE upstream never leaves a provider
1447
+ * empty — but "offline" presumes the region is usable at all. A region with no
1448
+ * local sign-in cannot serve one single request, so seeding it with a roster
1449
+ * puts models in the picker that are guaranteed to 401 (issue #12). The host
1450
+ * distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
1451
+ */
1431
1452
  var WorkBuddyCatalog = class {
1432
1453
  models;
1433
1454
  /**
1455
+ * Whether this region has any local sign-in. Defaults to `true` — the
1456
+ * permissive direction — so a scan that has not run yet (or failed) keeps
1457
+ * serving the fallback rather than blanking a region that may be fine.
1458
+ */
1459
+ usable = true;
1460
+ /**
1434
1461
  * @param region Seeds the static fallback for this region; each region's
1435
1462
  * provider must never serve the other region's roster before its first
1436
1463
  * live refresh lands.
@@ -1438,9 +1465,37 @@ var WorkBuddyCatalog = class {
1438
1465
  constructor(region = "cn") {
1439
1466
  this.models = fallbackModelsFor(region);
1440
1467
  }
1441
- /** Current entries; the fallback list until the upstream answer lands. */
1468
+ /**
1469
+ * Current entries; the fallback list until the upstream answer lands.
1470
+ *
1471
+ * Empty while this region has no local sign-in: the provider stays
1472
+ * registered (its row still hosts the settings card and the account picker),
1473
+ * but it advertises nothing to pick. DSH drops empty provider groups from
1474
+ * the picker (`buildModelCatalog` filters `models.length > 0`), so the group
1475
+ * disappears exactly when it would be pure noise.
1476
+ */
1442
1477
  current() {
1443
- return this.models;
1478
+ return this.usable ? this.models : [];
1479
+ }
1480
+ /** Whether this region currently advertises any model. */
1481
+ isRegionUsable() {
1482
+ return this.usable;
1483
+ }
1484
+ /**
1485
+ * Record whether this region has a local sign-in.
1486
+ *
1487
+ * Separate from {@link set} on purpose: `set()` carries a live upstream
1488
+ * answer and must never be empty, while "this region has no account" is a
1489
+ * legitimate empty state that must survive across refreshes. Keeping them
1490
+ * apart is what lets the non-empty guard stay strict.
1491
+ *
1492
+ * @returns whether the value changed, so the caller can skip invalidating
1493
+ * its adapter snapshots when nothing moved.
1494
+ */
1495
+ setRegionUsable(usable) {
1496
+ if (this.usable === usable) return false;
1497
+ this.usable = usable;
1498
+ return true;
1444
1499
  }
1445
1500
  /** Replace the list; callers invalidate their adapter snapshot after this. */
1446
1501
  set(models) {
@@ -1451,7 +1506,7 @@ var WorkBuddyCatalog = class {
1451
1506
  //#endregion
1452
1507
  //#region src/version.ts
1453
1508
  /** The npm package version this build was produced from. */
1454
- const WORKBUDDY_CONNECT_VERSION = "2.0.4";
1509
+ const WORKBUDDY_CONNECT_VERSION = "2.0.6";
1455
1510
  //#endregion
1456
1511
  //#region src/host-heartbeat.ts
1457
1512
  /**
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. */
@@ -468,17 +479,53 @@ declare function fallbackModelsFor(region: 'cn' | 'global'): readonly WorkBuddyM
468
479
  */
469
480
  type WorkBuddyContextBudget = number;
470
481
  declare function deriveCatalog(catalog: readonly WorkBuddyModelInfo[], enabled: ReadonlySet<string>, budgets?: Readonly<Record<string, WorkBuddyContextBudget | undefined>>): WorkBuddyModelInfo[];
471
- /** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
482
+ /**
483
+ * Mutable catalog shared by the shim's `/v1/models` and the adapter.
484
+ *
485
+ * The static fallback exists so an OFFLINE upstream never leaves a provider
486
+ * empty — but "offline" presumes the region is usable at all. A region with no
487
+ * local sign-in cannot serve one single request, so seeding it with a roster
488
+ * puts models in the picker that are guaranteed to 401 (issue #12). The host
489
+ * distinguishes the two cases through {@link WorkBuddyCatalog.setRegionUsable}.
490
+ */
472
491
  declare class WorkBuddyCatalog {
473
492
  private models;
493
+ /**
494
+ * Whether this region has any local sign-in. Defaults to `true` — the
495
+ * permissive direction — so a scan that has not run yet (or failed) keeps
496
+ * serving the fallback rather than blanking a region that may be fine.
497
+ */
498
+ private usable;
474
499
  /**
475
500
  * @param region Seeds the static fallback for this region; each region's
476
501
  * provider must never serve the other region's roster before its first
477
502
  * live refresh lands.
478
503
  */
479
504
  constructor(region?: 'cn' | 'global');
480
- /** Current entries; the fallback list until the upstream answer lands. */
505
+ /**
506
+ * Current entries; the fallback list until the upstream answer lands.
507
+ *
508
+ * Empty while this region has no local sign-in: the provider stays
509
+ * registered (its row still hosts the settings card and the account picker),
510
+ * but it advertises nothing to pick. DSH drops empty provider groups from
511
+ * the picker (`buildModelCatalog` filters `models.length > 0`), so the group
512
+ * disappears exactly when it would be pure noise.
513
+ */
481
514
  current(): readonly WorkBuddyModelInfo[];
515
+ /** Whether this region currently advertises any model. */
516
+ isRegionUsable(): boolean;
517
+ /**
518
+ * Record whether this region has a local sign-in.
519
+ *
520
+ * Separate from {@link set} on purpose: `set()` carries a live upstream
521
+ * answer and must never be empty, while "this region has no account" is a
522
+ * legitimate empty state that must survive across refreshes. Keeping them
523
+ * apart is what lets the non-empty guard stay strict.
524
+ *
525
+ * @returns whether the value changed, so the caller can skip invalidating
526
+ * its adapter snapshots when nothing moved.
527
+ */
528
+ setRegionUsable(usable: boolean): boolean;
482
529
  /** Replace the list; callers invalidate their adapter snapshot after this. */
483
530
  set(models: readonly WorkBuddyModelInfo[]): void;
484
531
  }
@@ -766,6 +813,11 @@ type WorkBuddyWebUsage =
766
813
  * sign-in again), versus nothing usable was found at all (sign in in the
767
814
  * desktop app). Only the latter makes the "sign in again" hint truthful —
768
815
  * re-signing in does not repair an orphaned id.
816
+ *
817
+ * `selectionExplicit` answers a question the account list cannot: whether
818
+ * the region runs a saved choice at all. Clearing restores the default, and
819
+ * when the default resolves to the same account the card would otherwise
820
+ * look unchanged — the "clear did nothing" report.
769
821
  */
770
822
  {
771
823
  status: 'signed-out';
@@ -773,6 +825,8 @@ type WorkBuddyWebUsage =
773
825
  message?: string;
774
826
  /** The persisted account id matches no local account. */
775
827
  selectionLost?: boolean;
828
+ /** A saved per-region choice is in effect (false = following the app). */
829
+ selectionExplicit: boolean;
776
830
  } | {
777
831
  status: 'signed-in';
778
832
  accountId: string;
@@ -783,6 +837,8 @@ type WorkBuddyWebUsage =
783
837
  region: WorkBuddyWebRegion;
784
838
  source?: 'desktop' | 'dsh';
785
839
  tokenExpiresAtMs: number;
840
+ /** A saved per-region choice is in effect (false = following the app). */
841
+ selectionExplicit: boolean;
786
842
  accounts: readonly WorkBuddyWebAccount[];
787
843
  models: readonly WorkBuddyWebModel[];
788
844
  enabledModelIds: readonly string[];
@@ -817,6 +873,16 @@ interface WorkBuddyStatusRouteOptions {
817
873
  contextBudgets(region: WorkBuddyRegion): Readonly<Record<string, number | undefined>>;
818
874
  /** Re-read the live catalog of one region from the upstream. */
819
875
  discoverModels?(region: WorkBuddyRegion, signal?: AbortSignal): Promise<readonly WorkBuddyModelInfo[]>;
876
+ /**
877
+ * Report whether a region still has at least one local sign-in.
878
+ *
879
+ * The card is where sign-in state actually changes under the plugin's nose
880
+ * (the user signs in to the desktop app, then presses "detect accounts
881
+ * again"), so the routes are the earliest honest place to notice. Both
882
+ * callers already read `store.accounts()` for their own answer, so this adds
883
+ * no scan of its own.
884
+ */
885
+ regionUsable?(region: WorkBuddyRegion, usable: boolean): void;
820
886
  }
821
887
  /**
822
888
  * Assemble one region's card document. `region` is the tab the card is on;
@@ -898,6 +964,47 @@ declare const Config: z<Config>;
898
964
  * picks.
899
965
  */
900
966
  declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBuddyRegionState;
967
+ /**
968
+ * The account id a region should select, or `undefined` for the documented
969
+ * default: follow whatever the WorkBuddy app is currently signed in as.
970
+ *
971
+ * Three inputs decide it, and the ORDER is the whole point:
972
+ *
973
+ * 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
974
+ * dropped this region's choice", which is the opposite of the key being
975
+ * absent ("never configured"). It therefore terminates the lookup: falling
976
+ * through to the legacy `accountId` here would re-bind the very account the
977
+ * user just dropped, and would do it after clearing appeared to succeed.
978
+ * 2. `accounts[region]` set to a real id — an explicit per-region choice.
979
+ * 3. absent key, with the pre-split `accountId` belonging to this region —
980
+ * the legacy migration, attributed once at startup from the local scan.
981
+ *
982
+ * `legacyAccountRegion` is resolved asynchronously after the first
983
+ * `applySelection` pass, so this is a pure function of it rather than a
984
+ * closure over the stores: the same call answers both the early pass (no
985
+ * attribution yet) and every later one.
986
+ */
987
+ declare function selectAccountFor(region: WorkBuddyRegion, value: Config, legacyAccountRegion: WorkBuddyRegion | undefined): string | undefined;
988
+ /** Whether a region carries the Clear sentinel rather than a saved choice. */
989
+ declare function regionCleared(value: Config, region: WorkBuddyRegion): boolean;
990
+ /**
991
+ * Which region owns the pre-split `accountId`, or `undefined` when it cannot
992
+ * be attributed (the field is absent, or the account is gone from every local
993
+ * sign-in list).
994
+ *
995
+ * Regions the user explicitly cleared are skipped, even when the saved account
996
+ * IS among their local sign-ins. `selectAccountFor` already refuses to fall
997
+ * back for a cleared region, so this cannot change what runs — but it keeps the
998
+ * attribution honest about its own decision: the region that owns this id is
999
+ * not the one the user just told the plugin to stop pinning, so the other
1000
+ * region must get the chance to claim it.
1001
+ *
1002
+ * `accountsFor` is injected so the sequential, stop-at-first-match scan is
1003
+ * testable without a filesystem.
1004
+ */
1005
+ declare function legacyAttributionRegion(value: Config, accountsFor: (region: WorkBuddyRegion) => Promise<readonly {
1006
+ id: string;
1007
+ }[]>): Promise<WorkBuddyRegion | undefined>;
901
1008
  /**
902
1009
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
903
1010
  * `workbuddy-global` (international) providers, and refresh each region's
@@ -907,4 +1014,4 @@ declare function regionStateOf(config: Config, region: WorkBuddyRegion): WorkBud
907
1014
  */
908
1015
  declare function apply(ctx: Context, config: Config): void;
909
1016
  //#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 };
1017
+ 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--iKDuwr5.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,13 @@ 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
+ deps.regionUsable?.(region, accounts.length > 0);
565
+ const authStatus = await store.status();
566
+ const selectionExplicit = store.hasExplicitSelection();
567
+ if (authStatus.state !== "signed-out" && accounts.length === 0) return {
565
568
  status: "signed-out",
566
- accounts: []
569
+ accounts: [],
570
+ selectionExplicit
567
571
  };
568
572
  let credential;
569
573
  try {
@@ -573,6 +577,7 @@ async function workBuddyWebStatus(deps, region) {
573
577
  status: "signed-out",
574
578
  accounts: accounts.map(toWebAccount),
575
579
  message: safeMessage(error),
580
+ selectionExplicit,
576
581
  ...await store.selectionLost() ? { selectionLost: true } : {}
577
582
  };
578
583
  }
@@ -584,6 +589,7 @@ async function workBuddyWebStatus(deps, region) {
584
589
  region,
585
590
  source: credential.source,
586
591
  tokenExpiresAtMs: credential.expiresAtMs,
592
+ selectionExplicit,
587
593
  accounts: accounts.map(toWebAccount),
588
594
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
589
595
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -646,7 +652,9 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
646
652
  const region = requestRegion(req, res);
647
653
  if (region === void 0) return;
648
654
  try {
649
- json(res, 200, { accounts: (await deps.store(region).accounts()).map(toWebAccount) });
655
+ const accounts = await deps.store(region).accounts();
656
+ deps.regionUsable?.(region, accounts.length > 0);
657
+ json(res, 200, { accounts: accounts.map(toWebAccount) });
650
658
  } catch (error) {
651
659
  json(res, 500, { error: safeMessage(error) });
652
660
  }
@@ -759,6 +767,59 @@ function regionStateOf(config, region) {
759
767
  /** Every region, in card tab order. */
760
768
  const REGION_KEYS = ["cn", "global"];
761
769
  /**
770
+ * The account id a region should select, or `undefined` for the documented
771
+ * default: follow whatever the WorkBuddy app is currently signed in as.
772
+ *
773
+ * Three inputs decide it, and the ORDER is the whole point:
774
+ *
775
+ * 1. `accounts[region] === ''` — the Clear sentinel. It means "the user
776
+ * dropped this region's choice", which is the opposite of the key being
777
+ * absent ("never configured"). It therefore terminates the lookup: falling
778
+ * through to the legacy `accountId` here would re-bind the very account the
779
+ * user just dropped, and would do it after clearing appeared to succeed.
780
+ * 2. `accounts[region]` set to a real id — an explicit per-region choice.
781
+ * 3. absent key, with the pre-split `accountId` belonging to this region —
782
+ * the legacy migration, attributed once at startup from the local scan.
783
+ *
784
+ * `legacyAccountRegion` is resolved asynchronously after the first
785
+ * `applySelection` pass, so this is a pure function of it rather than a
786
+ * closure over the stores: the same call answers both the early pass (no
787
+ * attribution yet) and every later one.
788
+ */
789
+ function selectAccountFor(region, value, legacyAccountRegion) {
790
+ const configured = value.accounts?.[region];
791
+ if (configured === "") return void 0;
792
+ if (configured !== void 0) return configured;
793
+ return legacyAccountRegion === region ? value.accountId : void 0;
794
+ }
795
+ /** Whether a region carries the Clear sentinel rather than a saved choice. */
796
+ function regionCleared(value, region) {
797
+ return value.accounts?.[region] === "";
798
+ }
799
+ /**
800
+ * Which region owns the pre-split `accountId`, or `undefined` when it cannot
801
+ * be attributed (the field is absent, or the account is gone from every local
802
+ * sign-in list).
803
+ *
804
+ * Regions the user explicitly cleared are skipped, even when the saved account
805
+ * IS among their local sign-ins. `selectAccountFor` already refuses to fall
806
+ * back for a cleared region, so this cannot change what runs — but it keeps the
807
+ * attribution honest about its own decision: the region that owns this id is
808
+ * not the one the user just told the plugin to stop pinning, so the other
809
+ * region must get the chance to claim it.
810
+ *
811
+ * `accountsFor` is injected so the sequential, stop-at-first-match scan is
812
+ * testable without a filesystem.
813
+ */
814
+ async function legacyAttributionRegion(value, accountsFor) {
815
+ const id = value.accountId;
816
+ if (id === void 0) return void 0;
817
+ for (const region of REGION_KEYS) {
818
+ if (regionCleared(value, region)) continue;
819
+ if ((await accountsFor(region)).some((account) => account.id === id)) return region;
820
+ }
821
+ }
822
+ /**
762
823
  * Start both regions' loopback endpoints, register the `workbuddy` (CN) and
763
824
  * `workbuddy-global` (international) providers, and refresh each region's
764
825
  * model catalog from the upstream once that region's credentials allow it.
@@ -808,11 +869,7 @@ function apply(ctx, config) {
808
869
  * a selection that belongs to the other side of the split.
809
870
  */
810
871
  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
- };
872
+ const effectiveAccountFor = (region, value) => selectAccountFor(region, value, legacyAccountRegion);
816
873
  const discoverModels = async (region, signal) => {
817
874
  const credential = await stacks[region].store.resolve();
818
875
  return client.fetchModels(credential, signal);
@@ -825,6 +882,34 @@ function apply(ctx, config) {
825
882
  stacks[region].catalog.set(configuredModels(value, region));
826
883
  }
827
884
  invalidateCatalog();
885
+ refreshRegionUsability();
886
+ };
887
+ /**
888
+ * Tell each catalog whether its region has ANY local sign-in, so a region the
889
+ * user has no account for advertises nothing instead of a roster that can
890
+ * only 401 (issue #12).
891
+ *
892
+ * `accounts()` is the right source rather than the SELECTED credential: an
893
+ * orphaned saved id must not blank a region that still has other sign-ins to
894
+ * fall back on, and a region the user deliberately cleared must come back to
895
+ * life the moment its first account appears.
896
+ *
897
+ * Deliberately fire-and-forget and idempotent — it runs on every settings
898
+ * change, and `setRegionUsable` reports whether anything moved so a
899
+ * no-change pass costs nothing beyond the scan. A failed scan leaves the
900
+ * previous answer alone (the catalog starts permissive), so a transient
901
+ * filesystem error never blanks a working region.
902
+ */
903
+ const refreshRegionUsability = async () => {
904
+ for (const region of REGION_KEYS) {
905
+ let accounts;
906
+ try {
907
+ accounts = await stacks[region].store.accounts();
908
+ } catch {
909
+ continue;
910
+ }
911
+ if (stacks[region].catalog.setRegionUsable(accounts.length > 0)) invalidateCatalog();
912
+ }
828
913
  };
829
914
  ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
830
915
  store: (region) => stacks[region].store,
@@ -833,7 +918,10 @@ function apply(ctx, config) {
833
918
  enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
834
919
  imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
835
920
  contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
836
- discoverModels
921
+ discoverModels,
922
+ regionUsable(region, usable) {
923
+ if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
924
+ }
837
925
  }));
838
926
  ctx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
839
927
  setSource(source) {
@@ -845,14 +933,11 @@ function apply(ctx, config) {
845
933
  });
846
934
  applySelection(config);
847
935
  (async () => {
848
- const id = current().accountId;
849
- if (id === void 0) return;
850
936
  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
- }
937
+ const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
938
+ if (region === void 0) return;
939
+ legacyAccountRegion = region;
940
+ applySelection(current());
856
941
  } catch {}
857
942
  })();
858
943
  let stopped = false;
@@ -950,4 +1035,4 @@ function apply(ctx, config) {
950
1035
  });
951
1036
  }
952
1037
  //#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 };
1038
+ 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.6",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",