dsh-connect-workbuddy 4.2.0 → 4.3.1

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.
@@ -1706,6 +1706,107 @@ function parseVaultDocumentWithWriteTime(text, filePath) {
1706
1706
  ...refreshedFrom === void 0 ? {} : { refreshedFrom }
1707
1707
  };
1708
1708
  }
1709
+ /**
1710
+ * File name of the vault's DISMISSAL list, stored beside the vault entries.
1711
+ *
1712
+ * It exists because deleting a vault entry is not the same as deleting an
1713
+ * account. `syncVault` writes every desktop sign-in it scans back into the
1714
+ * vault — that is how a desktop account becomes selectable — so removing the
1715
+ * vault file alone was undone by the very next scan: the row came back and the
1716
+ * button read as broken (measured: `removeAccount` returns true, `accounts()`
1717
+ * still lists it).
1718
+ *
1719
+ * Dismissal means HIDDEN UNTIL THE USER SCANS AGAIN, not deleted forever. A
1720
+ * desktop sign-in is still there in the app, and the manual 「re-scan」 action is
1721
+ * the user asking to see what the machine currently has — so
1722
+ * {@link WorkBuddyCredentialStore.clearDismissals} empties this list on every
1723
+ * manual scan and the accounts return. Without that, a removed account could
1724
+ * never be recovered except by reinstalling or re-signing in.
1725
+ *
1726
+ * The list must survive the automatic refreshes in between: the card polls the
1727
+ * usage route every 60 seconds, and an in-memory-only dismissal would let the
1728
+ * account reappear a minute after the user removed it. Hence a file.
1729
+ *
1730
+ * Dismissal is per ACCOUNT, not per source: hiding only the vault copy would
1731
+ * leave the same account listed from the desktop file, which is the
1732
+ * half-measure that produced the report.
1733
+ */
1734
+ const VAULT_DISMISSED_FILENAME = ".dismissed.json";
1735
+ /** Current on-disk format of the dismissal list; readers reject others. */
1736
+ const DISMISSED_FORMAT_VERSION = 1;
1737
+ /**
1738
+ * How long one scan may be shared across callers, in milliseconds.
1739
+ *
1740
+ * One page open asks the store for the same account list six times (usage
1741
+ * route: accounts, status, resolve; the credits panel: once PER ACCOUNT through
1742
+ * `credentialFor`; and both region tabs poll together). Each read re-listed the
1743
+ * auth directory, re-parsed every file and re-ran the vault merge — measured at
1744
+ * 6 `readdir` and (6N+12) `readFile` for N accounts.
1745
+ *
1746
+ * Why a WINDOW and not a plain in-flight share: the six reads are SEQUENTIAL
1747
+ * (`await` one after another), so by the time the second starts the first has
1748
+ * resolved and there is nothing in flight to join. Sharing only concurrent reads
1749
+ * optimised nothing at all.
1750
+ *
1751
+ * Why so short: this is not a cache of the account list, it is an admission that
1752
+ * one page open IS one logical read. Anything longer would delay noticing a
1753
+ * sign-in the desktop app made on its own — discovery IS polling, so this window
1754
+ * is how long such a change may stay unseen. The card polls every 60 s, so 1 s is
1755
+ * invisible to the user while collapsing one page open from six scans to one. A
1756
+ * write through this store invalidates the window outright, so the actions the
1757
+ * user takes here (OAuth add, import, remove, rescan) are never delayed by it.
1758
+ */
1759
+ const SCAN_SHARE_WINDOW_MS = 1e3;
1760
+ /** Read the dismissal list; an absent or unreadable file reads as empty. */
1761
+ async function readDismissed(dir) {
1762
+ const dismissed = /* @__PURE__ */ new Set();
1763
+ try {
1764
+ const parsed = JSON.parse(await readFile(join(dir, VAULT_DISMISSED_FILENAME), "utf8"));
1765
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return dismissed;
1766
+ const document = parsed;
1767
+ if (document["version"] !== DISMISSED_FORMAT_VERSION) return dismissed;
1768
+ const ids = document["accountIds"];
1769
+ if (!Array.isArray(ids)) return dismissed;
1770
+ for (const id of ids) if (typeof id === "string" && VAULT_ACCOUNT_ID_RE.test(id)) dismissed.add(id);
1771
+ } catch {}
1772
+ return dismissed;
1773
+ }
1774
+ /** Write the dismissal list atomically under its own lock. */
1775
+ async function writeDismissed(dir, dismissed) {
1776
+ const path = join(dir, VAULT_DISMISSED_FILENAME);
1777
+ const ids = [...dismissed].slice(-1e3);
1778
+ const document = {
1779
+ version: DISMISSED_FORMAT_VERSION,
1780
+ accountIds: ids
1781
+ };
1782
+ await withFileLock(path, async () => {
1783
+ await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
1784
+ mode: 384,
1785
+ dirMode: 448
1786
+ });
1787
+ });
1788
+ }
1789
+ /**
1790
+ * Whether a directory entry is ONE ACCOUNT's vault credential file.
1791
+ *
1792
+ * The vault directory holds more than credentials: `writeDismissed` puts
1793
+ * `.dismissed.json` here, and atomic writes transiently leave
1794
+ * `<id>.json.<hex>.tmp` siblings behind. Both used to be swept up by a bare
1795
+ * `endsWith('.json')` check, and the DISMISSAL file in particular is
1796
+ * well-formed JSON that is not a credential — so `diagnose` reported it at the
1797
+ * user as 「not a readable vault credential」 on any machine where an account
1798
+ * had been removed, turning a healthy sign-in into an unreadable-looking one.
1799
+ *
1800
+ * The rule is the account id shape the store itself writes
1801
+ * ({@link VAULT_ACCOUNT_ID_RE}), not a naming convention: a file qualifies only
1802
+ * when its stem is a real id, which no bookkeeping or temp file can be. A
1803
+ * `.tmp` sibling therefore fails on the dot of its extension, and a hidden file
1804
+ * fails on its leading dot even before that.
1805
+ */
1806
+ function isVaultCredentialFileName(name) {
1807
+ if (!name.endsWith(".json")) return false;
1808
+ return VAULT_ACCOUNT_ID_RE.test(name.slice(0, -5));
1809
+ }
1709
1810
  /** Current on-disk format of the vault; readers reject others. */
1710
1811
  const VAULT_FORMAT_VERSION = 1;
1711
1812
  /**
@@ -2027,7 +2128,7 @@ async function readVaultEntries(dir) {
2027
2128
  throw error;
2028
2129
  }
2029
2130
  for (const name of names) {
2030
- if (!name.endsWith(".json")) continue;
2131
+ if (!isVaultCredentialFileName(name)) continue;
2031
2132
  const path = join(dir, name);
2032
2133
  try {
2033
2134
  const parsed = parseVaultDocumentWithWriteTime(await readFile(path, "utf8"), path);
@@ -2140,6 +2241,28 @@ var WorkBuddyCredentialStore = class {
2140
2241
  accountId;
2141
2242
  inflight;
2142
2243
  /**
2244
+ * In-flight/completed scan for the CURRENT `readAll` generation.
2245
+ *
2246
+ * One page open calls `readAll` six times: the usage route asks for the
2247
+ * account list, the sign-in state and the resolved credential, then the
2248
+ * per-account credits panel asks once PER ACCOUNT (`credentialFor`), and both
2249
+ * region tabs poll on the same timer. Each call re-read every auth file,
2250
+ * re-parsed it, and re-ran the vault merge — measured at 6 `readdir` + (6N+12)
2251
+ * `readFile` for N accounts, i.e. the cost grew with the account count on a
2252
+ * path whose answer cannot change within one tick.
2253
+ *
2254
+ * This is deliberately NOT a time-based cache: a TTL would make the card show
2255
+ * a stale account list after an OAuth add or an import, which is the very
2256
+ * class of bug this card keeps chasing. Instead the promise is shared while it
2257
+ * is in flight, and every WRITE clears it (see `invalidateScan`) — so a read
2258
+ * after a write always scans again, and a read during a scan joins it.
2259
+ *
2260
+ * Undefined means "no scan in flight and nothing cached".
2261
+ */
2262
+ scanInflight;
2263
+ /** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
2264
+ scanAtMs = 0;
2265
+ /**
2143
2266
  * A RUNTIME account override from the pool's rotation, or undefined.
2144
2267
  *
2145
2268
  * Deliberately separate from {@link accountId}, which is the user's persisted
@@ -2202,6 +2325,7 @@ var WorkBuddyCredentialStore = class {
2202
2325
  setDesktopPath(path) {
2203
2326
  this.desktopPathOverride = path;
2204
2327
  this.inflight = void 0;
2328
+ this.invalidateScan();
2205
2329
  }
2206
2330
  /**
2207
2331
  * Select an account by id; tokens stay outside settings.
@@ -2215,6 +2339,7 @@ var WorkBuddyCredentialStore = class {
2215
2339
  selectAccount(accountId) {
2216
2340
  this.accountId = accountId === "" ? void 0 : accountId;
2217
2341
  this.inflight = void 0;
2342
+ this.invalidateScan();
2218
2343
  }
2219
2344
  /** Selected account id, for diagnostics and route assembly. */
2220
2345
  selectedAccountId() {
@@ -2290,13 +2415,42 @@ var WorkBuddyCredentialStore = class {
2290
2415
  * region's accounts are invisible to selection, refresh, and status alike,
2291
2416
  * which is what keeps the two regions' providers from cross-billing.
2292
2417
  */
2418
+ /**
2419
+ * The account list, sharing one scan across concurrent and repeated reads.
2420
+ *
2421
+ * See {@link scanInflight}: the whole performance fix lives in these four
2422
+ * lines. Every caller keeps its OWN semantics (`current`, `accounts`,
2423
+ * `status`, `selectionLost`), because this returns the same array they used
2424
+ * to build themselves — only the filesystem work is shared.
2425
+ *
2426
+ * A failed scan is NOT cached: the rejection clears the slot so the next read
2427
+ * retries, rather than pinning every later caller to one transient failure.
2428
+ */
2293
2429
  async readAll() {
2430
+ const now = Date.now();
2431
+ if (this.scanInflight !== void 0 && now - this.scanAtMs < SCAN_SHARE_WINDOW_MS) return this.scanInflight;
2432
+ this.scanAtMs = now;
2433
+ this.scanInflight = this.scanAll().catch((error) => {
2434
+ this.scanInflight = void 0;
2435
+ throw error;
2436
+ });
2437
+ return this.scanInflight;
2438
+ }
2439
+ /** Drop the shared scan so the next read re-reads the filesystem. */
2440
+ invalidateScan() {
2441
+ this.scanInflight = void 0;
2442
+ this.scanAtMs = 0;
2443
+ }
2444
+ /** The one real scan: read every candidate file, then merge the vault. */
2445
+ async scanAll() {
2294
2446
  const files = await this.candidateFiles();
2447
+ const dismissed = await readDismissed(this.vaultDir());
2295
2448
  const byId = /* @__PURE__ */ new Map();
2296
2449
  for (const file of files) {
2297
2450
  const credential = await readAuthFile(file, this.resolveAtRestKey);
2298
2451
  if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
2299
2452
  const id = workbuddyAccountId(credential);
2453
+ if (dismissed.has(id)) continue;
2300
2454
  const existing = byId.get(id);
2301
2455
  if (existing === void 0) {
2302
2456
  byId.set(id, credential);
@@ -2307,12 +2461,13 @@ var WorkBuddyCredentialStore = class {
2307
2461
  for (const own of await this.readOwns()) {
2308
2462
  if (!this.matchesRegion(own.domain)) continue;
2309
2463
  const id = workbuddyAccountId(own);
2464
+ if (dismissed.has(id)) continue;
2310
2465
  const existing = byId.get(id);
2311
2466
  if (existing === void 0) byId.set(id, own);
2312
2467
  else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
2313
2468
  }
2314
- await this.syncVault(byId);
2315
- return [...byId.values()];
2469
+ await this.syncVault(byId, dismissed);
2470
+ return Object.freeze([...byId.values()]);
2316
2471
  }
2317
2472
  /**
2318
2473
  * Merge the scan's credentials into the vault and read the vault back.
@@ -2323,7 +2478,7 @@ var WorkBuddyCredentialStore = class {
2323
2478
  * mtime-stable and every sync is O(accounts) reads with at most one write
2324
2479
  * per CHANGED account.
2325
2480
  */
2326
- async syncVault(scan) {
2481
+ async syncVault(scan, dismissed) {
2327
2482
  const dir = this.vaultDir();
2328
2483
  let entries;
2329
2484
  try {
@@ -2333,6 +2488,7 @@ var WorkBuddyCredentialStore = class {
2333
2488
  }
2334
2489
  let dirReady = false;
2335
2490
  for (const [id, credential] of scan) {
2491
+ if (dismissed.has(id)) continue;
2336
2492
  const incumbent = entries.get(id);
2337
2493
  if (incumbent !== void 0 && !shouldReplaceVaultEntry(credential, incumbent)) continue;
2338
2494
  if (!dirReady) try {
@@ -2353,6 +2509,7 @@ var WorkBuddyCredentialStore = class {
2353
2509
  for (const [id, entry] of entries) {
2354
2510
  if (!this.matchesRegion(entry.credential.domain)) continue;
2355
2511
  const existing = scan.get(id);
2512
+ if (dismissed.has(id)) continue;
2356
2513
  if (existing === void 0) scan.set(id, entry.credential);
2357
2514
  else if (entry.refreshedFrom !== void 0 && entry.refreshedFrom === authFileName(existing.filePath)) scan.set(id, entry.credential);
2358
2515
  else if (fileRank(existing.filePath) !== 0 && entry.credential.expiresAtMs > existing.expiresAtMs) scan.set(id, entry.credential);
@@ -2526,20 +2683,92 @@ var WorkBuddyCredentialStore = class {
2526
2683
  source: "dsh",
2527
2684
  filePath: join(dir, `${id}.json`)
2528
2685
  });
2686
+ await this.forgetDismissal(dir, id);
2529
2687
  this.inflight = void 0;
2688
+ this.invalidateScan();
2530
2689
  return id;
2531
2690
  }
2532
2691
  /**
2533
- * Remove ONE account's plugin-stored credential by id.
2692
+ * Drop one id from the dismissal list, if present.
2534
2693
  *
2535
- * Only the VAULT entry is deleted — the desktop app's auth files are read-only
2536
- * to this plugin and are never touched, so an account that still exists as a
2537
- * desktop sign-in reappears on the next scan. That is the honest behaviour:
2538
- * the button means "forget what the plugin stored", not "sign the app out".
2694
+ * Silent by design: called on the ADD paths, where a list that cannot be
2695
+ * updated must not fail the action the user asked for. The consequence is the
2696
+ * same as before the fix — the account stays hidden until the list is
2697
+ * repaired — while throwing here would report a failed sign-in that succeeded.
2698
+ */
2699
+ async forgetDismissal(dir, accountId) {
2700
+ try {
2701
+ const dismissed = await readDismissed(dir);
2702
+ if (!dismissed.has(accountId)) return;
2703
+ dismissed.delete(accountId);
2704
+ await writeDismissed(dir, dismissed);
2705
+ } catch {}
2706
+ }
2707
+ /**
2708
+ * Forget EVERY dismissal, so the next read lists the accounts again.
2709
+ *
2710
+ * Called by the manual 「re-scan」 action. Dismissal is "hide until I ask to
2711
+ * scan again", and this is that ask: the user is requesting what the machine
2712
+ * currently has, which includes accounts they hid earlier. Leaving the list
2713
+ * intact would make a removed desktop account unrecoverable from the UI — the
2714
+ * opposite of what the button promises.
2715
+ *
2716
+ * PREFERS deleting the file over writing an empty list: an absent file is the
2717
+ * normal state and keeps the vault directory free of a file that says nothing.
2718
+ * The unlink is not the only way, though, because on Windows removing a file
2719
+ * another process holds open fails with `EPERM`/`EBUSY`/`EACCES` — an antivirus
2720
+ * scanner, OneDrive, or an editor with it open, the same contention the card's
2721
+ * `isFileContentionWriteError` classifies for profile writes and the reason
2722
+ * `docs/WINDOWS.md` keeps a register. POSIX unlinks open files happily, so
2723
+ * without this the promise 「点重新检测账号即可找回」 would be false, silently,
2724
+ * on exactly the platform it matters most on.
2725
+ *
2726
+ * So the unlink falls back to OVERWRITING the list with an empty one through
2727
+ * the same locked atomic write `writeDismissed` uses. For every reader the two
2728
+ * outcomes are identical (`readDismissed` yields no ids either way), so the
2729
+ * user's ask is honoured even when the file itself cannot be removed.
2730
+ *
2731
+ * Returns whether the dismissals are now genuinely gone, VERIFIED by reading
2732
+ * the list back rather than by trusting the write: the caller renders this, so
2733
+ * "it worked" has to mean it. A `false` is the honest answer for a vault that
2734
+ * could neither be unlinked nor rewritten, and the rescan still runs — the
2735
+ * accounts simply stay hidden, which the UI can now say out loud instead of
2736
+ * showing a button that appears to have done nothing.
2737
+ */
2738
+ async clearDismissals() {
2739
+ this.inflight = void 0;
2740
+ this.invalidateScan();
2741
+ const dir = this.vaultDir();
2742
+ const path = join(dir, VAULT_DISMISSED_FILENAME);
2743
+ try {
2744
+ await rm(path, { force: true });
2745
+ } catch {
2746
+ try {
2747
+ await writeDismissed(dir, /* @__PURE__ */ new Set());
2748
+ } catch {
2749
+ return (await readDismissed(dir)).size === 0;
2750
+ }
2751
+ }
2752
+ return (await readDismissed(dir)).size === 0;
2753
+ }
2754
+ /**
2755
+ * Remove ONE account from this region's list, by id.
2539
2756
  *
2540
- * Returns whether a stored entry was actually removed, so the caller can
2541
- * distinguish "removed" from "this account only ever existed as a desktop
2542
- * sign-in" without a second scan.
2757
+ * The account is DISMISSED, not merely deleted. Deleting the vault entry alone
2758
+ * was undone by the next scan: `syncVault` writes every desktop sign-in it
2759
+ * finds back into the vault (that is how a desktop account becomes selectable)
2760
+ * and `readAll` merges the vault in afterwards, so a removed desktop account
2761
+ * returned on the very next read while `removeAccount` had reported `true`.
2762
+ * Measured before the fix: remove → `true`, then `accounts()` still lists it.
2763
+ *
2764
+ * The desktop app's auth files stay read-only: dismissing hides the account
2765
+ * from this plugin without signing the app out, and that is exactly what the
2766
+ * card says. Re-adding it (a fresh OAuth sign-in or an import) clears the
2767
+ * dismissal, so the account is not lost to a future login.
2768
+ *
2769
+ * Returns whether a stored vault entry was also removed, so the caller can
2770
+ * still tell "removed" from "only a desktop sign-in" — the dismissal happens
2771
+ * either way, because only the user gets to decide an account is dismissed.
2543
2772
  */
2544
2773
  async removeAccount(accountId) {
2545
2774
  if (!VAULT_ACCOUNT_ID_RE.test(accountId)) throw new Error(`workbuddy: refusing to remove '${accountId}': not a vault account id`);
@@ -2557,6 +2786,17 @@ var WorkBuddyCredentialStore = class {
2557
2786
  });
2558
2787
  if (this.rotatedAccountId === accountId) this.rotatedAccountId = void 0;
2559
2788
  if (this.accountId === accountId) this.accountId = void 0;
2789
+ const dir = this.vaultDir();
2790
+ try {
2791
+ await mkdir(dir, { recursive: true });
2792
+ const dismissed = await readDismissed(dir);
2793
+ if (!dismissed.has(accountId)) {
2794
+ dismissed.add(accountId);
2795
+ await writeDismissed(dir, dismissed);
2796
+ }
2797
+ } catch {}
2798
+ this.inflight = void 0;
2799
+ this.invalidateScan();
2560
2800
  return removed;
2561
2801
  }
2562
2802
  /**
@@ -2575,6 +2815,7 @@ var WorkBuddyCredentialStore = class {
2575
2815
  recursive: true,
2576
2816
  force: true
2577
2817
  });
2818
+ this.invalidateScan();
2578
2819
  }
2579
2820
  needsRefresh(credential) {
2580
2821
  if (credential.expiresAtMs <= 0) return true;
@@ -2595,6 +2836,7 @@ var WorkBuddyCredentialStore = class {
2595
2836
  ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
2596
2837
  source: "dsh"
2597
2838
  };
2839
+ this.invalidateScan();
2598
2840
  await writeVaultEntry(this.vaultDir(), workbuddyAccountId(credential), refreshed, credential.filePath).catch(() => {});
2599
2841
  return refreshed;
2600
2842
  } catch (error) {
@@ -2679,7 +2921,7 @@ var WorkBuddyCredentialStore = class {
2679
2921
  const tried = [...await this.candidateFiles(), ...this.ownCandidates()];
2680
2922
  try {
2681
2923
  const names = await readdir(this.vaultDir());
2682
- for (const name of names.filter((entry) => entry.endsWith(".json"))) {
2924
+ for (const name of names.filter(isVaultCredentialFileName)) {
2683
2925
  const path = join(this.vaultDir(), name);
2684
2926
  tried.push(path);
2685
2927
  let text;
@@ -3197,7 +3439,7 @@ function generationOf(version) {
3197
3439
  //#endregion
3198
3440
  //#region src/version.ts
3199
3441
  /** The npm package version this build was produced from. */
3200
- const WORKBUDDY_CONNECT_VERSION = "4.2.0";
3442
+ const WORKBUDDY_CONNECT_VERSION = "4.3.1";
3201
3443
  //#endregion
3202
3444
  //#region src/host-heartbeat.ts
3203
3445
  /**
package/lib/index.d.ts CHANGED
@@ -247,6 +247,28 @@ declare class WorkBuddyCredentialStore {
247
247
  private desktopPathOverride;
248
248
  private accountId;
249
249
  private inflight;
250
+ /**
251
+ * In-flight/completed scan for the CURRENT `readAll` generation.
252
+ *
253
+ * One page open calls `readAll` six times: the usage route asks for the
254
+ * account list, the sign-in state and the resolved credential, then the
255
+ * per-account credits panel asks once PER ACCOUNT (`credentialFor`), and both
256
+ * region tabs poll on the same timer. Each call re-read every auth file,
257
+ * re-parsed it, and re-ran the vault merge — measured at 6 `readdir` + (6N+12)
258
+ * `readFile` for N accounts, i.e. the cost grew with the account count on a
259
+ * path whose answer cannot change within one tick.
260
+ *
261
+ * This is deliberately NOT a time-based cache: a TTL would make the card show
262
+ * a stale account list after an OAuth add or an import, which is the very
263
+ * class of bug this card keeps chasing. Instead the promise is shared while it
264
+ * is in flight, and every WRITE clears it (see `invalidateScan`) — so a read
265
+ * after a write always scans again, and a read during a scan joins it.
266
+ *
267
+ * Undefined means "no scan in flight and nothing cached".
268
+ */
269
+ private scanInflight;
270
+ /** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
271
+ private scanAtMs;
250
272
  /**
251
273
  * A RUNTIME account override from the pool's rotation, or undefined.
252
274
  *
@@ -331,7 +353,22 @@ declare class WorkBuddyCredentialStore {
331
353
  * region's accounts are invisible to selection, refresh, and status alike,
332
354
  * which is what keeps the two regions' providers from cross-billing.
333
355
  */
356
+ /**
357
+ * The account list, sharing one scan across concurrent and repeated reads.
358
+ *
359
+ * See {@link scanInflight}: the whole performance fix lives in these four
360
+ * lines. Every caller keeps its OWN semantics (`current`, `accounts`,
361
+ * `status`, `selectionLost`), because this returns the same array they used
362
+ * to build themselves — only the filesystem work is shared.
363
+ *
364
+ * A failed scan is NOT cached: the rejection clears the slot so the next read
365
+ * retries, rather than pinning every later caller to one transient failure.
366
+ */
334
367
  private readAll;
368
+ /** Drop the shared scan so the next read re-reads the filesystem. */
369
+ private invalidateScan;
370
+ /** The one real scan: read every candidate file, then merge the vault. */
371
+ private scanAll;
335
372
  /**
336
373
  * Merge the scan's credentials into the vault and read the vault back.
337
374
  *
@@ -430,16 +467,64 @@ declare class WorkBuddyCredentialStore {
430
467
  */
431
468
  addOAuthAccount(credential: Omit<WorkBuddyCredential, 'source' | 'filePath'>): Promise<string>;
432
469
  /**
433
- * Remove ONE account's plugin-stored credential by id.
434
- *
435
- * Only the VAULT entry is deleted — the desktop app's auth files are read-only
436
- * to this plugin and are never touched, so an account that still exists as a
437
- * desktop sign-in reappears on the next scan. That is the honest behaviour:
438
- * the button means "forget what the plugin stored", not "sign the app out".
439
- *
440
- * Returns whether a stored entry was actually removed, so the caller can
441
- * distinguish "removed" from "this account only ever existed as a desktop
442
- * sign-in" without a second scan.
470
+ * Drop one id from the dismissal list, if present.
471
+ *
472
+ * Silent by design: called on the ADD paths, where a list that cannot be
473
+ * updated must not fail the action the user asked for. The consequence is the
474
+ * same as before the fix — the account stays hidden until the list is
475
+ * repaired — while throwing here would report a failed sign-in that succeeded.
476
+ */
477
+ private forgetDismissal;
478
+ /**
479
+ * Forget EVERY dismissal, so the next read lists the accounts again.
480
+ *
481
+ * Called by the manual 「re-scan」 action. Dismissal is "hide until I ask to
482
+ * scan again", and this is that ask: the user is requesting what the machine
483
+ * currently has, which includes accounts they hid earlier. Leaving the list
484
+ * intact would make a removed desktop account unrecoverable from the UI — the
485
+ * opposite of what the button promises.
486
+ *
487
+ * PREFERS deleting the file over writing an empty list: an absent file is the
488
+ * normal state and keeps the vault directory free of a file that says nothing.
489
+ * The unlink is not the only way, though, because on Windows removing a file
490
+ * another process holds open fails with `EPERM`/`EBUSY`/`EACCES` — an antivirus
491
+ * scanner, OneDrive, or an editor with it open, the same contention the card's
492
+ * `isFileContentionWriteError` classifies for profile writes and the reason
493
+ * `docs/WINDOWS.md` keeps a register. POSIX unlinks open files happily, so
494
+ * without this the promise 「点重新检测账号即可找回」 would be false, silently,
495
+ * on exactly the platform it matters most on.
496
+ *
497
+ * So the unlink falls back to OVERWRITING the list with an empty one through
498
+ * the same locked atomic write `writeDismissed` uses. For every reader the two
499
+ * outcomes are identical (`readDismissed` yields no ids either way), so the
500
+ * user's ask is honoured even when the file itself cannot be removed.
501
+ *
502
+ * Returns whether the dismissals are now genuinely gone, VERIFIED by reading
503
+ * the list back rather than by trusting the write: the caller renders this, so
504
+ * "it worked" has to mean it. A `false` is the honest answer for a vault that
505
+ * could neither be unlinked nor rewritten, and the rescan still runs — the
506
+ * accounts simply stay hidden, which the UI can now say out loud instead of
507
+ * showing a button that appears to have done nothing.
508
+ */
509
+ clearDismissals(): Promise<boolean>;
510
+ /**
511
+ * Remove ONE account from this region's list, by id.
512
+ *
513
+ * The account is DISMISSED, not merely deleted. Deleting the vault entry alone
514
+ * was undone by the next scan: `syncVault` writes every desktop sign-in it
515
+ * finds back into the vault (that is how a desktop account becomes selectable)
516
+ * and `readAll` merges the vault in afterwards, so a removed desktop account
517
+ * returned on the very next read while `removeAccount` had reported `true`.
518
+ * Measured before the fix: remove → `true`, then `accounts()` still lists it.
519
+ *
520
+ * The desktop app's auth files stay read-only: dismissing hides the account
521
+ * from this plugin without signing the app out, and that is exactly what the
522
+ * card says. Re-adding it (a fresh OAuth sign-in or an import) clears the
523
+ * dismissal, so the account is not lost to a future login.
524
+ *
525
+ * Returns whether a stored vault entry was also removed, so the caller can
526
+ * still tell "removed" from "only a desktop sign-in" — the dismissal happens
527
+ * either way, because only the user gets to decide an account is dismissed.
443
528
  */
444
529
  removeAccount(accountId: string): Promise<boolean>;
445
530
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-C4TXu8ew.js";
1
+ import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BQkmibKu.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -3952,9 +3952,14 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
3952
3952
  const region = requestRegion(req, res);
3953
3953
  if (region === void 0) return;
3954
3954
  try {
3955
- const accounts = await deps.store(region).accounts();
3955
+ const store = deps.store(region);
3956
+ const dismissalsCleared = await store.clearDismissals();
3957
+ const accounts = await store.accounts();
3956
3958
  deps.regionUsable?.(region, accounts.length > 0);
3957
- json(res, 200, { accounts: accounts.map(toWebAccount) });
3959
+ json(res, 200, {
3960
+ accounts: accounts.map(toWebAccount),
3961
+ dismissalsCleared
3962
+ });
3958
3963
  } catch (error) {
3959
3964
  json(res, 500, { error: safeMessage(error) });
3960
3965
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.2.0",
5
+ "version": "4.3.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",