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/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:
|
|
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:
|
|
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
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
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
|
|
11673
|
+
* Is this package installed and able to run?
|
|
11338
11674
|
*
|
|
11339
|
-
*
|
|
11340
|
-
*
|
|
11341
|
-
*
|
|
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.
|
|
11692
|
+
if (entry.options?.disabled === true) continue;
|
|
11693
|
+
return true;
|
|
11347
11694
|
}
|
|
11348
11695
|
return false;
|
|
11349
11696
|
}
|
|
11350
11697
|
/**
|
|
11351
|
-
*
|
|
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
|
-
|
|
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,
|