dsh-xdpool 2.1.0 → 2.2.0

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/lib/bin.js CHANGED
@@ -224,6 +224,34 @@ function chatHeaders(credential) {
224
224
  "X-Product": "SaaS"
225
225
  };
226
226
  }
227
+ /**
228
+ * The client-identity headers the desktop app puts on every chat completion.
229
+ *
230
+ * WHY THESE EXIST AND WHY THEY BELONG ON /v2/chat/completions ONLY
231
+ *
232
+ * The gateway attributes each request to a client, and that attribution is what
233
+ * surfaces in the user's billing console as the "使用端" column. The desktop app
234
+ * (`buildVlmGatewayHeaders` in its bundled `main/server.js`) sends
235
+ * `X-IDE-Type`/`X-IDE-Name`/`X-IDE-Version` on every `/v2/chat/completions`, so
236
+ * its traffic reads as "WorkBuddy". Our chat requests sent none of them, so the
237
+ * same spend attributed to nothing — the column showed "–" and the user's
238
+ * billing page looked like the requests had come from nowhere.
239
+ *
240
+ * They are deliberately NOT part of `chatHeaders()`, which is shared with the
241
+ * points-automation endpoints (`listTasks` / `acceptTasks`). Those are not chat
242
+ * completions, and the evidence for the desktop app sending identity headers on
243
+ * them is nil — adding identity headers to an endpoint that never carried them
244
+ * risks changing how that traffic is attributed or filtered, and the automation
245
+ * was never reported broken. Only the two chat completions get these.
246
+ *
247
+ * The version is the same literal `openConversation()` already sends, so both
248
+ * chat paths — the pool's and the automation's — claim the same client.
249
+ */
250
+ const CHAT_IDENTITY_HEADERS = Object.freeze({
251
+ "X-IDE-Name": "WorkBuddy",
252
+ "X-IDE-Type": "WorkBuddy",
253
+ "X-IDE-Version": "5.5.6"
254
+ });
227
255
  /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
228
256
  function refreshHeaders(credential) {
229
257
  const headers = {
@@ -819,7 +847,10 @@ var WorkBuddyUpstreamClient = class {
819
847
  async completeChat(credential, prepared, signal) {
820
848
  const response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
821
849
  method: "POST",
822
- headers: chatHeaders(credential),
850
+ headers: {
851
+ ...chatHeaders(credential),
852
+ ...CHAT_IDENTITY_HEADERS
853
+ },
823
854
  body: prepared,
824
855
  ...signal === void 0 ? {} : { signal }
825
856
  });
@@ -836,7 +867,10 @@ var WorkBuddyUpstreamClient = class {
836
867
  try {
837
868
  response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
838
869
  method: "POST",
839
- headers: chatHeaders(credential),
870
+ headers: {
871
+ ...chatHeaders(credential),
872
+ ...CHAT_IDENTITY_HEADERS
873
+ },
840
874
  body: prepared,
841
875
  ...signal === void 0 ? {} : { signal }
842
876
  });
@@ -1316,15 +1350,13 @@ var WorkBuddyUpstreamClient = class {
1316
1350
  method: "POST",
1317
1351
  headers: {
1318
1352
  ...chatHeaders(credential),
1353
+ ...CHAT_IDENTITY_HEADERS,
1319
1354
  "Accept": "text/event-stream",
1320
1355
  "User-Agent": DESKTOP_TASK_UA,
1321
1356
  "X-Conversation-ID": conversationId,
1322
1357
  "X-Request-ID": String(Date.now()) + "000000",
1323
1358
  "X-Agent-Intent": "craft",
1324
1359
  "X-Agent-Type": "main",
1325
- "X-IDE-Name": "WorkBuddy",
1326
- "X-IDE-Type": "WorkBuddy",
1327
- "X-IDE-Version": "5.5.6",
1328
1360
  "x-codebuddy-request": "1",
1329
1361
  ...expertId === "" ? {} : { "X-Expert-Id": expertId }
1330
1362
  },
@@ -1699,6 +1731,240 @@ var WorkBuddyUpstreamClient = class {
1699
1731
  }
1700
1732
  };
1701
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
1702
1968
  //#region src/at-rest.ts
1703
1969
  /**
1704
1970
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -2547,6 +2813,23 @@ async function authFilesIn(dir) {
2547
2813
  return files.map((name) => join(dir, name));
2548
2814
  }
2549
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
+ /**
2550
2833
  * One credential file, or undefined when it cannot be used.
2551
2834
  *
2552
2835
  * Returns undefined — rather than throwing — for every failure mode, so ONE bad
@@ -2825,6 +3108,18 @@ var WorkBuddyAccountPool = class {
2825
3108
  */
2826
3109
  lastUsedAt = /* @__PURE__ */ new Map();
2827
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;
2828
3123
  constructor(options = {}) {
2829
3124
  this.logger = options.logger;
2830
3125
  this.authDirs = options.authDirs ?? candidateAuthDirs();
@@ -2834,6 +3129,8 @@ var WorkBuddyAccountPool = class {
2834
3129
  this.client = options.client;
2835
3130
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2836
3131
  this.distribution = options.distribution ?? "priority";
3132
+ this.persistent = options.persistent ?? false;
3133
+ this.storePath = accountStorePath();
2837
3134
  }
2838
3135
  /**
2839
3136
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -2883,26 +3180,44 @@ var WorkBuddyAccountPool = class {
2883
3180
  async scan() {
2884
3181
  const found = [];
2885
3182
  const skipped = [];
2886
- for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2887
- if (this.ignoredIds.size > 0) {
2888
- const cheapId = await cheapIdentityIdFromFile(file);
2889
- if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2890
- }
2891
- const credential = await readCredential(file);
2892
- if (credential === void 0) {
2893
- skipped.push({
2894
- path: file,
2895
- reason: await skipReasonFor(file)
2896
- });
2897
- 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);
2898
3201
  }
2899
- if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2900
- found.push(credential);
2901
3202
  }
2902
3203
  this.skippedFiles = skipped;
2903
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("; "));
2904
3205
  const byId = /* @__PURE__ */ new Map();
2905
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
+ }
2906
3221
  for (const credential of found) {
2907
3222
  const id = workbuddyAccountId(credential);
2908
3223
  const existing = byId.get(id);
@@ -2931,6 +3246,11 @@ var WorkBuddyAccountPool = class {
2931
3246
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
2932
3247
  this.accounts = ordered;
2933
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
+ }
2934
3254
  return this.accounts;
2935
3255
  }
2936
3256
  /** All accounts, cooldown state included. */
@@ -10306,137 +10626,6 @@ function formatRates(status) {
10306
10626
  }
10307
10627
  return lines.join("\n");
10308
10628
  }
10309
- //#endregion
10310
- //#region src/ignored.ts
10311
- /**
10312
- * The permanent ignore list: accounts the user has thrown out of the pool.
10313
- *
10314
- * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
10315
- * account stays on the card, can be switched back on, and its credential keeps
10316
- * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
10317
- * one is not mine any more" — so an ignored account is skipped BEFORE its
10318
- * credential is decrypted, never appears among the pool's accounts, and does not
10319
- * come back when the desktop app writes a fresh `.info` file for it.
10320
- *
10321
- * It lives in a plugin-owned file rather than in the settings document for one
10322
- * practical reason: the CLI has no settings service. `dsh-xdpool
10323
- * ignore <id>` runs without a host, so a settings-only list could be written by
10324
- * the card and never by the CLI. One file, read by both halves, keeps the two
10325
- * views of "which accounts are ignored" identical by construction.
10326
- *
10327
- * @module dsh-xdpool/ignored
10328
- */
10329
- /** Directory holding this plugin's own state (imported snapshots, ignore list). */
10330
- const PLUGIN_DATA_DIR_NAME = ".xdpool";
10331
- /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
10332
- const IGNORED_FILE_NAME = "ignored.json";
10333
- /**
10334
- * The DSH home directory, honouring the same override the host uses.
10335
- *
10336
- * Shared by the CLI and the host so both halves resolve the same file: an
10337
- * `ignore` written from the terminal has to be visible to the running plugin,
10338
- * which is only true if they agree on where "home" is.
10339
- */
10340
- function dshHome$1(env = process.env) {
10341
- const fromEnv = env["DSH_HOME"];
10342
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
10343
- return join(homedir(), ".dsh");
10344
- }
10345
- /** This plugin's own state directory. */
10346
- function pluginDataDir(env = process.env) {
10347
- return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
10348
- }
10349
- /** Absolute path of the ignore list. */
10350
- function ignoredIdsPath(env = process.env) {
10351
- return join(pluginDataDir(env), IGNORED_FILE_NAME);
10352
- }
10353
- /**
10354
- * Normalize whatever the file holds into a clean list.
10355
- *
10356
- * Tolerant on purpose: this file is hand-editable and a malformed entry must not
10357
- * take the pool down. An entry without a usable id is dropped; a missing label
10358
- * falls back to the id so the card always has something to print.
10359
- */
10360
- function normalize$1(raw) {
10361
- if (typeof raw !== "object" || raw === null) return [];
10362
- const entries = raw.accounts;
10363
- if (!Array.isArray(entries)) return [];
10364
- const out = [];
10365
- const seen = /* @__PURE__ */ new Set();
10366
- for (const entry of entries) {
10367
- if (typeof entry !== "object" || entry === null) continue;
10368
- const record = entry;
10369
- const id = typeof record["id"] === "string" ? record["id"].trim() : "";
10370
- if (id === "" || seen.has(id)) continue;
10371
- seen.add(id);
10372
- const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
10373
- const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
10374
- out.push({
10375
- id,
10376
- label,
10377
- ignoredAt
10378
- });
10379
- }
10380
- return out;
10381
- }
10382
- /**
10383
- * Read the ignore list, tolerating every "no list yet" shape.
10384
- *
10385
- * A missing file, unreadable file, or invalid JSON all mean the same thing to
10386
- * the caller — nothing is ignored — so none of them throws. The pool must be
10387
- * able to start on a machine that has never ignored anything.
10388
- */
10389
- async function readIgnoredAccounts(path = ignoredIdsPath()) {
10390
- try {
10391
- return normalize$1(JSON.parse(await readFile(path, "utf8")));
10392
- } catch {
10393
- return [];
10394
- }
10395
- }
10396
- /**
10397
- * Replace the ignore list, atomically.
10398
- *
10399
- * Written to a sibling temp file and renamed over the target so a crash (or a
10400
- * concurrent reader) can never observe a half-written document — the ignore
10401
- * list is the only thing standing between a dead account and the rotation, and
10402
- * a truncated file reads as "nothing is ignored", which would quietly put every
10403
- * discarded account back in the pool.
10404
- */
10405
- async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
10406
- const payload = JSON.stringify({
10407
- version: 1,
10408
- accounts
10409
- }, null, 2);
10410
- await mkdir(dirname(path), { recursive: true });
10411
- const temp = `${path}.tmp`;
10412
- await writeFile$1(temp, `${payload}\n`, "utf8");
10413
- await rename(temp, path);
10414
- }
10415
- /**
10416
- * Add one account to the ignore list, preserving the rest.
10417
- *
10418
- * A read-modify-write rather than a wholesale replace: the card and the CLI can
10419
- * both be open, and each request names exactly one account, so re-writing the
10420
- * whole list from a stale view would drop the other side's edits.
10421
- */
10422
- async function ignoreAccount(account, path = ignoredIdsPath()) {
10423
- const current = await readIgnoredAccounts(path);
10424
- if (current.some((entry) => entry.id === account.id)) return current;
10425
- const next = [...current, {
10426
- id: account.id,
10427
- label: account.label ?? account.id,
10428
- ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
10429
- }];
10430
- await writeIgnoredAccounts(next, path);
10431
- return next;
10432
- }
10433
- /** Drop one account from the ignore list. Returns the resulting list. */
10434
- async function unignoreAccount(accountId, path = ignoredIdsPath()) {
10435
- const current = await readIgnoredAccounts(path);
10436
- const next = current.filter((entry) => entry.id !== accountId);
10437
- if (next.length !== current.length) await writeIgnoredAccounts(next, path);
10438
- return next;
10439
- }
10440
10629
  Object.freeze({
10441
10630
  "workbuddy-xdpool": "xdpool",
10442
10631
  "workbuddy-xdpool-global": "xdpool-global",
@@ -10615,7 +10804,8 @@ function createCore(logger) {
10615
10804
  const client = new WorkBuddyUpstreamClient();
10616
10805
  const pool = new WorkBuddyAccountPool({
10617
10806
  ...logger === void 0 ? {} : { logger },
10618
- client
10807
+ client,
10808
+ persistent: true
10619
10809
  });
10620
10810
  return {
10621
10811
  pool,
@@ -10634,6 +10824,11 @@ function createCore(logger) {
10634
10824
  *
10635
10825
  * dsh plugin --profile desktop exec dsh-xdpool <command>
10636
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
+ *
10637
10832
  * @module dsh-xdpool/bin
10638
10833
  */
10639
10834
  /** Directory holding imported account snapshots. */
@@ -10654,7 +10849,7 @@ async function cliCore() {
10654
10849
  }
10655
10850
  /** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
10656
10851
  function snapshotPath(key, dir) {
10657
- 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`);
10658
10853
  }
10659
10854
  function dshHome() {
10660
10855
  const fromEnv = process.env["DSH_HOME"];
package/lib/client.js CHANGED
@@ -445,7 +445,6 @@ window.__ModuleLoader__.load({
445
445
  sharing a line with a total. */
446
446
  .dsm-xdpool-packages li{display:inline-flex;align-items:baseline;gap:5px;min-width:0;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1,#2f3035);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 62%, transparent);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:17px}
447
447
  .dsm-xdpool-packages-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
448
- .dsm-xdpool-packages-value{color:var(--dsw-alias-label-tertiary,#999);font-variant-numeric:tabular-nums}
449
448
  /* Per-package deadline: the upstream grants one-off packs at arbitrary clock
450
449
  times, so each pack carries its own timestamp. It sits inside its own chip on
451
450
  its own line — a pack name plus two timestamps did not fit on one line, and
@@ -453,8 +452,17 @@ window.__ModuleLoader__.load({
453
452
  .dsm-xdpool-packages-when{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:10.5px;line-height:15px;font-variant-numeric:tabular-nums}
454
453
  .dsm-xdpool-packages-when-soon{color:var(--dsw-alias-state-warning-primary,#d97706)}
455
454
  /* The balance. Still the most important figure on the row — colour and weight,
456
- not size: at 28px it outweighed the account name it belongs to. */
457
- .dsm-xdpool-total-value{color:var(--dsw-alias-state-success-primary,#22a06b);font-size:16px;line-height:22px;font-weight:700;letter-spacing:-.2px;white-space:nowrap;font-variant-numeric:tabular-nums}
455
+ not size: at 28px it outweighed the account name it belongs to. The green is
456
+ pinned rather than taken from the theme alias: at 16px/700 the alias green
457
+ read darker than the 11px figures beside it on the same row — the same hex
458
+ looks dimmer in large bold type than in small text — and the user read the
459
+ balance as grey next to the bright green streak chips. A slightly brighter
460
+ literal keeps the two readings of "green" on this row identical. */
461
+ .dsm-xdpool-total-value{color:#2fbf7f;font-size:16px;line-height:22px;font-weight:700;letter-spacing:-.2px;white-space:nowrap;font-variant-numeric:tabular-nums}
462
+ /* Per-package figures share the balance's green: they are the same credit
463
+ balance broken down, and rendering them in grey made the packages look
464
+ spent while the total looked alive. */
465
+ .dsm-xdpool-packages-value{color:#2fbf7f;font-variant-numeric:tabular-nums}
458
466
  /* Model directory list. */
459
467
  .dsm-xdpool-models{gap:10px}
460
468
  .dsm-xdpool-models-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
@@ -5177,7 +5185,7 @@ window.__ModuleLoader__.load({
5177
5185
  *
5178
5186
  * @module dsh-xdpool/version
5179
5187
  */
5180
- const POOL_VERSION = "2.1.0";
5188
+ const POOL_VERSION = "2.2.0";
5181
5189
  //#endregion
5182
5190
  //#region src/client/PoolCard.tsx
5183
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,