dsh-xdpool 2.5.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createDecipheriv, createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
- import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
3
+ import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
  import { execFile, execFileSync } from "node:child_process";
@@ -108,24 +108,53 @@ function isAbortError(error) {
108
108
  }
109
109
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
110
110
  const COMPLETION_TEXT_LIMIT = 65536;
111
- /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
111
+ /**
112
+ * Insufficient-credit markers, ASCII lowercase plus the original Chinese.
113
+ *
114
+ * The PLURAL forms are listed separately on purpose: this table is matched with
115
+ * `includes`, so `'credits exhausted'.includes('credit exhausted')` is false.
116
+ * The gateway's own 14018 message is `Credits exhausted.` — plural — which
117
+ * therefore matched nothing, fell through to `soft_rate`, and cooled the account
118
+ * and rotated instead of parking it. A rate-limit cooldown on a drained account
119
+ * means every model on it keeps being retried and keeps failing (#from the
120
+ * connect-workbuddy cross-check).
121
+ */
112
122
  const HARD_CREDIT_MARKERS = [
113
123
  "insufficient credit",
124
+ "insufficient credits",
114
125
  "no credit",
126
+ "no credits",
115
127
  "credit exhausted",
128
+ "credits exhausted",
129
+ "credit exhausted.",
116
130
  "out of credit",
131
+ "out of credits",
117
132
  "quota exceeded",
118
133
  "quota exhaust",
134
+ "quota exhausted",
119
135
  "payment required",
120
136
  "credit not enough",
137
+ "credits not enough",
121
138
  "not enough credit",
139
+ "not enough credits",
122
140
  "积分不足",
123
141
  "额度不足",
124
142
  "余额不足",
125
143
  "积分用完",
126
144
  "额度用尽",
127
- "没有积分"
145
+ "没有积分",
146
+ "额度耗尽",
147
+ "积分耗尽"
128
148
  ];
149
+ /**
150
+ * Business codes that mean "this account cannot pay", independent of wording.
151
+ *
152
+ * `14018` is the gateway's credits-exhausted code; the number survived several
153
+ * rewordings of the sentence around it, so matching it is steadier than matching
154
+ * prose. Read from the body rather than from the HTTP status because the gateway
155
+ * answers these inside a 200 or a 4xx envelope.
156
+ */
157
+ const HARD_CREDIT_CODES = ["14018", "14003"];
129
158
  /** Session-invalidation markers that mean "this credential is dead; use another".
130
159
  * Kept alongside the HTTP-status rule in `classifyUpstreamError`: the status is
131
160
  * enough for a direct 401/403, but some failures arrive wrapped in a 200
@@ -370,7 +399,10 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
370
399
  const BANNED_SYSTEM_PHRASES = Object.freeze([
371
400
  [/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
372
401
  [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
373
- [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
402
+ [/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"],
403
+ [/You are a coding agent running in the Codex CLI, a terminal-based coding assistant/gi, "You are a coding agent running in this environment, a terminal-based coding assistant"],
404
+ [/You are a coding agent running in the Codex CLI/gi, "You are a coding agent running in this environment"],
405
+ [/Codex CLI is an open source project led by OpenAI/gi, "This coding agent is an open source project"]
374
406
  ]);
375
407
  /**
376
408
  * Rewrite fingerprinted system/assistant content, leaving everything else
@@ -676,6 +708,7 @@ function classifyUpstreamError(status, body) {
676
708
  if (status === 402) return "hard_credit";
677
709
  if (status === 401 || status === 403) return "session_dead";
678
710
  const lower = body.toLowerCase();
711
+ for (const code of HARD_CREDIT_CODES) if (body.includes(`"code":${code}`) || body.includes(`"code": ${code}`) || body.includes(`"code":"${code}"`)) return "hard_credit";
679
712
  for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
680
713
  for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
681
714
  if (status === 429) return "soft_rate";
@@ -1355,14 +1388,17 @@ var WorkBuddyUpstreamClient = class {
1355
1388
  if (typeof raw !== "object" || raw === null) continue;
1356
1389
  const account = raw;
1357
1390
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1358
- const monthly = num("CapacityType") === 4;
1359
- const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1360
- const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1391
+ const capacityType = num("CapacityType");
1392
+ const monthly = capacityType === 4;
1393
+ const periodic = monthly || capacityType === 2;
1394
+ const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1395
+ const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1361
1396
  const capped = remain < 0 ? 0 : remain;
1362
1397
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1363
- const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1398
+ const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1364
1399
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1365
- if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1400
+ if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1401
+ if (!periodic && capped <= 0) continue;
1366
1402
  total += capped;
1367
1403
  if (expiresAtMs !== void 0) {
1368
1404
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -2093,6 +2129,92 @@ var WorkBuddyUpstreamClient = class {
2093
2129
  }
2094
2130
  };
2095
2131
  //#endregion
2132
+ //#region src/file-perms.ts
2133
+ /**
2134
+ * Write a file that holds credentials, without leaving it world-readable.
2135
+ *
2136
+ * Every credential this plugin keeps on disk — the durable account list, the
2137
+ * ignore list, any mirrored balance — travelled through a plain `writeFile` with
2138
+ * the process umask as the only protection. On a POSIX host that typically means
2139
+ * `0644`: readable by every other user on the machine, and these files carry
2140
+ * `accessToken` / `refreshToken` verbatim.
2141
+ *
2142
+ * Two rules, and the order matters:
2143
+ *
2144
+ * 1. **Create, don't patch.** `mode` on `writeFile` only applies when the file is
2145
+ * created. The atomic-write pattern already writes a fresh temp file and
2146
+ * renames it over the target, so the temp is created with `0600` and the
2147
+ * rename carries that permission to the destination. Chmod'ing afterwards
2148
+ * would also work but leaves a window where the file exists with the old
2149
+ * mode.
2150
+ * 2. **Still chmod best-effort.** A file written by an older version already
2151
+ * exists with `0644`; the rename replaces the inode, so rule 1 covers it —
2152
+ * but a host that keeps the target in place (or a future caller that writes
2153
+ * directly) would not be covered, and `chmod` is cheap.
2154
+ *
2155
+ * WINDOWS: `mode` is ignored by Node on win32, and there is no ACL equivalent in
2156
+ * the standard library. The honest statement is what actually protects these
2157
+ * files there: they live under the user's own profile (%USERPROFILE%, %LOCALAPPDATA%),
2158
+ * whose inherited ACL excludes other standard users by default. Calling `icacls`
2159
+ * per write would spawn a process on every balance update to re-apply a
2160
+ * protection the parent directory already provides, so this module does not. A
2161
+ * profile relocated to a shared directory is outside what this code can detect —
2162
+ * the README says where the files live so the user can check.
2163
+ *
2164
+ * @module dsh-xdpool/file-perms
2165
+ */
2166
+ /** Owner-only, for a file that holds a secret. */
2167
+ const SECRET_FILE_MODE = 384;
2168
+ /** Owner-only, for the directory that holds them. */
2169
+ const SECRET_DIR_MODE = 448;
2170
+ /**
2171
+ * True on a platform where POSIX modes are meaningful.
2172
+ *
2173
+ * Computed through a `typeof process` guard rather than read straight off the
2174
+ * global: this module is host-only in intent, but `store.ts` imports it and the
2175
+ * browser bundle imports `store.ts` for its row types, so the module body DOES
2176
+ * get bundled for the browser. A bare `process.platform` there throws
2177
+ * `ReferenceError: process is not defined` at first render — and it is the kind
2178
+ * of failure that only shows up in the packaged app.
2179
+ */
2180
+ const POSIX = typeof process === "undefined" ? false : process.platform !== "win32";
2181
+ /**
2182
+ * Create the directory a secret lives in, owner-only.
2183
+ *
2184
+ * `recursive` so a fresh install works, and the mode applies to every directory
2185
+ * this call creates. An existing directory keeps its mode — `mkdir` does not
2186
+ * change what is already there — so `chmod` runs too, best-effort: tightening a
2187
+ * directory that a previous version created permissively is exactly the case
2188
+ * worth fixing, and a failure is not worth aborting a save over.
2189
+ */
2190
+ async function ensureSecretDir(dir) {
2191
+ await mkdir(dir, {
2192
+ recursive: true,
2193
+ ...POSIX ? { mode: SECRET_DIR_MODE } : {}
2194
+ });
2195
+ if (!POSIX) return;
2196
+ try {
2197
+ await chmod(dir, SECRET_DIR_MODE);
2198
+ } catch {}
2199
+ }
2200
+ /**
2201
+ * Write `contents` to `path` with owner-only permissions.
2202
+ *
2203
+ * Not atomic on its own — callers that need the crash-safe temp+rename dance keep
2204
+ * doing it and just route the temp write through here, which is why the mode is
2205
+ * applied at creation: the rename then carries it to the final file.
2206
+ */
2207
+ async function writeSecretFile(path, contents) {
2208
+ await writeFile$1(path, contents, POSIX ? {
2209
+ encoding: "utf8",
2210
+ mode: SECRET_FILE_MODE
2211
+ } : "utf8");
2212
+ if (!POSIX) return;
2213
+ try {
2214
+ await chmod(path, SECRET_FILE_MODE);
2215
+ } catch {}
2216
+ }
2217
+ //#endregion
2096
2218
  //#region src/ignored.ts
2097
2219
  /**
2098
2220
  * The permanent ignore list: accounts the user has thrown out of the pool.
@@ -2193,9 +2315,9 @@ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
2193
2315
  version: 1,
2194
2316
  accounts
2195
2317
  }, null, 2);
2196
- await mkdir(dirname(path), { recursive: true });
2318
+ await ensureSecretDir(dirname(path));
2197
2319
  const temp = `${path}.tmp`;
2198
- await writeFile$1(temp, `${payload}\n`, "utf8");
2320
+ await writeSecretFile(temp, `${payload}\n`);
2199
2321
  await rename(temp, path);
2200
2322
  }
2201
2323
  /**
@@ -2321,9 +2443,9 @@ async function saveStoredAccounts(path, accounts) {
2321
2443
  version: 1,
2322
2444
  accounts: stored
2323
2445
  }, null, 2) + "\n";
2324
- await mkdir(join(path, ".."), { recursive: true });
2446
+ await ensureSecretDir(join(path, ".."));
2325
2447
  const tmp = `${path}.tmp`;
2326
- await writeFile$1(tmp, document, "utf8");
2448
+ await writeSecretFile(tmp, document);
2327
2449
  await rename(tmp, path);
2328
2450
  }
2329
2451
  //#endregion
@@ -3087,16 +3209,20 @@ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
3087
3209
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
3088
3210
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
3089
3211
  const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
3212
+ const nickname = optionalString(decryptableString(identity["nickname"], decrypt).value);
3213
+ const uin = optionalString(identity["uin"]);
3214
+ const uid = optionalString(identity["uid"]);
3215
+ const enterpriseId = optionalString(identity["enterpriseId"]);
3090
3216
  return {
3091
3217
  accessToken,
3092
3218
  refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
3093
3219
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
3094
3220
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
3095
3221
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
3096
- ...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
3097
- ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
3098
- ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
3099
- ...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
3222
+ ...nickname === void 0 ? {} : { nickname },
3223
+ ...uin === void 0 ? {} : { uin },
3224
+ ...uid === void 0 ? {} : { uid },
3225
+ ...enterpriseId === void 0 ? {} : { enterpriseId },
3100
3226
  domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
3101
3227
  sourcePath
3102
3228
  };
@@ -3441,6 +3567,16 @@ var WorkBuddyAccountPool = class {
3441
3567
  */
3442
3568
  disabledIds = /* @__PURE__ */ new Set();
3443
3569
  /**
3570
+ * The account ids the store held after the last successful write.
3571
+ *
3572
+ * Read back on the next scan to say WHICH accounts left, not just how many.
3573
+ * The list is written wholesale, so a scan that does not find an account
3574
+ * deletes it — and that account's credential is then unrecoverable without a
3575
+ * fresh sign-in (#117). `undefined` until the first write, so the first scan
3576
+ * never reports every account as "leave".
3577
+ */
3578
+ lastPersistedIds;
3579
+ /**
3444
3580
  * Account ids the user threw out of the pool for good.
3445
3581
  *
3446
3582
  * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
@@ -3517,6 +3653,11 @@ var WorkBuddyAccountPool = class {
3517
3653
  * A function rather than a list because that pool is mounted asynchronously
3518
3654
  * and can be re-read on demand; `undefined` means "no such source", which is
3519
3655
  * the state every test and every pre-mount startup is in.
3656
+ *
3657
+ * The `| undefined` is explicit because `applyExternalSource` CLEARS the field by
3658
+ * assigning `undefined` — under `exactOptionalPropertyTypes` a bare `?:` means
3659
+ * "may be absent, never undefined", which made that documented clearing action
3660
+ * a type error.
3520
3661
  */
3521
3662
  externalSource;
3522
3663
  constructor(options = {}) {
@@ -3622,9 +3763,10 @@ var WorkBuddyAccountPool = class {
3622
3763
  }
3623
3764
  const byId = /* @__PURE__ */ new Map();
3624
3765
  for (const account of this.accounts) byId.set(account.id, account);
3625
- if (this.persistent && this.accounts.length === 0) {
3766
+ if (this.persistent) {
3626
3767
  const stored = await loadStoredAccounts(this.storePath);
3627
3768
  for (const entry of stored) {
3769
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(entry.id)) continue;
3628
3770
  if (byId.has(entry.id)) continue;
3629
3771
  byId.set(entry.id, {
3630
3772
  id: entry.id,
@@ -3674,7 +3816,10 @@ var WorkBuddyAccountPool = class {
3674
3816
  this.accounts = ordered;
3675
3817
  this.lastScanAtMs = Date.now();
3676
3818
  if (this.persistent) try {
3819
+ const leaving = this.lastPersistedIds === void 0 ? [] : [...this.lastPersistedIds].filter((id) => !this.accounts.some((account) => account.id === id));
3820
+ if (leaving.length > 0) this.logger?.warn?.(`dsh-xdpool: ${String(leaving.length)} account(s) are no longer in the pool after a rescan and were removed from the store: ${leaving.join(", ")}. Their credentials are gone unless they are signed in again.`);
3677
3821
  await saveStoredAccounts(this.storePath, this.accounts);
3822
+ this.lastPersistedIds = new Set(this.accounts.map((account) => account.id));
3678
3823
  } catch (error) {
3679
3824
  this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3680
3825
  }
@@ -4190,6 +4335,43 @@ var WorkBuddyAccountPool = class {
4190
4335
  return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
4191
4336
  }
4192
4337
  /**
4338
+ * Try to bring benched accounts back WITHOUT waiting out the cooldown.
4339
+ *
4340
+ * The cooldown is a guess about how long a problem lasts, and the guess is
4341
+ * usually wrong in one direction or the other: a token revoked by a password
4342
+ * change stays dead for the full window even after the user signs in again in
4343
+ * some other client, and a transient upstream hiccup benches a perfectly good
4344
+ * account for half an hour.
4345
+ *
4346
+ * So instead of only waiting, ask. For each benched account, one cheap
4347
+ * authenticated read: if it answers, the credential works and the account goes
4348
+ * back into rotation immediately. Nothing about the failure is trusted — only a
4349
+ * successful call clears it — and a probe that fails keeps the original
4350
+ * cooldown untouched (probing must never EXTEND a bench, or a broken endpoint
4351
+ * would keep every account locked out for as long as it stayed broken).
4352
+ *
4353
+ * `probe` is injected rather than imported so this class stays free of the
4354
+ * upstream client: the pool knows about accounts, not about HTTP.
4355
+ *
4356
+ * Returns the accounts it revived, for logging and for the run summary.
4357
+ */
4358
+ async reviveBenched(probe) {
4359
+ const benched = this.deadCredentials();
4360
+ if (benched.length === 0) return [];
4361
+ const revived = [];
4362
+ for (const account of benched) {
4363
+ try {
4364
+ await probe(account.credential);
4365
+ } catch {
4366
+ continue;
4367
+ }
4368
+ this.clearCredentialDead(account.id);
4369
+ revived.push(account);
4370
+ this.logger?.info?.(`dsh-xdpool: ${account.label} answered a probe; back in rotation without waiting out the cooldown`);
4371
+ }
4372
+ return revived;
4373
+ }
4374
+ /**
4193
4375
  * Refresh the account's access token when it is within the margin (or already
4194
4376
  * expired), in-flight de-duped per account. A failed refresh keeps the
4195
4377
  * existing token when it has not yet expired, so an unreachable refresh
@@ -4336,8 +4518,23 @@ ${new Date(until).toISOString()}
4336
4518
  * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
4337
4519
  * authoritative while being wrong.
4338
4520
  *
4339
- * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
4340
- * which is what turns on the free badge.
4521
+ * NO PRICES AND NO PROMO BADGES HERE. This table is what the picker shows when
4522
+ * the startup fetch fails, and a multiplier captured months ago is a price the
4523
+ * user will budget against while it is quietly wrong. Two specific harms:
4524
+ *
4525
+ * - `multiplier: 0` renders the "free" badge, so a model that was free at
4526
+ * capture time keeps claiming to be free after the promotion ends;
4527
+ * - a non-zero multiplier renders next to the model, so a stale figure reads
4528
+ * as the current rate.
4529
+ *
4530
+ * `undefined` is the honest state: the card prints no rate and no badge, which
4531
+ * is exactly true — this build does not know the price. The live fetch fills
4532
+ * both in within a second of a working startup, and `catalogSource` already
4533
+ * tells the card to say the list is the built-in one.
4534
+ *
4535
+ * Windows, output ceilings and the `craft` capability tag stay: those are
4536
+ * properties of the model, not of a campaign, and they are what makes the list
4537
+ * usable offline at all.
4341
4538
  */
4342
4539
  const FALLBACK_WORKBUDDY_MODELS = [
4343
4540
  {
@@ -4354,8 +4551,7 @@ const FALLBACK_WORKBUDDY_MODELS = [
4354
4551
  contextWindow: 96e4,
4355
4552
  maxOutputTokens: 64e3,
4356
4553
  supportsImages: true,
4357
- multiplier: .29,
4358
- tags: ["craft", "badge:夜间免费:#FF0000"]
4554
+ tags: ["craft"]
4359
4555
  },
4360
4556
  {
4361
4557
  id: "hy3",
@@ -4363,40 +4559,35 @@ const FALLBACK_WORKBUDDY_MODELS = [
4363
4559
  contextWindow: 192e3,
4364
4560
  maxOutputTokens: 64e3,
4365
4561
  supportsImages: true,
4366
- multiplier: 0,
4367
- tags: ["craft", "badge:限时免费:#FF0000"]
4562
+ tags: ["craft"]
4368
4563
  },
4369
4564
  {
4370
4565
  id: "hy3-x",
4371
4566
  name: "Hy3",
4372
4567
  contextWindow: 192e3,
4373
4568
  maxOutputTokens: 64e3,
4374
- supportsImages: true,
4375
- multiplier: .05
4569
+ supportsImages: true
4376
4570
  },
4377
4571
  {
4378
4572
  id: "space-bunny",
4379
4573
  name: "Space-Bunny",
4380
4574
  contextWindow: 1e6,
4381
4575
  maxOutputTokens: 64e3,
4382
- supportsImages: true,
4383
- multiplier: .08
4576
+ supportsImages: true
4384
4577
  },
4385
4578
  {
4386
4579
  id: "deepseek-v4.1-flash",
4387
4580
  name: "Deepseek-V4.1-Flash",
4388
4581
  contextWindow: 1e6,
4389
4582
  maxOutputTokens: 128e3,
4390
- supportsImages: true,
4391
- multiplier: .11
4583
+ supportsImages: true
4392
4584
  },
4393
4585
  {
4394
4586
  id: "deepseek-v4-pro",
4395
4587
  name: "Deepseek-V4-Pro",
4396
4588
  contextWindow: 1e6,
4397
4589
  maxOutputTokens: 128e3,
4398
- supportsImages: true,
4399
- multiplier: .51
4590
+ supportsImages: true
4400
4591
  },
4401
4592
  {
4402
4593
  id: "deepseek-v4-flash",
@@ -4410,16 +4601,14 @@ const FALLBACK_WORKBUDDY_MODELS = [
4410
4601
  name: "GLM-5.3",
4411
4602
  contextWindow: 1e6,
4412
4603
  maxOutputTokens: 64e3,
4413
- supportsImages: true,
4414
- multiplier: .79
4604
+ supportsImages: true
4415
4605
  },
4416
4606
  {
4417
4607
  id: "glm-5.3-flash",
4418
4608
  name: "GLM-5.3-Flash",
4419
4609
  contextWindow: 1e6,
4420
4610
  maxOutputTokens: 131072,
4421
- supportsImages: true,
4422
- multiplier: .06
4611
+ supportsImages: true
4423
4612
  },
4424
4613
  {
4425
4614
  id: "glm-5.2",
@@ -4427,64 +4616,56 @@ const FALLBACK_WORKBUDDY_MODELS = [
4427
4616
  contextWindow: 1e6,
4428
4617
  maxOutputTokens: 64e3,
4429
4618
  supportsImages: true,
4430
- multiplier: .79,
4431
- tags: ["craft", "badge:夜间折扣:#1E90FF"]
4619
+ tags: ["craft"]
4432
4620
  },
4433
4621
  {
4434
4622
  id: "glm-5.1",
4435
4623
  name: "GLM-5.1",
4436
4624
  contextWindow: 2e5,
4437
4625
  maxOutputTokens: 48e3,
4438
- supportsImages: false,
4439
- multiplier: .79
4626
+ supportsImages: false
4440
4627
  },
4441
4628
  {
4442
4629
  id: "glm-5v-turbo",
4443
4630
  name: "GLM-5v-Turbo",
4444
4631
  contextWindow: 2e5,
4445
4632
  maxOutputTokens: 64e3,
4446
- supportsImages: true,
4447
- multiplier: .71
4633
+ supportsImages: true
4448
4634
  },
4449
4635
  {
4450
4636
  id: "kimi-k3-1",
4451
4637
  name: "Kimi-K3",
4452
4638
  contextWindow: 1e6,
4453
4639
  maxOutputTokens: 32e3,
4454
- supportsImages: true,
4455
- multiplier: 1.62
4640
+ supportsImages: true
4456
4641
  },
4457
4642
  {
4458
4643
  id: "kimi-k2.8-preview",
4459
4644
  name: "Kimi-K2.8-Preview",
4460
4645
  contextWindow: 1e6,
4461
4646
  maxOutputTokens: 64e3,
4462
- supportsImages: true,
4463
- multiplier: .77
4647
+ supportsImages: true
4464
4648
  },
4465
4649
  {
4466
4650
  id: "kimi-k2.7",
4467
4651
  name: "Kimi-K2.7-Code",
4468
4652
  contextWindow: 256e3,
4469
4653
  maxOutputTokens: 32e3,
4470
- supportsImages: true,
4471
- multiplier: .57
4654
+ supportsImages: true
4472
4655
  },
4473
4656
  {
4474
4657
  id: "kimi-k2.6",
4475
4658
  name: "Kimi-K2.6",
4476
4659
  contextWindow: 256e3,
4477
4660
  maxOutputTokens: 32e3,
4478
- supportsImages: true,
4479
- multiplier: .52
4661
+ supportsImages: true
4480
4662
  },
4481
4663
  {
4482
4664
  id: "minimax-m3",
4483
4665
  name: "MiniMax-M3",
4484
4666
  contextWindow: 512e3,
4485
4667
  maxOutputTokens: 64e3,
4486
- supportsImages: true,
4487
- multiplier: .25
4668
+ supportsImages: true
4488
4669
  }
4489
4670
  ];
4490
4671
  /**
@@ -4557,11 +4738,24 @@ var WorkBuddyCatalog = class {
4557
4738
  }
4558
4739
  /**
4559
4740
  * The models DSH should actually offer, after applying the user's selection:
4560
- * disabled models are dropped, an explicit image list overrides the upstream
4561
- * capability flag, and a per-model budget caps the advertised window.
4741
+ * disabled models are dropped, an explicit image list decides image support,
4742
+ * and a per-model budget caps the advertised window.
4562
4743
  *
4563
4744
  * An absent `enabledModelIds` means "everything" — a fresh install with no
4564
4745
  * saved selection must not present an empty picker.
4746
+ *
4747
+ * IMAGE SUPPORT IS THE USER'S SET, NOT AN OR. The upstream flag is `true` for
4748
+ * essentially every model of this vendor (measured: 15 of 16 on CN, 23 of 23
4749
+ * on international), so `supportsImages || imageSet.has(id)` could only ever
4750
+ * turn the flag ON — un-ticking the box in the card changed nothing, and the
4751
+ * adapter kept advertising `['text','image']`. The checkbox existed and did
4752
+ * not work, which is worse than not offering it.
4753
+ *
4754
+ * So when the user HAS saved an image selection, that set is the answer, in
4755
+ * both directions. When they have not (undefined — a fresh install, or a
4756
+ * config written before this fix), the upstream capability stands: silently
4757
+ * disabling image input for everyone who never touched the switch would be a
4758
+ * regression, and the absent case is exactly "no opinion".
4565
4759
  */
4566
4760
  visible() {
4567
4761
  const enabled = this.selection.enabledModelIds;
@@ -4571,7 +4765,7 @@ var WorkBuddyCatalog = class {
4571
4765
  const budgets = this.selection.contextBudgets;
4572
4766
  return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
4573
4767
  const next = { ...model };
4574
- if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
4768
+ if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
4575
4769
  const budget = budgets?.[model.id];
4576
4770
  if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
4577
4771
  return next;
@@ -4676,8 +4870,13 @@ function catalogFromUpstream(models, region = "cn") {
4676
4870
  if (models.length === 0) return fallbackModelsFor(region);
4677
4871
  return models.map(toModelInfo);
4678
4872
  }
4679
- //#endregion
4680
- //#region src/status-paths.ts
4873
+ Object.freeze([
4874
+ "priority",
4875
+ "round-robin",
4876
+ "balanced",
4877
+ "sticky",
4878
+ "expiry"
4879
+ ]);
4681
4880
  /**
4682
4881
  * The schedule every automation job falls back to.
4683
4882
  *
@@ -9636,6 +9835,24 @@ const PROVIDERS = Object.freeze([
9636
9835
  "website": "https://openai.com/",
9637
9836
  "local": false,
9638
9837
  "source": "fcc"
9838
+ },
9839
+ {
9840
+ "id": "custom_openai",
9841
+ "name": "Custom OpenAI-compatible",
9842
+ "category": "apikey",
9843
+ "auth": {
9844
+ "kind": "apiKey",
9845
+ "env": "CUSTOM_OPENAI_API_KEY",
9846
+ "url": null
9847
+ },
9848
+ "baseUrl": "",
9849
+ "format": "openai-chat",
9850
+ "models": [],
9851
+ "notes": "Any OpenAI-compatible endpoint — vLLM, LiteLLM, one-api, LM Studio, a self-hosted gateway. Paste the base URL (ending in /v1) and the key on this card, then press Fetch models.",
9852
+ "website": null,
9853
+ "local": false,
9854
+ "baseUrlOverrideEnv": "CUSTOM_OPENAI_BASE_URL",
9855
+ "custom": true
9639
9856
  }
9640
9857
  ].map((entry) => Object.freeze({
9641
9858
  ...entry,
@@ -10293,10 +10510,35 @@ function slotKey(date, timeZone = AUTOMATION_TIME_ZONE) {
10293
10510
  const { hour } = zonedParts(date, timeZone);
10294
10511
  return `${dayKey(date, timeZone)}T${hour}`;
10295
10512
  }
10296
- /** Whether this account may be used: not switched off, not cooling. */
10297
- function eligible(account, now) {
10513
+ /**
10514
+ * Which account classes each job may touch.
10515
+ *
10516
+ * This replaced a single `global → false`, which was right about the growth
10517
+ * system and wrong as a blanket: it excluded a global account from EVERY job,
10518
+ * including the ones that are idempotent, cheap, and pay credits regardless of
10519
+ * region. The blanket also made the rule unfalsifiable — "global does nothing"
10520
+ * cannot be checked, whereas "the checkin job tries global accounts and here is
10521
+ * what came back" can.
10522
+ *
10523
+ * So the scope is per job, and the default for a job that is not named is `cn`:
10524
+ * a new job must opt in explicitly rather than inherit an accident.
10525
+ */
10526
+ const JOB_REGION_SCOPE = {
10527
+ report: ["cn"],
10528
+ tasks: ["cn"],
10529
+ streak: ["cn"],
10530
+ travel: ["cn"],
10531
+ compensation: ["cn"],
10532
+ checkin: ["cn", "global"]
10533
+ };
10534
+ /** Whether `job` may run against `region`; unnamed jobs stay CN. */
10535
+ function jobAllowsRegion(job, region) {
10536
+ return (JOB_REGION_SCOPE[job] ?? ["cn"]).includes(region);
10537
+ }
10538
+ /** Whether this account may be used for a job: not cooling, and right region. */
10539
+ function eligible(account, now, job) {
10298
10540
  if (account.cooldownUntilMs > now) return false;
10299
- if (regionOf(account.credential.domain) === "global") return false;
10541
+ if (job !== void 0 && !jobAllowsRegion(job, regionOf(account.credential.domain))) return false;
10300
10542
  return true;
10301
10543
  }
10302
10544
  const sleep = (ms) => new Promise((resolve) => {
@@ -10763,8 +11005,22 @@ var WorkBuddyScheduler = class {
10763
11005
  let energy = 0;
10764
11006
  let claimed = 0;
10765
11007
  const detail = [];
11008
+ /**
11009
+ * Every item this run considered, with a status attached.
11010
+ *
11011
+ * `detail` stays as the human-readable log line; this is the same work in a
11012
+ * shape the card can GROUP and COUNT, so "3 tasks unclaimed — locked" is
11013
+ * distinguishable from "0 tasks, everything already collected".
11014
+ */
11015
+ const items = [];
10766
11016
  let progressNote;
10767
- const accounts = this.accountsInOrder();
11017
+ try {
11018
+ const revived = await this.pool.reviveBenched((credential) => this.client.fetchCredits(credential));
11019
+ for (const account of revived) detail.push(`复活 ${account.label}`);
11020
+ } catch (error) {
11021
+ this.logger.warn?.("automation: could not probe benched accounts:", error);
11022
+ }
11023
+ const accounts = this.accountsInOrder(kind);
10768
11024
  if (kind === "tasks") await this.sendEventChains(accounts);
10769
11025
  for (let index = 0; index < accounts.length; index++) {
10770
11026
  if (this.stopped) break;
@@ -10772,12 +11028,22 @@ var WorkBuddyScheduler = class {
10772
11028
  if (account === void 0) continue;
10773
11029
  try {
10774
11030
  switch (kind) {
10775
- case "checkin":
11031
+ case "checkin": {
11032
+ let alreadyDone = false;
11033
+ try {
11034
+ alreadyDone = (await this.client.fetchCheckinStatus(account.credential)).todayCheckedIn === true;
11035
+ } catch (error) {
11036
+ this.logger.warn?.(`automation checkin ${account.label}: status read failed, claiming anyway:`, error);
11037
+ }
11038
+ if (alreadyDone) {
11039
+ this.logger.info?.(`automation checkin ${account.label}: already done today (read, not claimed)`);
11040
+ break;
11041
+ }
10776
11042
  try {
10777
11043
  const claim = await this.client.claimDailyCheckin(account.credential);
10778
11044
  this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
10779
11045
  if (claim.credit > 0) {
10780
- if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
11046
+ detail.push(`签到 +${claim.credit}`);
10781
11047
  this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
10782
11048
  }
10783
11049
  } catch (error) {
@@ -10785,6 +11051,7 @@ var WorkBuddyScheduler = class {
10785
11051
  this.logger.info?.(`automation checkin ${account.label}: already done today`);
10786
11052
  }
10787
11053
  break;
11054
+ }
10788
11055
  case "report": {
10789
11056
  const days = await this.reportOne(account);
10790
11057
  this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
@@ -10796,6 +11063,7 @@ var WorkBuddyScheduler = class {
10796
11063
  energy += result.energy;
10797
11064
  claimed += result.claimed;
10798
11065
  detail.push(...result.titles);
11066
+ items.push(...result.items);
10799
11067
  this.claimableSeen += result.claimableCount;
10800
11068
  this.recordEarnings(account.id, today, {
10801
11069
  credit: result.credit,
@@ -10843,7 +11111,9 @@ var WorkBuddyScheduler = class {
10843
11111
  state.claimed = claimed;
10844
11112
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
10845
11113
  state.detail = detail;
10846
- state.progress = progressNote;
11114
+ state.items = items;
11115
+ if (progressNote === void 0) delete state.progress;
11116
+ else state.progress = progressNote;
10847
11117
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
10848
11118
  if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
10849
11119
  }
@@ -10857,12 +11127,16 @@ var WorkBuddyScheduler = class {
10857
11127
  *
10858
11128
  * Disabled accounts are excluded here rather than filtered by the caller so a
10859
11129
  * card switch takes effect on the next pass without any event plumbing.
11130
+ *
11131
+ * `job` narrows the set by region scope: the growth jobs are CN-only while
11132
+ * check-in runs on both gateways, so eligibility cannot be decided without
11133
+ * knowing which job is about to run.
10860
11134
  */
10861
- accountsInOrder() {
11135
+ accountsInOrder(job) {
10862
11136
  const now = Date.now();
10863
11137
  return this.pool.list().filter((account) => {
10864
11138
  if (this.pool.isDisabled(account.id)) return false;
10865
- return eligible(account, now);
11139
+ return eligible(account, now, job);
10866
11140
  });
10867
11141
  }
10868
11142
  /**
@@ -10988,10 +11262,55 @@ var WorkBuddyScheduler = class {
10988
11262
  * logs "could not be built" only for exceptions, and a task shape this code
10989
11263
  * does not understand is information, not a fault.
10990
11264
  */
11265
+ /**
11266
+ * Find the expert a task is about, by matching its title against the
11267
+ * marketplace.
11268
+ *
11269
+ * The marketplace listing is ~500 entries across `agent` and `team`, and this
11270
+ * only runs for a task whose deeplink carried no id — at most a handful per
11271
+ * run — so the cost is acceptable. It is not cached because a freshly
11272
+ * published expert must become findable without a restart.
11273
+ *
11274
+ * Matching rules, all of them strict on purpose:
11275
+ * - the task's title must appear IN the expert's name or profession, or the
11276
+ * expert's display name must appear in the title (both directions, since
11277
+ * the board and the marketplace word things differently);
11278
+ * - comparison ignores case, whitespace and the punctuation vendors sprinkle
11279
+ * through names;
11280
+ * - several matches means NO answer. Summoning the wrong agent scores 0, so a
11281
+ * guess costs an upstream call and still fails — worse than declining.
11282
+ */
11283
+ async expertIdByKeyword(credential, task) {
11284
+ const normalise = (value) => value.toLowerCase().replace(/[\s_\-·::,,.。()()【】[\]「」]/gu, "");
11285
+ const wanted = normalise(task.title);
11286
+ if (wanted === "") return void 0;
11287
+ let candidates;
11288
+ try {
11289
+ const [agents, teams] = await Promise.all([this.client.marketExpertList(credential, "agent"), this.client.marketExpertList(credential, "team")]);
11290
+ candidates = [...agents, ...teams];
11291
+ } catch (error) {
11292
+ this.logger.warn?.(`automation events ${task.taskCode}: marketplace lookup failed:`, error);
11293
+ return;
11294
+ }
11295
+ const matches = candidates.filter((candidate) => {
11296
+ const name = normalise(candidate.displayName ?? "");
11297
+ const profession = normalise(candidate.profession ?? "");
11298
+ if (name === "" && profession === "") return false;
11299
+ return name !== "" && (name.includes(wanted) || wanted.includes(name)) || profession !== "" && (profession.includes(wanted) || wanted.includes(profession));
11300
+ });
11301
+ if (matches.length !== 1) {
11302
+ if (matches.length > 1) this.logger.info?.(`automation events ${task.taskCode}: ${String(matches.length)} marketplace experts match "${task.title}", not guessing`);
11303
+ return;
11304
+ }
11305
+ const found = matches[0];
11306
+ if (found === void 0) return void 0;
11307
+ this.logger.info?.(`automation events ${task.taskCode}: resolved expertId ${found.expertId} by keyword "${task.title}"`);
11308
+ return found.expertId;
11309
+ }
10991
11310
  async taskExpertChains(credential, task) {
10992
- const expertId = expertIdFromJumpUrl(task.jumpUrl);
11311
+ const expertId = expertIdFromJumpUrl(task.jumpUrl) ?? await this.expertIdByKeyword(credential, task);
10993
11312
  if (expertId === void 0) {
10994
- this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url, skipping`);
11313
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url and no marketplace match, skipping`);
10995
11314
  return [];
10996
11315
  }
10997
11316
  const expert = expertFromTask(expertId, task.title);
@@ -11076,21 +11395,41 @@ var WorkBuddyScheduler = class {
11076
11395
  const credential = account.credential;
11077
11396
  const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
11078
11397
  if (open.length > 0) await this.client.acceptTasks(credential, open);
11079
- const claimable = (await this.client.listTasks(credential)).filter((task) => task.claimable && !task.locked);
11398
+ const refreshed = await this.client.listTasks(credential);
11399
+ const claimable = refreshed.filter((task) => task.claimable && !task.locked);
11080
11400
  let credit = 0;
11081
11401
  let energy = 0;
11082
11402
  let claimed = 0;
11083
11403
  const titles = [];
11404
+ const items = [];
11405
+ for (const task of refreshed) if (task.locked === true || task.claimable !== true) items.push({
11406
+ title: task.title === "" ? task.taskCode : task.title,
11407
+ status: "skipped",
11408
+ note: task.locked === true ? "locked" : task.acceptStatus === "not_accepted" ? "not accepted" : "not scored yet"
11409
+ });
11084
11410
  for (const task of claimable) {
11085
11411
  if (this.stopped) break;
11086
11412
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
11087
11413
  credit += reward.credit;
11088
11414
  energy += reward.energy;
11415
+ const label = task.title === "" ? task.taskCode : task.title;
11089
11416
  if (reward.credit > 0 || reward.energy > 0) {
11090
11417
  claimed++;
11091
11418
  titles.push(task.title);
11419
+ items.push({
11420
+ title: label,
11421
+ status: "claimed",
11422
+ ...reward.credit > 0 ? { credit: reward.credit } : {},
11423
+ ...reward.energy > 0 ? { energy: reward.energy } : {}
11424
+ });
11092
11425
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
11093
- } else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
11426
+ } else {
11427
+ items.push({
11428
+ title: label,
11429
+ status: "already"
11430
+ });
11431
+ this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
11432
+ }
11094
11433
  if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
11095
11434
  }
11096
11435
  return {
@@ -11098,7 +11437,8 @@ var WorkBuddyScheduler = class {
11098
11437
  credit,
11099
11438
  energy,
11100
11439
  claimableCount: claimable.length,
11101
- titles
11440
+ titles,
11441
+ items
11102
11442
  };
11103
11443
  }
11104
11444
  /**
@@ -11240,6 +11580,7 @@ Object.freeze({
11240
11580
  "kimi_code": "Kimi Code 订阅,模型 id 取自 9router 的 kimi 注册表",
11241
11581
  "lightning": "Lightning AI 的 Model APIs 网关",
11242
11582
  "llamacpp": "本地服务器,模型列表取决于用户已加载的模型",
11583
+ "custom_openai": "自定义 OpenAI 兼容端点:vLLM / LiteLLM / one-api / 自建网关,填 base URL 和 key 后拉取模型",
11243
11584
  "llm7": "default/fast/pro 别名被固定,只留流式工具模型",
11244
11585
  "lmstudio": "本地服务器,模型列表取决于用户已加载的模型",
11245
11586
  "minimax": "推理内容从正文流中单独拆出,语音与图像 id 已剔除",
@@ -11385,7 +11726,7 @@ async function readPreferredId(path = preferredIdPath()) {
11385
11726
  * free of documents that describe the absence of a decision.
11386
11727
  */
11387
11728
  async function writePreferredId(accountId, path = preferredIdPath()) {
11388
- await mkdir(dirname(path), { recursive: true });
11729
+ await ensureSecretDir(dirname(path));
11389
11730
  if (accountId === void 0) {
11390
11731
  await rm(path, { force: true });
11391
11732
  return;
@@ -11395,7 +11736,7 @@ async function writePreferredId(accountId, path = preferredIdPath()) {
11395
11736
  accountId: accountId.trim()
11396
11737
  }, null, 2);
11397
11738
  const temp = `${path}.tmp`;
11398
- await writeFile$1(temp, `${payload}\n`, "utf8");
11739
+ await writeSecretFile(temp, `${payload}\n`);
11399
11740
  await rename(temp, path);
11400
11741
  }
11401
11742
  fileURLToPath(new URL("..", import.meta.url));
@@ -11471,7 +11812,19 @@ z.object({
11471
11812
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
11472
11813
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
11473
11814
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
11474
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
11815
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
11816
+ sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)")),
11817
+ /**
11818
+ * The 免密 free lane (`free:` models).
11819
+ *
11820
+ * OFF unless set, and it is the only lane that works that way. It is a shared
11821
+ * third-party endpoint with no credential of its own, so there is nothing for
11822
+ * a user to "configure" — it simply appears in the picker or does not. On by
11823
+ * default it added a group nobody asked for that the aggregate switch does not
11824
+ * govern (it is not an aggregate provider), leaving no way to remove it
11825
+ * without editing a file. Reported as "关不掉,应该默认是关闭的".
11826
+ */
11827
+ freeLane: asVolatile(z.boolean().default(false).description("Offer the shared 免密 free models (the \"XD Pool · 免费模型\" group). Off by default: it is a third-party endpoint with no key of your own."))
11475
11828
  });
11476
11829
  /** Assemble the runtime objects without registering anything. */
11477
11830
  /**