dsh-connect-workbuddy 2.0.2 → 2.0.4

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.4 (2026-09-21)
4
+
5
+ ### Fixes
6
+
7
+ - **修复「保存的账号在本机消失后,界面自相矛盾」**(issue #10 的缺陷 3;其症状属实,但其声称的机制与严重性不准确)。显式选择的账号若在本机不再存在(WorkBuddy 更换登录或清理备份文件——`current()` 的注释早已点名这个成因),同一个状态下四条读取路径给出两种说法:
8
+
9
+ | 路径 | 修复前 | 修复后 |
10
+ |---|---|---|
11
+ | `accounts()` | 靠对象同一性比较**仍标记一个账号为 selected** | 无人生效时不标记任何行 |
12
+ | `current()` | 返回 `undefined`(拒绝计费,刻意不回退) | 不变(账单安全语义保留) |
13
+ | `status()` | `signed-out` | 不变 |
14
+ | 卡片下拉框 | `value` 取 signed-in 分支才有的 `accountId` → `''` → **空白**(选项仍在) | 显示「当前没有生效的账号」占位项 |
15
+
16
+ - **根因**:`accounts()` 与 `current()` 对「id 失配」的口径不一致。卡片渲染前者、计费走后者,于是面板同时出现「下拉框选中 Alpha」「状态栏未登录」「提示去桌面端登录一次」——而用户明明已登录,那句提示还把人指向唯一无法修复该问题的操作。commit `1279234` 移除 `current()` 的回退(`?? this.preferred()`)以避免账单落到别的账号,本修复保留该决定,只消除显示层矛盾。
17
+ - **`accounts()` 不再报告幽灵选中**:显式选择失配时不再回退到对象同一性比较(原写法依赖 `preferred()` 返回同一数组元素,能工作但语义脆弱);未显式选择时仍如实标出正在跟随的默认账号。
18
+ - **新增 `selectionLost`**:usage 文档的 `signed-out` 分支新增该可选字段,区分「保存的选择已失效(凭据是好的,请重选或清除)」与「本机确实没有登录(去桌面端登录)」。仅前者为真时才显示新的说明文案,后者保留原文案。
19
+ - **卡片下拉框改从账号列表取选中值**(原先取 signed-in 文档的 `accountId`)。这是同一类问题的第二处:当保存的账号**仍然有效**、只是 token 过期且刷新失败时,`resolve()` 同样抛错,下拉框也会变空白——用户的完整选择看起来像消失了。现在由账号列表这一个来源决定,并以此区分「没有任何账号生效」与「已登录」。
20
+ - **新增「跟随 App 当前登录」按钮**:清除该区域的显式选择,回到文档承诺的默认行为。清除写入空字符串哨兵值,`selectAccount('')` 归一化为 `undefined`;此前空字符串会像任何失配 id 一样永久卡死(即「没有清除路径」)。
21
+ - **`rescanAccounts()` 不再写入选中的账号**:原先只要 `accounts()` 报告的选中项与保存值不同就回写设置,导致两点危害——无显式选择时把「跟随 App 登录」的默认行为固化成一个永久绑定(此后 App 换登录不再跟随,与 `README`/`DESIGN` 承诺不符),以及保存的 id 失配时静默把区域改绑到**另一个账号**(正是严格不回退规则要防的静默改选)。「重新检测」现在只重读,选择权归下拉框与清除按钮。
22
+
23
+ ### Tests
24
+
25
+ 测试总数 131 → 142(新增 11 例):
26
+
27
+ - `auth.spec.ts` 新增 7 例:失配选择不标记任何账号、未显式选择仍标出默认账号、有效选择在刷新失败时保持可见、`selectionLost` 的三种取值(无选择/有效选择/失配)、无本机登录时不报 `selectionLost`、空字符串哨兵恢复默认、以及区域隔离(一个区域失配不影响另一区域)。
28
+ - `web-status.spec.ts` 新增 3 例:orphaned 选择标记 `selectionLost` 且保留账号列表、真正的未登录不标记、signed-in 文档不含该字段。
29
+ - `settings-integration.spec.ts` 新增 1 例:空字符串哨兵经真实的 `settings.update` 链路写入后仍为 `''`,且两个 provider 继续正常服务。
30
+
31
+ ### Docs
32
+
33
+ - `README.md` / `README.en.md` 的「已知限制 / Known limitations」补充账号失效后的表现与恢复方式,并说明重新登录桌面端无法修复该状态。
34
+
3
35
  ## 2.0.2 (2026-09-15)
4
36
 
5
37
  ### Fixes
package/README.en.md CHANGED
@@ -115,6 +115,7 @@ 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.
118
119
  - 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
120
 
120
121
  ## Disclaimer
package/README.md CHANGED
@@ -123,6 +123,7 @@ 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 无法修复这种状态**:凭据本身是好的,失效的只是保存下来的账号标识。
126
127
  - Windows / Linux 的凭据默认路径为按平台约定推导,未经真机验证;必要时可通过环境变量 `WORKBUDDY_AUTH_FILE` 指定实际位置。
127
128
 
128
129
  ## 免责声明
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-CaS5Koaw.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-CPIHUxKL.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
@@ -356,6 +356,19 @@ window.__ModuleLoader__.load({
356
356
  refreshUsage,
357
357
  status.status
358
358
  ]);
359
+ /**
360
+ * Re-detect the local sign-ins and refresh the panel.
361
+ *
362
+ * This deliberately writes NO account selection. It used to persist whatever
363
+ * row the store reported as "selected" whenever that differed from the saved
364
+ * value, which had two harmful effects: with no explicit choice it turned the
365
+ * documented default (follow the app's current sign-in) into a permanent
366
+ * explicit binding — so a later sign-in in the app was no longer followed —
367
+ * and with an orphaned saved id it silently re-bound the region to a
368
+ * different account, which is the silent switch that the strict
369
+ * no-fallback rule exists to prevent. Re-detecting and re-picking are
370
+ * separate actions now; the picker and Clear button own the selection.
371
+ */
359
372
  const rescanAccounts = async () => {
360
373
  setBusy(true);
361
374
  try {
@@ -366,13 +379,6 @@ window.__ModuleLoader__.load({
366
379
  });
367
380
  const body = await response.json();
368
381
  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
382
  await refreshUsage(activeRegion);
377
383
  } finally {
378
384
  if (mounted.current) setBusy(false);
@@ -392,6 +398,26 @@ window.__ModuleLoader__.load({
392
398
  if (mounted.current) setSwitchingAccount(false);
393
399
  }
394
400
  };
401
+ /**
402
+ * Drop the explicit choice so the region returns to its documented default:
403
+ * follow whatever the WorkBuddy app is currently signed in as. The empty
404
+ * string is the settings-level sentinel for "no explicit selection" (the
405
+ * store normalizes it away); the account id itself is never written here.
406
+ */
407
+ const clearAccount = async () => {
408
+ if (settingsScope === void 0) return;
409
+ setSwitchingAccount(true);
410
+ try {
411
+ const configuredAccounts = configuredAccountsOf(settingsScope.getSnapshot().value);
412
+ await settingsScope.set("accounts", {
413
+ ...configuredAccounts,
414
+ [activeRegion]: ""
415
+ });
416
+ await refreshUsage(activeRegion);
417
+ } finally {
418
+ if (mounted.current) setSwitchingAccount(false);
419
+ }
420
+ };
395
421
  const claimDailyCheckin = async () => {
396
422
  setCheckingIn(true);
397
423
  setCheckinActionError(void 0);
@@ -536,6 +562,8 @@ window.__ModuleLoader__.load({
536
562
  };
537
563
  const title = t("row.title");
538
564
  const label = status.status === "signed-in" ? t("row.signedIn", { accountName: status.accountName }) : status.status === "error" ? t("row.requestFailed") : t("row.signedOut");
565
+ /** The saved choice no longer matches a local sign-in (tokens are fine). */
566
+ const selectionLost = status.status === "signed-out" && status.selectionLost === true;
539
567
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
540
568
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
541
569
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -621,6 +649,10 @@ window.__ModuleLoader__.load({
621
649
  status.status === "error" || status.status === "signed-in" && status.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
622
650
  className: "dsm-workbuddy-usage-hint",
623
651
  children: t("row.reloginHint")
652
+ }) : null,
653
+ selectionLost ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
654
+ className: "dsm-workbuddy-usage-hint",
655
+ children: t("row.selectionLostHint")
624
656
  }) : null
625
657
  ]
626
658
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -633,24 +665,36 @@ window.__ModuleLoader__.load({
633
665
  children: busy ? t("row.accountsScanning") : t("row.accountsRescan")
634
666
  })]
635
667
  }),
636
- status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
668
+ status.status !== "error" && status.accounts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
637
669
  className: "dsm-workbuddy-account-picker",
638
670
  "aria-label": t("row.accountsTitle"),
639
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
671
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
640
672
  className: "dsm-workbuddy-usage-select-wrap",
641
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
673
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
642
674
  className: "dsm-workbuddy-usage-select",
643
- value: status.status === "signed-in" ? status.accountId : "",
675
+ value: status.accounts.find((account) => account.selected)?.id ?? "",
644
676
  disabled: switchingAccount || settingsScope?.getSnapshot().writable !== true,
645
677
  onChange: (event) => {
646
678
  switchAccount(event.currentTarget.value);
647
679
  },
648
- children: status.accounts.map((account) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("option", {
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", {
649
685
  value: account.id,
650
686
  children: [account.accountName, account.domain === "" ? "" : ` · ${account.domain}`]
651
- }, account.id))
687
+ }, account.id))]
652
688
  })
653
- })
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
+ })]
654
698
  }) : null,
655
699
  status.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
656
700
  status.credits === void 0 ? null : (() => {
@@ -876,7 +920,7 @@ window.__ModuleLoader__.load({
876
920
  ] }) : null,
877
921
  status.status === "signed-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
878
922
  className: "dsm-workbuddy-usage-text",
879
- children: status.message ?? t("row.signedOutHint")
923
+ children: selectionLost ? t("row.selectionLostMessage") : status.message ?? t("row.signedOutHint")
880
924
  }) : null,
881
925
  status.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
882
926
  className: "dsm-workbuddy-usage-error",
@@ -929,6 +973,10 @@ window.__ModuleLoader__.load({
929
973
  "row.accountsHint": "Choose from locally detected sign-ins. Tokens are never shown or saved in DSH settings.",
930
974
  "row.accountsRescan": "Detect accounts again",
931
975
  "row.accountsScanning": "Detecting…",
976
+ "row.accountsFollowApp": "Follow the app's sign-in",
977
+ "row.accountNoneInEffect": "No account in effect",
978
+ "row.selectionLostHint": "The saved account no longer exists locally.",
979
+ "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
980
  "row.modelsTitle": "Models",
933
981
  "row.modelsSummary": "{count} enabled",
934
982
  "row.modelsRefresh": "Refresh from WorkBuddy",
@@ -977,6 +1025,10 @@ window.__ModuleLoader__.load({
977
1025
  "row.accountsHint": "选择本机检测到的登录账号;Token 不会显示,也不会保存到 DSH 设置。",
978
1026
  "row.accountsRescan": "重新检测账号",
979
1027
  "row.accountsScanning": "正在检测…",
1028
+ "row.accountsFollowApp": "跟随 App 当前登录",
1029
+ "row.accountNoneInEffect": "当前没有生效的账号",
1030
+ "row.selectionLostHint": "已保存的账号在本机已不存在。",
1031
+ "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方选择一个,或点「跟随 App 当前登录」清除已保存的选择。重新登录桌面端 App 无法修复此问题。",
980
1032
  "row.modelsTitle": "模型",
981
1033
  "row.modelsSummary": "已启用 {count} 个",
982
1034
  "row.modelsRefresh": "从 WorkBuddy 刷新",
@@ -271,13 +271,56 @@ function parseCreditMultiplier(value) {
271
271
  const parsed = Number(match[1]);
272
272
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
273
273
  }
274
- /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
274
+ /**
275
+ * The effort vocabulary the upstream's plural-form payloads declare across both
276
+ * gateways (live union of every `supportedEfforts` list seen; `minimal` has
277
+ * never appeared). Both gateways also accept every level in it on
278
+ * singular-form models — medium/xhigh fold into high, low/max answer with
279
+ * their own budgets — so the singular `effort` value is a DEFAULT, never the
280
+ * model's only level.
281
+ */
282
+ const SINGULAR_EFFORT_LADDER = [
283
+ "low",
284
+ "medium",
285
+ "high",
286
+ "xhigh",
287
+ "max"
288
+ ];
289
+ /**
290
+ * The singular spelling of reasoning metadata: `effort` (plus the ignored
291
+ * `summary`) and none of the plural-form fields. Observed on CN
292
+ * `deepseek-v4.1-flash`/`kimi-k3-1`/`glm-5.2`… and global
293
+ * `deepseek-v4.1-flash`/`kimi-k3`/`gemini-3.5-flash`… (issue #7).
294
+ */
295
+ function isSingularEffortForm(raw) {
296
+ return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
297
+ }
298
+ /**
299
+ * Fold a singular-form `effort` into the plural shape the rest of the plugin
300
+ * already understands. Live probes on both gateways (issue #7) show these
301
+ * models answer with distinct `reasoning_content` across the whole ladder —
302
+ * and think NOT AT ALL when no `reasoning_effort` is sent — so the fold
303
+ * widens `supportedEfforts` and carries the declared value into
304
+ * `defaultEffort`. An unrecognized `effort` value passes through as the lone
305
+ * level, leaving its fate to the adapter's known-level filter.
306
+ */
307
+ function singularEffortLadder(raw) {
308
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
309
+ if (effort === void 0) return void 0;
310
+ return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
311
+ }
312
+ /**
313
+ * Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`.
314
+ * Both spellings normalize here: the plural form passes through as declared,
315
+ * and the singular `effort` form folds via {@link singularEffortLadder}.
316
+ */
275
317
  function parseReasoning(value) {
276
318
  if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
277
319
  const raw = value;
278
- const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
279
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
280
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
320
+ const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
321
+ const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
322
+ const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
323
+ const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : isSingularEffortForm(raw) ? true : void 0;
281
324
  if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
282
325
  return {
283
326
  ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
@@ -871,9 +914,17 @@ var WorkBuddyCredentialStore = class {
871
914
  this.desktopPathOverride = path;
872
915
  this.inflight = void 0;
873
916
  }
874
- /** 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
+ */
875
926
  selectAccount(accountId) {
876
- this.accountId = accountId;
927
+ this.accountId = accountId === "" ? void 0 : accountId;
877
928
  this.inflight = void 0;
878
929
  }
879
930
  /** Selected account id, for diagnostics and route assembly. */
@@ -971,11 +1022,25 @@ var WorkBuddyCredentialStore = class {
971
1022
  if (credentials.length === 0) return void 0;
972
1023
  return credentials.reduce((best, credential) => isFresher(credential, best) ? credential : best);
973
1024
  }
974
- /** Token-free account list for the plugin card. */
1025
+ /**
1026
+ * Token-free account list for the plugin card.
1027
+ *
1028
+ * `selected` answers one question only: which row is the account the plugin
1029
+ * is actually going to use? That is exactly what {@link current} decides, so
1030
+ * the two must never disagree — the card renders this list while the shim
1031
+ * bills through `current()`, and a row marked "selected" that `current()`
1032
+ * refuses to use is what made the dropdown look healthy while every request
1033
+ * failed with 401.
1034
+ *
1035
+ * So an explicit selection that matches NO local account marks nothing as
1036
+ * selected (no silent fallback to a different account — see {@link current}).
1037
+ * The implicit default is marked only when nothing was explicitly chosen.
1038
+ */
975
1039
  async accounts() {
976
1040
  const credentials = await this.readAll();
977
1041
  if (credentials.length === 0) return [];
978
- const selectedExists = this.accountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
1042
+ const hasExplicitSelection = this.accountId !== void 0;
1043
+ const selectedExists = hasExplicitSelection && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
979
1044
  const defaultSelected = this.preferred(credentials);
980
1045
  return credentials.map((credential) => {
981
1046
  const id = workbuddyAccountId(credential);
@@ -987,10 +1052,26 @@ var WorkBuddyCredentialStore = class {
987
1052
  source: credential.source,
988
1053
  tokenExpiresAtMs: credential.expiresAtMs,
989
1054
  filePath: credential.filePath,
990
- selected: selectedExists ? id === this.accountId : credential === defaultSelected
1055
+ selected: selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
991
1056
  };
992
1057
  });
993
1058
  }
1059
+ /**
1060
+ * Whether a persisted selection exists that matches no local account, while
1061
+ * other local sign-ins ARE available to choose from.
1062
+ *
1063
+ * The card uses this to explain the state honestly (the account is signed in,
1064
+ * but the SAVED choice is gone) instead of showing the generic "sign in
1065
+ * again" hint, which misdirects: the token is usually perfectly healthy and
1066
+ * signing in again does not repair an orphaned id. When no local sign-in is
1067
+ * available at all, that hint IS accurate and this returns false.
1068
+ */
1069
+ async selectionLost() {
1070
+ if (this.accountId === void 0) return false;
1071
+ const credentials = await this.readAll();
1072
+ if (credentials.length === 0) return false;
1073
+ return !credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
1074
+ }
994
1075
  /** The freshest stored credential for the current selection, no refresh. */
995
1076
  async current() {
996
1077
  const credentials = await this.readAll();
@@ -1370,7 +1451,7 @@ var WorkBuddyCatalog = class {
1370
1451
  //#endregion
1371
1452
  //#region src/version.ts
1372
1453
  /** The npm package version this build was produced from. */
1373
- const WORKBUDDY_CONNECT_VERSION = "2.0.2";
1454
+ const WORKBUDDY_CONNECT_VERSION = "2.0.4";
1374
1455
  //#endregion
1375
1456
  //#region src/host-heartbeat.ts
1376
1457
  /**
package/lib/index.d.ts CHANGED
@@ -175,7 +175,15 @@ 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;
@@ -215,8 +223,32 @@ declare class WorkBuddyCredentialStore {
215
223
  * remaining credit.
216
224
  */
217
225
  private preferred;
218
- /** Token-free account list for the plugin card. */
226
+ /**
227
+ * Token-free account list for the plugin card.
228
+ *
229
+ * `selected` answers one question only: which row is the account the plugin
230
+ * is actually going to use? That is exactly what {@link current} decides, so
231
+ * the two must never disagree — the card renders this list while the shim
232
+ * bills through `current()`, and a row marked "selected" that `current()`
233
+ * refuses to use is what made the dropdown look healthy while every request
234
+ * failed with 401.
235
+ *
236
+ * So an explicit selection that matches NO local account marks nothing as
237
+ * selected (no silent fallback to a different account — see {@link current}).
238
+ * The implicit default is marked only when nothing was explicitly chosen.
239
+ */
219
240
  accounts(): Promise<WorkBuddyAccountChoice[]>;
241
+ /**
242
+ * Whether a persisted selection exists that matches no local account, while
243
+ * other local sign-ins ARE available to choose from.
244
+ *
245
+ * The card uses this to explain the state honestly (the account is signed in,
246
+ * but the SAVED choice is gone) instead of showing the generic "sign in
247
+ * again" hint, which misdirects: the token is usually perfectly healthy and
248
+ * signing in again does not repair an orphaned id. When no local sign-in is
249
+ * available at all, that hint IS accurate and this returns false.
250
+ */
251
+ selectionLost(): Promise<boolean>;
220
252
  /** The freshest stored credential for the current selection, no refresh. */
221
253
  current(): Promise<WorkBuddyCredential | undefined>;
222
254
  /** The credential to send upstream: {@link current}, refreshed on demand. */
@@ -360,7 +392,11 @@ declare function prepareChatBody(source: string): string;
360
392
  * card simply omits the rate instead of displaying a fabricated one.
361
393
  */
362
394
  declare function parseCreditMultiplier(value: unknown): number | undefined;
363
- /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
395
+ /**
396
+ * Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`.
397
+ * Both spellings normalize here: the plural form passes through as declared,
398
+ * and the singular `effort` form folds via {@link singularEffortLadder}.
399
+ */
364
400
  declare function parseReasoning(value: unknown): WorkBuddyReasoning | undefined;
365
401
  /** Parse one catalog entry; entries without usable token limits are dropped. */
366
402
  declare function parseUpstreamModel(value: unknown): WorkBuddyUpstreamModel | undefined;
@@ -721,10 +757,22 @@ type WorkBuddyWebPackage = WorkBuddyWebCreditPackage;
721
757
  */
722
758
  type WorkBuddyWebRegion = 'cn' | 'global';
723
759
  /** The JSON document the plugin card renders. */
724
- type WorkBuddyWebUsage = {
760
+ type WorkBuddyWebUsage =
761
+ /**
762
+ * Not usable right now. `accounts` is still populated so the picker can
763
+ * offer a way out, and `selectionLost` separates the two very different
764
+ * reasons this happens: local sign-ins exist but the SAVED choice no longer
765
+ * matches any of them (re-select it, or clear it to follow the app's current
766
+ * sign-in again), versus nothing usable was found at all (sign in in the
767
+ * desktop app). Only the latter makes the "sign in again" hint truthful —
768
+ * re-signing in does not repair an orphaned id.
769
+ */
770
+ {
725
771
  status: 'signed-out';
726
772
  accounts: readonly WorkBuddyWebAccount[];
727
773
  message?: string;
774
+ /** The persisted account id matches no local account. */
775
+ selectionLost?: boolean;
728
776
  } | {
729
777
  status: 'signed-in';
730
778
  accountId: string;
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-CaS5Koaw.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-CPIHUxKL.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";
@@ -572,7 +572,8 @@ async function workBuddyWebStatus(deps, region) {
572
572
  return {
573
573
  status: "signed-out",
574
574
  accounts: accounts.map(toWebAccount),
575
- message: safeMessage(error)
575
+ message: safeMessage(error),
576
+ ...await store.selectionLost() ? { selectionLost: true } : {}
576
577
  };
577
578
  }
578
579
  const account = {
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.2",
5
+ "version": "2.0.4",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",