dsh-xdpool 2.1.1 → 2.2.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+ ## 2.2.1 — README 已知限制描述更正
5
+ 纯文档修正:README「已知限制」里写着池「没法替你发起登录或扫码」——这是改名前的旧描述。现在的 XD Pool 有扫码登录页,可以为自己十三家渠道发起扫码登录;WorkBuddy 本体的 token 仍由桌面 App 的腾讯 SSO 铸造并绑定设备,这条限制保留但改成了准确的说法。代码无变化,发布这一版只为让 npm 页面的 README 跟上。
6
+ ## 2.2.0 — 账号池重启不再失忆,CLI 修好,快照真正回读
7
+ > 群友报的三个问题一起收掉:重启 DSH 后账号池只剩当前登录账号;`dsh-xdpool` 命令行任何子命令都报 `ERR_MODULE_NOT_FOUND`;`import` 写进去的快照写了就忘,永远读不回来。
8
+ ### 重启后账号还在:账号池持久化
9
+ **现象**:在桌面端来回切换登录,攒了一池账号;重启 DSH,池里只剩当前登录的那一个。
10
+ **根因**:账号池的账号列表是纯内存——`scan()` 只合并不落盘。而上游桌面端在磁盘上只保留**当前登录**的凭据文件(每次登录覆盖同一个文件),所以「重启 = 磁盘上有什么就是什么」,磁盘上永远只有一个。
11
+ **修法**:新增 `account-store.ts`,池的扫描从 `~/.dsh/.xdpool/accounts.json` 播种、每次扫描后原子回写(先写 `.tmp` 再改名,崩溃不会留半个文件)。桌面端只记得最后一次登录,池子自己记账。生产入口(宿主和 CLI 共用的 `createCore()`)默认开启;测试构造的池默认关闭,不会把 fixture 写进真实目录。
12
+ ### `import` 的快照终于有人读了
13
+ `dsh-xdpool import` 一直把 `<md5前8>.json` 快照写进 `~/.dsh/.xdpool/`,但**没有任何代码路径读回它**——写了就忘。现在插件数据目录本身就是一个扫描源,快照里的每个凭据都会在下次扫描时收进池子;插件自己的状态文件(`accounts.json`、`ignored.json`、`preferred.json`)明确排除,绝不会被当成凭据解析出幻影账号。
14
+ ### CLI 能跑了
15
+ `lib/bin.js` 顶部带着四个打包时从 `index.ts` 一路带入的副作用 import(`@earendil-works/pi-ai` 等 optional peer),绑定里零引用——后果是所有 CLI 子命令直接 `ERR_MODULE_NOT_FOUND`。这四个包改为 external(源码里本来就没有绑定引用),`bin.js` 加载即净,只有命令真正走到 adapter 才会解析。
16
+ ### 文档对齐
17
+ README 里「历史登录快照」的四处描述与实际行为不符,一并改写为如实口径:桌面端只保留当前登录,池子自己把攒到的账号持久保存到 `~/.dsh/.xdpool/accounts.json`,重启不丢;`import` 快照同目录,每次扫描收进池。
4
18
  ## 2.1.1 — 修掉两个用户看得见的 bug
5
19
  > 一个让你在插件清单里看到一条失败的红条目,一个让你的账单上「使用端」是空的。都是 2.1.0 之前就存在的,这一版一起收掉。
6
20
  ### 插件清单里那条 failed 的 billion-context,没有了
package/README.en.md CHANGED
@@ -31,7 +31,7 @@ Then it grew into seven pages. Beyond the account pool: sign in to thirteen vend
31
31
 
32
32
  **It also throws in a full points-automation suite**: check-ins, task rewards, streak redemption, the buddy trip — all the chores you'd otherwise click through every day, done in the background.
33
33
 
34
- > **How this differs from the single-account connectors**: plugins like `dsh-workbuddy-connect` handle one account at a time. XD Pool treats **multiple accounts as the normal case** — it doesn't pick favourites or ask you to import anything. Every sign-in snapshot on this machine goes into one shared pool, exposed as a single `xdpool` provider group, with automatic failover inside.
34
+ > **How this differs from the single-account connectors**: plugins like `dsh-workbuddy-connect` handle one account at a time. XD Pool treats **multiple accounts as the normal case** — it doesn't pick favourites or ask you to import anything. Every account you've switched through on the desktop app is remembered across restarts, exposed as a single `xdpool` provider group, with automatic failover inside.
35
35
 
36
36
  > **Upgrading from the old name**: this plugin used to be `dsh-workbuddy-xdpool`. Your data is **carried over automatically** (API keys, ignore list, account pin). The one thing you may need to do by hand is a default model pinned to an old id — see [Upgrading from an older version](#upgrading-from-an-older-version).
37
37
 
@@ -214,7 +214,7 @@ dsh plugin --profile desktop exec dsh-xdpool login # guide to adding another
214
214
 
215
215
  ## How accounts get into the pool
216
216
 
217
- Auto-discovery. Every sign-in on the WorkBuddy desktop app leaves a token-bearing snapshot; XD Pool scans them and absorbs each into the pool. So multi-account = sign in / switch accounts in the desktop app, then hit "Detect accounts again" or restart DSH.
217
+ The desktop app keeps only the **currently signed-in** account on disk (each switch overwrites the same file), so XD Pool remembers accounts itself: every time you switch sign-ins in the desktop app, the outgoing account is captured into `~/.dsh/.xdpool/accounts.json` and **survives restarts**. Hit "Detect accounts again" or just start using the pool — every historical account comes back.
218
218
 
219
219
  To snapshot an extra login explicitly (e.g. to pin one account for verification):
220
220
 
@@ -224,7 +224,7 @@ dsh plugin --profile desktop exec dsh-xdpool accounts
224
224
  dsh plugin --profile desktop exec dsh-xdpool remove myKey
225
225
  ```
226
226
 
227
- Snapshots are stored under `~/.dsh/.xdpool/` named by the **MD5-8 prefix** of their key (so keys with Chinese, `/`, or spaces are safe). Long-lived use relies on refresh-token auto-renewal; if it lapses, re-sign-in on the desktop and `import <key> --force`.
227
+ Imported snapshots live in the same directory, `~/.dsh/.xdpool/`, and every scan absorbs them into the pool — they are no longer written and never read back. Snapshots are named by the **MD5-8 prefix** of their key (so keys with Chinese, `/`, or spaces are safe). Long-lived use relies on refresh-token auto-renewal; if it lapses, re-sign-in on the desktop and `import <key> --force`.
228
228
 
229
229
  ## Configuration
230
230
 
@@ -246,7 +246,7 @@ xdpool:
246
246
 
247
247
  - **Host side** (`src/`, inside the DSH main process)
248
248
  - `index.ts` — registers the `xdpool` provider, the `xdpool` settings section, the same-origin routes (status / re-scan / clear cooldowns / check-in / automation run / credit reserve), plus account discovery and catalog seeding.
249
- - `accounts.ts` — `WorkBuddyAccountPool`: reads the desktop auth snapshots, 429 cooldowns, rotation and token refresh; account disabling and credit reserves live here too.
249
+ - `accounts.ts` — `WorkBuddyAccountPool`: discovers and durably stores local WorkBuddy accounts (`account-store.ts`), 429 cooldowns, rotation and token refresh; account disabling and credit reserves live here too.
250
250
  - `scheduler.ts` — the points-automation scheduler: runs five jobs on local time points (check-in / report / tasks / streak / travel), persists the daily earnings ledger, and backs the card's "Run now" button with the same code path.
251
251
  - `task-events.ts` — builds the 13 task event chains. Each chain is plain data plus the fingerprint channel it must go out on (desktop or web); the tasks that need a REAL conversation (skill, experts) open one here to get the server-side id.
252
252
  - `catalog.ts` / `upstream.ts` — the upstream client: model catalog (with per-model multipliers and free / image tags), credits, check-in, and every automation endpoint, switching CN/global by credential domain.
@@ -259,7 +259,7 @@ xdpool:
259
259
 
260
260
  ## Known limitations
261
261
 
262
- - **Only accounts on THIS machine's desktop app**: the pool cannot — and will not — sign you in or scan a QR code (tokens are minted by the WorkBuddy desktop app's own Tencent SSO and bound to the device). Adding an account = signing in on the desktop app; XD Pool absorbs it.
262
+ - **The WorkBuddy sign-in itself still goes through the desktop app**: XD Pool can launch QR sign-in for its own thirteen login channels, but WorkBuddy's own token is minted by the desktop app's Tencent SSO and bound to the device — the pool will not sign you in to WorkBuddy. Adding an account = signing in on the desktop app; XD Pool absorbs it.
263
263
 
264
264
  - **A few automation tasks are deliberately not implemented**
265
265
  - `Expert_Philanthropy` (charity expert): requires a real donation; there is no way around it.
package/README.md CHANGED
@@ -31,7 +31,7 @@
31
31
 
32
32
  **另外附送一整套「积分自动化」**:签到、领任务奖励、连登兑换、开猫猫旅行……这些每天都要手动点一遍的活儿,插件在后台替你干完了。
33
33
 
34
- > **和单账号连接插件的区别**:`dsh-workbuddy-connect` 那类插件是「一次一个账号」。XD Pool 把**多账号当默认情况**——不挑号、不手动导入,本机 WorkBuddy 所有历史登录快照全部收进一个共享池,对外用一个 `xdpool` provider 分组,池内自动容错。
34
+ > **和单账号连接插件的区别**:`dsh-workbuddy-connect` 那类插件是「一次一个账号」。XD Pool 把**多账号当默认情况**——不挑号、不手动导入,你在桌面 App 换登过的每个号都被池记下并跨重启保存,对外用一个 `xdpool` provider 分组,池内自动容错。
35
35
 
36
36
  > **从旧名升级**:这个插件以前叫 `dsh-workbuddy-xdpool`。改名后**旧数据会自动接过来**(你的 API key、忽略名单、账号 pin 都还在);唯一需要你动手的是「默认模型」如果指向旧 id,见[从旧版升级](#从旧版升级)。
37
37
 
@@ -70,7 +70,7 @@
70
70
 
71
71
  ### 一、多账号池
72
72
 
73
- - **零配置**:装上、打开,就完事了。WorkBuddy 桌面 App 里每个登过的账号都会被自动发现并入池,不需要在插件里录任何东西。
73
+ - **零配置**:装上、打开,就完事了。WorkBuddy 桌面 App 里每个登过的账号都会被自动发现、持久保存并入池(重启不丢),不需要在插件里录任何东西。
74
74
 
75
75
  - **自动容错换号**:池子记着每个账号的限流状态。某个号触发 429 就冷却它,后面的请求自动落到下一个健康的号上;冷却结束自动恢复。所有号都在冷却时,请求才排队等待。
76
76
  - **四种账号分配方式**:`priority` 先用完一个号;`round-robin` 按顺序轮换;`balanced` 随机抽、偏向闲置久的;`sticky` 让**一个对话只用一个账号,新对话才换下一个**——上游缓存按账号隔离,中途换号等于把整段对话的缓存作废,`sticky` 就是为这件事准备的。卡片上点一下即可切换,默认仍是 `priority`。
@@ -221,7 +221,7 @@ dsh plugin --profile desktop exec dsh-xdpool login # 教你怎么在桌面
221
221
 
222
222
  ## 池里怎么多账号?
223
223
 
224
- 池子走**自动发现**:WorkBuddy 桌面 App 每次登录都会在本机留一个带令牌的历史快照,XD Pool 扫描这些快照,把每个账号都收进池。所以多账号 = 在桌面 App 里换着登几次,之后点卡片上的「重新检测账号」或者重启 DSH,新号就自动进池了。
224
+ 桌面 App 同一时间只保留**当前登录账号**的凭据(每次换号都会覆盖同一个文件),所以 XD Pool 自己把账号记下来:你在桌面 App 里换着登几个号,每个号在切走的瞬间就被池收进 `~/.dsh/.xdpool/accounts.json` 持久保存——**重启 DSH 也不会丢**。之后点卡片上的「重新检测账号」,或者直接开始用,池都会带着全部历史账号服务。
225
225
 
226
226
  如果你想给桌面 App 之外的一次登录做个**显式快照**(比如临时固定某个号做验证),也可以手动导入:
227
227
 
@@ -233,7 +233,7 @@ dsh plugin --profile desktop exec dsh-xdpool accounts
233
233
  dsh plugin --profile desktop exec dsh-xdpool remove myKey
234
234
  ```
235
235
 
236
- 导入的快照以 key 的 **MD5 前 8 位**命名,放在 `~/.dsh/.xdpool/`(key 本身记在文件里),中文、带 `/`、带空格的 key 都安全。长期使用靠 refresh token 自动续期;失效了就回桌面重新登录,再 `import <key> --force` 覆盖。
236
+ 导入的快照与自动保存的账号存在同一个目录 `~/.dsh/.xdpool/` 下,每次扫描都会被一并收进池——不再是「导入了但没人读」。快照以 key 的 **MD5 前 8 位**命名(key 本身记在文件里),中文、带 `/`、带空格的 key 都安全。长期使用靠 refresh token 自动续期;失效了就回桌面重新登录,再 `import <key> --force` 覆盖。
237
237
 
238
238
  ## 配置
239
239
 
@@ -259,7 +259,7 @@ xdpool:
259
259
 
260
260
  - **宿主侧**(`src/`,跑在 DSH 主进程里)
261
261
  - `index.ts` —— 注册 `xdpool` provider、`xdpool` 设置节、各条同源路由(状态 / 重新检测 / 清除冷却 / 签到 / 自动化运行 / 保留积分),以及账号发现与模型目录播种。
262
- - `accounts.ts` —— `WorkBuddyAccountPool`:读本机 WorkBuddy 桌面 auth 快照、429 冷却、轮换与 token 刷新;账号停用与积分保留也在这里。
262
+ - `accounts.ts` —— `WorkBuddyAccountPool`:发现并持久保存本机 WorkBuddy 账号(`account-store.ts` 落盘)、429 冷却、轮换与 token 刷新;账号停用与积分保留也在这里。
263
263
  - `scheduler.ts` —— 积分自动化的调度器:按本地时点跑五个任务(签到 / 上报 / 任务 / 连登 / 旅行),每日收益账本落盘,手动「立即运行」走的是同一套逻辑。
264
264
  - `task-events.ts` —— 13 条任务事件链的构造。每条链都是纯数据,外加它该走哪个指纹通道(桌面 / 网页);需要真实会话的任务(技能、专家)在这里先开一次真对话,拿到服务端 id。
265
265
  - `catalog.ts` / `upstream.ts` —— 上游模型目录(含每模型积分倍率、免费 / 图片能力标签)、积分查询、签到、自动化各接口的上游客户端(按凭据域名自动切国内外)。
@@ -272,7 +272,7 @@ xdpool:
272
272
 
273
273
  ## 已知限制
274
274
 
275
- - **只能用本机桌面 App 里的账号**:池不会、也没法替你发起 WorkBuddy 的登录或扫码(token 由 WorkBuddy 桌面 App 自己的腾讯 SSO 铸造并和设备绑定)。加号 = 在桌面 App 里登录 / 切换,XD Pool 自动吸收。
275
+ - **WorkBuddy 主账号的登录仍走桌面 App**:XD Pool 可以为插件自己的十三家扫码登录渠道发起扫码,但 WorkBuddy 本体的 token 由桌面 App 的腾讯 SSO 铸造并和设备绑定,池不会替你登录 WorkBuddy。加号 = 在桌面 App 里登录 / 切换,XD Pool 自动吸收。
276
276
 
277
277
  - **自动化里有几个任务刻意没做**
278
278
  - `Expert_Philanthropy`(公益专家):需要真捐钱,绕不过去。
package/lib/bin.js CHANGED
@@ -1731,6 +1731,240 @@ var WorkBuddyUpstreamClient = class {
1731
1731
  }
1732
1732
  };
1733
1733
  //#endregion
1734
+ //#region src/ignored.ts
1735
+ /**
1736
+ * The permanent ignore list: accounts the user has thrown out of the pool.
1737
+ *
1738
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
1739
+ * account stays on the card, can be switched back on, and its credential keeps
1740
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
1741
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
1742
+ * credential is decrypted, never appears among the pool's accounts, and does not
1743
+ * come back when the desktop app writes a fresh `.info` file for it.
1744
+ *
1745
+ * It lives in a plugin-owned file rather than in the settings document for one
1746
+ * practical reason: the CLI has no settings service. `dsh-xdpool
1747
+ * ignore <id>` runs without a host, so a settings-only list could be written by
1748
+ * the card and never by the CLI. One file, read by both halves, keeps the two
1749
+ * views of "which accounts are ignored" identical by construction.
1750
+ *
1751
+ * @module dsh-xdpool/ignored
1752
+ */
1753
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
1754
+ const PLUGIN_DATA_DIR_NAME = ".xdpool";
1755
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
1756
+ const IGNORED_FILE_NAME = "ignored.json";
1757
+ /**
1758
+ * The DSH home directory, honouring the same override the host uses.
1759
+ *
1760
+ * Shared by the CLI and the host so both halves resolve the same file: an
1761
+ * `ignore` written from the terminal has to be visible to the running plugin,
1762
+ * which is only true if they agree on where "home" is.
1763
+ */
1764
+ function dshHome$1(env = process.env) {
1765
+ const fromEnv = env["DSH_HOME"];
1766
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
1767
+ return join(homedir(), ".dsh");
1768
+ }
1769
+ /** This plugin's own state directory. */
1770
+ function pluginDataDir(env = process.env) {
1771
+ return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
1772
+ }
1773
+ /** Absolute path of the ignore list. */
1774
+ function ignoredIdsPath(env = process.env) {
1775
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
1776
+ }
1777
+ /**
1778
+ * Normalize whatever the file holds into a clean list.
1779
+ *
1780
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
1781
+ * take the pool down. An entry without a usable id is dropped; a missing label
1782
+ * falls back to the id so the card always has something to print.
1783
+ */
1784
+ function normalize$1(raw) {
1785
+ if (typeof raw !== "object" || raw === null) return [];
1786
+ const entries = raw.accounts;
1787
+ if (!Array.isArray(entries)) return [];
1788
+ const out = [];
1789
+ const seen = /* @__PURE__ */ new Set();
1790
+ for (const entry of entries) {
1791
+ if (typeof entry !== "object" || entry === null) continue;
1792
+ const record = entry;
1793
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
1794
+ if (id === "" || seen.has(id)) continue;
1795
+ seen.add(id);
1796
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
1797
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
1798
+ out.push({
1799
+ id,
1800
+ label,
1801
+ ignoredAt
1802
+ });
1803
+ }
1804
+ return out;
1805
+ }
1806
+ /**
1807
+ * Read the ignore list, tolerating every "no list yet" shape.
1808
+ *
1809
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
1810
+ * the caller — nothing is ignored — so none of them throws. The pool must be
1811
+ * able to start on a machine that has never ignored anything.
1812
+ */
1813
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
1814
+ try {
1815
+ return normalize$1(JSON.parse(await readFile(path, "utf8")));
1816
+ } catch {
1817
+ return [];
1818
+ }
1819
+ }
1820
+ /**
1821
+ * Replace the ignore list, atomically.
1822
+ *
1823
+ * Written to a sibling temp file and renamed over the target so a crash (or a
1824
+ * concurrent reader) can never observe a half-written document — the ignore
1825
+ * list is the only thing standing between a dead account and the rotation, and
1826
+ * a truncated file reads as "nothing is ignored", which would quietly put every
1827
+ * discarded account back in the pool.
1828
+ */
1829
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
1830
+ const payload = JSON.stringify({
1831
+ version: 1,
1832
+ accounts
1833
+ }, null, 2);
1834
+ await mkdir(dirname(path), { recursive: true });
1835
+ const temp = `${path}.tmp`;
1836
+ await writeFile$1(temp, `${payload}\n`, "utf8");
1837
+ await rename(temp, path);
1838
+ }
1839
+ /**
1840
+ * Add one account to the ignore list, preserving the rest.
1841
+ *
1842
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
1843
+ * both be open, and each request names exactly one account, so re-writing the
1844
+ * whole list from a stale view would drop the other side's edits.
1845
+ */
1846
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
1847
+ const current = await readIgnoredAccounts(path);
1848
+ if (current.some((entry) => entry.id === account.id)) return current;
1849
+ const next = [...current, {
1850
+ id: account.id,
1851
+ label: account.label ?? account.id,
1852
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
1853
+ }];
1854
+ await writeIgnoredAccounts(next, path);
1855
+ return next;
1856
+ }
1857
+ /** Drop one account from the ignore list. Returns the resulting list. */
1858
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
1859
+ const current = await readIgnoredAccounts(path);
1860
+ const next = current.filter((entry) => entry.id !== accountId);
1861
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
1862
+ return next;
1863
+ }
1864
+ //#endregion
1865
+ //#region src/account-store.ts
1866
+ /**
1867
+ * Durable copy of the discovered accounts, inside the plugin's data directory.
1868
+ *
1869
+ * The pool's account list is otherwise pure memory: the desktop app keeps only
1870
+ * the CURRENT sign-in on disk (each login overwrites the one auth file), so
1871
+ * accounts gathered by switching sign-ins evaporated on every restart. This
1872
+ * store is the fix — `scan()` seeds from it and writes the merged list back, so
1873
+ * the pool now survives restarts.
1874
+ *
1875
+ * JSON only, atomic rename on write: a crash mid-write leaves the previous
1876
+ * generation intact, and the pool never reads a half-written account again.
1877
+ *
1878
+ * @module dsh-xdpool/account-store
1879
+ */
1880
+ /** File name of the durable account list, inside the plugin data directory. */
1881
+ const ACCOUNTS_FILE_NAME = "accounts.json";
1882
+ /**
1883
+ * State files that live in the same directory but are NOT account snapshots.
1884
+ *
1885
+ * The snapshot importer (`dsh-xdpool import <key>`) writes `<md5prefix>.json`
1886
+ * into this directory, and the pool scans it — so every other `.json` file here
1887
+ * is treated as an imported snapshot. These three are the plugin's own
1888
+ * bookkeeping and must never be parsed as credentials.
1889
+ */
1890
+ const NON_SNAPSHOT_FILES = [
1891
+ ACCOUNTS_FILE_NAME,
1892
+ "ignored.json",
1893
+ "preferred.json"
1894
+ ];
1895
+ /** Absolute path of the durable account list. */
1896
+ function accountStorePath(env = process.env) {
1897
+ return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME, ACCOUNTS_FILE_NAME);
1898
+ }
1899
+ /** The directory the pool scans for imported `<md5prefix>.json` snapshots. */
1900
+ function snapshotDirPath(env = process.env) {
1901
+ return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
1902
+ }
1903
+ /**
1904
+ * True when a file name in the snapshot directory is plugin bookkeeping rather
1905
+ * than an imported snapshot.
1906
+ */
1907
+ function isStateFile(name) {
1908
+ return NON_SNAPSHOT_FILES.includes(name);
1909
+ }
1910
+ /** True when a value has the shape of a stored account. */
1911
+ function isStoredAccount(value) {
1912
+ if (typeof value !== "object" || value === null) return false;
1913
+ const record = value;
1914
+ const credential = record["credential"];
1915
+ return typeof record["id"] === "string" && typeof record["label"] === "string" && typeof credential === "object" && credential !== null && typeof credential["accessToken"] === "string" && typeof credential["refreshToken"] === "string";
1916
+ }
1917
+ /**
1918
+ * Read the durable list. Tolerant on purpose: a malformed or half-written file
1919
+ * yields `[]` and the pool simply rediscovers from disk, rather than a corrupt
1920
+ * store taking the whole pool down.
1921
+ */
1922
+ async function loadStoredAccounts(path) {
1923
+ let text;
1924
+ try {
1925
+ text = await readFile(path, "utf8");
1926
+ } catch {
1927
+ return [];
1928
+ }
1929
+ let parsed;
1930
+ try {
1931
+ parsed = JSON.parse(text);
1932
+ } catch {
1933
+ return [];
1934
+ }
1935
+ const entries = parsed?.["accounts"];
1936
+ if (!Array.isArray(entries)) return [];
1937
+ const out = [];
1938
+ const seen = /* @__PURE__ */ new Set();
1939
+ for (const entry of entries) {
1940
+ if (!isStoredAccount(entry)) continue;
1941
+ if (seen.has(entry.id)) continue;
1942
+ seen.add(entry.id);
1943
+ out.push(entry);
1944
+ }
1945
+ return out;
1946
+ }
1947
+ /**
1948
+ * Atomically write the durable list: `${path}.tmp` first, then rename. A crash
1949
+ * mid-write leaves the previous generation intact.
1950
+ */
1951
+ async function saveStoredAccounts(path, accounts) {
1952
+ const stored = accounts.map((account) => ({
1953
+ id: account.id,
1954
+ label: account.label,
1955
+ credential: account.credential,
1956
+ savedAtMs: Date.now()
1957
+ }));
1958
+ const document = JSON.stringify({
1959
+ version: 1,
1960
+ accounts: stored
1961
+ }, null, 2) + "\n";
1962
+ await mkdir(join(path, ".."), { recursive: true });
1963
+ const tmp = `${path}.tmp`;
1964
+ await writeFile$1(tmp, document, "utf8");
1965
+ await rename(tmp, path);
1966
+ }
1967
+ //#endregion
1734
1968
  //#region src/at-rest.ts
1735
1969
  /**
1736
1970
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -2579,6 +2813,23 @@ async function authFilesIn(dir) {
2579
2813
  return files.map((name) => join(dir, name));
2580
2814
  }
2581
2815
  /**
2816
+ * Snapshot files in the plugin data directory that are account snapshots.
2817
+ *
2818
+ * Every `*.info` file there is one (the importer keeps that extension for
2819
+ * uniformity with the auth dirs it copies from); the plugin's own JSON state
2820
+ * files (`accounts.json`, `ignored.json`, `preferred.json`) are NOT and must
2821
+ * never reach the credential parser.
2822
+ */
2823
+ async function snapshotFilesIn(dir) {
2824
+ let entries;
2825
+ try {
2826
+ entries = await readdir(dir);
2827
+ } catch {
2828
+ return [];
2829
+ }
2830
+ return entries.filter((name) => name.endsWith(".info")).filter((name) => !isStateFile(name)).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((name) => join(dir, name));
2831
+ }
2832
+ /**
2582
2833
  * One credential file, or undefined when it cannot be used.
2583
2834
  *
2584
2835
  * Returns undefined — rather than throwing — for every failure mode, so ONE bad
@@ -2857,6 +3108,18 @@ var WorkBuddyAccountPool = class {
2857
3108
  */
2858
3109
  lastUsedAt = /* @__PURE__ */ new Map();
2859
3110
  refreshInflight = /* @__PURE__ */ new Map();
3111
+ /**
3112
+ * Whether `scan()` seeds from and writes back the durable account store.
3113
+ *
3114
+ * Off by default (tests build fixtures over temp dirs and must not touch the
3115
+ * user's real store); `createCore()` turns it on for production, which is
3116
+ * what makes accounts gathered by switching desktop sign-ins survive a
3117
+ * restart — the desktop app keeps only the current sign-in's credential file
3118
+ * on disk.
3119
+ */
3120
+ persistent;
3121
+ /** Path of the durable store; computed once so tests can pin it. */
3122
+ storePath;
2860
3123
  constructor(options = {}) {
2861
3124
  this.logger = options.logger;
2862
3125
  this.authDirs = options.authDirs ?? candidateAuthDirs();
@@ -2866,6 +3129,8 @@ var WorkBuddyAccountPool = class {
2866
3129
  this.client = options.client;
2867
3130
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2868
3131
  this.distribution = options.distribution ?? "priority";
3132
+ this.persistent = options.persistent ?? false;
3133
+ this.storePath = accountStorePath();
2869
3134
  }
2870
3135
  /**
2871
3136
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -2915,26 +3180,44 @@ var WorkBuddyAccountPool = class {
2915
3180
  async scan() {
2916
3181
  const found = [];
2917
3182
  const skipped = [];
2918
- for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2919
- if (this.ignoredIds.size > 0) {
2920
- const cheapId = await cheapIdentityIdFromFile(file);
2921
- if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2922
- }
2923
- const credential = await readCredential(file);
2924
- if (credential === void 0) {
2925
- skipped.push({
2926
- path: file,
2927
- reason: await skipReasonFor(file)
2928
- });
2929
- continue;
3183
+ const scanDirs = this.persistent ? [...this.authDirs, snapshotDirPath()] : this.authDirs;
3184
+ for (const dir of scanDirs) {
3185
+ const files = dir === snapshotDirPath() ? await snapshotFilesIn(dir) : await authFilesIn(dir);
3186
+ for (const file of files) {
3187
+ if (this.ignoredIds.size > 0) {
3188
+ const cheapId = await cheapIdentityIdFromFile(file);
3189
+ if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
3190
+ }
3191
+ const credential = await readCredential(file);
3192
+ if (credential === void 0) {
3193
+ skipped.push({
3194
+ path: file,
3195
+ reason: await skipReasonFor(file)
3196
+ });
3197
+ continue;
3198
+ }
3199
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
3200
+ found.push(credential);
2930
3201
  }
2931
- if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2932
- found.push(credential);
2933
3202
  }
2934
3203
  this.skippedFiles = skipped;
2935
3204
  if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
2936
3205
  const byId = /* @__PURE__ */ new Map();
2937
3206
  for (const account of this.accounts) byId.set(account.id, account);
3207
+ if (this.persistent && this.accounts.length === 0) {
3208
+ const stored = await loadStoredAccounts(this.storePath);
3209
+ for (const entry of stored) {
3210
+ if (byId.has(entry.id)) continue;
3211
+ byId.set(entry.id, {
3212
+ id: entry.id,
3213
+ label: entry.label,
3214
+ credential: entry.credential,
3215
+ cooldownUntilMs: 0,
3216
+ modelCooldowns: {},
3217
+ rateLimitHits: 0
3218
+ });
3219
+ }
3220
+ }
2938
3221
  for (const credential of found) {
2939
3222
  const id = workbuddyAccountId(credential);
2940
3223
  const existing = byId.get(id);
@@ -2963,6 +3246,11 @@ var WorkBuddyAccountPool = class {
2963
3246
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
2964
3247
  this.accounts = ordered;
2965
3248
  this.lastScanAtMs = Date.now();
3249
+ if (this.persistent) try {
3250
+ await saveStoredAccounts(this.storePath, this.accounts);
3251
+ } catch (error) {
3252
+ this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3253
+ }
2966
3254
  return this.accounts;
2967
3255
  }
2968
3256
  /** All accounts, cooldown state included. */
@@ -10338,137 +10626,6 @@ function formatRates(status) {
10338
10626
  }
10339
10627
  return lines.join("\n");
10340
10628
  }
10341
- //#endregion
10342
- //#region src/ignored.ts
10343
- /**
10344
- * The permanent ignore list: accounts the user has thrown out of the pool.
10345
- *
10346
- * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
10347
- * account stays on the card, can be switched back on, and its credential keeps
10348
- * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
10349
- * one is not mine any more" — so an ignored account is skipped BEFORE its
10350
- * credential is decrypted, never appears among the pool's accounts, and does not
10351
- * come back when the desktop app writes a fresh `.info` file for it.
10352
- *
10353
- * It lives in a plugin-owned file rather than in the settings document for one
10354
- * practical reason: the CLI has no settings service. `dsh-xdpool
10355
- * ignore <id>` runs without a host, so a settings-only list could be written by
10356
- * the card and never by the CLI. One file, read by both halves, keeps the two
10357
- * views of "which accounts are ignored" identical by construction.
10358
- *
10359
- * @module dsh-xdpool/ignored
10360
- */
10361
- /** Directory holding this plugin's own state (imported snapshots, ignore list). */
10362
- const PLUGIN_DATA_DIR_NAME = ".xdpool";
10363
- /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
10364
- const IGNORED_FILE_NAME = "ignored.json";
10365
- /**
10366
- * The DSH home directory, honouring the same override the host uses.
10367
- *
10368
- * Shared by the CLI and the host so both halves resolve the same file: an
10369
- * `ignore` written from the terminal has to be visible to the running plugin,
10370
- * which is only true if they agree on where "home" is.
10371
- */
10372
- function dshHome$1(env = process.env) {
10373
- const fromEnv = env["DSH_HOME"];
10374
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
10375
- return join(homedir(), ".dsh");
10376
- }
10377
- /** This plugin's own state directory. */
10378
- function pluginDataDir(env = process.env) {
10379
- return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
10380
- }
10381
- /** Absolute path of the ignore list. */
10382
- function ignoredIdsPath(env = process.env) {
10383
- return join(pluginDataDir(env), IGNORED_FILE_NAME);
10384
- }
10385
- /**
10386
- * Normalize whatever the file holds into a clean list.
10387
- *
10388
- * Tolerant on purpose: this file is hand-editable and a malformed entry must not
10389
- * take the pool down. An entry without a usable id is dropped; a missing label
10390
- * falls back to the id so the card always has something to print.
10391
- */
10392
- function normalize$1(raw) {
10393
- if (typeof raw !== "object" || raw === null) return [];
10394
- const entries = raw.accounts;
10395
- if (!Array.isArray(entries)) return [];
10396
- const out = [];
10397
- const seen = /* @__PURE__ */ new Set();
10398
- for (const entry of entries) {
10399
- if (typeof entry !== "object" || entry === null) continue;
10400
- const record = entry;
10401
- const id = typeof record["id"] === "string" ? record["id"].trim() : "";
10402
- if (id === "" || seen.has(id)) continue;
10403
- seen.add(id);
10404
- const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
10405
- const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
10406
- out.push({
10407
- id,
10408
- label,
10409
- ignoredAt
10410
- });
10411
- }
10412
- return out;
10413
- }
10414
- /**
10415
- * Read the ignore list, tolerating every "no list yet" shape.
10416
- *
10417
- * A missing file, unreadable file, or invalid JSON all mean the same thing to
10418
- * the caller — nothing is ignored — so none of them throws. The pool must be
10419
- * able to start on a machine that has never ignored anything.
10420
- */
10421
- async function readIgnoredAccounts(path = ignoredIdsPath()) {
10422
- try {
10423
- return normalize$1(JSON.parse(await readFile(path, "utf8")));
10424
- } catch {
10425
- return [];
10426
- }
10427
- }
10428
- /**
10429
- * Replace the ignore list, atomically.
10430
- *
10431
- * Written to a sibling temp file and renamed over the target so a crash (or a
10432
- * concurrent reader) can never observe a half-written document — the ignore
10433
- * list is the only thing standing between a dead account and the rotation, and
10434
- * a truncated file reads as "nothing is ignored", which would quietly put every
10435
- * discarded account back in the pool.
10436
- */
10437
- async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
10438
- const payload = JSON.stringify({
10439
- version: 1,
10440
- accounts
10441
- }, null, 2);
10442
- await mkdir(dirname(path), { recursive: true });
10443
- const temp = `${path}.tmp`;
10444
- await writeFile$1(temp, `${payload}\n`, "utf8");
10445
- await rename(temp, path);
10446
- }
10447
- /**
10448
- * Add one account to the ignore list, preserving the rest.
10449
- *
10450
- * A read-modify-write rather than a wholesale replace: the card and the CLI can
10451
- * both be open, and each request names exactly one account, so re-writing the
10452
- * whole list from a stale view would drop the other side's edits.
10453
- */
10454
- async function ignoreAccount(account, path = ignoredIdsPath()) {
10455
- const current = await readIgnoredAccounts(path);
10456
- if (current.some((entry) => entry.id === account.id)) return current;
10457
- const next = [...current, {
10458
- id: account.id,
10459
- label: account.label ?? account.id,
10460
- ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
10461
- }];
10462
- await writeIgnoredAccounts(next, path);
10463
- return next;
10464
- }
10465
- /** Drop one account from the ignore list. Returns the resulting list. */
10466
- async function unignoreAccount(accountId, path = ignoredIdsPath()) {
10467
- const current = await readIgnoredAccounts(path);
10468
- const next = current.filter((entry) => entry.id !== accountId);
10469
- if (next.length !== current.length) await writeIgnoredAccounts(next, path);
10470
- return next;
10471
- }
10472
10629
  Object.freeze({
10473
10630
  "workbuddy-xdpool": "xdpool",
10474
10631
  "workbuddy-xdpool-global": "xdpool-global",
@@ -10647,7 +10804,8 @@ function createCore(logger) {
10647
10804
  const client = new WorkBuddyUpstreamClient();
10648
10805
  const pool = new WorkBuddyAccountPool({
10649
10806
  ...logger === void 0 ? {} : { logger },
10650
- client
10807
+ client,
10808
+ persistent: true
10651
10809
  });
10652
10810
  return {
10653
10811
  pool,
@@ -10666,6 +10824,11 @@ function createCore(logger) {
10666
10824
  *
10667
10825
  * dsh plugin --profile desktop exec dsh-xdpool <command>
10668
10826
  *
10827
+ * The CLI is packaged as a separate entry (`lib/bin.js`) and must stay free of
10828
+ * host-injected optional-peer imports: it runs standalone, before any argument
10829
+ * is parsed, so a single side-effect import of `@earendil-works/pi-ai` made
10830
+ * every command die with ERR_MODULE_NOT_FOUND.
10831
+ *
10669
10832
  * @module dsh-xdpool/bin
10670
10833
  */
10671
10834
  /** Directory holding imported account snapshots. */
@@ -10686,7 +10849,7 @@ async function cliCore() {
10686
10849
  }
10687
10850
  /** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
10688
10851
  function snapshotPath(key, dir) {
10689
- return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
10852
+ return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.info`);
10690
10853
  }
10691
10854
  function dshHome() {
10692
10855
  const fromEnv = process.env["DSH_HOME"];
package/lib/client.js CHANGED
@@ -5185,7 +5185,7 @@ window.__ModuleLoader__.load({
5185
5185
  *
5186
5186
  * @module dsh-xdpool/version
5187
5187
  */
5188
- const POOL_VERSION = "2.1.1";
5188
+ const POOL_VERSION = "2.2.1";
5189
5189
  //#endregion
5190
5190
  //#region src/client/PoolCard.tsx
5191
5191
  /**
package/lib/index.d.ts CHANGED
@@ -886,6 +886,21 @@ interface AccountPoolOptions {
886
886
  * behaves like `priority`.
887
887
  */
888
888
  distribution?: AccountDistribution;
889
+ /**
890
+ * Enable the durable account store.
891
+ *
892
+ * When on, `scan()` seeds its merge from `~/.dsh/.xdpool/accounts.json` and
893
+ * writes the merged list back, so accounts gathered by switching desktop
894
+ * sign-ins survive a restart, and the plugin's snapshot directory becomes a
895
+ * scan source.
896
+ *
897
+ * DEFAULT OFF, on purpose: the 70+ existing pool tests build fixtures over
898
+ * temp auth dirs, and a store that wrote by default would flush those
899
+ * fixtures into the user's real `~/.dsh/.xdpool/accounts.json`. Production
900
+ * assembles the pool through `createCore()`, which passes `persistent: true`;
901
+ * tests opt in explicitly.
902
+ */
903
+ persistent?: boolean;
889
904
  }
890
905
  export declare class WorkBuddyAccountPool {
891
906
  private readonly logger;
@@ -1001,6 +1016,18 @@ export declare class WorkBuddyAccountPool {
1001
1016
  */
1002
1017
  private lastUsedAt;
1003
1018
  private refreshInflight;
1019
+ /**
1020
+ * Whether `scan()` seeds from and writes back the durable account store.
1021
+ *
1022
+ * Off by default (tests build fixtures over temp dirs and must not touch the
1023
+ * user's real store); `createCore()` turns it on for production, which is
1024
+ * what makes accounts gathered by switching desktop sign-ins survive a
1025
+ * restart — the desktop app keeps only the current sign-in's credential file
1026
+ * on disk.
1027
+ */
1028
+ private readonly persistent;
1029
+ /** Path of the durable store; computed once so tests can pin it. */
1030
+ private readonly storePath;
1004
1031
  constructor(options?: AccountPoolOptions);
1005
1032
  /**
1006
1033
  * Re-apply configuration that only affects discovery and cooldown policy,
package/lib/index.js CHANGED
@@ -4,8 +4,8 @@ import z from "@deepseek-ai/schemastery";
4
4
  import crypto, { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
- import { execFile, execFileSync } from "node:child_process";
8
7
  import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, truncateSync } from "node:fs";
8
+ import { execFile, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { createProvider } from "@earendil-works/pi-ai";
11
11
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
@@ -1891,6 +1891,256 @@ var WorkBuddyUpstreamClient = class {
1891
1891
  }
1892
1892
  };
1893
1893
  //#endregion
1894
+ //#region src/ignored.ts
1895
+ /**
1896
+ * The permanent ignore list: accounts the user has thrown out of the pool.
1897
+ *
1898
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
1899
+ * account stays on the card, can be switched back on, and its credential keeps
1900
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
1901
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
1902
+ * credential is decrypted, never appears among the pool's accounts, and does not
1903
+ * come back when the desktop app writes a fresh `.info` file for it.
1904
+ *
1905
+ * It lives in a plugin-owned file rather than in the settings document for one
1906
+ * practical reason: the CLI has no settings service. `dsh-xdpool
1907
+ * ignore <id>` runs without a host, so a settings-only list could be written by
1908
+ * the card and never by the CLI. One file, read by both halves, keeps the two
1909
+ * views of "which accounts are ignored" identical by construction.
1910
+ *
1911
+ * @module dsh-xdpool/ignored
1912
+ */
1913
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
1914
+ const PLUGIN_DATA_DIR_NAME = ".xdpool";
1915
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
1916
+ const IGNORED_FILE_NAME = "ignored.json";
1917
+ /**
1918
+ * The DSH home directory, honouring the same override the host uses.
1919
+ *
1920
+ * Shared by the CLI and the host so both halves resolve the same file: an
1921
+ * `ignore` written from the terminal has to be visible to the running plugin,
1922
+ * which is only true if they agree on where "home" is.
1923
+ */
1924
+ function dshHome(env = process.env) {
1925
+ const fromEnv = env["DSH_HOME"];
1926
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
1927
+ return join(homedir(), ".dsh");
1928
+ }
1929
+ /** This plugin's own state directory. */
1930
+ function pluginDataDir(env = process.env) {
1931
+ return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
1932
+ }
1933
+ /** Absolute path of the ignore list. */
1934
+ function ignoredIdsPath(env = process.env) {
1935
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
1936
+ }
1937
+ /**
1938
+ * Normalize whatever the file holds into a clean list.
1939
+ *
1940
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
1941
+ * take the pool down. An entry without a usable id is dropped; a missing label
1942
+ * falls back to the id so the card always has something to print.
1943
+ */
1944
+ function normalize$1(raw) {
1945
+ if (typeof raw !== "object" || raw === null) return [];
1946
+ const entries = raw.accounts;
1947
+ if (!Array.isArray(entries)) return [];
1948
+ const out = [];
1949
+ const seen = /* @__PURE__ */ new Set();
1950
+ for (const entry of entries) {
1951
+ if (typeof entry !== "object" || entry === null) continue;
1952
+ const record = entry;
1953
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
1954
+ if (id === "" || seen.has(id)) continue;
1955
+ seen.add(id);
1956
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
1957
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
1958
+ out.push({
1959
+ id,
1960
+ label,
1961
+ ignoredAt
1962
+ });
1963
+ }
1964
+ return out;
1965
+ }
1966
+ /**
1967
+ * Read the ignore list, tolerating every "no list yet" shape.
1968
+ *
1969
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
1970
+ * the caller — nothing is ignored — so none of them throws. The pool must be
1971
+ * able to start on a machine that has never ignored anything.
1972
+ */
1973
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
1974
+ try {
1975
+ return normalize$1(JSON.parse(await readFile(path, "utf8")));
1976
+ } catch {
1977
+ return [];
1978
+ }
1979
+ }
1980
+ /**
1981
+ * Synchronous read, for startup.
1982
+ *
1983
+ * The host applies the ignore list from inside `apply()`, which is synchronous,
1984
+ * and doing it there removes a startup race: an async load could resolve AFTER
1985
+ * the first account scan, which would let an ignored account slip into the pool
1986
+ * once per boot. The file is a few hundred bytes, so a blocking read at startup
1987
+ * costs nothing measurable.
1988
+ */
1989
+ function readIgnoredAccountsSync(path = ignoredIdsPath()) {
1990
+ try {
1991
+ return normalize$1(JSON.parse(readFileSync(path, "utf8")));
1992
+ } catch {
1993
+ return [];
1994
+ }
1995
+ }
1996
+ /**
1997
+ * Replace the ignore list, atomically.
1998
+ *
1999
+ * Written to a sibling temp file and renamed over the target so a crash (or a
2000
+ * concurrent reader) can never observe a half-written document — the ignore
2001
+ * list is the only thing standing between a dead account and the rotation, and
2002
+ * a truncated file reads as "nothing is ignored", which would quietly put every
2003
+ * discarded account back in the pool.
2004
+ */
2005
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
2006
+ const payload = JSON.stringify({
2007
+ version: 1,
2008
+ accounts
2009
+ }, null, 2);
2010
+ await mkdir(dirname(path), { recursive: true });
2011
+ const temp = `${path}.tmp`;
2012
+ await writeFile(temp, `${payload}\n`, "utf8");
2013
+ await rename(temp, path);
2014
+ }
2015
+ /**
2016
+ * Add one account to the ignore list, preserving the rest.
2017
+ *
2018
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
2019
+ * both be open, and each request names exactly one account, so re-writing the
2020
+ * whole list from a stale view would drop the other side's edits.
2021
+ */
2022
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
2023
+ const current = await readIgnoredAccounts(path);
2024
+ if (current.some((entry) => entry.id === account.id)) return current;
2025
+ const next = [...current, {
2026
+ id: account.id,
2027
+ label: account.label ?? account.id,
2028
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
2029
+ }];
2030
+ await writeIgnoredAccounts(next, path);
2031
+ return next;
2032
+ }
2033
+ /** Drop one account from the ignore list. Returns the resulting list. */
2034
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
2035
+ const current = await readIgnoredAccounts(path);
2036
+ const next = current.filter((entry) => entry.id !== accountId);
2037
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
2038
+ return next;
2039
+ }
2040
+ //#endregion
2041
+ //#region src/account-store.ts
2042
+ /**
2043
+ * Durable copy of the discovered accounts, inside the plugin's data directory.
2044
+ *
2045
+ * The pool's account list is otherwise pure memory: the desktop app keeps only
2046
+ * the CURRENT sign-in on disk (each login overwrites the one auth file), so
2047
+ * accounts gathered by switching sign-ins evaporated on every restart. This
2048
+ * store is the fix — `scan()` seeds from it and writes the merged list back, so
2049
+ * the pool now survives restarts.
2050
+ *
2051
+ * JSON only, atomic rename on write: a crash mid-write leaves the previous
2052
+ * generation intact, and the pool never reads a half-written account again.
2053
+ *
2054
+ * @module dsh-xdpool/account-store
2055
+ */
2056
+ /** File name of the durable account list, inside the plugin data directory. */
2057
+ const ACCOUNTS_FILE_NAME = "accounts.json";
2058
+ /**
2059
+ * State files that live in the same directory but are NOT account snapshots.
2060
+ *
2061
+ * The snapshot importer (`dsh-xdpool import <key>`) writes `<md5prefix>.json`
2062
+ * into this directory, and the pool scans it — so every other `.json` file here
2063
+ * is treated as an imported snapshot. These three are the plugin's own
2064
+ * bookkeeping and must never be parsed as credentials.
2065
+ */
2066
+ const NON_SNAPSHOT_FILES = [
2067
+ ACCOUNTS_FILE_NAME,
2068
+ "ignored.json",
2069
+ "preferred.json"
2070
+ ];
2071
+ /** Absolute path of the durable account list. */
2072
+ function accountStorePath(env = process.env) {
2073
+ return join(dshHome(env), PLUGIN_DATA_DIR_NAME, ACCOUNTS_FILE_NAME);
2074
+ }
2075
+ /** The directory the pool scans for imported `<md5prefix>.json` snapshots. */
2076
+ function snapshotDirPath(env = process.env) {
2077
+ return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
2078
+ }
2079
+ /**
2080
+ * True when a file name in the snapshot directory is plugin bookkeeping rather
2081
+ * than an imported snapshot.
2082
+ */
2083
+ function isStateFile(name) {
2084
+ return NON_SNAPSHOT_FILES.includes(name);
2085
+ }
2086
+ /** True when a value has the shape of a stored account. */
2087
+ function isStoredAccount(value) {
2088
+ if (typeof value !== "object" || value === null) return false;
2089
+ const record = value;
2090
+ const credential = record["credential"];
2091
+ return typeof record["id"] === "string" && typeof record["label"] === "string" && typeof credential === "object" && credential !== null && typeof credential["accessToken"] === "string" && typeof credential["refreshToken"] === "string";
2092
+ }
2093
+ /**
2094
+ * Read the durable list. Tolerant on purpose: a malformed or half-written file
2095
+ * yields `[]` and the pool simply rediscovers from disk, rather than a corrupt
2096
+ * store taking the whole pool down.
2097
+ */
2098
+ async function loadStoredAccounts(path) {
2099
+ let text;
2100
+ try {
2101
+ text = await readFile(path, "utf8");
2102
+ } catch {
2103
+ return [];
2104
+ }
2105
+ let parsed;
2106
+ try {
2107
+ parsed = JSON.parse(text);
2108
+ } catch {
2109
+ return [];
2110
+ }
2111
+ const entries = parsed?.["accounts"];
2112
+ if (!Array.isArray(entries)) return [];
2113
+ const out = [];
2114
+ const seen = /* @__PURE__ */ new Set();
2115
+ for (const entry of entries) {
2116
+ if (!isStoredAccount(entry)) continue;
2117
+ if (seen.has(entry.id)) continue;
2118
+ seen.add(entry.id);
2119
+ out.push(entry);
2120
+ }
2121
+ return out;
2122
+ }
2123
+ /**
2124
+ * Atomically write the durable list: `${path}.tmp` first, then rename. A crash
2125
+ * mid-write leaves the previous generation intact.
2126
+ */
2127
+ async function saveStoredAccounts(path, accounts) {
2128
+ const stored = accounts.map((account) => ({
2129
+ id: account.id,
2130
+ label: account.label,
2131
+ credential: account.credential,
2132
+ savedAtMs: Date.now()
2133
+ }));
2134
+ const document = JSON.stringify({
2135
+ version: 1,
2136
+ accounts: stored
2137
+ }, null, 2) + "\n";
2138
+ await mkdir(join(path, ".."), { recursive: true });
2139
+ const tmp = `${path}.tmp`;
2140
+ await writeFile(tmp, document, "utf8");
2141
+ await rename(tmp, path);
2142
+ }
2143
+ //#endregion
1894
2144
  //#region src/at-rest.ts
1895
2145
  /**
1896
2146
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -2739,6 +2989,23 @@ async function authFilesIn(dir) {
2739
2989
  return files.map((name) => join(dir, name));
2740
2990
  }
2741
2991
  /**
2992
+ * Snapshot files in the plugin data directory that are account snapshots.
2993
+ *
2994
+ * Every `*.info` file there is one (the importer keeps that extension for
2995
+ * uniformity with the auth dirs it copies from); the plugin's own JSON state
2996
+ * files (`accounts.json`, `ignored.json`, `preferred.json`) are NOT and must
2997
+ * never reach the credential parser.
2998
+ */
2999
+ async function snapshotFilesIn(dir) {
3000
+ let entries;
3001
+ try {
3002
+ entries = await readdir(dir);
3003
+ } catch {
3004
+ return [];
3005
+ }
3006
+ return entries.filter((name) => name.endsWith(".info")).filter((name) => !isStateFile(name)).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((name) => join(dir, name));
3007
+ }
3008
+ /**
2742
3009
  * One credential file, or undefined when it cannot be used.
2743
3010
  *
2744
3011
  * Returns undefined — rather than throwing — for every failure mode, so ONE bad
@@ -3017,6 +3284,18 @@ var WorkBuddyAccountPool = class {
3017
3284
  */
3018
3285
  lastUsedAt = /* @__PURE__ */ new Map();
3019
3286
  refreshInflight = /* @__PURE__ */ new Map();
3287
+ /**
3288
+ * Whether `scan()` seeds from and writes back the durable account store.
3289
+ *
3290
+ * Off by default (tests build fixtures over temp dirs and must not touch the
3291
+ * user's real store); `createCore()` turns it on for production, which is
3292
+ * what makes accounts gathered by switching desktop sign-ins survive a
3293
+ * restart — the desktop app keeps only the current sign-in's credential file
3294
+ * on disk.
3295
+ */
3296
+ persistent;
3297
+ /** Path of the durable store; computed once so tests can pin it. */
3298
+ storePath;
3020
3299
  constructor(options = {}) {
3021
3300
  this.logger = options.logger;
3022
3301
  this.authDirs = options.authDirs ?? candidateAuthDirs();
@@ -3026,6 +3305,8 @@ var WorkBuddyAccountPool = class {
3026
3305
  this.client = options.client;
3027
3306
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
3028
3307
  this.distribution = options.distribution ?? "priority";
3308
+ this.persistent = options.persistent ?? false;
3309
+ this.storePath = accountStorePath();
3029
3310
  }
3030
3311
  /**
3031
3312
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -3075,26 +3356,44 @@ var WorkBuddyAccountPool = class {
3075
3356
  async scan() {
3076
3357
  const found = [];
3077
3358
  const skipped = [];
3078
- for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
3079
- if (this.ignoredIds.size > 0) {
3080
- const cheapId = await cheapIdentityIdFromFile(file);
3081
- if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
3082
- }
3083
- const credential = await readCredential(file);
3084
- if (credential === void 0) {
3085
- skipped.push({
3086
- path: file,
3087
- reason: await skipReasonFor(file)
3088
- });
3089
- continue;
3359
+ const scanDirs = this.persistent ? [...this.authDirs, snapshotDirPath()] : this.authDirs;
3360
+ for (const dir of scanDirs) {
3361
+ const files = dir === snapshotDirPath() ? await snapshotFilesIn(dir) : await authFilesIn(dir);
3362
+ for (const file of files) {
3363
+ if (this.ignoredIds.size > 0) {
3364
+ const cheapId = await cheapIdentityIdFromFile(file);
3365
+ if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
3366
+ }
3367
+ const credential = await readCredential(file);
3368
+ if (credential === void 0) {
3369
+ skipped.push({
3370
+ path: file,
3371
+ reason: await skipReasonFor(file)
3372
+ });
3373
+ continue;
3374
+ }
3375
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
3376
+ found.push(credential);
3090
3377
  }
3091
- if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
3092
- found.push(credential);
3093
3378
  }
3094
3379
  this.skippedFiles = skipped;
3095
3380
  if (skipped.length > 0) this.logger?.warn?.(`dsh-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
3096
3381
  const byId = /* @__PURE__ */ new Map();
3097
3382
  for (const account of this.accounts) byId.set(account.id, account);
3383
+ if (this.persistent && this.accounts.length === 0) {
3384
+ const stored = await loadStoredAccounts(this.storePath);
3385
+ for (const entry of stored) {
3386
+ if (byId.has(entry.id)) continue;
3387
+ byId.set(entry.id, {
3388
+ id: entry.id,
3389
+ label: entry.label,
3390
+ credential: entry.credential,
3391
+ cooldownUntilMs: 0,
3392
+ modelCooldowns: {},
3393
+ rateLimitHits: 0
3394
+ });
3395
+ }
3396
+ }
3098
3397
  for (const credential of found) {
3099
3398
  const id = workbuddyAccountId(credential);
3100
3399
  const existing = byId.get(id);
@@ -3123,6 +3422,11 @@ var WorkBuddyAccountPool = class {
3123
3422
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
3124
3423
  this.accounts = ordered;
3125
3424
  this.lastScanAtMs = Date.now();
3425
+ if (this.persistent) try {
3426
+ await saveStoredAccounts(this.storePath, this.accounts);
3427
+ } catch (error) {
3428
+ this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3429
+ }
3126
3430
  return this.accounts;
3127
3431
  }
3128
3432
  /** All accounts, cooldown state included. */
@@ -18048,153 +18352,6 @@ function formatRates(status) {
18048
18352
  return lines.join("\n");
18049
18353
  }
18050
18354
  //#endregion
18051
- //#region src/ignored.ts
18052
- /**
18053
- * The permanent ignore list: accounts the user has thrown out of the pool.
18054
- *
18055
- * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
18056
- * account stays on the card, can be switched back on, and its credential keeps
18057
- * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
18058
- * one is not mine any more" — so an ignored account is skipped BEFORE its
18059
- * credential is decrypted, never appears among the pool's accounts, and does not
18060
- * come back when the desktop app writes a fresh `.info` file for it.
18061
- *
18062
- * It lives in a plugin-owned file rather than in the settings document for one
18063
- * practical reason: the CLI has no settings service. `dsh-xdpool
18064
- * ignore <id>` runs without a host, so a settings-only list could be written by
18065
- * the card and never by the CLI. One file, read by both halves, keeps the two
18066
- * views of "which accounts are ignored" identical by construction.
18067
- *
18068
- * @module dsh-xdpool/ignored
18069
- */
18070
- /** Directory holding this plugin's own state (imported snapshots, ignore list). */
18071
- const PLUGIN_DATA_DIR_NAME = ".xdpool";
18072
- /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
18073
- const IGNORED_FILE_NAME = "ignored.json";
18074
- /**
18075
- * The DSH home directory, honouring the same override the host uses.
18076
- *
18077
- * Shared by the CLI and the host so both halves resolve the same file: an
18078
- * `ignore` written from the terminal has to be visible to the running plugin,
18079
- * which is only true if they agree on where "home" is.
18080
- */
18081
- function dshHome(env = process.env) {
18082
- const fromEnv = env["DSH_HOME"];
18083
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
18084
- return join(homedir(), ".dsh");
18085
- }
18086
- /** This plugin's own state directory. */
18087
- function pluginDataDir(env = process.env) {
18088
- return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
18089
- }
18090
- /** Absolute path of the ignore list. */
18091
- function ignoredIdsPath(env = process.env) {
18092
- return join(pluginDataDir(env), IGNORED_FILE_NAME);
18093
- }
18094
- /**
18095
- * Normalize whatever the file holds into a clean list.
18096
- *
18097
- * Tolerant on purpose: this file is hand-editable and a malformed entry must not
18098
- * take the pool down. An entry without a usable id is dropped; a missing label
18099
- * falls back to the id so the card always has something to print.
18100
- */
18101
- function normalize$1(raw) {
18102
- if (typeof raw !== "object" || raw === null) return [];
18103
- const entries = raw.accounts;
18104
- if (!Array.isArray(entries)) return [];
18105
- const out = [];
18106
- const seen = /* @__PURE__ */ new Set();
18107
- for (const entry of entries) {
18108
- if (typeof entry !== "object" || entry === null) continue;
18109
- const record = entry;
18110
- const id = typeof record["id"] === "string" ? record["id"].trim() : "";
18111
- if (id === "" || seen.has(id)) continue;
18112
- seen.add(id);
18113
- const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
18114
- const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
18115
- out.push({
18116
- id,
18117
- label,
18118
- ignoredAt
18119
- });
18120
- }
18121
- return out;
18122
- }
18123
- /**
18124
- * Read the ignore list, tolerating every "no list yet" shape.
18125
- *
18126
- * A missing file, unreadable file, or invalid JSON all mean the same thing to
18127
- * the caller — nothing is ignored — so none of them throws. The pool must be
18128
- * able to start on a machine that has never ignored anything.
18129
- */
18130
- async function readIgnoredAccounts(path = ignoredIdsPath()) {
18131
- try {
18132
- return normalize$1(JSON.parse(await readFile(path, "utf8")));
18133
- } catch {
18134
- return [];
18135
- }
18136
- }
18137
- /**
18138
- * Synchronous read, for startup.
18139
- *
18140
- * The host applies the ignore list from inside `apply()`, which is synchronous,
18141
- * and doing it there removes a startup race: an async load could resolve AFTER
18142
- * the first account scan, which would let an ignored account slip into the pool
18143
- * once per boot. The file is a few hundred bytes, so a blocking read at startup
18144
- * costs nothing measurable.
18145
- */
18146
- function readIgnoredAccountsSync(path = ignoredIdsPath()) {
18147
- try {
18148
- return normalize$1(JSON.parse(readFileSync(path, "utf8")));
18149
- } catch {
18150
- return [];
18151
- }
18152
- }
18153
- /**
18154
- * Replace the ignore list, atomically.
18155
- *
18156
- * Written to a sibling temp file and renamed over the target so a crash (or a
18157
- * concurrent reader) can never observe a half-written document — the ignore
18158
- * list is the only thing standing between a dead account and the rotation, and
18159
- * a truncated file reads as "nothing is ignored", which would quietly put every
18160
- * discarded account back in the pool.
18161
- */
18162
- async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
18163
- const payload = JSON.stringify({
18164
- version: 1,
18165
- accounts
18166
- }, null, 2);
18167
- await mkdir(dirname(path), { recursive: true });
18168
- const temp = `${path}.tmp`;
18169
- await writeFile(temp, `${payload}\n`, "utf8");
18170
- await rename(temp, path);
18171
- }
18172
- /**
18173
- * Add one account to the ignore list, preserving the rest.
18174
- *
18175
- * A read-modify-write rather than a wholesale replace: the card and the CLI can
18176
- * both be open, and each request names exactly one account, so re-writing the
18177
- * whole list from a stale view would drop the other side's edits.
18178
- */
18179
- async function ignoreAccount(account, path = ignoredIdsPath()) {
18180
- const current = await readIgnoredAccounts(path);
18181
- if (current.some((entry) => entry.id === account.id)) return current;
18182
- const next = [...current, {
18183
- id: account.id,
18184
- label: account.label ?? account.id,
18185
- ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
18186
- }];
18187
- await writeIgnoredAccounts(next, path);
18188
- return next;
18189
- }
18190
- /** Drop one account from the ignore list. Returns the resulting list. */
18191
- async function unignoreAccount(accountId, path = ignoredIdsPath()) {
18192
- const current = await readIgnoredAccounts(path);
18193
- const next = current.filter((entry) => entry.id !== accountId);
18194
- if (next.length !== current.length) await writeIgnoredAccounts(next, path);
18195
- return next;
18196
- }
18197
- //#endregion
18198
18355
  //#region src/migrate.ts
18199
18356
  /**
18200
18357
  * One-time adoption of state written under the plugin's pre-rename name.
@@ -18845,7 +19002,8 @@ function createCore(logger) {
18845
19002
  const client = new WorkBuddyUpstreamClient();
18846
19003
  const pool = new WorkBuddyAccountPool({
18847
19004
  ...logger === void 0 ? {} : { logger },
18848
- client
19005
+ client,
19006
+ persistent: true
18849
19007
  });
18850
19008
  return {
18851
19009
  pool,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-xdpool",
3
3
  "displayName": "DSH XD Pool",
4
4
  "description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
5
- "version": "2.1.1",
5
+ "version": "2.2.1",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",