dsh-xdpool 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -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";
@@ -993,7 +1026,8 @@ function parseTask(value) {
993
1026
  status: typeof raw["status"] === "string" ? raw["status"] : "",
994
1027
  claimable: !claimed && target > 0 && current >= target,
995
1028
  claimed,
996
- locked: raw["locked"] === true
1029
+ locked: raw["locked"] === true,
1030
+ jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
997
1031
  };
998
1032
  }
999
1033
  var WorkBuddyUpstreamClient = class {
@@ -2095,6 +2129,92 @@ var WorkBuddyUpstreamClient = class {
2095
2129
  }
2096
2130
  };
2097
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
2098
2218
  //#region src/ignored.ts
2099
2219
  /**
2100
2220
  * The permanent ignore list: accounts the user has thrown out of the pool.
@@ -2195,9 +2315,9 @@ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
2195
2315
  version: 1,
2196
2316
  accounts
2197
2317
  }, null, 2);
2198
- await mkdir(dirname(path), { recursive: true });
2318
+ await ensureSecretDir(dirname(path));
2199
2319
  const temp = `${path}.tmp`;
2200
- await writeFile$1(temp, `${payload}\n`, "utf8");
2320
+ await writeSecretFile(temp, `${payload}\n`);
2201
2321
  await rename(temp, path);
2202
2322
  }
2203
2323
  /**
@@ -2323,9 +2443,9 @@ async function saveStoredAccounts(path, accounts) {
2323
2443
  version: 1,
2324
2444
  accounts: stored
2325
2445
  }, null, 2) + "\n";
2326
- await mkdir(join(path, ".."), { recursive: true });
2446
+ await ensureSecretDir(join(path, ".."));
2327
2447
  const tmp = `${path}.tmp`;
2328
- await writeFile$1(tmp, document, "utf8");
2448
+ await writeSecretFile(tmp, document);
2329
2449
  await rename(tmp, path);
2330
2450
  }
2331
2451
  //#endregion
@@ -3089,16 +3209,20 @@ function parseWorkBuddyAuth(text, sourcePath, decrypt) {
3089
3209
  const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
3090
3210
  if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
3091
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"]);
3092
3216
  return {
3093
3217
  accessToken,
3094
3218
  refreshToken: decryptableString(auth["refreshToken"], decrypt).value,
3095
3219
  expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
3096
3220
  ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
3097
3221
  ...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
3098
- ...optionalString(decryptableString(identity["nickname"], decrypt).value) === void 0 ? {} : { nickname: optionalString(decryptableString(identity["nickname"], decrypt).value) },
3099
- ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
3100
- ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
3101
- ...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 },
3102
3226
  domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
3103
3227
  sourcePath
3104
3228
  };
@@ -3443,6 +3567,16 @@ var WorkBuddyAccountPool = class {
3443
3567
  */
3444
3568
  disabledIds = /* @__PURE__ */ new Set();
3445
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
+ /**
3446
3580
  * Account ids the user threw out of the pool for good.
3447
3581
  *
3448
3582
  * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
@@ -3519,6 +3653,11 @@ var WorkBuddyAccountPool = class {
3519
3653
  * A function rather than a list because that pool is mounted asynchronously
3520
3654
  * and can be re-read on demand; `undefined` means "no such source", which is
3521
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.
3522
3661
  */
3523
3662
  externalSource;
3524
3663
  constructor(options = {}) {
@@ -3624,9 +3763,10 @@ var WorkBuddyAccountPool = class {
3624
3763
  }
3625
3764
  const byId = /* @__PURE__ */ new Map();
3626
3765
  for (const account of this.accounts) byId.set(account.id, account);
3627
- if (this.persistent && this.accounts.length === 0) {
3766
+ if (this.persistent) {
3628
3767
  const stored = await loadStoredAccounts(this.storePath);
3629
3768
  for (const entry of stored) {
3769
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(entry.id)) continue;
3630
3770
  if (byId.has(entry.id)) continue;
3631
3771
  byId.set(entry.id, {
3632
3772
  id: entry.id,
@@ -3676,7 +3816,10 @@ var WorkBuddyAccountPool = class {
3676
3816
  this.accounts = ordered;
3677
3817
  this.lastScanAtMs = Date.now();
3678
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.`);
3679
3821
  await saveStoredAccounts(this.storePath, this.accounts);
3822
+ this.lastPersistedIds = new Set(this.accounts.map((account) => account.id));
3680
3823
  } catch (error) {
3681
3824
  this.logger?.warn?.("dsh-xdpool: could not persist the account store", error);
3682
3825
  }
@@ -3696,11 +3839,11 @@ var WorkBuddyAccountPool = class {
3696
3839
  * model id the legacy account-wide check applies (callers that cannot name a
3697
3840
  * model, e.g. CLI diagnostics).
3698
3841
  */
3699
- available(now, modelId, region) {
3842
+ available(now, modelId, region, reserveExempt) {
3700
3843
  return this.accounts.filter((account) => {
3701
3844
  if (this.disabledIds.has(account.id)) return false;
3702
3845
  const reserve = this.creditReserves.get(account.id);
3703
- if (reserve !== void 0 && reserve > 0) {
3846
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3704
3847
  const balance = this.creditBalances.get(account.id);
3705
3848
  if (balance !== void 0 && balance <= reserve) return false;
3706
3849
  }
@@ -3724,7 +3867,7 @@ var WorkBuddyAccountPool = class {
3724
3867
  * Counts are over the region's accounts, since a provider only ever sees its
3725
3868
  * own gateway.
3726
3869
  */
3727
- unavailableReason(modelId, region) {
3870
+ unavailableReason(modelId, region, reserveExempt) {
3728
3871
  const now = Date.now();
3729
3872
  const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
3730
3873
  if (inRegion.length === 0) return {
@@ -3737,6 +3880,29 @@ var WorkBuddyAccountPool = class {
3737
3880
  let cooling = 0;
3738
3881
  let disabled = 0;
3739
3882
  let dead = 0;
3883
+ /**
3884
+ * Accounts that survived every OTHER filter and are held back only by their
3885
+ * credit floor.
3886
+ *
3887
+ * Counted rather than returned on the first hit. The old `return` fired the
3888
+ * moment it met one reserved account, so a single account parked under its
3889
+ * floor reported `reserve` for the whole gateway — even while four other
3890
+ * accounts were healthy and had just been tried. The caller turns
3891
+ * `reason === 'reserve'` into a 402 telling the user to lower the reserve,
3892
+ * which pointed at a setting that was not the problem (#98: a five-account
3893
+ * pool reported `reserve_floor` because of one account that had sat below
3894
+ * its floor for weeks, while the actual state was "the rest are cooling").
3895
+ */
3896
+ let reserved = 0;
3897
+ /**
3898
+ * Accounts that reached the end of the walk unblocked.
3899
+ *
3900
+ * Non-zero means the pool still has somewhere to send a request, so a
3901
+ * reserve is NOT what this answer is about: editing a number cannot fix a
3902
+ * request that some other account is already able to serve, and saying
3903
+ * otherwise sends the reader to the wrong setting.
3904
+ */
3905
+ let usable = 0;
3740
3906
  for (const account of inRegion) {
3741
3907
  if (this.disabledIds.has(account.id)) {
3742
3908
  disabled += 1;
@@ -3752,16 +3918,14 @@ var WorkBuddyAccountPool = class {
3752
3918
  continue;
3753
3919
  }
3754
3920
  const reserve = this.creditReserves.get(account.id);
3755
- if (reserve !== void 0 && reserve > 0) {
3921
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3756
3922
  const balance = this.creditBalances.get(account.id);
3757
- if (balance !== void 0 && balance <= reserve) return {
3758
- total: inRegion.length,
3759
- cooling,
3760
- disabled,
3761
- dead,
3762
- reason: "reserve"
3763
- };
3923
+ if (balance !== void 0 && balance <= reserve) {
3924
+ reserved += 1;
3925
+ continue;
3926
+ }
3764
3927
  }
3928
+ usable += 1;
3765
3929
  }
3766
3930
  if (dead > 0) return {
3767
3931
  total: inRegion.length,
@@ -3784,6 +3948,13 @@ var WorkBuddyAccountPool = class {
3784
3948
  dead,
3785
3949
  reason: "disabled"
3786
3950
  };
3951
+ if (usable === 0 && reserved > 0) return {
3952
+ total: inRegion.length,
3953
+ cooling,
3954
+ disabled,
3955
+ dead,
3956
+ reason: "reserve"
3957
+ };
3787
3958
  return {
3788
3959
  total: inRegion.length,
3789
3960
  cooling,
@@ -3960,16 +4131,18 @@ var WorkBuddyAccountPool = class {
3960
4131
  * Scans on first use, and rescans when every known account is cooling down: a
3961
4132
  * fresh desktop login is the usual way out of an exhausted pool.
3962
4133
  *
4134
+ * `reserveExempt` is host-only: account ids whose own credentials freshly
4135
+ * confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
3963
4136
  * `conversationKey` is only consulted under `sticky`; other modes ignore it.
3964
4137
  */
3965
- async acquire(modelId, region, conversationKey) {
4138
+ async acquire(modelId, region, conversationKey, reserveExempt) {
3966
4139
  if (this.accounts.length === 0) await this.scan();
3967
4140
  const maxTries = this.accounts.length + 1;
3968
4141
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
3969
- let pool = this.available(Date.now(), modelId, region);
4142
+ let pool = this.available(Date.now(), modelId, region, reserveExempt);
3970
4143
  if (pool.length === 0 && attempt === 0) {
3971
4144
  await this.scan();
3972
- pool = this.available(Date.now(), modelId, region);
4145
+ pool = this.available(Date.now(), modelId, region, reserveExempt);
3973
4146
  }
3974
4147
  if (pool.length === 0) return void 0;
3975
4148
  if (this.preferredAccountId !== void 0) {
@@ -4162,6 +4335,43 @@ var WorkBuddyAccountPool = class {
4162
4335
  return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
4163
4336
  }
4164
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
+ /**
4165
4375
  * Refresh the account's access token when it is within the margin (or already
4166
4376
  * expired), in-flight de-duped per account. A failed refresh keeps the
4167
4377
  * existing token when it has not yet expired, so an unreachable refresh
@@ -4308,8 +4518,23 @@ ${new Date(until).toISOString()}
4308
4518
  * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
4309
4519
  * authoritative while being wrong.
4310
4520
  *
4311
- * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
4312
- * 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.
4313
4538
  */
4314
4539
  const FALLBACK_WORKBUDDY_MODELS = [
4315
4540
  {
@@ -4326,8 +4551,7 @@ const FALLBACK_WORKBUDDY_MODELS = [
4326
4551
  contextWindow: 96e4,
4327
4552
  maxOutputTokens: 64e3,
4328
4553
  supportsImages: true,
4329
- multiplier: .29,
4330
- tags: ["craft", "badge:夜间免费:#FF0000"]
4554
+ tags: ["craft"]
4331
4555
  },
4332
4556
  {
4333
4557
  id: "hy3",
@@ -4335,40 +4559,35 @@ const FALLBACK_WORKBUDDY_MODELS = [
4335
4559
  contextWindow: 192e3,
4336
4560
  maxOutputTokens: 64e3,
4337
4561
  supportsImages: true,
4338
- multiplier: 0,
4339
- tags: ["craft", "badge:限时免费:#FF0000"]
4562
+ tags: ["craft"]
4340
4563
  },
4341
4564
  {
4342
4565
  id: "hy3-x",
4343
4566
  name: "Hy3",
4344
4567
  contextWindow: 192e3,
4345
4568
  maxOutputTokens: 64e3,
4346
- supportsImages: true,
4347
- multiplier: .05
4569
+ supportsImages: true
4348
4570
  },
4349
4571
  {
4350
4572
  id: "space-bunny",
4351
4573
  name: "Space-Bunny",
4352
4574
  contextWindow: 1e6,
4353
4575
  maxOutputTokens: 64e3,
4354
- supportsImages: true,
4355
- multiplier: .08
4576
+ supportsImages: true
4356
4577
  },
4357
4578
  {
4358
4579
  id: "deepseek-v4.1-flash",
4359
4580
  name: "Deepseek-V4.1-Flash",
4360
4581
  contextWindow: 1e6,
4361
4582
  maxOutputTokens: 128e3,
4362
- supportsImages: true,
4363
- multiplier: .11
4583
+ supportsImages: true
4364
4584
  },
4365
4585
  {
4366
4586
  id: "deepseek-v4-pro",
4367
4587
  name: "Deepseek-V4-Pro",
4368
4588
  contextWindow: 1e6,
4369
4589
  maxOutputTokens: 128e3,
4370
- supportsImages: true,
4371
- multiplier: .51
4590
+ supportsImages: true
4372
4591
  },
4373
4592
  {
4374
4593
  id: "deepseek-v4-flash",
@@ -4382,16 +4601,14 @@ const FALLBACK_WORKBUDDY_MODELS = [
4382
4601
  name: "GLM-5.3",
4383
4602
  contextWindow: 1e6,
4384
4603
  maxOutputTokens: 64e3,
4385
- supportsImages: true,
4386
- multiplier: .79
4604
+ supportsImages: true
4387
4605
  },
4388
4606
  {
4389
4607
  id: "glm-5.3-flash",
4390
4608
  name: "GLM-5.3-Flash",
4391
4609
  contextWindow: 1e6,
4392
4610
  maxOutputTokens: 131072,
4393
- supportsImages: true,
4394
- multiplier: .06
4611
+ supportsImages: true
4395
4612
  },
4396
4613
  {
4397
4614
  id: "glm-5.2",
@@ -4399,64 +4616,56 @@ const FALLBACK_WORKBUDDY_MODELS = [
4399
4616
  contextWindow: 1e6,
4400
4617
  maxOutputTokens: 64e3,
4401
4618
  supportsImages: true,
4402
- multiplier: .79,
4403
- tags: ["craft", "badge:夜间折扣:#1E90FF"]
4619
+ tags: ["craft"]
4404
4620
  },
4405
4621
  {
4406
4622
  id: "glm-5.1",
4407
4623
  name: "GLM-5.1",
4408
4624
  contextWindow: 2e5,
4409
4625
  maxOutputTokens: 48e3,
4410
- supportsImages: false,
4411
- multiplier: .79
4626
+ supportsImages: false
4412
4627
  },
4413
4628
  {
4414
4629
  id: "glm-5v-turbo",
4415
4630
  name: "GLM-5v-Turbo",
4416
4631
  contextWindow: 2e5,
4417
4632
  maxOutputTokens: 64e3,
4418
- supportsImages: true,
4419
- multiplier: .71
4633
+ supportsImages: true
4420
4634
  },
4421
4635
  {
4422
4636
  id: "kimi-k3-1",
4423
4637
  name: "Kimi-K3",
4424
4638
  contextWindow: 1e6,
4425
4639
  maxOutputTokens: 32e3,
4426
- supportsImages: true,
4427
- multiplier: 1.62
4640
+ supportsImages: true
4428
4641
  },
4429
4642
  {
4430
4643
  id: "kimi-k2.8-preview",
4431
4644
  name: "Kimi-K2.8-Preview",
4432
4645
  contextWindow: 1e6,
4433
4646
  maxOutputTokens: 64e3,
4434
- supportsImages: true,
4435
- multiplier: .77
4647
+ supportsImages: true
4436
4648
  },
4437
4649
  {
4438
4650
  id: "kimi-k2.7",
4439
4651
  name: "Kimi-K2.7-Code",
4440
4652
  contextWindow: 256e3,
4441
4653
  maxOutputTokens: 32e3,
4442
- supportsImages: true,
4443
- multiplier: .57
4654
+ supportsImages: true
4444
4655
  },
4445
4656
  {
4446
4657
  id: "kimi-k2.6",
4447
4658
  name: "Kimi-K2.6",
4448
4659
  contextWindow: 256e3,
4449
4660
  maxOutputTokens: 32e3,
4450
- supportsImages: true,
4451
- multiplier: .52
4661
+ supportsImages: true
4452
4662
  },
4453
4663
  {
4454
4664
  id: "minimax-m3",
4455
4665
  name: "MiniMax-M3",
4456
4666
  contextWindow: 512e3,
4457
4667
  maxOutputTokens: 64e3,
4458
- supportsImages: true,
4459
- multiplier: .25
4668
+ supportsImages: true
4460
4669
  }
4461
4670
  ];
4462
4671
  /**
@@ -4529,11 +4738,24 @@ var WorkBuddyCatalog = class {
4529
4738
  }
4530
4739
  /**
4531
4740
  * The models DSH should actually offer, after applying the user's selection:
4532
- * disabled models are dropped, an explicit image list overrides the upstream
4533
- * 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.
4534
4743
  *
4535
4744
  * An absent `enabledModelIds` means "everything" — a fresh install with no
4536
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".
4537
4759
  */
4538
4760
  visible() {
4539
4761
  const enabled = this.selection.enabledModelIds;
@@ -4543,7 +4765,7 @@ var WorkBuddyCatalog = class {
4543
4765
  const budgets = this.selection.contextBudgets;
4544
4766
  return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
4545
4767
  const next = { ...model };
4546
- if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
4768
+ if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
4547
4769
  const budget = budgets?.[model.id];
4548
4770
  if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
4549
4771
  return next;
@@ -4648,8 +4870,13 @@ function catalogFromUpstream(models, region = "cn") {
4648
4870
  if (models.length === 0) return fallbackModelsFor(region);
4649
4871
  return models.map(toModelInfo);
4650
4872
  }
4651
- //#endregion
4652
- //#region src/status-paths.ts
4873
+ Object.freeze([
4874
+ "priority",
4875
+ "round-robin",
4876
+ "balanced",
4877
+ "sticky",
4878
+ "expiry"
4879
+ ]);
4653
4880
  /**
4654
4881
  * The schedule every automation job falls back to.
4655
4882
  *
@@ -9608,6 +9835,24 @@ const PROVIDERS = Object.freeze([
9608
9835
  "website": "https://openai.com/",
9609
9836
  "local": false,
9610
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
9611
9856
  }
9612
9857
  ].map((entry) => Object.freeze({
9613
9858
  ...entry,
@@ -9967,6 +10212,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
9967
10212
  };
9968
10213
  }
9969
10214
  /**
10215
+ * Pull the `expertId` out of a task's `jump_url`.
10216
+ *
10217
+ * The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
10218
+ * listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
10219
+ * types and got zero hits — so the only place the id appears is the deeplink
10220
+ * embedded in the task itself:
10221
+ *
10222
+ * https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
10223
+ *
10224
+ * The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
10225
+ * would otherwise end the outer query. So the parse is two steps — decode the
10226
+ * `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
10227
+ * NOT a regex over the whole URL, which would also match the outer `expertId`
10228
+ * spelling in a future URL shape and silently pick the wrong one.
10229
+ *
10230
+ * Returns undefined for the tasks whose deeplink carries no id at all
10231
+ * (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
10232
+ * the honest answer: those need a different source and are not guessed here.
10233
+ */
10234
+ function expertIdFromJumpUrl(jumpUrl) {
10235
+ if (jumpUrl === "") return void 0;
10236
+ /** Read `expertId` off any URL-ish string, decoded or not. */
10237
+ const readId = (value) => {
10238
+ let query = value;
10239
+ try {
10240
+ const parsed = new URL(value);
10241
+ query = parsed.search === "" ? value : parsed.search;
10242
+ } catch {
10243
+ query = value;
10244
+ }
10245
+ const marker = query.indexOf("expertId=");
10246
+ if (marker === -1) return void 0;
10247
+ const rest = query.slice(marker + 9);
10248
+ const end = rest.search(/[&#]/u);
10249
+ const id = (end === -1 ? rest : rest.slice(0, end)).trim();
10250
+ return id === "" ? void 0 : id;
10251
+ };
10252
+ let source = jumpUrl;
10253
+ try {
10254
+ const raw = new URL(jumpUrl).searchParams.get("deeplink");
10255
+ if (raw !== null && raw !== "") source = raw;
10256
+ } catch {
10257
+ source = jumpUrl;
10258
+ }
10259
+ return readId(source);
10260
+ }
10261
+ /**
10262
+ * Build a summon record for an expert the marketplace does not list.
10263
+ *
10264
+ * Everything the chains need beyond the id is cosmetic — the scorer keys on
10265
+ * `expertId` — so the title from the task is reused as the display name rather
10266
+ * than inventing one. `version` is left empty, which every consumer already
10267
+ * defaults to `1.0.0`.
10268
+ */
10269
+ function expertFromTask(expertId, title) {
10270
+ return {
10271
+ expertId,
10272
+ expertType: "agent",
10273
+ displayName: title,
10274
+ profession: title,
10275
+ version: "",
10276
+ categories: []
10277
+ };
10278
+ }
10279
+ /**
9970
10280
  * The three "summon an expert" events (`expert_summon_click` and friends).
9971
10281
  *
9972
10282
  * Paid before the conversation, in the order the app emits them.
@@ -10080,7 +10390,12 @@ const EVENT_CHAIN_BUILDERS = {
10080
10390
  skill_1: true,
10081
10391
  expert_5: true,
10082
10392
  Expert_team_use_3: true,
10083
- Expert_lighthouse: true
10393
+ Expert_lighthouse: true,
10394
+ expert_use_maidehao: true,
10395
+ expert_use_douyinyin: true,
10396
+ expert_use_shangjia_xuanchuan: true,
10397
+ expert_use_bowangwang: true,
10398
+ WorkBuddy_Valorant: true
10084
10399
  };
10085
10400
  const AUTOMATION_TICK_MS = 6e4;
10086
10401
  const JOB_KINDS = [
@@ -10195,10 +10510,35 @@ function slotKey(date, timeZone = AUTOMATION_TIME_ZONE) {
10195
10510
  const { hour } = zonedParts(date, timeZone);
10196
10511
  return `${dayKey(date, timeZone)}T${hour}`;
10197
10512
  }
10198
- /** Whether this account may be used: not switched off, not cooling. */
10199
- 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) {
10200
10540
  if (account.cooldownUntilMs > now) return false;
10201
- if (regionOf(account.credential.domain) === "global") return false;
10541
+ if (job !== void 0 && !jobAllowsRegion(job, regionOf(account.credential.domain))) return false;
10202
10542
  return true;
10203
10543
  }
10204
10544
  const sleep = (ms) => new Promise((resolve) => {
@@ -10269,6 +10609,16 @@ var WorkBuddyScheduler = class {
10269
10609
  /** Host hooks that persist the ledger across restarts. */
10270
10610
  loadEarnings;
10271
10611
  saveEarningsFn;
10612
+ /**
10613
+ * The most recent ledger write that may still be in flight.
10614
+ *
10615
+ * `persistEarnings` is synchronous from its callers' point of view, but the
10616
+ * host's settings write is not: it is an async document update. Keeping the
10617
+ * promise here is what lets {@link flushEarnings} wait for the write that
10618
+ * matters — the last one before a restart — without making every counter
10619
+ * update await disk.
10620
+ */
10621
+ pendingEarningsWrite = Promise.resolve();
10272
10622
  constructor(pool, client, options = {}) {
10273
10623
  this.pool = pool;
10274
10624
  this.client = client;
@@ -10305,6 +10655,18 @@ var WorkBuddyScheduler = class {
10305
10655
  this.saveEarningsFn = save;
10306
10656
  }
10307
10657
  /**
10658
+ * Wait for the ledger write that is (or was last) in flight.
10659
+ *
10660
+ * Called at the END of a task pass, right after the last claim, so the rewards
10661
+ * that were just collected are on disk before a restart can lose them. It is
10662
+ * deliberately NOT awaited on every counter bump: the settings document write
10663
+ * is the slow part, and paying it per account would slow the pass for no
10664
+ * benefit while the process is still alive.
10665
+ */
10666
+ async flushEarnings() {
10667
+ await this.pendingEarningsWrite;
10668
+ }
10669
+ /**
10308
10670
  * Fold a previously persisted ledger back in, when it belongs to today.
10309
10671
  *
10310
10672
  * Used after the settings document becomes readable, which happens after
@@ -10574,7 +10936,7 @@ var WorkBuddyScheduler = class {
10574
10936
  compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
10575
10937
  date: today
10576
10938
  });
10577
- this.persistEarnings();
10939
+ this.pendingEarningsWrite = this.persistEarnings();
10578
10940
  }
10579
10941
  /** Clear the per-account counters when the local day changes. */
10580
10942
  rollEarnings(today) {
@@ -10590,9 +10952,18 @@ var WorkBuddyScheduler = class {
10590
10952
  * Best effort on purpose: a failed save must never abort a run that has
10591
10953
  * already collected rewards, and the in-memory ledger keeps serving the card
10592
10954
  * for the rest of the session either way.
10955
+ *
10956
+ * The returned promise settles when the write has landed (or failed). Callers
10957
+ * that are about to END a run await it: reported in #98, a task pass that
10958
+ * claimed 21 tasks had its process restarted before the volatile settings
10959
+ * write reached disk, so `automationEarnings` still read `credit: 0` while
10960
+ * the upstream had really paid out. Fire-and-forget is fine for a counter the
10961
+ * card will re-read in this process; it is not fine for the last write before
10962
+ * exit.
10593
10963
  */
10594
10964
  persistEarnings() {
10595
- if (this.saveEarningsFn === void 0) return;
10965
+ const save = this.saveEarningsFn;
10966
+ if (save === void 0) return Promise.resolve();
10596
10967
  const accounts = {};
10597
10968
  for (const [id, entry] of this.earnings) accounts[id] = entry;
10598
10969
  const ledger = {
@@ -10600,12 +10971,14 @@ var WorkBuddyScheduler = class {
10600
10971
  accounts
10601
10972
  };
10602
10973
  try {
10603
- const saved = this.saveEarningsFn(ledger);
10604
- if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
10974
+ const saved = save(ledger);
10975
+ if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
10605
10976
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
10606
10977
  });
10978
+ return Promise.resolve();
10607
10979
  } catch (error) {
10608
10980
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
10981
+ return Promise.resolve();
10609
10982
  }
10610
10983
  }
10611
10984
  /**
@@ -10632,8 +11005,22 @@ var WorkBuddyScheduler = class {
10632
11005
  let energy = 0;
10633
11006
  let claimed = 0;
10634
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 = [];
10635
11016
  let progressNote;
10636
- 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);
10637
11024
  if (kind === "tasks") await this.sendEventChains(accounts);
10638
11025
  for (let index = 0; index < accounts.length; index++) {
10639
11026
  if (this.stopped) break;
@@ -10641,12 +11028,22 @@ var WorkBuddyScheduler = class {
10641
11028
  if (account === void 0) continue;
10642
11029
  try {
10643
11030
  switch (kind) {
10644
- 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
+ }
10645
11042
  try {
10646
11043
  const claim = await this.client.claimDailyCheckin(account.credential);
10647
11044
  this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
10648
11045
  if (claim.credit > 0) {
10649
- if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
11046
+ detail.push(`签到 +${claim.credit}`);
10650
11047
  this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
10651
11048
  }
10652
11049
  } catch (error) {
@@ -10654,6 +11051,7 @@ var WorkBuddyScheduler = class {
10654
11051
  this.logger.info?.(`automation checkin ${account.label}: already done today`);
10655
11052
  }
10656
11053
  break;
11054
+ }
10657
11055
  case "report": {
10658
11056
  const days = await this.reportOne(account);
10659
11057
  this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
@@ -10665,6 +11063,7 @@ var WorkBuddyScheduler = class {
10665
11063
  energy += result.energy;
10666
11064
  claimed += result.claimed;
10667
11065
  detail.push(...result.titles);
11066
+ items.push(...result.items);
10668
11067
  this.claimableSeen += result.claimableCount;
10669
11068
  this.recordEarnings(account.id, today, {
10670
11069
  credit: result.credit,
@@ -10712,8 +11111,11 @@ var WorkBuddyScheduler = class {
10712
11111
  state.claimed = claimed;
10713
11112
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
10714
11113
  state.detail = detail;
10715
- state.progress = progressNote;
11114
+ state.items = items;
11115
+ if (progressNote === void 0) delete state.progress;
11116
+ else state.progress = progressNote;
10716
11117
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
11118
+ if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
10717
11119
  }
10718
11120
  /** Compose the one-line summary shown on the card. */
10719
11121
  summarise(kind, ok, failed, claimed, credit, energy) {
@@ -10725,12 +11127,16 @@ var WorkBuddyScheduler = class {
10725
11127
  *
10726
11128
  * Disabled accounts are excluded here rather than filtered by the caller so a
10727
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.
10728
11134
  */
10729
- accountsInOrder() {
11135
+ accountsInOrder(job) {
10730
11136
  const now = Date.now();
10731
11137
  return this.pool.list().filter((account) => {
10732
11138
  if (this.pool.isDisabled(account.id)) return false;
10733
- return eligible(account, now);
11139
+ return eligible(account, now, job);
10734
11140
  });
10735
11141
  }
10736
11142
  /**
@@ -10836,10 +11242,88 @@ var WorkBuddyScheduler = class {
10836
11242
  case "expert_5": return this.expertChains(credential, "agent", task);
10837
11243
  case "Expert_team_use_3": return this.expertChains(credential, "team", task);
10838
11244
  case "Expert_lighthouse": return this.lighthouseChains(credential);
11245
+ case "expert_use_maidehao":
11246
+ case "expert_use_douyinyin":
11247
+ case "expert_use_shangjia_xuanchuan":
11248
+ case "expert_use_bowangwang":
11249
+ case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
10839
11250
  default: return [];
10840
11251
  }
10841
11252
  }
10842
11253
  /**
11254
+ * Summon-and-use one expert NAMED BY THE TASK.
11255
+ *
11256
+ * Structurally `expertChains` minus the marketplace: the expert id is read
11257
+ * from the task's deeplink instead of by browsing `marketExpertList`, because
11258
+ * this family's experts are not listed there at all (#98 measured 500 entries
11259
+ * across both `agent` and `team`, zero hits).
11260
+ *
11261
+ * Returns `[]` — not a throw — when the task carries no usable id: the caller
11262
+ * logs "could not be built" only for exceptions, and a task shape this code
11263
+ * does not understand is information, not a fault.
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
+ }
11310
+ async taskExpertChains(credential, task) {
11311
+ const expertId = expertIdFromJumpUrl(task.jumpUrl) ?? await this.expertIdByKeyword(credential, task);
11312
+ if (expertId === void 0) {
11313
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url and no marketplace match, skipping`);
11314
+ return [];
11315
+ }
11316
+ const expert = expertFromTask(expertId, task.title);
11317
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
11318
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
11319
+ const chat = await this.client.openConversation(credential, expert.expertId);
11320
+ if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
11321
+ return [{
11322
+ transport: "desktop",
11323
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
11324
+ }];
11325
+ }
11326
+ /**
10843
11327
  * The summon-and-use chains for the expert tasks.
10844
11328
  *
10845
11329
  * Two steps per expert, and both are load-bearing: the summon events alone are
@@ -10911,21 +11395,41 @@ var WorkBuddyScheduler = class {
10911
11395
  const credential = account.credential;
10912
11396
  const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
10913
11397
  if (open.length > 0) await this.client.acceptTasks(credential, open);
10914
- 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);
10915
11400
  let credit = 0;
10916
11401
  let energy = 0;
10917
11402
  let claimed = 0;
10918
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
+ });
10919
11410
  for (const task of claimable) {
10920
11411
  if (this.stopped) break;
10921
11412
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
10922
11413
  credit += reward.credit;
10923
11414
  energy += reward.energy;
11415
+ const label = task.title === "" ? task.taskCode : task.title;
10924
11416
  if (reward.credit > 0 || reward.energy > 0) {
10925
11417
  claimed++;
10926
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
+ });
10927
11425
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
10928
- } 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
+ }
10929
11433
  if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
10930
11434
  }
10931
11435
  return {
@@ -10933,7 +11437,8 @@ var WorkBuddyScheduler = class {
10933
11437
  credit,
10934
11438
  energy,
10935
11439
  claimableCount: claimable.length,
10936
- titles
11440
+ titles,
11441
+ items
10937
11442
  };
10938
11443
  }
10939
11444
  /**
@@ -11075,6 +11580,7 @@ Object.freeze({
11075
11580
  "kimi_code": "Kimi Code 订阅,模型 id 取自 9router 的 kimi 注册表",
11076
11581
  "lightning": "Lightning AI 的 Model APIs 网关",
11077
11582
  "llamacpp": "本地服务器,模型列表取决于用户已加载的模型",
11583
+ "custom_openai": "自定义 OpenAI 兼容端点:vLLM / LiteLLM / one-api / 自建网关,填 base URL 和 key 后拉取模型",
11078
11584
  "llm7": "default/fast/pro 别名被固定,只留流式工具模型",
11079
11585
  "lmstudio": "本地服务器,模型列表取决于用户已加载的模型",
11080
11586
  "minimax": "推理内容从正文流中单独拆出,语音与图像 id 已剔除",
@@ -11220,7 +11726,7 @@ async function readPreferredId(path = preferredIdPath()) {
11220
11726
  * free of documents that describe the absence of a decision.
11221
11727
  */
11222
11728
  async function writePreferredId(accountId, path = preferredIdPath()) {
11223
- await mkdir(dirname(path), { recursive: true });
11729
+ await ensureSecretDir(dirname(path));
11224
11730
  if (accountId === void 0) {
11225
11731
  await rm(path, { force: true });
11226
11732
  return;
@@ -11230,7 +11736,7 @@ async function writePreferredId(accountId, path = preferredIdPath()) {
11230
11736
  accountId: accountId.trim()
11231
11737
  }, null, 2);
11232
11738
  const temp = `${path}.tmp`;
11233
- await writeFile$1(temp, `${payload}\n`, "utf8");
11739
+ await writeSecretFile(temp, `${payload}\n`);
11234
11740
  await rename(temp, path);
11235
11741
  }
11236
11742
  fileURLToPath(new URL("..", import.meta.url));
@@ -11307,7 +11813,18 @@ z.object({
11307
11813
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
11308
11814
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
11309
11815
  automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
11310
- sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
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."))
11311
11828
  });
11312
11829
  /** Assemble the runtime objects without registering anything. */
11313
11830
  /**