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/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";
@@ -275,6 +275,34 @@ function chatHeaders(credential) {
275
275
  "X-Product": "SaaS"
276
276
  };
277
277
  }
278
+ /**
279
+ * The client-identity headers the desktop app puts on every chat completion.
280
+ *
281
+ * WHY THESE EXIST AND WHY THEY BELONG ON /v2/chat/completions ONLY
282
+ *
283
+ * The gateway attributes each request to a client, and that attribution is what
284
+ * surfaces in the user's billing console as the "使用端" column. The desktop app
285
+ * (`buildVlmGatewayHeaders` in its bundled `main/server.js`) sends
286
+ * `X-IDE-Type`/`X-IDE-Name`/`X-IDE-Version` on every `/v2/chat/completions`, so
287
+ * its traffic reads as "WorkBuddy". Our chat requests sent none of them, so the
288
+ * same spend attributed to nothing — the column showed "–" and the user's
289
+ * billing page looked like the requests had come from nowhere.
290
+ *
291
+ * They are deliberately NOT part of `chatHeaders()`, which is shared with the
292
+ * points-automation endpoints (`listTasks` / `acceptTasks`). Those are not chat
293
+ * completions, and the evidence for the desktop app sending identity headers on
294
+ * them is nil — adding identity headers to an endpoint that never carried them
295
+ * risks changing how that traffic is attributed or filtered, and the automation
296
+ * was never reported broken. Only the two chat completions get these.
297
+ *
298
+ * The version is the same literal `openConversation()` already sends, so both
299
+ * chat paths — the pool's and the automation's — claim the same client.
300
+ */
301
+ const CHAT_IDENTITY_HEADERS = Object.freeze({
302
+ "X-IDE-Name": "WorkBuddy",
303
+ "X-IDE-Type": "WorkBuddy",
304
+ "X-IDE-Version": "5.5.6"
305
+ });
278
306
  /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
279
307
  function refreshHeaders(credential) {
280
308
  const headers = {
@@ -979,7 +1007,10 @@ var WorkBuddyUpstreamClient = class {
979
1007
  async completeChat(credential, prepared, signal) {
980
1008
  const response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
981
1009
  method: "POST",
982
- headers: chatHeaders(credential),
1010
+ headers: {
1011
+ ...chatHeaders(credential),
1012
+ ...CHAT_IDENTITY_HEADERS
1013
+ },
983
1014
  body: prepared,
984
1015
  ...signal === void 0 ? {} : { signal }
985
1016
  });
@@ -996,7 +1027,10 @@ var WorkBuddyUpstreamClient = class {
996
1027
  try {
997
1028
  response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
998
1029
  method: "POST",
999
- headers: chatHeaders(credential),
1030
+ headers: {
1031
+ ...chatHeaders(credential),
1032
+ ...CHAT_IDENTITY_HEADERS
1033
+ },
1000
1034
  body: prepared,
1001
1035
  ...signal === void 0 ? {} : { signal }
1002
1036
  });
@@ -1476,15 +1510,13 @@ var WorkBuddyUpstreamClient = class {
1476
1510
  method: "POST",
1477
1511
  headers: {
1478
1512
  ...chatHeaders(credential),
1513
+ ...CHAT_IDENTITY_HEADERS,
1479
1514
  "Accept": "text/event-stream",
1480
1515
  "User-Agent": DESKTOP_TASK_UA,
1481
1516
  "X-Conversation-ID": conversationId,
1482
1517
  "X-Request-ID": String(Date.now()) + "000000",
1483
1518
  "X-Agent-Intent": "craft",
1484
1519
  "X-Agent-Type": "main",
1485
- "X-IDE-Name": "WorkBuddy",
1486
- "X-IDE-Type": "WorkBuddy",
1487
- "X-IDE-Version": "5.5.6",
1488
1520
  "x-codebuddy-request": "1",
1489
1521
  ...expertId === "" ? {} : { "X-Expert-Id": expertId }
1490
1522
  },
@@ -1859,6 +1891,256 @@ var WorkBuddyUpstreamClient = class {
1859
1891
  }
1860
1892
  };
1861
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
1862
2144
  //#region src/at-rest.ts
1863
2145
  /**
1864
2146
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -2707,6 +2989,23 @@ async function authFilesIn(dir) {
2707
2989
  return files.map((name) => join(dir, name));
2708
2990
  }
2709
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
+ /**
2710
3009
  * One credential file, or undefined when it cannot be used.
2711
3010
  *
2712
3011
  * Returns undefined — rather than throwing — for every failure mode, so ONE bad
@@ -2985,6 +3284,18 @@ var WorkBuddyAccountPool = class {
2985
3284
  */
2986
3285
  lastUsedAt = /* @__PURE__ */ new Map();
2987
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;
2988
3299
  constructor(options = {}) {
2989
3300
  this.logger = options.logger;
2990
3301
  this.authDirs = options.authDirs ?? candidateAuthDirs();
@@ -2994,6 +3305,8 @@ var WorkBuddyAccountPool = class {
2994
3305
  this.client = options.client;
2995
3306
  this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
2996
3307
  this.distribution = options.distribution ?? "priority";
3308
+ this.persistent = options.persistent ?? false;
3309
+ this.storePath = accountStorePath();
2997
3310
  }
2998
3311
  /**
2999
3312
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -3043,26 +3356,44 @@ var WorkBuddyAccountPool = class {
3043
3356
  async scan() {
3044
3357
  const found = [];
3045
3358
  const skipped = [];
3046
- for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
3047
- if (this.ignoredIds.size > 0) {
3048
- const cheapId = await cheapIdentityIdFromFile(file);
3049
- if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
3050
- }
3051
- const credential = await readCredential(file);
3052
- if (credential === void 0) {
3053
- skipped.push({
3054
- path: file,
3055
- reason: await skipReasonFor(file)
3056
- });
3057
- 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);
3058
3377
  }
3059
- if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
3060
- found.push(credential);
3061
3378
  }
3062
3379
  this.skippedFiles = skipped;
3063
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("; "));
3064
3381
  const byId = /* @__PURE__ */ new Map();
3065
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
+ }
3066
3397
  for (const credential of found) {
3067
3398
  const id = workbuddyAccountId(credential);
3068
3399
  const existing = byId.get(id);
@@ -3091,6 +3422,11 @@ var WorkBuddyAccountPool = class {
3091
3422
  ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
3092
3423
  this.accounts = ordered;
3093
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
+ }
3094
3430
  return this.accounts;
3095
3431
  }
3096
3432
  /** All accounts, cooldown state included. */
@@ -11334,21 +11670,32 @@ function createWorkBuddyAdapter(options) {
11334
11670
  /** The package name the loader resolves, exactly as our patch row names it. */
11335
11671
  const BILLION_PACKAGE = "billion-context";
11336
11672
  /**
11337
- * Is this package already running?
11673
+ * Is this package installed and able to run?
11338
11674
  *
11339
- * `fiber.uid` is the host's "this fiber has actually started" flag: an entry
11340
- * that exists but never finished importing is not a second mount, it is a
11341
- * mount in progress, and treating it as absent would race it.
11675
+ * Any entry whose name matches counts — including one whose fiber has not
11676
+ * started yet. When this function still decided whether to mount, a fiber-less
11677
+ * entry had to be treated as absent: treating an import-in-flight as present
11678
+ * would have skipped the mount and left the compressor never starting. Now that
11679
+ * nothing mounts, the distinction only picks a label, and the honest one is
11680
+ * "installed": a user watching billion-context's own boot does not want the
11681
+ * card telling them it is missing while its proxy is half-way up.
11682
+ *
11683
+ * A DISABLED entry does not count. `loader.entries()` yields disabled rows too
11684
+ * — the host skips starting them, it does not drop them — and our own patch
11685
+ * row is disabled on purpose. Counting it would make every profile that never
11686
+ * installed billion-context read as "installed", which is precisely the lie the
11687
+ * card must not tell.
11342
11688
  */
11343
11689
  function mountedInLoader(loader, packageName) {
11344
11690
  for (const entry of loader.entries()) {
11345
11691
  if (entry.options?.name !== packageName) continue;
11346
- if (entry.fiber?.uid !== void 0 && entry.fiber.uid !== null) return true;
11692
+ if (entry.options?.disabled === true) continue;
11693
+ return true;
11347
11694
  }
11348
11695
  return false;
11349
11696
  }
11350
11697
  /**
11351
- * Bring billion-context up if it is not already running.
11698
+ * Report whether the compressor is already running. Never mounts.
11352
11699
  *
11353
11700
  * Resolves rather than rejects on every path: a compressor that will not start
11354
11701
  * is a card that says so, never a pool that fails to boot. The pool's own model
@@ -11369,12 +11716,7 @@ async function mountBillionContext(ctx, logger) {
11369
11716
  logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
11370
11717
  return { state: "already-mounted" };
11371
11718
  }
11372
- await loader.create({
11373
- id: "billion-context-xdpool",
11374
- name: BILLION_PACKAGE
11375
- });
11376
- logger.info?.(`[xdpool] mounted billion-context for the billion card`);
11377
- return { state: "mounted" };
11719
+ return { state: "not-required" };
11378
11720
  } catch (error) {
11379
11721
  return {
11380
11722
  state: "unavailable",
@@ -18010,153 +18352,6 @@ function formatRates(status) {
18010
18352
  return lines.join("\n");
18011
18353
  }
18012
18354
  //#endregion
18013
- //#region src/ignored.ts
18014
- /**
18015
- * The permanent ignore list: accounts the user has thrown out of the pool.
18016
- *
18017
- * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
18018
- * account stays on the card, can be switched back on, and its credential keeps
18019
- * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
18020
- * one is not mine any more" — so an ignored account is skipped BEFORE its
18021
- * credential is decrypted, never appears among the pool's accounts, and does not
18022
- * come back when the desktop app writes a fresh `.info` file for it.
18023
- *
18024
- * It lives in a plugin-owned file rather than in the settings document for one
18025
- * practical reason: the CLI has no settings service. `dsh-xdpool
18026
- * ignore <id>` runs without a host, so a settings-only list could be written by
18027
- * the card and never by the CLI. One file, read by both halves, keeps the two
18028
- * views of "which accounts are ignored" identical by construction.
18029
- *
18030
- * @module dsh-xdpool/ignored
18031
- */
18032
- /** Directory holding this plugin's own state (imported snapshots, ignore list). */
18033
- const PLUGIN_DATA_DIR_NAME = ".xdpool";
18034
- /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
18035
- const IGNORED_FILE_NAME = "ignored.json";
18036
- /**
18037
- * The DSH home directory, honouring the same override the host uses.
18038
- *
18039
- * Shared by the CLI and the host so both halves resolve the same file: an
18040
- * `ignore` written from the terminal has to be visible to the running plugin,
18041
- * which is only true if they agree on where "home" is.
18042
- */
18043
- function dshHome(env = process.env) {
18044
- const fromEnv = env["DSH_HOME"];
18045
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
18046
- return join(homedir(), ".dsh");
18047
- }
18048
- /** This plugin's own state directory. */
18049
- function pluginDataDir(env = process.env) {
18050
- return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
18051
- }
18052
- /** Absolute path of the ignore list. */
18053
- function ignoredIdsPath(env = process.env) {
18054
- return join(pluginDataDir(env), IGNORED_FILE_NAME);
18055
- }
18056
- /**
18057
- * Normalize whatever the file holds into a clean list.
18058
- *
18059
- * Tolerant on purpose: this file is hand-editable and a malformed entry must not
18060
- * take the pool down. An entry without a usable id is dropped; a missing label
18061
- * falls back to the id so the card always has something to print.
18062
- */
18063
- function normalize$1(raw) {
18064
- if (typeof raw !== "object" || raw === null) return [];
18065
- const entries = raw.accounts;
18066
- if (!Array.isArray(entries)) return [];
18067
- const out = [];
18068
- const seen = /* @__PURE__ */ new Set();
18069
- for (const entry of entries) {
18070
- if (typeof entry !== "object" || entry === null) continue;
18071
- const record = entry;
18072
- const id = typeof record["id"] === "string" ? record["id"].trim() : "";
18073
- if (id === "" || seen.has(id)) continue;
18074
- seen.add(id);
18075
- const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
18076
- const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
18077
- out.push({
18078
- id,
18079
- label,
18080
- ignoredAt
18081
- });
18082
- }
18083
- return out;
18084
- }
18085
- /**
18086
- * Read the ignore list, tolerating every "no list yet" shape.
18087
- *
18088
- * A missing file, unreadable file, or invalid JSON all mean the same thing to
18089
- * the caller — nothing is ignored — so none of them throws. The pool must be
18090
- * able to start on a machine that has never ignored anything.
18091
- */
18092
- async function readIgnoredAccounts(path = ignoredIdsPath()) {
18093
- try {
18094
- return normalize$1(JSON.parse(await readFile(path, "utf8")));
18095
- } catch {
18096
- return [];
18097
- }
18098
- }
18099
- /**
18100
- * Synchronous read, for startup.
18101
- *
18102
- * The host applies the ignore list from inside `apply()`, which is synchronous,
18103
- * and doing it there removes a startup race: an async load could resolve AFTER
18104
- * the first account scan, which would let an ignored account slip into the pool
18105
- * once per boot. The file is a few hundred bytes, so a blocking read at startup
18106
- * costs nothing measurable.
18107
- */
18108
- function readIgnoredAccountsSync(path = ignoredIdsPath()) {
18109
- try {
18110
- return normalize$1(JSON.parse(readFileSync(path, "utf8")));
18111
- } catch {
18112
- return [];
18113
- }
18114
- }
18115
- /**
18116
- * Replace the ignore list, atomically.
18117
- *
18118
- * Written to a sibling temp file and renamed over the target so a crash (or a
18119
- * concurrent reader) can never observe a half-written document — the ignore
18120
- * list is the only thing standing between a dead account and the rotation, and
18121
- * a truncated file reads as "nothing is ignored", which would quietly put every
18122
- * discarded account back in the pool.
18123
- */
18124
- async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
18125
- const payload = JSON.stringify({
18126
- version: 1,
18127
- accounts
18128
- }, null, 2);
18129
- await mkdir(dirname(path), { recursive: true });
18130
- const temp = `${path}.tmp`;
18131
- await writeFile(temp, `${payload}\n`, "utf8");
18132
- await rename(temp, path);
18133
- }
18134
- /**
18135
- * Add one account to the ignore list, preserving the rest.
18136
- *
18137
- * A read-modify-write rather than a wholesale replace: the card and the CLI can
18138
- * both be open, and each request names exactly one account, so re-writing the
18139
- * whole list from a stale view would drop the other side's edits.
18140
- */
18141
- async function ignoreAccount(account, path = ignoredIdsPath()) {
18142
- const current = await readIgnoredAccounts(path);
18143
- if (current.some((entry) => entry.id === account.id)) return current;
18144
- const next = [...current, {
18145
- id: account.id,
18146
- label: account.label ?? account.id,
18147
- ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
18148
- }];
18149
- await writeIgnoredAccounts(next, path);
18150
- return next;
18151
- }
18152
- /** Drop one account from the ignore list. Returns the resulting list. */
18153
- async function unignoreAccount(accountId, path = ignoredIdsPath()) {
18154
- const current = await readIgnoredAccounts(path);
18155
- const next = current.filter((entry) => entry.id !== accountId);
18156
- if (next.length !== current.length) await writeIgnoredAccounts(next, path);
18157
- return next;
18158
- }
18159
- //#endregion
18160
18355
  //#region src/migrate.ts
18161
18356
  /**
18162
18357
  * One-time adoption of state written under the plugin's pre-rename name.
@@ -18807,7 +19002,8 @@ function createCore(logger) {
18807
19002
  const client = new WorkBuddyUpstreamClient();
18808
19003
  const pool = new WorkBuddyAccountPool({
18809
19004
  ...logger === void 0 ? {} : { logger },
18810
- client
19005
+ client,
19006
+ persistent: true
18811
19007
  });
18812
19008
  return {
18813
19009
  pool,