dsh-connect-workbuddy 2.0.5 → 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,36 @@
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
+
3
34
  ## 2.0.5 (2026-09-22)
4
35
 
5
36
  ### Fixes
@@ -131,7 +162,7 @@
131
162
 
132
163
  - **🆕 国内版与国际版拆分为两个并行供应商,可同时使用**——此前一个 provider 一次只能活一个区域(选哪个账号整个 `workbuddy` 就服务于哪个区域,切账号 = 翻转整个运行时目录)。现在两侧是两套完全独立的实例,**互不干扰**:
133
164
 
134
- - **双 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 一节。)
135
166
  - **每个区域一套完整运行时栈**:凭据 store、模型 catalog、回环 shim、adapter 各一份。store 按凭据域名过滤可见账号(`workbuddy.ai` → 国际版,其余 → 国内版),两个区域的账号可同时在线。
136
167
  - **插件卡片 tab 化**:设置卡片顶部新增「国内版 / 国际版」tab 栏(带各自登录状态圆点)。每个 tab 有独立的账号选择、积分概览、签到与模型管理;**切 tab 不丢另一侧未保存的草稿**(模型勾选 / 图片开关 / 上下文预算的草稿按区域隔离保存)。4 条卡片路由全部按 `?region=cn|global` 参数化。
137
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.
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 设置。
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-BOrNj3pY.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
@@ -1440,10 +1440,24 @@ function applyContextBudgets(catalog, budgets = {}) {
1440
1440
  function deriveCatalog(catalog, enabled, budgets = {}) {
1441
1441
  return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
1442
1442
  }
1443
- /** 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
+ */
1444
1452
  var WorkBuddyCatalog = class {
1445
1453
  models;
1446
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
+ /**
1447
1461
  * @param region Seeds the static fallback for this region; each region's
1448
1462
  * provider must never serve the other region's roster before its first
1449
1463
  * live refresh lands.
@@ -1451,9 +1465,37 @@ var WorkBuddyCatalog = class {
1451
1465
  constructor(region = "cn") {
1452
1466
  this.models = fallbackModelsFor(region);
1453
1467
  }
1454
- /** 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
+ */
1455
1477
  current() {
1456
- 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;
1457
1499
  }
1458
1500
  /** Replace the list; callers invalidate their adapter snapshot after this. */
1459
1501
  set(models) {
@@ -1464,7 +1506,7 @@ var WorkBuddyCatalog = class {
1464
1506
  //#endregion
1465
1507
  //#region src/version.ts
1466
1508
  /** The npm package version this build was produced from. */
1467
- const WORKBUDDY_CONNECT_VERSION = "2.0.5";
1509
+ const WORKBUDDY_CONNECT_VERSION = "2.0.6";
1468
1510
  //#endregion
1469
1511
  //#region src/host-heartbeat.ts
1470
1512
  /**
package/lib/index.d.ts CHANGED
@@ -479,17 +479,53 @@ declare function fallbackModelsFor(region: 'cn' | 'global'): readonly WorkBuddyM
479
479
  */
480
480
  type WorkBuddyContextBudget = number;
481
481
  declare function deriveCatalog(catalog: readonly WorkBuddyModelInfo[], enabled: ReadonlySet<string>, budgets?: Readonly<Record<string, WorkBuddyContextBudget | undefined>>): WorkBuddyModelInfo[];
482
- /** 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
+ */
483
491
  declare class WorkBuddyCatalog {
484
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;
485
499
  /**
486
500
  * @param region Seeds the static fallback for this region; each region's
487
501
  * provider must never serve the other region's roster before its first
488
502
  * live refresh lands.
489
503
  */
490
504
  constructor(region?: 'cn' | 'global');
491
- /** 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
+ */
492
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;
493
529
  /** Replace the list; callers invalidate their adapter snapshot after this. */
494
530
  set(models: readonly WorkBuddyModelInfo[]): void;
495
531
  }
@@ -837,6 +873,16 @@ interface WorkBuddyStatusRouteOptions {
837
873
  contextBudgets(region: WorkBuddyRegion): Readonly<Record<string, number | undefined>>;
838
874
  /** Re-read the live catalog of one region from the upstream. */
839
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;
840
886
  }
841
887
  /**
842
888
  * Assemble one region's card document. `region` is the tab the card is on;
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-BOrNj3pY.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,6 +561,7 @@ 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
+ deps.regionUsable?.(region, accounts.length > 0);
564
565
  const authStatus = await store.status();
565
566
  const selectionExplicit = store.hasExplicitSelection();
566
567
  if (authStatus.state !== "signed-out" && accounts.length === 0) return {
@@ -651,7 +652,9 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
651
652
  const region = requestRegion(req, res);
652
653
  if (region === void 0) return;
653
654
  try {
654
- 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) });
655
658
  } catch (error) {
656
659
  json(res, 500, { error: safeMessage(error) });
657
660
  }
@@ -879,6 +882,34 @@ function apply(ctx, config) {
879
882
  stacks[region].catalog.set(configuredModels(value, region));
880
883
  }
881
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
+ }
882
913
  };
883
914
  ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
884
915
  store: (region) => stacks[region].store,
@@ -887,7 +918,10 @@ function apply(ctx, config) {
887
918
  enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
888
919
  imageModelIds: (region) => regionStateOf(current(), region).imageModelIds ?? [],
889
920
  contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
890
- discoverModels
921
+ discoverModels,
922
+ regionUsable(region, usable) {
923
+ if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
924
+ }
891
925
  }));
892
926
  ctx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
893
927
  setSource(source) {
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.5",
5
+ "version": "2.0.6",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",