dsh-workbuddy-xdpool 1.7.0 → 1.7.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
@@ -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) {
@@ -5415,36 +5591,6 @@ function formatRates(status) {
5415
5591
  return lines.join("\n");
5416
5592
  }
5417
5593
  //#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
5594
  //#region src/web-status.ts
5449
5595
  /** Redact token-like content before it crosses to the browser. */
5450
5596
  /**
@@ -5569,6 +5715,27 @@ function parseAccountToggle(body, known) {
5569
5715
  };
5570
5716
  }
5571
5717
  /**
5718
+ * Validate an ignore/unignore request.
5719
+ *
5720
+ * Two different notions of "known" apply, which is why the caller passes both:
5721
+ *
5722
+ * - ignoring requires the account to be IN THE POOL, because the card can only
5723
+ * name an account it was just shown;
5724
+ * - un-ignoring requires the account to be ON THE IGNORE LIST instead — by
5725
+ * definition it is not in the pool any more, so checking the pool would make
5726
+ * the undo button impossible to use.
5727
+ */
5728
+ function parseAccountIgnore(body, knownInPool, knownIgnored) {
5729
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
5730
+ const ignored = body["ignored"];
5731
+ if (accountId === "" || typeof ignored !== "boolean") return void 0;
5732
+ if (ignored ? !knownInPool(accountId) : !knownIgnored(accountId)) return void 0;
5733
+ return {
5734
+ accountId,
5735
+ ignored
5736
+ };
5737
+ }
5738
+ /**
5572
5739
  * Parse a manual-run request.
5573
5740
  *
5574
5741
  * The job name is checked against the real job list rather than passed through:
@@ -5740,7 +5907,8 @@ async function poolWebStatus(deps, region = "cn") {
5740
5907
  regions,
5741
5908
  shim,
5742
5909
  automation,
5743
- creditReserves: deps.pool.creditReservesInOrder()
5910
+ creditReserves: deps.pool.creditReservesInOrder(),
5911
+ ignored: deps.ignoredAccounts?.() ?? []
5744
5912
  };
5745
5913
  }
5746
5914
  /**
@@ -5864,6 +6032,42 @@ function registerPoolStatusRoute(ctx, deps) {
5864
6032
  }
5865
6033
  });
5866
6034
  /**
6035
+ * Throw one account out of the pool for good, or take it back.
6036
+ *
6037
+ * Same three guards as the disable route — POST only, loopback origin only,
6038
+ * and an explicit `accountId` — plus the semantic split in
6039
+ * {@link parseAccountIgnore}: ignoring checks the pool, un-ignoring checks
6040
+ * the ignore list.
6041
+ *
6042
+ * The credential FILE is deliberately left alone. Deleting it would sign the
6043
+ * desktop app out (the live file IS the app's current session) and would not
6044
+ * even stick, since the app rewrites it on the next sign-in. Ignoring is
6045
+ * enforced on the read side instead, which is both reversible and durable.
6046
+ */
6047
+ const disposeAccountIgnore = ctx.webServer.register({
6048
+ kind: "exact",
6049
+ path: POOL_ACCOUNT_IGNORE_PATH,
6050
+ handler: async (req, res) => {
6051
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6052
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6053
+ if (deps.setAccountIgnored === void 0) return json(res, 503, { error: "settings service unavailable; the ignore list cannot be saved" });
6054
+ try {
6055
+ const body = await readJsonBody(req);
6056
+ const knownInPool = new Set(deps.pool.list().map((account) => account.id));
6057
+ const knownIgnored = new Set((deps.ignoredAccounts?.() ?? []).map((entry) => entry.id));
6058
+ const parsed = parseAccountIgnore(body, (id) => knownInPool.has(id), (id) => knownIgnored.has(id));
6059
+ if (parsed === void 0) return json(res, 400, { error: "invalid account ignore payload" });
6060
+ await deps.setAccountIgnored(parsed.accountId, parsed.ignored);
6061
+ json(res, 200, {
6062
+ ok: true,
6063
+ ...parsed
6064
+ });
6065
+ } catch (error) {
6066
+ json(res, 500, { error: safeMessage(error) });
6067
+ }
6068
+ }
6069
+ });
6070
+ /**
5867
6071
  * Run one automation job on demand.
5868
6072
  *
5869
6073
  * POST only, loopback origin only, and the job name must be one of the four
@@ -5923,6 +6127,7 @@ function registerPoolStatusRoute(ctx, deps) {
5923
6127
  return () => {
5924
6128
  disposeAutomationRun();
5925
6129
  disposeCreditReserve();
6130
+ disposeAccountIgnore();
5926
6131
  disposeCheckin();
5927
6132
  disposeAccountDisable();
5928
6133
  disposeModelsSave();
@@ -5933,6 +6138,153 @@ function registerPoolStatusRoute(ctx, deps) {
5933
6138
  }, "dsh-workbuddy-xdpool: Web status route");
5934
6139
  }
5935
6140
  //#endregion
6141
+ //#region src/ignored.ts
6142
+ /**
6143
+ * The permanent ignore list: accounts the user has thrown out of the pool.
6144
+ *
6145
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
6146
+ * account stays on the card, can be switched back on, and its credential keeps
6147
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
6148
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
6149
+ * credential is decrypted, never appears among the pool's accounts, and does not
6150
+ * come back when the desktop app writes a fresh `.info` file for it.
6151
+ *
6152
+ * It lives in a plugin-owned file rather than in the settings document for one
6153
+ * practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
6154
+ * ignore <id>` runs without a host, so a settings-only list could be written by
6155
+ * the card and never by the CLI. One file, read by both halves, keeps the two
6156
+ * views of "which accounts are ignored" identical by construction.
6157
+ *
6158
+ * @module dsh-workbuddy-xdpool/ignored
6159
+ */
6160
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
6161
+ const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
6162
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
6163
+ const IGNORED_FILE_NAME = "ignored.json";
6164
+ /**
6165
+ * The DSH home directory, honouring the same override the host uses.
6166
+ *
6167
+ * Shared by the CLI and the host so both halves resolve the same file: an
6168
+ * `ignore` written from the terminal has to be visible to the running plugin,
6169
+ * which is only true if they agree on where "home" is.
6170
+ */
6171
+ function dshHome(env = process.env) {
6172
+ const fromEnv = env["DSH_HOME"];
6173
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
6174
+ return join(homedir(), ".dsh");
6175
+ }
6176
+ /** This plugin's own state directory. */
6177
+ function pluginDataDir(env = process.env) {
6178
+ return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
6179
+ }
6180
+ /** Absolute path of the ignore list. */
6181
+ function ignoredIdsPath(env = process.env) {
6182
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
6183
+ }
6184
+ /**
6185
+ * Normalize whatever the file holds into a clean list.
6186
+ *
6187
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
6188
+ * take the pool down. An entry without a usable id is dropped; a missing label
6189
+ * falls back to the id so the card always has something to print.
6190
+ */
6191
+ function normalize(raw) {
6192
+ if (typeof raw !== "object" || raw === null) return [];
6193
+ const entries = raw.accounts;
6194
+ if (!Array.isArray(entries)) return [];
6195
+ const out = [];
6196
+ const seen = /* @__PURE__ */ new Set();
6197
+ for (const entry of entries) {
6198
+ if (typeof entry !== "object" || entry === null) continue;
6199
+ const record = entry;
6200
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
6201
+ if (id === "" || seen.has(id)) continue;
6202
+ seen.add(id);
6203
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
6204
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
6205
+ out.push({
6206
+ id,
6207
+ label,
6208
+ ignoredAt
6209
+ });
6210
+ }
6211
+ return out;
6212
+ }
6213
+ /**
6214
+ * Read the ignore list, tolerating every "no list yet" shape.
6215
+ *
6216
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
6217
+ * the caller — nothing is ignored — so none of them throws. The pool must be
6218
+ * able to start on a machine that has never ignored anything.
6219
+ */
6220
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
6221
+ try {
6222
+ return normalize(JSON.parse(await readFile(path, "utf8")));
6223
+ } catch {
6224
+ return [];
6225
+ }
6226
+ }
6227
+ /**
6228
+ * Synchronous read, for startup.
6229
+ *
6230
+ * The host applies the ignore list from inside `apply()`, which is synchronous,
6231
+ * and doing it there removes a startup race: an async load could resolve AFTER
6232
+ * the first account scan, which would let an ignored account slip into the pool
6233
+ * once per boot. The file is a few hundred bytes, so a blocking read at startup
6234
+ * costs nothing measurable.
6235
+ */
6236
+ function readIgnoredAccountsSync(path = ignoredIdsPath()) {
6237
+ try {
6238
+ return normalize(JSON.parse(readFileSync(path, "utf8")));
6239
+ } catch {
6240
+ return [];
6241
+ }
6242
+ }
6243
+ /**
6244
+ * Replace the ignore list, atomically.
6245
+ *
6246
+ * Written to a sibling temp file and renamed over the target so a crash (or a
6247
+ * concurrent reader) can never observe a half-written document — the ignore
6248
+ * list is the only thing standing between a dead account and the rotation, and
6249
+ * a truncated file reads as "nothing is ignored", which would quietly put every
6250
+ * discarded account back in the pool.
6251
+ */
6252
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
6253
+ const payload = JSON.stringify({
6254
+ version: 1,
6255
+ accounts
6256
+ }, null, 2);
6257
+ await mkdir(dirname(path), { recursive: true });
6258
+ const temp = `${path}.tmp`;
6259
+ await writeFile(temp, `${payload}\n`, "utf8");
6260
+ await rename(temp, path);
6261
+ }
6262
+ /**
6263
+ * Add one account to the ignore list, preserving the rest.
6264
+ *
6265
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
6266
+ * both be open, and each request names exactly one account, so re-writing the
6267
+ * whole list from a stale view would drop the other side's edits.
6268
+ */
6269
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
6270
+ const current = await readIgnoredAccounts(path);
6271
+ if (current.some((entry) => entry.id === account.id)) return current;
6272
+ const next = [...current, {
6273
+ id: account.id,
6274
+ label: account.label ?? account.id,
6275
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
6276
+ }];
6277
+ await writeIgnoredAccounts(next, path);
6278
+ return next;
6279
+ }
6280
+ /** Drop one account from the ignore list. Returns the resulting list. */
6281
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
6282
+ const current = await readIgnoredAccounts(path);
6283
+ const next = current.filter((entry) => entry.id !== accountId);
6284
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
6285
+ return next;
6286
+ }
6287
+ //#endregion
5936
6288
  //#region src/index.ts
5937
6289
  /**
5938
6290
  * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
@@ -6135,6 +6487,14 @@ function createCore(logger) {
6135
6487
  */
6136
6488
  function apply(ctx, config = {}) {
6137
6489
  const core = createCore(ctx.logger);
6490
+ const ignoredPath = ignoredIdsPath();
6491
+ let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6492
+ core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6493
+ /** Re-read the ignore file and push it into the pool. */
6494
+ const refreshIgnored = () => {
6495
+ ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
6496
+ core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6497
+ };
6138
6498
  /**
6139
6499
  * Invalidate the provider snapshot so the picker re-reads the catalog.
6140
6500
  *
@@ -6183,8 +6543,11 @@ function apply(ctx, config = {}) {
6183
6543
  ...imageModelIds === void 0 ? {} : { imageModelIds },
6184
6544
  ...contextBudgets === void 0 ? {} : { contextBudgets }
6185
6545
  };
6186
- core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
6187
- core.catalogs.global.applySelection(modelSelectionGlobal ?? legacySelection);
6546
+ const fallbackSelection = (region) => {
6547
+ return legacySelection.enabledModelIds === void 0 && legacySelection.imageModelIds === void 0 && legacySelection.contextBudgets === void 0 ? core.catalogs[region].currentSelection() : legacySelection;
6548
+ };
6549
+ core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
6550
+ core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
6188
6551
  core.scheduler.applyConfig(automationOptions(automation));
6189
6552
  };
6190
6553
  const settingsService = ctx.settings;
@@ -6194,6 +6557,7 @@ function apply(ctx, config = {}) {
6194
6557
  ctx.on("loader/volatile-update", () => {
6195
6558
  applyConfigFromSource();
6196
6559
  });
6560
+ applyConfigFromSource();
6197
6561
  /**
6198
6562
  * Write one key of the plugin's own settings section. Only ever called with
6199
6563
  * the three model-selection keys, so the settings file cannot be steered from
@@ -6324,7 +6688,19 @@ function apply(ctx, config = {}) {
6324
6688
  if (reserve > 0) next[accountId] = reserve;
6325
6689
  else delete next[accountId];
6326
6690
  await setSetting("creditReserves", next, next);
6327
- }
6691
+ },
6692
+ setAccountIgnored: async (accountId, ignored) => {
6693
+ if (ignored) {
6694
+ const known = core.pool.list().find((account) => account.id === accountId);
6695
+ await ignoreAccount({
6696
+ id: accountId,
6697
+ ...known === void 0 ? {} : { label: known.label }
6698
+ }, ignoredPath);
6699
+ } else await unignoreAccount(accountId, ignoredPath);
6700
+ refreshIgnored();
6701
+ if (!ignored) await core.pool.scan();
6702
+ },
6703
+ ignoredAccounts: () => ignoredAccounts
6328
6704
  }));
6329
6705
  api = {
6330
6706
  ...core,
@@ -6457,4 +6833,4 @@ function apply(ctx, config = {}) {
6457
6833
  });
6458
6834
  }
6459
6835
  //#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 };
6836
+ 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 };