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/CHANGELOG.md +1024 -994
- package/README.en.md +11 -7
- package/README.md +12 -8
- package/assets/account-credits.png +0 -0
- package/cordis.patch.yml +48 -49
- package/lib/bin.js +347 -152
- package/lib/client.js +12 -4
- package/lib/index.d.ts +27 -0
- package/lib/index.js +376 -180
- package/package.json +137 -137
- package/screenshots.json +1 -1
- package/assets/settings-card.png +0 -0
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:
|
|
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:
|
|
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
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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)}.
|
|
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
|
-
|
|
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.
|
|
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,
|