dsh-xdpool 2.5.0 → 2.5.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/index.js CHANGED
@@ -15,6 +15,15 @@ import { env } from "node:process";
15
15
  import http, { createServer, request } from "node:http";
16
16
  import https, { request as request$1 } from "node:https";
17
17
  import { Readable, Transform } from "node:stream";
18
+ //#region src/credit-reserve.ts
19
+ /** Build a merge-safe reserve update. Zero overrides inherited/previous floors. */
20
+ function creditReservesWith(current, accountId, reserve) {
21
+ return {
22
+ ...current,
23
+ [accountId]: reserve
24
+ };
25
+ }
26
+ //#endregion
18
27
  //#region src/badges.ts
19
28
  /**
20
29
  * Promotion badges the upstream attaches to a model.
@@ -1153,7 +1162,8 @@ function parseTask(value) {
1153
1162
  status: typeof raw["status"] === "string" ? raw["status"] : "",
1154
1163
  claimable: !claimed && target > 0 && current >= target,
1155
1164
  claimed,
1156
- locked: raw["locked"] === true
1165
+ locked: raw["locked"] === true,
1166
+ jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
1157
1167
  };
1158
1168
  }
1159
1169
  var WorkBuddyUpstreamClient = class {
@@ -1514,17 +1524,14 @@ var WorkBuddyUpstreamClient = class {
1514
1524
  if (typeof raw !== "object" || raw === null) continue;
1515
1525
  const account = raw;
1516
1526
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1517
- const capacityType = num("CapacityType");
1518
- const monthly = capacityType === 4;
1519
- const periodic = monthly || capacityType === 2;
1520
- const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1521
- const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1527
+ const monthly = num("CapacityType") === 4;
1528
+ const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1529
+ const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1522
1530
  const capped = remain < 0 ? 0 : remain;
1523
1531
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1524
- const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1532
+ const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1525
1533
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1526
- if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1527
- if (!periodic && capped <= 0) continue;
1534
+ if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1528
1535
  total += capped;
1529
1536
  if (expiresAtMs !== void 0) {
1530
1537
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -3872,11 +3879,11 @@ var WorkBuddyAccountPool = class {
3872
3879
  * model id the legacy account-wide check applies (callers that cannot name a
3873
3880
  * model, e.g. CLI diagnostics).
3874
3881
  */
3875
- available(now, modelId, region) {
3882
+ available(now, modelId, region, reserveExempt) {
3876
3883
  return this.accounts.filter((account) => {
3877
3884
  if (this.disabledIds.has(account.id)) return false;
3878
3885
  const reserve = this.creditReserves.get(account.id);
3879
- if (reserve !== void 0 && reserve > 0) {
3886
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3880
3887
  const balance = this.creditBalances.get(account.id);
3881
3888
  if (balance !== void 0 && balance <= reserve) return false;
3882
3889
  }
@@ -3900,7 +3907,7 @@ var WorkBuddyAccountPool = class {
3900
3907
  * Counts are over the region's accounts, since a provider only ever sees its
3901
3908
  * own gateway.
3902
3909
  */
3903
- unavailableReason(modelId, region) {
3910
+ unavailableReason(modelId, region, reserveExempt) {
3904
3911
  const now = Date.now();
3905
3912
  const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
3906
3913
  if (inRegion.length === 0) return {
@@ -3913,6 +3920,29 @@ var WorkBuddyAccountPool = class {
3913
3920
  let cooling = 0;
3914
3921
  let disabled = 0;
3915
3922
  let dead = 0;
3923
+ /**
3924
+ * Accounts that survived every OTHER filter and are held back only by their
3925
+ * credit floor.
3926
+ *
3927
+ * Counted rather than returned on the first hit. The old `return` fired the
3928
+ * moment it met one reserved account, so a single account parked under its
3929
+ * floor reported `reserve` for the whole gateway — even while four other
3930
+ * accounts were healthy and had just been tried. The caller turns
3931
+ * `reason === 'reserve'` into a 402 telling the user to lower the reserve,
3932
+ * which pointed at a setting that was not the problem (#98: a five-account
3933
+ * pool reported `reserve_floor` because of one account that had sat below
3934
+ * its floor for weeks, while the actual state was "the rest are cooling").
3935
+ */
3936
+ let reserved = 0;
3937
+ /**
3938
+ * Accounts that reached the end of the walk unblocked.
3939
+ *
3940
+ * Non-zero means the pool still has somewhere to send a request, so a
3941
+ * reserve is NOT what this answer is about: editing a number cannot fix a
3942
+ * request that some other account is already able to serve, and saying
3943
+ * otherwise sends the reader to the wrong setting.
3944
+ */
3945
+ let usable = 0;
3916
3946
  for (const account of inRegion) {
3917
3947
  if (this.disabledIds.has(account.id)) {
3918
3948
  disabled += 1;
@@ -3928,16 +3958,14 @@ var WorkBuddyAccountPool = class {
3928
3958
  continue;
3929
3959
  }
3930
3960
  const reserve = this.creditReserves.get(account.id);
3931
- if (reserve !== void 0 && reserve > 0) {
3961
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3932
3962
  const balance = this.creditBalances.get(account.id);
3933
- if (balance !== void 0 && balance <= reserve) return {
3934
- total: inRegion.length,
3935
- cooling,
3936
- disabled,
3937
- dead,
3938
- reason: "reserve"
3939
- };
3963
+ if (balance !== void 0 && balance <= reserve) {
3964
+ reserved += 1;
3965
+ continue;
3966
+ }
3940
3967
  }
3968
+ usable += 1;
3941
3969
  }
3942
3970
  if (dead > 0) return {
3943
3971
  total: inRegion.length,
@@ -3960,6 +3988,13 @@ var WorkBuddyAccountPool = class {
3960
3988
  dead,
3961
3989
  reason: "disabled"
3962
3990
  };
3991
+ if (usable === 0 && reserved > 0) return {
3992
+ total: inRegion.length,
3993
+ cooling,
3994
+ disabled,
3995
+ dead,
3996
+ reason: "reserve"
3997
+ };
3963
3998
  return {
3964
3999
  total: inRegion.length,
3965
4000
  cooling,
@@ -4136,16 +4171,18 @@ var WorkBuddyAccountPool = class {
4136
4171
  * Scans on first use, and rescans when every known account is cooling down: a
4137
4172
  * fresh desktop login is the usual way out of an exhausted pool.
4138
4173
  *
4174
+ * `reserveExempt` is host-only: account ids whose own credentials freshly
4175
+ * confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
4139
4176
  * `conversationKey` is only consulted under `sticky`; other modes ignore it.
4140
4177
  */
4141
- async acquire(modelId, region, conversationKey) {
4178
+ async acquire(modelId, region, conversationKey, reserveExempt) {
4142
4179
  if (this.accounts.length === 0) await this.scan();
4143
4180
  const maxTries = this.accounts.length + 1;
4144
4181
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
4145
- let pool = this.available(Date.now(), modelId, region);
4182
+ let pool = this.available(Date.now(), modelId, region, reserveExempt);
4146
4183
  if (pool.length === 0 && attempt === 0) {
4147
4184
  await this.scan();
4148
- pool = this.available(Date.now(), modelId, region);
4185
+ pool = this.available(Date.now(), modelId, region, reserveExempt);
4149
4186
  }
4150
4187
  if (pool.length === 0) return void 0;
4151
4188
  if (this.preferredAccountId !== void 0) {
@@ -4998,22 +5035,6 @@ const POOL_COMBOS_SAVE_PATH = "/plugins/dsh-xdpool/combos/save";
4998
5035
  */
4999
5036
  const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
5000
5037
  /**
5001
- * What the locally running billion-context proxy is doing, read-only.
5002
- *
5003
- * Its own route because the answer comes from ANOTHER plugin: billion-context
5004
- * runs its own loopback listener and publishes its own JSON, so this route only
5005
- * finds that listener and relays a filtered subset. Nothing here is folded into
5006
- * {@link POOL_STATUS_PATH}: the pool card polls that every 30 seconds, and a
5007
- * third-party proxy's session table is not something every pool poll should
5008
- * carry.
5009
- *
5010
- * The word "read-only" is load-bearing on both ends. This plugin sends GETs
5011
- * only — it never configures, restarts or writes to the proxy — and the payload
5012
- * it relays deliberately omits every field that could carry conversation
5013
- * material.
5014
- */
5015
- const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
5016
- /**
5017
5038
  * Read the usage panel's aggregate.
5018
5039
  *
5019
5040
  * GET only, and token-free by construction: the recorder holds counts and
@@ -12230,65 +12251,6 @@ function createWorkBuddyAdapter(options) {
12230
12251
  };
12231
12252
  }
12232
12253
  //#endregion
12233
- //#region src/billion-mount.ts
12234
- /** The package name the loader resolves, exactly as our patch row names it. */
12235
- const BILLION_PACKAGE = "billion-context";
12236
- /**
12237
- * Is this package installed and able to run?
12238
- *
12239
- * Any entry whose name matches counts — including one whose fiber has not
12240
- * started yet. When this function still decided whether to mount, a fiber-less
12241
- * entry had to be treated as absent: treating an import-in-flight as present
12242
- * would have skipped the mount and left the compressor never starting. Now that
12243
- * nothing mounts, the distinction only picks a label, and the honest one is
12244
- * "installed": a user watching billion-context's own boot does not want the
12245
- * card telling them it is missing while its proxy is half-way up.
12246
- *
12247
- * A DISABLED entry does not count. `loader.entries()` yields disabled rows too
12248
- * — the host skips starting them, it does not drop them — and our own patch
12249
- * row is disabled on purpose. Counting it would make every profile that never
12250
- * installed billion-context read as "installed", which is precisely the lie the
12251
- * card must not tell.
12252
- */
12253
- function mountedInLoader(loader, packageName) {
12254
- for (const entry of loader.entries()) {
12255
- if (entry.options?.name !== packageName) continue;
12256
- if (entry.options?.disabled === true) continue;
12257
- return true;
12258
- }
12259
- return false;
12260
- }
12261
- /**
12262
- * Report whether the compressor is already running. Never mounts.
12263
- *
12264
- * Resolves rather than rejects on every path: a compressor that will not start
12265
- * is a card that says so, never a pool that fails to boot. The pool's own model
12266
- * registry has nothing to do with compression, and one missing optional feature
12267
- * must not take the accounts down with it.
12268
- */
12269
- async function mountBillionContext(ctx, logger) {
12270
- try {
12271
- let loader;
12272
- ctx.inject(["loader"], (scoped) => {
12273
- loader = scoped.loader;
12274
- });
12275
- if (loader === void 0) return {
12276
- state: "unavailable",
12277
- reason: "the host exposes no plugin loader"
12278
- };
12279
- if (mountedInLoader(loader, BILLION_PACKAGE)) {
12280
- logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
12281
- return { state: "already-mounted" };
12282
- }
12283
- return { state: "not-required" };
12284
- } catch (error) {
12285
- return {
12286
- state: "unavailable",
12287
- reason: error instanceof Error ? error.message : String(error)
12288
- };
12289
- }
12290
- }
12291
- //#endregion
12292
12254
  //#region src/pack-accounts.ts
12293
12255
  /**
12294
12256
  * Channel ids whose accounts this plugin serves itself.
@@ -13758,6 +13720,33 @@ function withSessionQuotaHint(error, retryAfterMs) {
13758
13720
  return new AggregateError(error.kind, `${error.message} — this quota is counted per conversation on the free tier, so a new conversation (or another model) starts with its own budget`, error.status);
13759
13721
  }
13760
13722
  /**
13723
+ * Say what a 403 on this lane actually means (#96).
13724
+ *
13725
+ * The gateway answers a client it does not recognise with `403 FreeTierError`
13726
+ * and no explanation of which of its four fingerprint facts was wrong. That
13727
+ * reaches the caller as a bare `client` failure, and a bare 4xx is what the
13728
+ * host then renders in its own words: the user in #96 saw "当前请求的额度已用尽"
13729
+ * for a request that had never been billed. The wording pointed at waiting,
13730
+ * while the actual cause was "this build's fingerprint no longer matches, update
13731
+ * the plugin" — the two need opposite responses.
13732
+ *
13733
+ * The lane cannot tell them apart from the outside (both look like a refusal
13734
+ * with a thin body), so this does not pretend to. It names the actionable
13735
+ * reading FIRST — the fingerprint, which the user can fix by updating — and
13736
+ * keeps the quota possibility second, because it is the one that fixes itself.
13737
+ *
13738
+ * A 429 keeps its own hint ({@link withSessionQuotaHint}): that one really is
13739
+ * quota, and blurring the two would lose a distinction worth keeping.
13740
+ */
13741
+ function withFreeTierHint(status, refusal, retryAfterMs) {
13742
+ const base = classifyUpstream(status, refusal, retryAfterMs, "");
13743
+ if (status !== 403) return base;
13744
+ const separator = base.message.indexOf(": ");
13745
+ const detail = separator === -1 ? "" : base.message.slice(separator + 2);
13746
+ const kept = detail === "" ? "" : ` (provider said: ${detail})`;
13747
+ return new AggregateError(base.kind, `the free lane refused this client: its request fingerprint no longer matches what opencode.ai accepts, so this is NOT an exhausted quota and waiting will not help — update dsh-xdpool and try again; if it persists, report this message. The shared free quota is also metered per conversation, so one retry in a fresh conversation covers the case where only that budget is spent${kept}`, status);
13748
+ }
13749
+ /**
13761
13750
  * POST one streaming turn and hand back chat-shaped deltas.
13762
13751
  *
13763
13752
  * The shape of the ANSWER decides how it is read, never its Content-Type: a front
@@ -13801,7 +13790,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13801
13790
  const retryAfter = retryAfterMs(response);
13802
13791
  if (!response.ok) {
13803
13792
  const refusal = await response.text().catch(() => "");
13804
- throw withSessionQuotaHint(classifyUpstream(response.status, refusal, retryAfter, ""), retryAfter);
13793
+ throw withSessionQuotaHint(withFreeTierHint(response.status, refusal, retryAfter), retryAfter);
13805
13794
  }
13806
13795
  if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
13807
13796
  let events;
@@ -13834,7 +13823,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
13834
13823
  } catch {
13835
13824
  continue;
13836
13825
  }
13837
- if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(classifyUpstream(response.status, event, retryAfter, ""), retryAfter);
13826
+ if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(withFreeTierHint(response.status, event, retryAfter), retryAfter);
13838
13827
  const translated = chatChunksFor(wire, decoded, tools);
13839
13828
  for (const delta of translated.frames) hooks.onDelta(delta);
13840
13829
  if (translated.usage !== void 0) usage = translated.usage;
@@ -15051,6 +15040,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
15051
15040
  };
15052
15041
  }
15053
15042
  /**
15043
+ * Pull the `expertId` out of a task's `jump_url`.
15044
+ *
15045
+ * The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
15046
+ * listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
15047
+ * types and got zero hits — so the only place the id appears is the deeplink
15048
+ * embedded in the task itself:
15049
+ *
15050
+ * https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
15051
+ *
15052
+ * The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
15053
+ * would otherwise end the outer query. So the parse is two steps — decode the
15054
+ * `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
15055
+ * NOT a regex over the whole URL, which would also match the outer `expertId`
15056
+ * spelling in a future URL shape and silently pick the wrong one.
15057
+ *
15058
+ * Returns undefined for the tasks whose deeplink carries no id at all
15059
+ * (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
15060
+ * the honest answer: those need a different source and are not guessed here.
15061
+ */
15062
+ function expertIdFromJumpUrl(jumpUrl) {
15063
+ if (jumpUrl === "") return void 0;
15064
+ /** Read `expertId` off any URL-ish string, decoded or not. */
15065
+ const readId = (value) => {
15066
+ let query = value;
15067
+ try {
15068
+ const parsed = new URL(value);
15069
+ query = parsed.search === "" ? value : parsed.search;
15070
+ } catch {
15071
+ query = value;
15072
+ }
15073
+ const marker = query.indexOf("expertId=");
15074
+ if (marker === -1) return void 0;
15075
+ const rest = query.slice(marker + 9);
15076
+ const end = rest.search(/[&#]/u);
15077
+ const id = (end === -1 ? rest : rest.slice(0, end)).trim();
15078
+ return id === "" ? void 0 : id;
15079
+ };
15080
+ let source = jumpUrl;
15081
+ try {
15082
+ const raw = new URL(jumpUrl).searchParams.get("deeplink");
15083
+ if (raw !== null && raw !== "") source = raw;
15084
+ } catch {
15085
+ source = jumpUrl;
15086
+ }
15087
+ return readId(source);
15088
+ }
15089
+ /**
15090
+ * Build a summon record for an expert the marketplace does not list.
15091
+ *
15092
+ * Everything the chains need beyond the id is cosmetic — the scorer keys on
15093
+ * `expertId` — so the title from the task is reused as the display name rather
15094
+ * than inventing one. `version` is left empty, which every consumer already
15095
+ * defaults to `1.0.0`.
15096
+ */
15097
+ function expertFromTask(expertId, title) {
15098
+ return {
15099
+ expertId,
15100
+ expertType: "agent",
15101
+ displayName: title,
15102
+ profession: title,
15103
+ version: "",
15104
+ categories: []
15105
+ };
15106
+ }
15107
+ /**
15054
15108
  * The three "summon an expert" events (`expert_summon_click` and friends).
15055
15109
  *
15056
15110
  * Paid before the conversation, in the order the app emits them.
@@ -15173,7 +15227,12 @@ const EVENT_CHAIN_BUILDERS = {
15173
15227
  skill_1: true,
15174
15228
  expert_5: true,
15175
15229
  Expert_team_use_3: true,
15176
- Expert_lighthouse: true
15230
+ Expert_lighthouse: true,
15231
+ expert_use_maidehao: true,
15232
+ expert_use_douyinyin: true,
15233
+ expert_use_shangjia_xuanchuan: true,
15234
+ expert_use_bowangwang: true,
15235
+ WorkBuddy_Valorant: true
15177
15236
  };
15178
15237
  const AUTOMATION_TICK_MS = 6e4;
15179
15238
  /** The jobs in the order a tick runs them: report before tasks, always. */
@@ -15381,6 +15440,16 @@ var WorkBuddyScheduler = class {
15381
15440
  /** Host hooks that persist the ledger across restarts. */
15382
15441
  loadEarnings;
15383
15442
  saveEarningsFn;
15443
+ /**
15444
+ * The most recent ledger write that may still be in flight.
15445
+ *
15446
+ * `persistEarnings` is synchronous from its callers' point of view, but the
15447
+ * host's settings write is not: it is an async document update. Keeping the
15448
+ * promise here is what lets {@link flushEarnings} wait for the write that
15449
+ * matters — the last one before a restart — without making every counter
15450
+ * update await disk.
15451
+ */
15452
+ pendingEarningsWrite = Promise.resolve();
15384
15453
  constructor(pool, client, options = {}) {
15385
15454
  this.pool = pool;
15386
15455
  this.client = client;
@@ -15417,6 +15486,18 @@ var WorkBuddyScheduler = class {
15417
15486
  this.saveEarningsFn = save;
15418
15487
  }
15419
15488
  /**
15489
+ * Wait for the ledger write that is (or was last) in flight.
15490
+ *
15491
+ * Called at the END of a task pass, right after the last claim, so the rewards
15492
+ * that were just collected are on disk before a restart can lose them. It is
15493
+ * deliberately NOT awaited on every counter bump: the settings document write
15494
+ * is the slow part, and paying it per account would slow the pass for no
15495
+ * benefit while the process is still alive.
15496
+ */
15497
+ async flushEarnings() {
15498
+ await this.pendingEarningsWrite;
15499
+ }
15500
+ /**
15420
15501
  * Fold a previously persisted ledger back in, when it belongs to today.
15421
15502
  *
15422
15503
  * Used after the settings document becomes readable, which happens after
@@ -15686,7 +15767,7 @@ var WorkBuddyScheduler = class {
15686
15767
  compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
15687
15768
  date: today
15688
15769
  });
15689
- this.persistEarnings();
15770
+ this.pendingEarningsWrite = this.persistEarnings();
15690
15771
  }
15691
15772
  /** Clear the per-account counters when the local day changes. */
15692
15773
  rollEarnings(today) {
@@ -15702,9 +15783,18 @@ var WorkBuddyScheduler = class {
15702
15783
  * Best effort on purpose: a failed save must never abort a run that has
15703
15784
  * already collected rewards, and the in-memory ledger keeps serving the card
15704
15785
  * for the rest of the session either way.
15786
+ *
15787
+ * The returned promise settles when the write has landed (or failed). Callers
15788
+ * that are about to END a run await it: reported in #98, a task pass that
15789
+ * claimed 21 tasks had its process restarted before the volatile settings
15790
+ * write reached disk, so `automationEarnings` still read `credit: 0` while
15791
+ * the upstream had really paid out. Fire-and-forget is fine for a counter the
15792
+ * card will re-read in this process; it is not fine for the last write before
15793
+ * exit.
15705
15794
  */
15706
15795
  persistEarnings() {
15707
- if (this.saveEarningsFn === void 0) return;
15796
+ const save = this.saveEarningsFn;
15797
+ if (save === void 0) return Promise.resolve();
15708
15798
  const accounts = {};
15709
15799
  for (const [id, entry] of this.earnings) accounts[id] = entry;
15710
15800
  const ledger = {
@@ -15712,12 +15802,14 @@ var WorkBuddyScheduler = class {
15712
15802
  accounts
15713
15803
  };
15714
15804
  try {
15715
- const saved = this.saveEarningsFn(ledger);
15716
- if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
15805
+ const saved = save(ledger);
15806
+ if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
15717
15807
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
15718
15808
  });
15809
+ return Promise.resolve();
15719
15810
  } catch (error) {
15720
15811
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
15812
+ return Promise.resolve();
15721
15813
  }
15722
15814
  }
15723
15815
  /**
@@ -15826,6 +15918,7 @@ var WorkBuddyScheduler = class {
15826
15918
  state.detail = detail;
15827
15919
  state.progress = progressNote;
15828
15920
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
15921
+ if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
15829
15922
  }
15830
15923
  /** Compose the one-line summary shown on the card. */
15831
15924
  summarise(kind, ok, failed, claimed, credit, energy) {
@@ -15948,10 +16041,43 @@ var WorkBuddyScheduler = class {
15948
16041
  case "expert_5": return this.expertChains(credential, "agent", task);
15949
16042
  case "Expert_team_use_3": return this.expertChains(credential, "team", task);
15950
16043
  case "Expert_lighthouse": return this.lighthouseChains(credential);
16044
+ case "expert_use_maidehao":
16045
+ case "expert_use_douyinyin":
16046
+ case "expert_use_shangjia_xuanchuan":
16047
+ case "expert_use_bowangwang":
16048
+ case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
15951
16049
  default: return [];
15952
16050
  }
15953
16051
  }
15954
16052
  /**
16053
+ * Summon-and-use one expert NAMED BY THE TASK.
16054
+ *
16055
+ * Structurally `expertChains` minus the marketplace: the expert id is read
16056
+ * from the task's deeplink instead of by browsing `marketExpertList`, because
16057
+ * this family's experts are not listed there at all (#98 measured 500 entries
16058
+ * across both `agent` and `team`, zero hits).
16059
+ *
16060
+ * Returns `[]` — not a throw — when the task carries no usable id: the caller
16061
+ * logs "could not be built" only for exceptions, and a task shape this code
16062
+ * does not understand is information, not a fault.
16063
+ */
16064
+ async taskExpertChains(credential, task) {
16065
+ const expertId = expertIdFromJumpUrl(task.jumpUrl);
16066
+ if (expertId === void 0) {
16067
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url, skipping`);
16068
+ return [];
16069
+ }
16070
+ const expert = expertFromTask(expertId, task.title);
16071
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
16072
+ if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
16073
+ const chat = await this.client.openConversation(credential, expert.expertId);
16074
+ if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
16075
+ return [{
16076
+ transport: "desktop",
16077
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
16078
+ }];
16079
+ }
16080
+ /**
15955
16081
  * The summon-and-use chains for the expert tasks.
15956
16082
  *
15957
16083
  * Two steps per expert, and both are load-bearing: the summon events alone are
@@ -17291,33 +17417,6 @@ function registerPoolStatusRoute(ctx, deps) {
17291
17417
  }
17292
17418
  });
17293
17419
  /**
17294
- * The billion-context relay.
17295
- *
17296
- * GET only, loopback origin only. Its own route because it is the one
17297
- * document here that comes from ANOTHER plugin's loopback listener: the
17298
- * proxy publishes its own JSON and this only reads it, so folding it into
17299
- * the pool status document would put a third-party service on the critical
17300
- * path of every 30-second pool poll.
17301
- *
17302
- * Answered even when the proxy is absent — `state: 'missing'` with the
17303
- * install command is far more actionable than a 503, and it is the normal
17304
- * state on a machine that has not installed billion-context.
17305
- */
17306
- const disposeBillion = ctx.webServer.register({
17307
- kind: "exact",
17308
- path: POOL_BILLION_PATH,
17309
- handler: async (req, res) => {
17310
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
17311
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
17312
- if (deps.billionDocument === void 0) return json(res, 503, { error: "the billion-context relay is not available in this build" });
17313
- try {
17314
- json(res, 200, await deps.billionDocument());
17315
- } catch (error) {
17316
- json(res, 500, { error: safeMessage(error) });
17317
- }
17318
- }
17319
- });
17320
- /**
17321
17420
  * The chain list plus the roster its members are drawn from.
17322
17421
  *
17323
17422
  * GET only. Deliberately not part of the pool status document: chains change
@@ -17393,7 +17492,6 @@ function registerPoolStatusRoute(ctx, deps) {
17393
17492
  disposeRelay();
17394
17493
  disposeCombosSave();
17395
17494
  disposeCombos();
17396
- disposeBillion();
17397
17495
  disposeChannels();
17398
17496
  disposeAggregate();
17399
17497
  disposeAggregateDiscover();
@@ -17417,247 +17515,6 @@ function registerPoolStatusRoute(ctx, deps) {
17417
17515
  }, "dsh-xdpool: Web status route");
17418
17516
  }
17419
17517
  //#endregion
17420
- //#region src/billion.ts
17421
- /**
17422
- * Read-only observation of a locally running billion-context proxy.
17423
- *
17424
- * WHAT THIS IS, AND WHY IT IS NOT AN EMBEDDED COMPRESSOR
17425
- *
17426
- * billion-context is a separate DSH plugin that is very likely already
17427
- * installed on this machine — on a stock desktop profile it is mounted by the
17428
- * profile's bundle layer and runs its own loopback listener. This module never
17429
- * implements compression and never proposes to: it finds that listener and
17430
- * relays a few numbers so the card can show what the compressor is doing.
17431
- * Re-implementing folding here would mean a second, divergent answer to the
17432
- * same question and a second set of ways to get it wrong.
17433
- *
17434
- * HOW THE LISTENER IS FOUND
17435
- *
17436
- * The proxy picks an ephemeral port at spawn and records it in its own state
17437
- * directory, which we read rather than guess. Guessing would mean scanning a
17438
- * port range on every card poll, and a wrong guess is worse than no answer: it
17439
- * could read some unrelated loopback service. The recorded file is
17440
- * `<state>/billion-context/proxy-origin` and holds a JSON object with at least
17441
- * an `origin`; `port-zone.json` is the same fact keyed by lane. Both are read,
17442
- * and the origin file wins, because it is the one the proxy itself reads on
17443
- * startup.
17444
- *
17445
- * `state` is resolved the same way the proxy resolves it: `XDG_STATE_HOME` when
17446
- * set, else `<homedir>/.local/state` on every platform including Windows — not
17447
- * `%LOCALAPPDATA%`, which is what a Windows developer would reach for first and
17448
- * which would therefore be wrong.
17449
- *
17450
- * WHAT IS RELAYED
17451
- *
17452
- * Counts, sizes, timestamps. Not titles, not first-block hints, not any
17453
- * conversation material. The proxy is happy to hand over more than that, and
17454
- * taking it would put the user's own words into a document the card fetches
17455
- * every 30 seconds and that lives in the plugin's HTTP log.
17456
- *
17457
- * The one addition to that list is the proxy's own origin, so the card can link
17458
- * out to the dashboard the proxy already ships instead of redrawing it. That is
17459
- * a service address rather than conversation material, and anyone who can read
17460
- * this document can already reach that listener directly — which is why it is
17461
- * safe to publish where a title would not be.
17462
- *
17463
- * The proxy also has NO AUTHENTICATION by its own documentation — the README
17464
- * says so outright — so every request here is a hard local one, and the relay
17465
- * only ever reads `/__bili/health` and `/__bili/sessions`. Both are GETs. This
17466
- * module has no code path that could write to that proxy.
17467
- *
17468
- * @module dsh-xdpool/billion
17469
- */
17470
- /** The proxy's own management prefix. Never guess it; it is theirs. */
17471
- const BILI_PREFIX = "/__bili";
17472
- /** Loopback only. The proxy binds 127.0.0.1 and we refuse anything else. */
17473
- const LOOPBACK_HOSTS$1 = [
17474
- "127.0.0.1",
17475
- "localhost",
17476
- "[::1]",
17477
- "::1"
17478
- ];
17479
- /**
17480
- * How long one relay request may take.
17481
- *
17482
- * Short on purpose: this runs inside the card's status request, and a stalled
17483
- * third-party proxy must degrade to a panel that says "no answer" rather than
17484
- * hold the pool card hostage. The proxy answers these two endpoints in
17485
- * milliseconds; the budget is for the case where it does not.
17486
- */
17487
- const RELAY_TIMEOUT_MS = 1500;
17488
- /** How many sessions the card is shown. 50 rows is already past the fold. */
17489
- const MAX_SESSIONS = 50;
17490
- /**
17491
- * Where the proxy keeps its state directory.
17492
- *
17493
- * Mirrors the proxy's own resolution exactly: `XDG_STATE_HOME` when it holds an
17494
- * absolute path, else `<homedir>/.local/state`. Deliberately NOT
17495
- * `DSH_HOME ?? homedir()` — DSH_HOME is the profile root and a live value on a
17496
- * real desktop install, so preferring it sent this reader to
17497
- * `<profile>/.local/state/billion-context` and reported "missing" on a machine
17498
- * where the proxy was running and answering. Probed against the live proxy,
17499
- * which is the only way that mistake is visible: every unit test with a faked
17500
- * state directory passes either way.
17501
- *
17502
- * Undefined when neither resolves, because inventing a path to probe would
17503
- * turn "not installed" into a silent hunt through the filesystem.
17504
- */
17505
- function stateRoot() {
17506
- const xdg = env.XDG_STATE_HOME;
17507
- if (typeof xdg === "string" && xdg !== "") {
17508
- if (xdg.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(xdg)) return join(xdg, "billion-context");
17509
- return;
17510
- }
17511
- const home = homedir();
17512
- if (typeof home !== "string" || home === "") return void 0;
17513
- return join(home, ".local", "state", "billion-context");
17514
- }
17515
- /** A loopback http(s) origin, or undefined for anything else. */
17516
- function parseLoopbackOrigin(raw) {
17517
- let url;
17518
- try {
17519
- url = new URL(raw);
17520
- } catch {
17521
- return;
17522
- }
17523
- if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
17524
- if (!LOOPBACK_HOSTS$1.includes(url.hostname.toLowerCase())) return void 0;
17525
- return url.origin;
17526
- }
17527
- /**
17528
- * The proxy origin, from the file the proxy itself reads on startup.
17529
- *
17530
- * `port-zone.json` is the fallback because it is keyed by lane and therefore
17531
- * distinguishes the dsh proxy from, say, a concurrently running opencode one —
17532
- * but `proxy-origin` is preferred because it is the single-writer record.
17533
- */
17534
- async function readProxyOrigin() {
17535
- const root = stateRoot();
17536
- if (root === void 0) return void 0;
17537
- for (const name of ["proxy-origin", "port-zone.json"]) {
17538
- let raw;
17539
- try {
17540
- raw = await readFile(join(root, name), "utf8");
17541
- } catch {
17542
- continue;
17543
- }
17544
- if (name === "proxy-origin") {
17545
- try {
17546
- const parsed = JSON.parse(raw);
17547
- const origin = typeof parsed.origin === "string" ? parseLoopbackOrigin(parsed.origin) : void 0;
17548
- if (origin !== void 0) return origin;
17549
- } catch {}
17550
- continue;
17551
- }
17552
- try {
17553
- const port = JSON.parse(raw).lanes?.dsh;
17554
- if (typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
17555
- } catch {
17556
- continue;
17557
- }
17558
- }
17559
- }
17560
- /** One GET against the proxy, with the relay budget on it. Never throws. */
17561
- async function fetchJson(origin, path) {
17562
- const controller = new AbortController();
17563
- const timer = setTimeout(() => {
17564
- controller.abort();
17565
- }, RELAY_TIMEOUT_MS);
17566
- try {
17567
- const response = await fetch(new URL(BILI_PREFIX + path, origin), {
17568
- method: "GET",
17569
- headers: { accept: "application/json" },
17570
- signal: controller.signal
17571
- });
17572
- if (!response.ok) return void 0;
17573
- return await response.json();
17574
- } catch {
17575
- return;
17576
- } finally {
17577
- clearTimeout(timer);
17578
- }
17579
- }
17580
- /** A finite number, or undefined. Guards every relayed field. */
17581
- function num(value) {
17582
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
17583
- }
17584
- /** A string, or ''. */
17585
- function str(value) {
17586
- return typeof value === "string" ? value : "";
17587
- }
17588
- /** A boolean, or false. */
17589
- function bool(value) {
17590
- return value === true;
17591
- }
17592
- /**
17593
- * One session row, reduced.
17594
- *
17595
- * Every field is defaulted rather than passed through: the proxy's document is
17596
- * a moving target across versions, and an unreadable row should render as
17597
- * zeroes we can explain, not as `undefined` reaching the card.
17598
- */
17599
- function toSession(raw) {
17600
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
17601
- const row = raw;
17602
- const id = str(row.id);
17603
- if (id === "") return void 0;
17604
- return {
17605
- id,
17606
- protocol: str(row.protocol),
17607
- live: bool(row.live),
17608
- requests: num(row.requests) ?? 0,
17609
- contextTokens: num(row.contextTokens) ?? 0,
17610
- contextTokensSource: str(row.contextTokensSource),
17611
- tokensSaved: num(row.tokensSaved) ?? 0,
17612
- inputTokens: num(row.inputTokens) ?? 0,
17613
- cachedTokens: num(row.cachedTokens) ?? 0,
17614
- outputTokens: num(row.outputTokens) ?? 0,
17615
- cacheHitPct: num(row.cacheHitPct) ?? -1,
17616
- blocks: num(row.blocks) ?? 0,
17617
- contextWindow: num(row.contextWindow) ?? 0,
17618
- lastSeen: str(row.lastSeen),
17619
- lastModel: str(row.lastModel)
17620
- };
17621
- }
17622
- /** The whole relay, safe to hand the card: it never rejects. */
17623
- async function readBillionStatus() {
17624
- const origin = await readProxyOrigin();
17625
- if (origin === void 0) return {
17626
- state: "missing",
17627
- reason: "billion-context is not running on this machine. Install it with \"dsh plugin --profile <name> add billion-context\" and this page fills in.",
17628
- sessions: [],
17629
- totalSessions: 0
17630
- };
17631
- const health = await fetchJson(origin, "/health");
17632
- if (health === null || typeof health !== "object") return {
17633
- state: "unreachable",
17634
- reason: `billion-context is recorded at ${origin} but did not answer. It may have just been restarted.`,
17635
- origin,
17636
- sessions: [],
17637
- totalSessions: 0
17638
- };
17639
- const meta = health;
17640
- const sessionsDoc = await fetchJson(origin, "/sessions");
17641
- const rawSessions = sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.sessions : void 0;
17642
- const rows = Array.isArray(rawSessions) ? rawSessions.slice(0, MAX_SESSIONS) : [];
17643
- const sessions = [];
17644
- for (const row of rows) {
17645
- const session = toSession(row);
17646
- if (session !== void 0) sessions.push(session);
17647
- }
17648
- const total = num(sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.total : void 0) ?? sessions.length;
17649
- const version = str(meta.version);
17650
- const startedAt = num(meta.startedAt);
17651
- return {
17652
- state: "ready",
17653
- origin,
17654
- ...version === "" ? {} : { version },
17655
- ...startedAt === void 0 ? {} : { startedAt },
17656
- sessions,
17657
- totalSessions: total
17658
- };
17659
- }
17660
- //#endregion
17661
17518
  //#region src/diagnostics.ts
17662
17519
  /**
17663
17520
  * A startup trace this plugin owns.
@@ -18663,6 +18520,34 @@ function createWorkBuddyShim(options) {
18663
18520
  const server = createServer((req, res) => {
18664
18521
  handle(req, res);
18665
18522
  });
18523
+ async function acquireForRequest(modelId, conversationKey, signal) {
18524
+ const ordinary = await pool.acquire(modelId, region, conversationKey);
18525
+ const reserveExempt = /* @__PURE__ */ new Set();
18526
+ if (ordinary !== void 0 || modelId === void 0 || signal.aborted) return {
18527
+ account: ordinary,
18528
+ reserveExempt
18529
+ };
18530
+ const now = Date.now();
18531
+ const held = pool.list(region).filter((account) => pool.isReserved(account.id) && !pool.isDisabled(account.id) && (account.credentialDeadUntilMs ?? 0) <= now && account.cooldownUntilMs <= now && (account.modelCooldowns[modelId] ?? 0) <= now);
18532
+ const pricingSignal = AbortSignal.any([signal, AbortSignal.timeout(5e3)]);
18533
+ await Promise.all(held.map(async (account) => {
18534
+ try {
18535
+ const price = (await client.fetchModels(account.credential, pricingSignal)).find((model) => model.id === modelId)?.creditMultiplier;
18536
+ logger?.info?.(`dsh-xdpool: reserve pricing model=${modelId} region=${region ?? "any"} price=${price ?? "unknown"} exempt=${!pricingSignal.aborted && price === 0}`);
18537
+ if (!pricingSignal.aborted && price === 0) reserveExempt.add(account.id);
18538
+ } catch {
18539
+ logger?.info?.(`dsh-xdpool: reserve pricing unavailable model=${modelId} region=${region ?? "any"} aborted=${pricingSignal.aborted}`);
18540
+ }
18541
+ }));
18542
+ if (signal.aborted) return {
18543
+ account: void 0,
18544
+ reserveExempt
18545
+ };
18546
+ return {
18547
+ account: await pool.acquire(modelId, region, conversationKey, reserveExempt),
18548
+ reserveExempt
18549
+ };
18550
+ }
18666
18551
  /**
18667
18552
  * "The same model has a paid row" sentence for a 429 body, or undefined.
18668
18553
  *
@@ -19163,9 +19048,9 @@ function createWorkBuddyShim(options) {
19163
19048
  if (signal.aborted) return;
19164
19049
  attempts.push(member.modelId);
19165
19050
  if (!member.aggregate) {
19166
- const account = await pool.acquire(member.modelId, region);
19051
+ const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, signal);
19167
19052
  if (account === void 0) {
19168
- const why = pool.unavailableReason(member.modelId, region);
19053
+ const why = pool.unavailableReason(member.modelId, region, reserveExempt);
19169
19054
  last = {
19170
19055
  kind: why.reason,
19171
19056
  status: why.reason === "cooling" ? 429 : 401,
@@ -19173,7 +19058,11 @@ function createWorkBuddyShim(options) {
19173
19058
  };
19174
19059
  continue;
19175
19060
  }
19176
- const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
19061
+ const memberRaw = JSON.stringify({
19062
+ ...JSON.parse(raw),
19063
+ model: member.modelId
19064
+ });
19065
+ const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
19177
19066
  const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
19178
19067
  if (result.ok) {
19179
19068
  const turnId = randomBytes(8).toString("hex");
@@ -19328,7 +19217,6 @@ function createWorkBuddyShim(options) {
19328
19217
  const conversationKey = conversationKeyOf(poolRaw);
19329
19218
  const tried = [];
19330
19219
  let last;
19331
- const refreshedThisRequest = /* @__PURE__ */ new Set();
19332
19220
  const turnId = randomBytes(8).toString("hex");
19333
19221
  const usageContext = usageRecorder === void 0 ? void 0 : {
19334
19222
  recorder: usageRecorder,
@@ -19338,9 +19226,9 @@ function createWorkBuddyShim(options) {
19338
19226
  };
19339
19227
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
19340
19228
  if (controller.signal.aborted) return;
19341
- const account = await pool.acquire(modelId, region, conversationKey);
19229
+ const { account, reserveExempt } = await acquireForRequest(modelId, conversationKey, controller.signal);
19342
19230
  if (account === void 0) {
19343
- const why = pool.unavailableReason(modelId, region);
19231
+ const why = pool.unavailableReason(modelId, region, reserveExempt);
19344
19232
  if (why.reason === "cooling") {
19345
19233
  const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
19346
19234
  const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
@@ -19353,7 +19241,7 @@ function createWorkBuddyShim(options) {
19353
19241
  return;
19354
19242
  }
19355
19243
  if (why.reason === "reserve") {
19356
- writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
19244
+ writeOpenAIError(res, 402, "reserve_floor", `no WorkBuddy account is available; every account not excluded by a cooldown or a revoked sign-in is at or below its credit reserve (${why.total} account(s) total); lower the reserve on the pool card or top up credits`);
19357
19245
  return;
19358
19246
  }
19359
19247
  if (why.reason === "session_dead") {
@@ -19383,12 +19271,6 @@ function createWorkBuddyShim(options) {
19383
19271
  message: result.message
19384
19272
  };
19385
19273
  if (result.kind === "session_dead") {
19386
- if (refreshedThisRequest.has(account.id)) {
19387
- pool.penalizeCredentialDead(account.id);
19388
- logger?.warn(`dsh-xdpool: ${account.label} still refused after a token refresh (attempt ${attempt + 1}/${maxAttempts}); rotating`);
19389
- continue;
19390
- }
19391
- refreshedThisRequest.add(account.id);
19392
19274
  logger?.warn(`dsh-xdpool: ${account.label} session dead; refreshing token and retrying`);
19393
19275
  const before = account.credential.accessToken;
19394
19276
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
@@ -19413,7 +19295,7 @@ function createWorkBuddyShim(options) {
19413
19295
  }
19414
19296
  if (isContextTooLong(last.message)) {
19415
19297
  const recovered = await recoverFromContextOverrun({
19416
- raw,
19298
+ raw: poolRaw,
19417
19299
  modelId,
19418
19300
  controller,
19419
19301
  region,
@@ -19421,6 +19303,7 @@ function createWorkBuddyShim(options) {
19421
19303
  client,
19422
19304
  pool,
19423
19305
  maxAttempts,
19306
+ acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
19424
19307
  usage: usageContext,
19425
19308
  contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
19426
19309
  });
@@ -19618,7 +19501,7 @@ async function recoverFromContextOverrun(options) {
19618
19501
  let compacted = messages;
19619
19502
  let compactionDetail = "";
19620
19503
  try {
19621
- const summariser = await pool.acquire(modelId, region);
19504
+ const summariser = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
19622
19505
  if (summariser === void 0) compactionDetail = "no account available to summarise with";
19623
19506
  else {
19624
19507
  const outcome = await compactWithSummary(messages, {
@@ -19646,13 +19529,12 @@ async function recoverFromContextOverrun(options) {
19646
19529
  };
19647
19530
  const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
19648
19531
  const tried = [];
19649
- const refreshedThisRequest = /* @__PURE__ */ new Set();
19650
19532
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
19651
19533
  if (controller.signal.aborted) return {
19652
19534
  ok: false,
19653
19535
  detail: "client disconnected"
19654
19536
  };
19655
- const account = await pool.acquire(modelId, region);
19537
+ const account = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
19656
19538
  if (account === void 0) return {
19657
19539
  ok: false,
19658
19540
  detail: "no account available after compaction"
@@ -19680,11 +19562,6 @@ async function recoverFromContextOverrun(options) {
19680
19562
  detail: "prompt still exceeded the window after compaction"
19681
19563
  };
19682
19564
  if (result.kind === "session_dead") {
19683
- if (refreshedThisRequest.has(account.id)) {
19684
- pool.penalizeCredentialDead(account.id);
19685
- continue;
19686
- }
19687
- refreshedThisRequest.add(account.id);
19688
19565
  const before = account.credential.accessToken;
19689
19566
  if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
19690
19567
  continue;
@@ -20744,8 +20621,7 @@ const Config = z.object({
20744
20621
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
20745
20622
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
20746
20623
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
20747
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
20748
- sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
20624
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
20749
20625
  });
20750
20626
  /** Live API, published for the CLI. */
20751
20627
  let api;
@@ -21134,9 +21010,7 @@ function apply(ctx, config = {}) {
21134
21010
  await setSetting("disabledAccountIds", next, next);
21135
21011
  },
21136
21012
  setCreditReserve: async (accountId, reserve) => {
21137
- const next = { ...current().creditReserves ?? {} };
21138
- if (reserve > 0) next[accountId] = reserve;
21139
- else delete next[accountId];
21013
+ const next = creditReservesWith(current().creditReserves, accountId, reserve);
21140
21014
  await setSetting("creditReserves", next, next);
21141
21015
  },
21142
21016
  setAccountIgnored: async (accountId, ignored) => {
@@ -21310,7 +21184,6 @@ function apply(ctx, config = {}) {
21310
21184
  await aggregateCatalog.refresh();
21311
21185
  return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
21312
21186
  },
21313
- billionDocument: readBillionStatus,
21314
21187
  /**
21315
21188
  * Relay state plus the gateway's model picker.
21316
21189
  *
@@ -21385,15 +21258,9 @@ function apply(ctx, config = {}) {
21385
21258
  }) };
21386
21259
  }).finally(() => {
21387
21260
  if (stopped) return;
21388
- adoptChannelPackAccounts(scoped, core.pool, ctx.logger);
21261
+ adoptChannelPackAccounts(ctx, core.pool, ctx.logger);
21389
21262
  });
21390
21263
  });
21391
- mountBillionContext(ctx, ctx.logger).then((outcome) => {
21392
- recordStartupNote(`billion-context ${outcome.state}`);
21393
- if (outcome.state === "unavailable") ctx.logger.warn(`[xdpool] billion-context not mounted — ${outcome.reason}`);
21394
- }, (error) => {
21395
- recordStartupNote(`billion-context mount failed: ${describeError(error)}`);
21396
- });
21397
21264
  Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
21398
21265
  if (stopped) return;
21399
21266
  recordStartupNote(`both shims ready cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}; entering registration`);