dsh-workbuddy-xdpool 1.7.1 → 1.7.3

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
@@ -12,6 +12,39 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
12
12
  import { createServer } from "node:http";
13
13
  import { Readable } from "node:stream";
14
14
  //#region src/upstream.ts
15
+ /**
16
+ * Backoff between catalog-fetch attempts.
17
+ *
18
+ * Three retries after the first try, so a startup network hiccup does not cost
19
+ * the user their model list for the whole session. The window covers the case
20
+ * that actually bit: several independent components reported `fetch failed`
21
+ * within the same second while the machine was still bringing its network up,
22
+ * and the very next request succeeded 0.7s later.
23
+ *
24
+ * Deliberately short and bounded. This runs during plugin startup, so a long
25
+ * ladder would delay the provider appearing at all; the total added latency
26
+ * here is ~12s in the worst case, and only when the network is genuinely down.
27
+ */
28
+ const CATALOG_RETRY_BACKOFF_MS = [
29
+ 1e3,
30
+ 3e3,
31
+ 8e3
32
+ ];
33
+ /**
34
+ * Sleep helper for the retry ladder.
35
+ *
36
+ * Deliberately NOT unref'd. An unref'd timer does not keep the event loop
37
+ * alive, so awaiting one can hang forever when nothing else is pending — the
38
+ * retry would simply never resume. That is exactly what the first version of
39
+ * this did, and it wedged the very first end-to-end run: `fetchModels` never
40
+ * settled. The wait is at most a few seconds and only happens on failure, so
41
+ * holding the loop open is the correct trade.
42
+ */
43
+ function delay(ms) {
44
+ return new Promise((resolve) => {
45
+ setTimeout(resolve, ms);
46
+ });
47
+ }
15
48
  /** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
16
49
  const CN_CHAT_BASE = "https://copilot.tencent.com";
17
50
  /** Billing/console base — `www.codebuddy.cn` is the billing origin. */
@@ -33,6 +66,13 @@ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
33
66
  const GLOBAL_CONFIG_PATH = "/v3/config";
34
67
  const JSON_TIMEOUT_MS = 3e4;
35
68
  const ERROR_BODY_LIMIT = 4096;
69
+ /** True for a request the CALLER aborted, which must never be retried. */
70
+ function isAbortError(error) {
71
+ if (typeof error !== "object" || error === null) return false;
72
+ if (error.name === "AbortError") return true;
73
+ const cause = error.cause;
74
+ return typeof cause === "object" && cause !== null && cause.name === "AbortError";
75
+ }
36
76
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
37
77
  const COMPLETION_TEXT_LIMIT = 65536;
38
78
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
@@ -745,9 +785,20 @@ function parseTask(value) {
745
785
  var WorkBuddyUpstreamClient = class {
746
786
  fetchImpl;
747
787
  clientVersion;
788
+ /** Backoff ladder for `fetchModels`; empty means a single attempt. */
789
+ catalogRetryBackoffMs;
790
+ /**
791
+ * Optional logger for retry notices.
792
+ *
793
+ * Set by the host so a retry is visible in the log with its attempt count —
794
+ * without it, a retry that eventually succeeds is invisible, and an operator
795
+ * debugging "why was the catalog slow" has nothing to look at.
796
+ */
797
+ logger;
748
798
  constructor(options = {}) {
749
799
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
750
800
  this.clientVersion = options.clientVersion ?? "2.0.4";
801
+ this.catalogRetryBackoffMs = options.catalogRetryBackoffMs ?? CATALOG_RETRY_BACKOFF_MS;
751
802
  }
752
803
  /**
753
804
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
@@ -904,6 +955,30 @@ var WorkBuddyUpstreamClient = class {
904
955
  * below is common to the two branches.
905
956
  */
906
957
  async fetchModels(credential, signal) {
958
+ const attempts = this.catalogRetryBackoffMs.length + 1;
959
+ let lastError;
960
+ for (let attempt = 0; attempt < attempts; attempt += 1) try {
961
+ return await this.fetchModelsOnce(credential, signal);
962
+ } catch (error) {
963
+ lastError = error;
964
+ if (isAbortError(error)) throw error;
965
+ const wait = this.catalogRetryBackoffMs[attempt];
966
+ if (wait === void 0) break;
967
+ this.logger?.warn?.(`dsh-workbuddy-xdpool: model catalog fetch failed (attempt ${attempt + 1}/${attempts}), retrying in ${wait}ms`, error);
968
+ await delay(wait);
969
+ }
970
+ throw lastError;
971
+ }
972
+ /**
973
+ * One catalog attempt, without retries.
974
+ *
975
+ * Split out so {@link fetchModels} can retry it: the failure this guards
976
+ * against is a startup network hiccup, where several independent components
977
+ * see `fetch failed` inside the same second and the very next request
978
+ * succeeds — exactly the case a single attempt turns into "the user's model
979
+ * list is missing half its entries for the rest of the session".
980
+ */
981
+ async fetchModelsOnce(credential, signal) {
907
982
  const global = regionOf(credential.domain) === "global";
908
983
  const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
909
984
  const headers = global ? {
@@ -2986,38 +3061,97 @@ ${new Date(until).toISOString()}
2986
3061
  * Static fallback used before the first live catalog fetch, and whenever the
2987
3062
  * upstream cannot be reached.
2988
3063
  *
2989
- * The multipliers are carried on purpose. Without them the provider's model
2990
- * picker silently loses every rate and every free badge the moment the live
2991
- * fetch fails — which reads to the user as "the plugin broke my model list"
2992
- * rather than "the upstream is unreachable". The values are the ones the two
2993
- * gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
2994
- * on), so a fallback row looks the same as a live one.
3064
+ * This table is the DOMESTIC (CN) roster, and it is kept in step with what
3065
+ * `copilot.tencent.com` actually advertises. It matters more than a "just in
3066
+ * case" list usually would: a failed startup fetch falls back to it, and the
3067
+ * user sees that as "half my models were deleted" — `deepseek-v4.1-flash`
3068
+ * and friends simply vanishing from the picker, with no visible explanation.
3069
+ *
3070
+ * Every row below was captured from the live endpoint, INCLUDING the window
3071
+ * sizes: the previous version carried a 32K window for `hy3` when the gateway
3072
+ * says 192K, and listed `kimi-k3` under an id the gateway no longer uses
3073
+ * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
3074
+ * authoritative while being wrong.
2995
3075
  *
2996
3076
  * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
2997
3077
  * which is what turns on the free badge.
2998
3078
  */
2999
3079
  const FALLBACK_WORKBUDDY_MODELS = [
3080
+ {
3081
+ id: "auto",
3082
+ name: "Auto",
3083
+ contextWindow: 256e3,
3084
+ maxOutputTokens: 32e3,
3085
+ supportsImages: true
3086
+ },
3087
+ {
3088
+ id: "hy4-preview",
3089
+ name: "Hy4 preview",
3090
+ contextWindow: 1e6,
3091
+ maxOutputTokens: 64e3,
3092
+ supportsImages: true,
3093
+ multiplier: .29
3094
+ },
3095
+ {
3096
+ id: "hy3",
3097
+ name: "Hy3",
3098
+ contextWindow: 192e3,
3099
+ maxOutputTokens: 64e3,
3100
+ supportsImages: true,
3101
+ multiplier: 0
3102
+ },
3103
+ {
3104
+ id: "hy3-x",
3105
+ name: "Hy3",
3106
+ contextWindow: 192e3,
3107
+ maxOutputTokens: 64e3,
3108
+ supportsImages: true,
3109
+ multiplier: .05
3110
+ },
3111
+ {
3112
+ id: "deepseek-v4.1-flash",
3113
+ name: "Deepseek-V4.1-Flash",
3114
+ contextWindow: 1e6,
3115
+ maxOutputTokens: 128e3,
3116
+ supportsImages: true,
3117
+ multiplier: .11
3118
+ },
3119
+ {
3120
+ id: "deepseek-v4-pro",
3121
+ name: "Deepseek-V4-Pro",
3122
+ contextWindow: 1e6,
3123
+ maxOutputTokens: 128e3,
3124
+ supportsImages: true,
3125
+ multiplier: .51
3126
+ },
3127
+ {
3128
+ id: "deepseek-v4-flash",
3129
+ name: "DeepSeek-V4-Flash",
3130
+ contextWindow: 1e6,
3131
+ maxOutputTokens: 128e3,
3132
+ supportsImages: true
3133
+ },
3000
3134
  {
3001
3135
  id: "glm-5.3",
3002
3136
  name: "GLM-5.3",
3003
- contextWindow: 2e5,
3004
- maxOutputTokens: 128e3,
3137
+ contextWindow: 1e6,
3138
+ maxOutputTokens: 64e3,
3005
3139
  supportsImages: true,
3006
3140
  multiplier: .79
3007
3141
  },
3008
3142
  {
3009
3143
  id: "glm-5.3-flash",
3010
3144
  name: "GLM-5.3-Flash",
3011
- contextWindow: 2e5,
3012
- maxOutputTokens: 128e3,
3145
+ contextWindow: 1e6,
3146
+ maxOutputTokens: 131072,
3013
3147
  supportsImages: true,
3014
3148
  multiplier: .06
3015
3149
  },
3016
3150
  {
3017
3151
  id: "glm-5.2",
3018
3152
  name: "GLM-5.2",
3019
- contextWindow: 2e5,
3020
- maxOutputTokens: 128e3,
3153
+ contextWindow: 1e6,
3154
+ maxOutputTokens: 64e3,
3021
3155
  supportsImages: true,
3022
3156
  multiplier: .79
3023
3157
  },
@@ -3025,53 +3159,57 @@ const FALLBACK_WORKBUDDY_MODELS = [
3025
3159
  id: "glm-5.1",
3026
3160
  name: "GLM-5.1",
3027
3161
  contextWindow: 2e5,
3028
- maxOutputTokens: 128e3,
3029
- supportsImages: false
3162
+ maxOutputTokens: 48e3,
3163
+ supportsImages: false,
3164
+ multiplier: .79
3030
3165
  },
3031
3166
  {
3032
- id: "deepseek-v4-pro",
3033
- name: "DeepSeek-V4-Pro",
3167
+ id: "glm-5v-turbo",
3168
+ name: "GLM-5v-Turbo",
3034
3169
  contextWindow: 2e5,
3035
- maxOutputTokens: 128e3,
3036
- supportsImages: true
3037
- },
3038
- {
3039
- id: "deepseek-v4-flash",
3040
- name: "DeepSeek-V4-Flash",
3041
- contextWindow: 2e5,
3042
- maxOutputTokens: 128e3,
3043
- supportsImages: true
3170
+ maxOutputTokens: 64e3,
3171
+ supportsImages: true,
3172
+ multiplier: .71
3044
3173
  },
3045
3174
  {
3046
- id: "kimi-k3",
3175
+ id: "kimi-k3-1",
3047
3176
  name: "Kimi-K3",
3048
- contextWindow: 2e5,
3049
- maxOutputTokens: 128e3,
3177
+ contextWindow: 1e6,
3178
+ maxOutputTokens: 32e3,
3050
3179
  supportsImages: true,
3051
3180
  multiplier: 1.62
3052
3181
  },
3053
3182
  {
3054
- id: "minimax-m3",
3055
- name: "MiniMax-M3",
3056
- contextWindow: 2e5,
3057
- maxOutputTokens: 128e3,
3058
- supportsImages: true
3183
+ id: "kimi-k2.8-preview",
3184
+ name: "Kimi-K2.8-Preview",
3185
+ contextWindow: 1e6,
3186
+ maxOutputTokens: 64e3,
3187
+ supportsImages: true,
3188
+ multiplier: .77
3059
3189
  },
3060
3190
  {
3061
- id: "hy3",
3062
- name: "Hy3",
3063
- contextWindow: 32e3,
3064
- maxOutputTokens: 8e3,
3191
+ id: "kimi-k2.7",
3192
+ name: "Kimi-K2.7-Code",
3193
+ contextWindow: 256e3,
3194
+ maxOutputTokens: 32e3,
3065
3195
  supportsImages: true,
3066
- multiplier: 0
3196
+ multiplier: .57
3067
3197
  },
3068
3198
  {
3069
- id: "hy4-preview",
3070
- name: "Hy4-Preview",
3071
- contextWindow: 1e6,
3072
- maxOutputTokens: 128e3,
3199
+ id: "kimi-k2.6",
3200
+ name: "Kimi-K2.6",
3201
+ contextWindow: 256e3,
3202
+ maxOutputTokens: 32e3,
3073
3203
  supportsImages: true,
3074
- multiplier: .29
3204
+ multiplier: .52
3205
+ },
3206
+ {
3207
+ id: "minimax-m3",
3208
+ name: "MiniMax-M3",
3209
+ contextWindow: 512e3,
3210
+ maxOutputTokens: 64e3,
3211
+ supportsImages: true,
3212
+ multiplier: .25
3075
3213
  }
3076
3214
  ];
3077
3215
  /** Live catalog with a static fallback behind it. */
@@ -3080,6 +3218,19 @@ var WorkBuddyCatalog = class {
3080
3218
  listeners = /* @__PURE__ */ new Set();
3081
3219
  /** User's model selection. Empty object = follow the catalog unfiltered. */
3082
3220
  selection = {};
3221
+ /**
3222
+ * Whether `models` came from the gateway or from the static table.
3223
+ *
3224
+ * Tracked so the card can SAY which it is showing. A failed fetch used to be
3225
+ * invisible on screen: the picker simply held fewer models than before, which
3226
+ * the user reasonably read as "the plugin deleted my models" rather than "the
3227
+ * network hiccuped at startup".
3228
+ */
3229
+ source = "fallback";
3230
+ /** When the live list last landed. */
3231
+ lastUpdatedAt;
3232
+ /** Why the last fetch failed, for display. */
3233
+ lastError;
3083
3234
  current() {
3084
3235
  return this.models;
3085
3236
  }
@@ -3114,6 +3265,7 @@ var WorkBuddyCatalog = class {
3114
3265
  /** Restore the static fallback, e.g. when the upstream stops answering. */
3115
3266
  reset() {
3116
3267
  this.models = FALLBACK_WORKBUDDY_MODELS;
3268
+ this.source = "fallback";
3117
3269
  this.notify();
3118
3270
  }
3119
3271
  /** Replace the user's selection; the adapter rebuilds from `visible()`. */
@@ -3125,6 +3277,31 @@ var WorkBuddyCatalog = class {
3125
3277
  currentSelection() {
3126
3278
  return this.selection;
3127
3279
  }
3280
+ /**
3281
+ * Whether this catalog is serving live data or the built-in table.
3282
+ *
3283
+ * `fallback` is not an error state, but it IS a degraded one: the user is
3284
+ * looking at a shorter roster than the gateway offers, so the card says so
3285
+ * and offers a retry instead of letting them wonder where the models went.
3286
+ */
3287
+ currentSource() {
3288
+ return this.source;
3289
+ }
3290
+ /**
3291
+ * When the live list last landed, if it ever did.
3292
+ *
3293
+ * Named `catalogUpdatedAt()` rather than `updatedAt()` because the class
3294
+ * already had an `updatedAt` member; two members of the same name is a
3295
+ * compile error, and the awkwardness is a useful signal that the concept is
3296
+ * "when THIS catalog was refreshed", not a generic timestamp.
3297
+ */
3298
+ catalogUpdatedAt() {
3299
+ return this.lastUpdatedAt;
3300
+ }
3301
+ /** Why the last fetch failed, if it did. */
3302
+ lastFetchError() {
3303
+ return this.lastError;
3304
+ }
3128
3305
  onChange(listener) {
3129
3306
  this.listeners.add(listener);
3130
3307
  return () => this.listeners.delete(listener);
@@ -3135,6 +3312,20 @@ var WorkBuddyCatalog = class {
3135
3312
  /** Replace the catalog from the live upstream list; keeps the fallback if empty. */
3136
3313
  updateFromUpstream(models) {
3137
3314
  this.update(catalogFromUpstream(models));
3315
+ this.source = "live";
3316
+ this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
3317
+ this.lastError = void 0;
3318
+ }
3319
+ /**
3320
+ * Record that a fetch attempt failed, leaving the current list in place.
3321
+ *
3322
+ * The list is deliberately NOT reset here: a refresh that fails should keep
3323
+ * whatever working catalog is already loaded, rather than demoting a healthy
3324
+ * session to the static table because one retry ran out.
3325
+ */
3326
+ noteFetchFailure(message) {
3327
+ this.lastError = message;
3328
+ this.notify();
3138
3329
  }
3139
3330
  notify() {
3140
3331
  for (const listener of this.listeners) listener();
@@ -3778,6 +3969,16 @@ function expertChatEvents(expert, conversationId, requestId) {
3778
3969
  const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
3779
3970
  /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
3780
3971
  const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
3972
+ /**
3973
+ * Re-fetch the upstream model catalog for both regions.
3974
+ *
3975
+ * Separate from {@link POOL_RESCAN_PATH} because they answer different
3976
+ * questions, and conflating them misled users: "detect accounts again" only
3977
+ * re-read the desktop snapshots, so it could NOT recover a model list that had
3978
+ * fallen back to the static table after a failed startup fetch. The only way
3979
+ * out was restarting DSH.
3980
+ */
3981
+ const POOL_CATALOG_REFRESH_PATH = "/plugins/dsh-workbuddy-xdpool/models/refresh";
3781
3982
  /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
3782
3983
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
3783
3984
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
@@ -4164,7 +4365,7 @@ var WorkBuddyScheduler = class {
4164
4365
  async runNow(kind, force = false) {
4165
4366
  const today = dayKey(this.now());
4166
4367
  const state = this.states[kind];
4167
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
4368
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
4168
4369
  this.busy = true;
4169
4370
  try {
4170
4371
  await this.runJob(kind, today);
@@ -4262,13 +4463,46 @@ var WorkBuddyScheduler = class {
4262
4463
  * two hours still runs twice a day — but a job whose hour passed while DSH was
4263
4464
  * closed runs immediately on the next tick instead of waiting for tomorrow.
4264
4465
  */
4265
- isDue(kind, now) {
4466
+ /**
4467
+ * The configured slot `kind` should consume at `now`, or undefined when none.
4468
+ *
4469
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
4470
+ * same value it acted on. Returning a boolean was the original bug's enabler:
4471
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
4472
+ * it now" — and those two answers were almost never equal.
4473
+ *
4474
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
4475
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
4476
+ * 10:00 still runs the job when it wakes, on the same day.
4477
+ *
4478
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
4479
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
4480
+ * later one for its own hour.
4481
+ */
4482
+ dueSlot(kind, now) {
4266
4483
  const hours = this.hoursOf(kind);
4267
- if (hours.length === 0) return false;
4268
- const { hour } = zonedParts(now);
4269
- const current = Number(hour);
4484
+ if (hours.length === 0) return void 0;
4485
+ const current = Number(zonedParts(now).hour);
4270
4486
  const today = dayKey(now);
4271
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
4487
+ const fired = this.firedSlotsOf(kind);
4488
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
4489
+ if (candidate > current) break;
4490
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
4491
+ if (!fired.has(slot)) return slot;
4492
+ }
4493
+ }
4494
+ /** Today's consumed slots for one job, as a set. */
4495
+ firedSlotsOf(kind) {
4496
+ const stored = this.states[kind].firedSlots;
4497
+ if (stored === void 0) return /* @__PURE__ */ new Set();
4498
+ const today = dayKey(this.now());
4499
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
4500
+ }
4501
+ /** Record one consumed slot on a job's state. */
4502
+ consumeSlot(kind, slot) {
4503
+ const state = this.states[kind];
4504
+ const today = dayKey(this.now());
4505
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
4272
4506
  }
4273
4507
  async tick() {
4274
4508
  if (!this.enabled || this.stopped || this.busy) return;
@@ -4279,9 +4513,10 @@ var WorkBuddyScheduler = class {
4279
4513
  const today = dayKey(now);
4280
4514
  for (const kind of JOB_KINDS) {
4281
4515
  if (this.stopped) return;
4282
- if (this.states[kind].lastRunSlot === slot) continue;
4283
- if (!this.isDue(kind, now)) continue;
4284
- await this.runJob(kind, today);
4516
+ if (this.states[kind].lastFiredHour === slot) continue;
4517
+ const consumed = this.dueSlot(kind, now);
4518
+ if (consumed === void 0) continue;
4519
+ await this.runJob(kind, today, consumed);
4285
4520
  }
4286
4521
  } catch (error) {
4287
4522
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -4367,6 +4602,12 @@ var WorkBuddyScheduler = class {
4367
4602
  /**
4368
4603
  * Run one job against every eligible account and record the outcome.
4369
4604
  *
4605
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4606
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4607
+ * the value the schedule decided on — the defect this replaces wrote
4608
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4609
+ * almost never equals the configured candidate the gate had compared against.
4610
+ *
4370
4611
  * The task job runs in TWO passes. The first sends the event chains that light
4371
4612
  * up client-scored tasks; the second collects rewards. They are separate
4372
4613
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -4374,7 +4615,7 @@ var WorkBuddyScheduler = class {
4374
4615
  * while claiming wants the whole pool to have been lit up first. Splitting
4375
4616
  * them costs one shared wait instead of one wait per account.
4376
4617
  */
4377
- async runJob(kind, today) {
4618
+ async runJob(kind, today, consumedSlot) {
4378
4619
  const accountWord = kind === "report" ? "report" : kind;
4379
4620
  let ok = 0;
4380
4621
  let failed = 0;
@@ -4439,7 +4680,8 @@ var WorkBuddyScheduler = class {
4439
4680
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
4440
4681
  }
4441
4682
  const state = this.states[kind];
4442
- state.lastRunSlot = slotKey(this.now());
4683
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4684
+ state.lastFiredHour = slotKey(this.now());
4443
4685
  state.lastRunDate = today;
4444
4686
  state.lastRunAtMs = this.now().getTime();
4445
4687
  state.ok = ok;
@@ -5908,7 +6150,10 @@ async function poolWebStatus(deps, region = "cn") {
5908
6150
  shim,
5909
6151
  automation,
5910
6152
  creditReserves: deps.pool.creditReservesInOrder(),
5911
- ignored: deps.ignoredAccounts?.() ?? []
6153
+ ignored: deps.ignoredAccounts?.() ?? [],
6154
+ catalogSource: deps.catalogs[region].currentSource(),
6155
+ ...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
6156
+ ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) }
5912
6157
  };
5913
6158
  }
5914
6159
  /**
@@ -5931,6 +6176,35 @@ function registerPoolStatusRoute(ctx, deps) {
5931
6176
  }
5932
6177
  }
5933
6178
  });
6179
+ /**
6180
+ * Re-fetch the upstream model catalog.
6181
+ *
6182
+ * Its own route, distinct from the account rescan, because that is the
6183
+ * distinction users were missing: a model list that had fallen back to the
6184
+ * static table could not be recovered from the card AT ALL — "detect
6185
+ * accounts again" only re-read the desktop snapshots, so the only way back
6186
+ * to the full roster was restarting DSH.
6187
+ *
6188
+ * POST only, loopback origin only. Returns the resulting roster size per
6189
+ * region so the card can confirm the refresh actually changed something.
6190
+ */
6191
+ const disposeCatalogRefresh = ctx.webServer.register({
6192
+ kind: "exact",
6193
+ path: POOL_CATALOG_REFRESH_PATH,
6194
+ handler: async (req, res) => {
6195
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6196
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6197
+ if (deps.refreshCatalog === void 0) return json(res, 503, { error: "catalog refresh is not available in this build" });
6198
+ try {
6199
+ json(res, 200, {
6200
+ ok: true,
6201
+ ...await deps.refreshCatalog()
6202
+ });
6203
+ } catch (error) {
6204
+ json(res, 500, { error: safeMessage(error) });
6205
+ }
6206
+ }
6207
+ });
5934
6208
  const disposeRescan = ctx.webServer.register({
5935
6209
  kind: "exact",
5936
6210
  path: POOL_RESCAN_PATH,
@@ -6128,6 +6402,7 @@ function registerPoolStatusRoute(ctx, deps) {
6128
6402
  disposeAutomationRun();
6129
6403
  disposeCreditReserve();
6130
6404
  disposeAccountIgnore();
6405
+ disposeCatalogRefresh();
6131
6406
  disposeCheckin();
6132
6407
  disposeAccountDisable();
6133
6408
  disposeModelsSave();
@@ -6496,6 +6771,51 @@ function apply(ctx, config = {}) {
6496
6771
  core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6497
6772
  };
6498
6773
  /**
6774
+ * Fetch and install the model catalog for both regions.
6775
+ *
6776
+ * Each region is isolated AND independently retried: they are separate
6777
+ * gateways on separate hosts, so one being unreachable must not degrade the
6778
+ * other. The failure this exists for is a startup network hiccup — several
6779
+ * components reporting `fetch failed` inside the same second, with the next
6780
+ * request succeeding 0.7s later — which used to cost the user their model
6781
+ * list for the whole session, with no way back short of a DSH restart.
6782
+ *
6783
+ * Returns what each region ended up with, so the card can confirm a manual
6784
+ * refresh actually changed something.
6785
+ */
6786
+ const seedCatalog = async () => {
6787
+ const accounts = await core.pool.scan();
6788
+ const regions = {};
6789
+ for (const region of ["cn", "global"]) try {
6790
+ const credential = accounts.find((account) => regionOf(account.credential.domain) === region)?.credential;
6791
+ if (credential === void 0) {
6792
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: no ${region} account yet; keeping the static ${region} catalog`);
6793
+ regions[region] = {
6794
+ source: "fallback",
6795
+ models: core.catalogs[region].current().length
6796
+ };
6797
+ continue;
6798
+ }
6799
+ const models = await core.client.fetchModels(credential);
6800
+ core.catalogs[region].updateFromUpstream(models);
6801
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: ${region} catalog seeded with ${models.length} model(s)`);
6802
+ regions[region] = {
6803
+ source: "live",
6804
+ models: models.length
6805
+ };
6806
+ } catch (error) {
6807
+ const message = error instanceof Error ? error.message : String(error);
6808
+ core.catalogs[region].noteFetchFailure(message);
6809
+ ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
6810
+ regions[region] = {
6811
+ source: core.catalogs[region].currentSource(),
6812
+ models: core.catalogs[region].current().length,
6813
+ error: message
6814
+ };
6815
+ }
6816
+ return { regions };
6817
+ };
6818
+ /**
6499
6819
  * Invalidate the provider snapshot so the picker re-reads the catalog.
6500
6820
  *
6501
6821
  * Seeded with a no-op and reassigned once the adapters exist. The settings
@@ -6700,7 +7020,12 @@ function apply(ctx, config = {}) {
6700
7020
  refreshIgnored();
6701
7021
  if (!ignored) await core.pool.scan();
6702
7022
  },
6703
- ignoredAccounts: () => ignoredAccounts
7023
+ ignoredAccounts: () => ignoredAccounts,
7024
+ refreshCatalog: async () => {
7025
+ const result = await seedCatalog();
7026
+ invalidateCatalog();
7027
+ return result;
7028
+ }
6704
7029
  }));
6705
7030
  api = {
6706
7031
  ...core,
@@ -6711,6 +7036,8 @@ function apply(ctx, config = {}) {
6711
7036
  async rescan() {
6712
7037
  const accounts = await core.pool.scan();
6713
7038
  ctx.logger.info?.(`dsh-workbuddy-xdpool: discovered ${accounts.length} account(s)`);
7039
+ await seedCatalog();
7040
+ invalidateCatalog();
6714
7041
  return accounts.length;
6715
7042
  },
6716
7043
  async status(includeCredits = false) {
@@ -6811,19 +7138,7 @@ function apply(ctx, config = {}) {
6811
7138
  ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
6812
7139
  });
6813
7140
  (async () => {
6814
- const accounts = await core.pool.scan();
6815
- for (const region of ["cn", "global"]) try {
6816
- const credential = accounts.find((account) => regionOf(account.credential.domain) === region)?.credential;
6817
- if (credential === void 0) {
6818
- ctx.logger.info?.(`dsh-workbuddy-xdpool: no ${region} account yet; keeping the static ${region} catalog`);
6819
- continue;
6820
- }
6821
- const models = await core.client.fetchModels(credential);
6822
- core.catalogs[region].updateFromUpstream(models);
6823
- ctx.logger.info?.(`dsh-workbuddy-xdpool: ${region} catalog seeded with ${models.length} model(s)`);
6824
- } catch (error) {
6825
- ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
6826
- }
7141
+ await seedCatalog();
6827
7142
  invalidateCatalog();
6828
7143
  })().catch((error) => {
6829
7144
  ctx.logger.warn("dsh-workbuddy-xdpool: account catalog seed failed", error);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-xdpool",
3
3
  "displayName": "DSH WorkBuddy XD Pool",
4
4
  "description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
5
- "version": "1.7.1",
5
+ "version": "1.7.3",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",