dsh-workbuddy-xdpool 1.7.0 → 1.7.2

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
@@ -1,7 +1,7 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
- import { readFile, readdir } from "node:fs/promises";
4
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { execFile, execFileSync } from "node:child_process";
7
7
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -443,6 +443,7 @@ function parseUpstreamModel(value) {
443
443
  const reasoning = parseReasoning(raw["reasoning"]);
444
444
  const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
445
445
  const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
446
+ const tags = Array.isArray(raw["tags"]) ? raw["tags"].filter((tag) => typeof tag === "string" && tag !== "") : void 0;
446
447
  return {
447
448
  id,
448
449
  name,
@@ -453,7 +454,8 @@ function parseUpstreamModel(value) {
453
454
  ...descriptionZh === void 0 ? {} : { descriptionZh },
454
455
  ...descriptionEn === void 0 ? {} : { descriptionEn },
455
456
  ...supportsToolCall === void 0 ? {} : { supportsToolCall },
456
- ...supportsImages === void 0 ? {} : { supportsImages }
457
+ ...supportsImages === void 0 ? {} : { supportsImages },
458
+ ...tags === void 0 || tags.length === 0 ? {} : { tags }
457
459
  };
458
460
  }
459
461
  /**
@@ -2428,6 +2430,41 @@ async function readCredential(path) {
2428
2430
  }
2429
2431
  }
2430
2432
  /**
2433
+ * The pool id a document would receive, WITHOUT decrypting anything.
2434
+ *
2435
+ * Used to honour the ignore list before the at-rest key lookup runs: `uin` and
2436
+ * `uid` are read as plain strings by {@link parseWorkBuddyAuth}, while
2437
+ * `nickname` is commonly encrypted — so this returns undefined for an account
2438
+ * whose identity lives only in the encrypted nickname, and the caller falls
2439
+ * back to the full parse for those.
2440
+ */
2441
+ function cheapIdentityId(text) {
2442
+ let parsed;
2443
+ try {
2444
+ parsed = JSON.parse(text);
2445
+ } catch {
2446
+ return;
2447
+ }
2448
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2449
+ const document = parsed;
2450
+ const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
2451
+ const uin = typeof identity["uin"] === "string" && identity["uin"] !== "" ? identity["uin"] : void 0;
2452
+ const uid = typeof identity["uid"] === "string" && identity["uid"] !== "" ? identity["uid"] : void 0;
2453
+ if (uin === void 0 && uid === void 0) return void 0;
2454
+ return workbuddyAccountId({
2455
+ ...uin === void 0 ? {} : { uin },
2456
+ ...uid === void 0 ? {} : { uid }
2457
+ });
2458
+ }
2459
+ /** {@link cheapIdentityId} for a file path; undefined when it cannot be read. */
2460
+ async function cheapIdentityIdFromFile(path) {
2461
+ try {
2462
+ return cheapIdentityId(await readFile(path, "utf8"));
2463
+ } catch {
2464
+ return;
2465
+ }
2466
+ }
2467
+ /**
2431
2468
  * Build the field opener, or undefined when the app cannot supply its key.
2432
2469
  *
2433
2470
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2506,6 +2543,21 @@ var WorkBuddyAccountPool = class {
2506
2543
  */
2507
2544
  disabledIds = /* @__PURE__ */ new Set();
2508
2545
  /**
2546
+ * Account ids the user threw out of the pool for good.
2547
+ *
2548
+ * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
2549
+ * identity is already ignored, so an ignored account costs no at-rest key
2550
+ * lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
2551
+ * when the app writes a fresh sign-in for it. That is the difference from
2552
+ * {@link disabledIds}, which only filters at pick time and leaves the account
2553
+ * listed, readable and re-discoverable.
2554
+ *
2555
+ * The set is supplied by the host from the plugin's own ignore file, and is
2556
+ * replaced wholesale on every {@link applyIgnored} so removing an entry takes
2557
+ * effect on the next scan without a restart.
2558
+ */
2559
+ ignoredIds = /* @__PURE__ */ new Set();
2560
+ /**
2509
2561
  * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
2510
2562
  * it all".
2511
2563
  *
@@ -2557,12 +2609,39 @@ var WorkBuddyAccountPool = class {
2557
2609
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
2558
2610
  if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
2559
2611
  }
2612
+ /**
2613
+ * Replace the permanent ignore list.
2614
+ *
2615
+ * Also drops any already-discovered account that is now ignored, so the change
2616
+ * is visible without waiting for the next scan: the card refreshes its status
2617
+ * document right after the write, and an account still sitting in `accounts`
2618
+ * would keep showing up there.
2619
+ */
2620
+ applyIgnored(ids) {
2621
+ this.ignoredIds = new Set(ids);
2622
+ if (this.ignoredIds.size === 0) return;
2623
+ this.accounts = this.accounts.filter((account) => !this.ignoredIds.has(account.id));
2624
+ }
2625
+ /** Whether this account has been thrown out of the pool for good. */
2626
+ isIgnored(accountId) {
2627
+ return this.ignoredIds.has(accountId);
2628
+ }
2629
+ /** Every ignored id currently in force, in insertion order. */
2630
+ ignoredIdsInOrder() {
2631
+ return [...this.ignoredIds];
2632
+ }
2560
2633
  /** Rescan the auth directories and merge newly discovered accounts. */
2561
2634
  async scan() {
2562
2635
  const found = [];
2563
2636
  for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2637
+ if (this.ignoredIds.size > 0) {
2638
+ const cheapId = await cheapIdentityIdFromFile(file);
2639
+ if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2640
+ }
2564
2641
  const credential = await readCredential(file);
2565
- if (credential !== void 0) found.push(credential);
2642
+ if (credential === void 0) continue;
2643
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2644
+ found.push(credential);
2566
2645
  }
2567
2646
  const byId = /* @__PURE__ */ new Map();
2568
2647
  for (const account of this.accounts) byId.set(account.id, account);
@@ -2903,28 +2982,44 @@ ${new Date(until).toISOString()}
2903
2982
  };
2904
2983
  //#endregion
2905
2984
  //#region src/catalog.ts
2906
- /** Static fallback used before the first live catalog fetch. */
2985
+ /**
2986
+ * Static fallback used before the first live catalog fetch, and whenever the
2987
+ * upstream cannot be reached.
2988
+ *
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.
2995
+ *
2996
+ * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
2997
+ * which is what turns on the free badge.
2998
+ */
2907
2999
  const FALLBACK_WORKBUDDY_MODELS = [
2908
3000
  {
2909
3001
  id: "glm-5.3",
2910
3002
  name: "GLM-5.3",
2911
3003
  contextWindow: 2e5,
2912
3004
  maxOutputTokens: 128e3,
2913
- supportsImages: true
3005
+ supportsImages: true,
3006
+ multiplier: .79
2914
3007
  },
2915
3008
  {
2916
3009
  id: "glm-5.3-flash",
2917
3010
  name: "GLM-5.3-Flash",
2918
3011
  contextWindow: 2e5,
2919
3012
  maxOutputTokens: 128e3,
2920
- supportsImages: true
3013
+ supportsImages: true,
3014
+ multiplier: .06
2921
3015
  },
2922
3016
  {
2923
3017
  id: "glm-5.2",
2924
3018
  name: "GLM-5.2",
2925
3019
  contextWindow: 2e5,
2926
3020
  maxOutputTokens: 128e3,
2927
- supportsImages: true
3021
+ supportsImages: true,
3022
+ multiplier: .79
2928
3023
  },
2929
3024
  {
2930
3025
  id: "glm-5.1",
@@ -2952,7 +3047,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
2952
3047
  name: "Kimi-K3",
2953
3048
  contextWindow: 2e5,
2954
3049
  maxOutputTokens: 128e3,
2955
- supportsImages: true
3050
+ supportsImages: true,
3051
+ multiplier: 1.62
2956
3052
  },
2957
3053
  {
2958
3054
  id: "minimax-m3",
@@ -2966,14 +3062,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
2966
3062
  name: "Hy3",
2967
3063
  contextWindow: 32e3,
2968
3064
  maxOutputTokens: 8e3,
2969
- supportsImages: true
3065
+ supportsImages: true,
3066
+ multiplier: 0
2970
3067
  },
2971
3068
  {
2972
3069
  id: "hy4-preview",
2973
3070
  name: "Hy4-Preview",
2974
3071
  contextWindow: 1e6,
2975
3072
  maxOutputTokens: 128e3,
2976
- supportsImages: true
3073
+ supportsImages: true,
3074
+ multiplier: .29
2977
3075
  }
2978
3076
  ];
2979
3077
  /** Live catalog with a static fallback behind it. */
@@ -3051,7 +3149,8 @@ function toModelInfo(model) {
3051
3149
  maxOutputTokens: model.maxTokens,
3052
3150
  supportsImages: model.supportsImages ?? false,
3053
3151
  ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
3054
- ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
3152
+ ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts },
3153
+ ...model.tags === void 0 || model.tags.length === 0 ? {} : { tags: model.tags }
3055
3154
  };
3056
3155
  }
3057
3156
  /** Map the live upstream list, falling back to the static list when empty. */
@@ -3161,7 +3260,8 @@ const TAG_LABEL = {
3161
3260
  * so we don't tack a trailing separator on a plain model. */
3162
3261
  function displaySuffix(info) {
3163
3262
  const parts = [];
3164
- if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
3263
+ if (info.multiplier === 0) parts.push("免费");
3264
+ else if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
3165
3265
  for (const tag of info.tags ?? []) {
3166
3266
  const label = TAG_LABEL[tag];
3167
3267
  if (label !== void 0 && !parts.includes(label)) parts.push(label);
@@ -3660,6 +3760,65 @@ function expertChatEvents(expert, conversationId, requestId) {
3660
3760
  expert_industry_id: ""
3661
3761
  } : event);
3662
3762
  }
3763
+ //#endregion
3764
+ //#region src/status-paths.ts
3765
+ /**
3766
+ * Node-free constants and types shared by the Host and browser halves of the
3767
+ * WorkBuddy XD Pool settings card.
3768
+ *
3769
+ * Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
3770
+ * `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
3771
+ * JSON document into a shape that stays token-free and matches what the
3772
+ * browser card renders. Route paths are plugin-owned and mounted on the Host's
3773
+ * same-origin web server (see `src/web-status.ts`).
3774
+ *
3775
+ * @module dsh-workbuddy-xdpool/status-paths
3776
+ */
3777
+ /** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
3778
+ const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
3779
+ /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
3780
+ const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
3781
+ /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
3782
+ const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
3783
+ /** Plugin-owned daily check-in action endpoint (claim today's reward). */
3784
+ const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
3785
+ /** Plugin-owned model-selection save endpoint (writes the settings section). */
3786
+ const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
3787
+ /** Switch one account in or out of the pool (card toggle). */
3788
+ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
3789
+ /**
3790
+ * Throw one account out of the pool for good, or take it back.
3791
+ *
3792
+ * Separate from the disable route because the semantics differ: disabling is a
3793
+ * rotation preference the account survives, ignoring survives the account.
3794
+ */
3795
+ const POOL_ACCOUNT_IGNORE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/ignored";
3796
+ /** Run one automation job immediately, so the card can verify it on demand. */
3797
+ const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
3798
+ /** Set or clear one account's reserved-credit floor. */
3799
+ const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
3800
+ /**
3801
+ * The schedule every automation job falls back to.
3802
+ *
3803
+ * Shared by both halves on purpose. The host uses it when a configured hour
3804
+ * list arrives empty (the settings schema materializes "never configured" into
3805
+ * `[]`), and the card uses it when it writes the `automation` block back, so a
3806
+ * document that already holds an empty list is healed instead of being saved
3807
+ * back as an unrunnable schedule.
3808
+ *
3809
+ * This lives here rather than in `scheduler.ts` because the browser half cannot
3810
+ * import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
3811
+ * upstream client, none of which exists in the browser bundle. Two hand-written
3812
+ * copies would drift, and the drift is invisible — the card would write a
3813
+ * schedule the scheduler does not run.
3814
+ */
3815
+ const DEFAULT_AUTOMATION_HOURS = {
3816
+ checkin: [9],
3817
+ report: [10],
3818
+ tasks: [11],
3819
+ streak: [12],
3820
+ travel: [9, 21]
3821
+ };
3663
3822
  /** How often the loop wakes to look for a due job. */
3664
3823
  /**
3665
3824
  * How long to wait for event scoring to land before re-reading the task list.
@@ -3718,6 +3877,23 @@ function isAutomationJobKind(value) {
3718
3877
  return typeof value === "string" && AUTOMATION_JOB_KINDS.includes(value);
3719
3878
  }
3720
3879
  const JOB_KINDS = AUTOMATION_JOB_KINDS;
3880
+ /**
3881
+ * Pick the hour list to run on: the configured one, or the default.
3882
+ *
3883
+ * An EMPTY list is treated as "not configured" rather than as "never run".
3884
+ * That distinction is the whole point: the settings schema materializes an
3885
+ * absent list into `[]` (a bare `z.array` with no `.default()`), so a config
3886
+ * that never mentioned the schedule arrives here looking exactly like one
3887
+ * deliberately set to nothing — and honouring the empty reading silently
3888
+ * disabled every job while the card still showed the switch as ON.
3889
+ *
3890
+ * A user who genuinely wants a job skipped can leave it out of the schedule
3891
+ * they save; they cannot express "off" with an empty array through the card
3892
+ * either way, because the card always writes a non-empty list.
3893
+ */
3894
+ function hoursOrDefault(configured, fallback) {
3895
+ return configured !== void 0 && configured.length > 0 ? configured : fallback;
3896
+ }
3721
3897
  const EMPTY_JOB_STATE = {
3722
3898
  ok: 0,
3723
3899
  failed: 0,
@@ -3906,11 +4082,11 @@ var WorkBuddyScheduler = class {
3906
4082
  this.earningsDate = today;
3907
4083
  }
3908
4084
  this.enabled = options.enabled ?? false;
3909
- this.checkinHours = options.checkinHours ?? [9];
3910
- this.reportHours = options.reportHours ?? [10];
3911
- this.taskHours = options.taskHours ?? [11];
3912
- this.streakHours = options.streakHours ?? [12];
3913
- this.travelHours = options.travelHours ?? [9, 21];
4085
+ this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
4086
+ this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
4087
+ this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
4088
+ this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
4089
+ this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
3914
4090
  }
3915
4091
  /** Apply a new configuration; safe to call while running. */
3916
4092
  /**
@@ -3938,11 +4114,11 @@ var WorkBuddyScheduler = class {
3938
4114
  }
3939
4115
  applyConfig(options) {
3940
4116
  if (options.enabled !== void 0) this.enabled = options.enabled;
3941
- if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
3942
- if (options.reportHours !== void 0) this.reportHours = options.reportHours;
3943
- if (options.taskHours !== void 0) this.taskHours = options.taskHours;
3944
- if (options.streakHours !== void 0) this.streakHours = options.streakHours;
3945
- if (options.travelHours !== void 0) this.travelHours = options.travelHours;
4117
+ if (options.checkinHours !== void 0) this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
4118
+ if (options.reportHours !== void 0) this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
4119
+ if (options.taskHours !== void 0) this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
4120
+ if (options.streakHours !== void 0) this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
4121
+ if (options.travelHours !== void 0) this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
3946
4122
  }
3947
4123
  /** Hours for one job, used by the loop and the status document. */
3948
4124
  hoursOf(kind) {
@@ -3988,7 +4164,7 @@ var WorkBuddyScheduler = class {
3988
4164
  async runNow(kind, force = false) {
3989
4165
  const today = dayKey(this.now());
3990
4166
  const state = this.states[kind];
3991
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
4167
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
3992
4168
  this.busy = true;
3993
4169
  try {
3994
4170
  await this.runJob(kind, today);
@@ -4086,13 +4262,46 @@ var WorkBuddyScheduler = class {
4086
4262
  * two hours still runs twice a day — but a job whose hour passed while DSH was
4087
4263
  * closed runs immediately on the next tick instead of waiting for tomorrow.
4088
4264
  */
4089
- isDue(kind, now) {
4265
+ /**
4266
+ * The configured slot `kind` should consume at `now`, or undefined when none.
4267
+ *
4268
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
4269
+ * same value it acted on. Returning a boolean was the original bug's enabler:
4270
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
4271
+ * it now" — and those two answers were almost never equal.
4272
+ *
4273
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
4274
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
4275
+ * 10:00 still runs the job when it wakes, on the same day.
4276
+ *
4277
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
4278
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
4279
+ * later one for its own hour.
4280
+ */
4281
+ dueSlot(kind, now) {
4090
4282
  const hours = this.hoursOf(kind);
4091
- if (hours.length === 0) return false;
4092
- const { hour } = zonedParts(now);
4093
- const current = Number(hour);
4283
+ if (hours.length === 0) return void 0;
4284
+ const current = Number(zonedParts(now).hour);
4094
4285
  const today = dayKey(now);
4095
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
4286
+ const fired = this.firedSlotsOf(kind);
4287
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
4288
+ if (candidate > current) break;
4289
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
4290
+ if (!fired.has(slot)) return slot;
4291
+ }
4292
+ }
4293
+ /** Today's consumed slots for one job, as a set. */
4294
+ firedSlotsOf(kind) {
4295
+ const stored = this.states[kind].firedSlots;
4296
+ if (stored === void 0) return /* @__PURE__ */ new Set();
4297
+ const today = dayKey(this.now());
4298
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
4299
+ }
4300
+ /** Record one consumed slot on a job's state. */
4301
+ consumeSlot(kind, slot) {
4302
+ const state = this.states[kind];
4303
+ const today = dayKey(this.now());
4304
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
4096
4305
  }
4097
4306
  async tick() {
4098
4307
  if (!this.enabled || this.stopped || this.busy) return;
@@ -4103,9 +4312,10 @@ var WorkBuddyScheduler = class {
4103
4312
  const today = dayKey(now);
4104
4313
  for (const kind of JOB_KINDS) {
4105
4314
  if (this.stopped) return;
4106
- if (this.states[kind].lastRunSlot === slot) continue;
4107
- if (!this.isDue(kind, now)) continue;
4108
- await this.runJob(kind, today);
4315
+ if (this.states[kind].lastFiredHour === slot) continue;
4316
+ const consumed = this.dueSlot(kind, now);
4317
+ if (consumed === void 0) continue;
4318
+ await this.runJob(kind, today, consumed);
4109
4319
  }
4110
4320
  } catch (error) {
4111
4321
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -4191,6 +4401,12 @@ var WorkBuddyScheduler = class {
4191
4401
  /**
4192
4402
  * Run one job against every eligible account and record the outcome.
4193
4403
  *
4404
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4405
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4406
+ * the value the schedule decided on — the defect this replaces wrote
4407
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4408
+ * almost never equals the configured candidate the gate had compared against.
4409
+ *
4194
4410
  * The task job runs in TWO passes. The first sends the event chains that light
4195
4411
  * up client-scored tasks; the second collects rewards. They are separate
4196
4412
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -4198,7 +4414,7 @@ var WorkBuddyScheduler = class {
4198
4414
  * while claiming wants the whole pool to have been lit up first. Splitting
4199
4415
  * them costs one shared wait instead of one wait per account.
4200
4416
  */
4201
- async runJob(kind, today) {
4417
+ async runJob(kind, today, consumedSlot) {
4202
4418
  const accountWord = kind === "report" ? "report" : kind;
4203
4419
  let ok = 0;
4204
4420
  let failed = 0;
@@ -4263,7 +4479,8 @@ var WorkBuddyScheduler = class {
4263
4479
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
4264
4480
  }
4265
4481
  const state = this.states[kind];
4266
- state.lastRunSlot = slotKey(this.now());
4482
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4483
+ state.lastFiredHour = slotKey(this.now());
4267
4484
  state.lastRunDate = today;
4268
4485
  state.lastRunAtMs = this.now().getTime();
4269
4486
  state.ok = ok;
@@ -5415,36 +5632,6 @@ function formatRates(status) {
5415
5632
  return lines.join("\n");
5416
5633
  }
5417
5634
  //#endregion
5418
- //#region src/status-paths.ts
5419
- /**
5420
- * Node-free constants and types shared by the Host and browser halves of the
5421
- * WorkBuddy XD Pool settings card.
5422
- *
5423
- * Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
5424
- * `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
5425
- * JSON document into a shape that stays token-free and matches what the
5426
- * browser card renders. Route paths are plugin-owned and mounted on the Host's
5427
- * same-origin web server (see `src/web-status.ts`).
5428
- *
5429
- * @module dsh-workbuddy-xdpool/status-paths
5430
- */
5431
- /** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
5432
- const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
5433
- /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
5434
- const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
5435
- /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
5436
- const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
5437
- /** Plugin-owned daily check-in action endpoint (claim today's reward). */
5438
- const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
5439
- /** Plugin-owned model-selection save endpoint (writes the settings section). */
5440
- const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
5441
- /** Switch one account in or out of the pool (card toggle). */
5442
- const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
5443
- /** Run one automation job immediately, so the card can verify it on demand. */
5444
- const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
5445
- /** Set or clear one account's reserved-credit floor. */
5446
- const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
5447
- //#endregion
5448
5635
  //#region src/web-status.ts
5449
5636
  /** Redact token-like content before it crosses to the browser. */
5450
5637
  /**
@@ -5569,6 +5756,27 @@ function parseAccountToggle(body, known) {
5569
5756
  };
5570
5757
  }
5571
5758
  /**
5759
+ * Validate an ignore/unignore request.
5760
+ *
5761
+ * Two different notions of "known" apply, which is why the caller passes both:
5762
+ *
5763
+ * - ignoring requires the account to be IN THE POOL, because the card can only
5764
+ * name an account it was just shown;
5765
+ * - un-ignoring requires the account to be ON THE IGNORE LIST instead — by
5766
+ * definition it is not in the pool any more, so checking the pool would make
5767
+ * the undo button impossible to use.
5768
+ */
5769
+ function parseAccountIgnore(body, knownInPool, knownIgnored) {
5770
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
5771
+ const ignored = body["ignored"];
5772
+ if (accountId === "" || typeof ignored !== "boolean") return void 0;
5773
+ if (ignored ? !knownInPool(accountId) : !knownIgnored(accountId)) return void 0;
5774
+ return {
5775
+ accountId,
5776
+ ignored
5777
+ };
5778
+ }
5779
+ /**
5572
5780
  * Parse a manual-run request.
5573
5781
  *
5574
5782
  * The job name is checked against the real job list rather than passed through:
@@ -5740,7 +5948,8 @@ async function poolWebStatus(deps, region = "cn") {
5740
5948
  regions,
5741
5949
  shim,
5742
5950
  automation,
5743
- creditReserves: deps.pool.creditReservesInOrder()
5951
+ creditReserves: deps.pool.creditReservesInOrder(),
5952
+ ignored: deps.ignoredAccounts?.() ?? []
5744
5953
  };
5745
5954
  }
5746
5955
  /**
@@ -5864,6 +6073,42 @@ function registerPoolStatusRoute(ctx, deps) {
5864
6073
  }
5865
6074
  });
5866
6075
  /**
6076
+ * Throw one account out of the pool for good, or take it back.
6077
+ *
6078
+ * Same three guards as the disable route — POST only, loopback origin only,
6079
+ * and an explicit `accountId` — plus the semantic split in
6080
+ * {@link parseAccountIgnore}: ignoring checks the pool, un-ignoring checks
6081
+ * the ignore list.
6082
+ *
6083
+ * The credential FILE is deliberately left alone. Deleting it would sign the
6084
+ * desktop app out (the live file IS the app's current session) and would not
6085
+ * even stick, since the app rewrites it on the next sign-in. Ignoring is
6086
+ * enforced on the read side instead, which is both reversible and durable.
6087
+ */
6088
+ const disposeAccountIgnore = ctx.webServer.register({
6089
+ kind: "exact",
6090
+ path: POOL_ACCOUNT_IGNORE_PATH,
6091
+ handler: async (req, res) => {
6092
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6093
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6094
+ if (deps.setAccountIgnored === void 0) return json(res, 503, { error: "settings service unavailable; the ignore list cannot be saved" });
6095
+ try {
6096
+ const body = await readJsonBody(req);
6097
+ const knownInPool = new Set(deps.pool.list().map((account) => account.id));
6098
+ const knownIgnored = new Set((deps.ignoredAccounts?.() ?? []).map((entry) => entry.id));
6099
+ const parsed = parseAccountIgnore(body, (id) => knownInPool.has(id), (id) => knownIgnored.has(id));
6100
+ if (parsed === void 0) return json(res, 400, { error: "invalid account ignore payload" });
6101
+ await deps.setAccountIgnored(parsed.accountId, parsed.ignored);
6102
+ json(res, 200, {
6103
+ ok: true,
6104
+ ...parsed
6105
+ });
6106
+ } catch (error) {
6107
+ json(res, 500, { error: safeMessage(error) });
6108
+ }
6109
+ }
6110
+ });
6111
+ /**
5867
6112
  * Run one automation job on demand.
5868
6113
  *
5869
6114
  * POST only, loopback origin only, and the job name must be one of the four
@@ -5923,6 +6168,7 @@ function registerPoolStatusRoute(ctx, deps) {
5923
6168
  return () => {
5924
6169
  disposeAutomationRun();
5925
6170
  disposeCreditReserve();
6171
+ disposeAccountIgnore();
5926
6172
  disposeCheckin();
5927
6173
  disposeAccountDisable();
5928
6174
  disposeModelsSave();
@@ -5933,6 +6179,153 @@ function registerPoolStatusRoute(ctx, deps) {
5933
6179
  }, "dsh-workbuddy-xdpool: Web status route");
5934
6180
  }
5935
6181
  //#endregion
6182
+ //#region src/ignored.ts
6183
+ /**
6184
+ * The permanent ignore list: accounts the user has thrown out of the pool.
6185
+ *
6186
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
6187
+ * account stays on the card, can be switched back on, and its credential keeps
6188
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
6189
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
6190
+ * credential is decrypted, never appears among the pool's accounts, and does not
6191
+ * come back when the desktop app writes a fresh `.info` file for it.
6192
+ *
6193
+ * It lives in a plugin-owned file rather than in the settings document for one
6194
+ * practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
6195
+ * ignore <id>` runs without a host, so a settings-only list could be written by
6196
+ * the card and never by the CLI. One file, read by both halves, keeps the two
6197
+ * views of "which accounts are ignored" identical by construction.
6198
+ *
6199
+ * @module dsh-workbuddy-xdpool/ignored
6200
+ */
6201
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
6202
+ const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
6203
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
6204
+ const IGNORED_FILE_NAME = "ignored.json";
6205
+ /**
6206
+ * The DSH home directory, honouring the same override the host uses.
6207
+ *
6208
+ * Shared by the CLI and the host so both halves resolve the same file: an
6209
+ * `ignore` written from the terminal has to be visible to the running plugin,
6210
+ * which is only true if they agree on where "home" is.
6211
+ */
6212
+ function dshHome(env = process.env) {
6213
+ const fromEnv = env["DSH_HOME"];
6214
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
6215
+ return join(homedir(), ".dsh");
6216
+ }
6217
+ /** This plugin's own state directory. */
6218
+ function pluginDataDir(env = process.env) {
6219
+ return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
6220
+ }
6221
+ /** Absolute path of the ignore list. */
6222
+ function ignoredIdsPath(env = process.env) {
6223
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
6224
+ }
6225
+ /**
6226
+ * Normalize whatever the file holds into a clean list.
6227
+ *
6228
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
6229
+ * take the pool down. An entry without a usable id is dropped; a missing label
6230
+ * falls back to the id so the card always has something to print.
6231
+ */
6232
+ function normalize(raw) {
6233
+ if (typeof raw !== "object" || raw === null) return [];
6234
+ const entries = raw.accounts;
6235
+ if (!Array.isArray(entries)) return [];
6236
+ const out = [];
6237
+ const seen = /* @__PURE__ */ new Set();
6238
+ for (const entry of entries) {
6239
+ if (typeof entry !== "object" || entry === null) continue;
6240
+ const record = entry;
6241
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
6242
+ if (id === "" || seen.has(id)) continue;
6243
+ seen.add(id);
6244
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
6245
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
6246
+ out.push({
6247
+ id,
6248
+ label,
6249
+ ignoredAt
6250
+ });
6251
+ }
6252
+ return out;
6253
+ }
6254
+ /**
6255
+ * Read the ignore list, tolerating every "no list yet" shape.
6256
+ *
6257
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
6258
+ * the caller — nothing is ignored — so none of them throws. The pool must be
6259
+ * able to start on a machine that has never ignored anything.
6260
+ */
6261
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
6262
+ try {
6263
+ return normalize(JSON.parse(await readFile(path, "utf8")));
6264
+ } catch {
6265
+ return [];
6266
+ }
6267
+ }
6268
+ /**
6269
+ * Synchronous read, for startup.
6270
+ *
6271
+ * The host applies the ignore list from inside `apply()`, which is synchronous,
6272
+ * and doing it there removes a startup race: an async load could resolve AFTER
6273
+ * the first account scan, which would let an ignored account slip into the pool
6274
+ * once per boot. The file is a few hundred bytes, so a blocking read at startup
6275
+ * costs nothing measurable.
6276
+ */
6277
+ function readIgnoredAccountsSync(path = ignoredIdsPath()) {
6278
+ try {
6279
+ return normalize(JSON.parse(readFileSync(path, "utf8")));
6280
+ } catch {
6281
+ return [];
6282
+ }
6283
+ }
6284
+ /**
6285
+ * Replace the ignore list, atomically.
6286
+ *
6287
+ * Written to a sibling temp file and renamed over the target so a crash (or a
6288
+ * concurrent reader) can never observe a half-written document — the ignore
6289
+ * list is the only thing standing between a dead account and the rotation, and
6290
+ * a truncated file reads as "nothing is ignored", which would quietly put every
6291
+ * discarded account back in the pool.
6292
+ */
6293
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
6294
+ const payload = JSON.stringify({
6295
+ version: 1,
6296
+ accounts
6297
+ }, null, 2);
6298
+ await mkdir(dirname(path), { recursive: true });
6299
+ const temp = `${path}.tmp`;
6300
+ await writeFile(temp, `${payload}\n`, "utf8");
6301
+ await rename(temp, path);
6302
+ }
6303
+ /**
6304
+ * Add one account to the ignore list, preserving the rest.
6305
+ *
6306
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
6307
+ * both be open, and each request names exactly one account, so re-writing the
6308
+ * whole list from a stale view would drop the other side's edits.
6309
+ */
6310
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
6311
+ const current = await readIgnoredAccounts(path);
6312
+ if (current.some((entry) => entry.id === account.id)) return current;
6313
+ const next = [...current, {
6314
+ id: account.id,
6315
+ label: account.label ?? account.id,
6316
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
6317
+ }];
6318
+ await writeIgnoredAccounts(next, path);
6319
+ return next;
6320
+ }
6321
+ /** Drop one account from the ignore list. Returns the resulting list. */
6322
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6323
+ const current = await readIgnoredAccounts(path);
6324
+ const next = current.filter((entry) => entry.id !== accountId);
6325
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
6326
+ return next;
6327
+ }
6328
+ //#endregion
5936
6329
  //#region src/index.ts
5937
6330
  /**
5938
6331
  * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
@@ -6135,6 +6528,14 @@ function createCore(logger) {
6135
6528
  */
6136
6529
  function apply(ctx, config = {}) {
6137
6530
  const core = createCore(ctx.logger);
6531
+ const ignoredPath = ignoredIdsPath();
6532
+ let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6533
+ core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6534
+ /** Re-read the ignore file and push it into the pool. */
6535
+ const refreshIgnored = () => {
6536
+ ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6537
+ core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6538
+ };
6138
6539
  /**
6139
6540
  * Invalidate the provider snapshot so the picker re-reads the catalog.
6140
6541
  *
@@ -6183,8 +6584,11 @@ function apply(ctx, config = {}) {
6183
6584
  ...imageModelIds === void 0 ? {} : { imageModelIds },
6184
6585
  ...contextBudgets === void 0 ? {} : { contextBudgets }
6185
6586
  };
6186
- core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
6187
- core.catalogs.global.applySelection(modelSelectionGlobal ?? legacySelection);
6587
+ const fallbackSelection = (region) => {
6588
+ return legacySelection.enabledModelIds === void 0 && legacySelection.imageModelIds === void 0 && legacySelection.contextBudgets === void 0 ? core.catalogs[region].currentSelection() : legacySelection;
6589
+ };
6590
+ core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
6591
+ core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
6188
6592
  core.scheduler.applyConfig(automationOptions(automation));
6189
6593
  };
6190
6594
  const settingsService = ctx.settings;
@@ -6194,6 +6598,7 @@ function apply(ctx, config = {}) {
6194
6598
  ctx.on("loader/volatile-update", () => {
6195
6599
  applyConfigFromSource();
6196
6600
  });
6601
+ applyConfigFromSource();
6197
6602
  /**
6198
6603
  * Write one key of the plugin's own settings section. Only ever called with
6199
6604
  * the three model-selection keys, so the settings file cannot be steered from
@@ -6324,7 +6729,19 @@ function apply(ctx, config = {}) {
6324
6729
  if (reserve > 0) next[accountId] = reserve;
6325
6730
  else delete next[accountId];
6326
6731
  await setSetting("creditReserves", next, next);
6327
- }
6732
+ },
6733
+ setAccountIgnored: async (accountId, ignored) => {
6734
+ if (ignored) {
6735
+ const known = core.pool.list().find((account) => account.id === accountId);
6736
+ await ignoreAccount({
6737
+ id: accountId,
6738
+ ...known === void 0 ? {} : { label: known.label }
6739
+ }, ignoredPath);
6740
+ } else await unignoreAccount(accountId, ignoredPath);
6741
+ refreshIgnored();
6742
+ if (!ignored) await core.pool.scan();
6743
+ },
6744
+ ignoredAccounts: () => ignoredAccounts
6328
6745
  }));
6329
6746
  api = {
6330
6747
  ...core,
@@ -6457,4 +6874,4 @@ function apply(ctx, config = {}) {
6457
6874
  });
6458
6875
  }
6459
6876
  //#endregion
6460
- export { APPEARANCE_THEME_KEY, AUTOMATION_JOB_KINDS, AUTOMATION_TICK_MS, BUDDY_APP_ID, BUDDY_APP_NAME, Config, DEFAULT_CONTEXT_BUDGET, EVENT_SCORE_WAIT_MS, FALLBACK_WORKBUDDY_MODELS, LIBRARY_DOC_URL, LIGHTHOUSE_EXPERT_ID, PLAYBOOK_CASE_ID, PLAYBOOK_CASE_NAME, POOL_AUTOMATION_RUN_PATH, POOL_CHECKIN_PATH, POOL_CREDIT_RESERVE_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, SKILL_ID, SKILL_NAME, TEMPLATE_PRESETS, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyScheduler, WorkBuddyUpstreamClient, appearanceChain, apply, automationChain, automationOptions, buddyAppChain, buddyAppEvents, buildStatus, candidateAuthDirs, canvasChain, chatChain, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, dayKey, defaultDesktopAuthDirs, desktopAutomationCreatedEvent, desktopCanvasEvents, desktopChatEvents, expertActualUseEvent, expertChatEvents, expertSummonEvents, formatRates, formatStatus, inject, isAutomationJobKind, isFireHour, libraryReadChain, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, playbookChain, poolWebStatus, registerPoolStatusRoute, setApi, skillChain, templateChain, templateChains, workbuddyAccountId };
6877
+ export { APPEARANCE_THEME_KEY, AUTOMATION_JOB_KINDS, AUTOMATION_TICK_MS, BUDDY_APP_ID, BUDDY_APP_NAME, Config, DEFAULT_AUTOMATION_HOURS, DEFAULT_CONTEXT_BUDGET, EVENT_SCORE_WAIT_MS, FALLBACK_WORKBUDDY_MODELS, IGNORED_FILE_NAME, LIBRARY_DOC_URL, LIGHTHOUSE_EXPERT_ID, PLAYBOOK_CASE_ID, PLAYBOOK_CASE_NAME, PLUGIN_DATA_DIR_NAME, POOL_ACCOUNT_IGNORE_PATH, POOL_AUTOMATION_RUN_PATH, POOL_CHECKIN_PATH, POOL_CREDIT_RESERVE_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, SKILL_ID, SKILL_NAME, TEMPLATE_PRESETS, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyScheduler, WorkBuddyUpstreamClient, appearanceChain, apply, automationChain, automationOptions, buddyAppChain, buddyAppEvents, buildStatus, candidateAuthDirs, canvasChain, chatChain, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, dayKey, defaultDesktopAuthDirs, desktopAutomationCreatedEvent, desktopCanvasEvents, desktopChatEvents, dshHome, expertActualUseEvent, expertChatEvents, expertSummonEvents, formatRates, formatStatus, ignoreAccount, ignoredIdsPath, inject, isAutomationJobKind, isFireHour, libraryReadChain, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, playbookChain, pluginDataDir, poolWebStatus, readIgnoredAccounts, readIgnoredAccountsSync, registerPoolStatusRoute, setApi, skillChain, templateChain, templateChains, unignoreAccount, workbuddyAccountId, writeIgnoredAccounts };