dsh-workbuddy-xdpool 1.6.1 → 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
  /**
@@ -1747,8 +1749,20 @@ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
1747
1749
  const SYMMETRIC_SCHEME = "sym-v1";
1748
1750
  /** Env override pointing at the WorkBuddy desktop executable. */
1749
1751
  const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
1750
- /** How long the app is given to answer with its key payload. */
1751
- const KEY_FETCH_TIMEOUT_MS = 1e4;
1752
+ /**
1753
+ * How long the app is given to answer with its key payload.
1754
+ *
1755
+ * 30s, not 10s: the child is the WorkBuddy Electron binary running as plain
1756
+ * Node, and its FIRST spawn on a cold machine costs several seconds on its own
1757
+ * (measured 4.5s here) before the endpoint security stack has warmed its scan
1758
+ * cache. Under load — a concurrent `pnpm install` from the market, a running
1759
+ * full-disk scan — that first spawn crosses a 10s budget, the fetch rejects,
1760
+ * `readAtRestKey` returns undefined, and every encrypted credential then reads
1761
+ * as `WorkBuddyEncryptedCredentialError` until the 60s negative cache expires.
1762
+ * A successful fetch is cached for the process lifetime, so the longer budget
1763
+ * is only ever paid once per process, and only when the app is present but slow.
1764
+ */
1765
+ const KEY_FETCH_TIMEOUT_MS = 3e4;
1752
1766
  /**
1753
1767
  * Executable file names the desktop app ships under, in probe order.
1754
1768
  *
@@ -2003,6 +2017,7 @@ function windowsFallbackAppPaths(env) {
2003
2017
  }
2004
2018
  roots.add(join(drive, "Program Files"));
2005
2019
  roots.add(join(drive, "Program Files (x86)"));
2020
+ roots.add(drive);
2006
2021
  }
2007
2022
  for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
2008
2023
  out.push(join(root, "WorkBuddy", name));
@@ -2415,6 +2430,41 @@ async function readCredential(path) {
2415
2430
  }
2416
2431
  }
2417
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
+ /**
2418
2468
  * Build the field opener, or undefined when the app cannot supply its key.
2419
2469
  *
2420
2470
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2493,6 +2543,21 @@ var WorkBuddyAccountPool = class {
2493
2543
  */
2494
2544
  disabledIds = /* @__PURE__ */ new Set();
2495
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
+ /**
2496
2561
  * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
2497
2562
  * it all".
2498
2563
  *
@@ -2544,12 +2609,39 @@ var WorkBuddyAccountPool = class {
2544
2609
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
2545
2610
  if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
2546
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
+ }
2547
2633
  /** Rescan the auth directories and merge newly discovered accounts. */
2548
2634
  async scan() {
2549
2635
  const found = [];
2550
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
+ }
2551
2641
  const credential = await readCredential(file);
2552
- 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);
2553
2645
  }
2554
2646
  const byId = /* @__PURE__ */ new Map();
2555
2647
  for (const account of this.accounts) byId.set(account.id, account);
@@ -2890,28 +2982,44 @@ ${new Date(until).toISOString()}
2890
2982
  };
2891
2983
  //#endregion
2892
2984
  //#region src/catalog.ts
2893
- /** 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
+ */
2894
2999
  const FALLBACK_WORKBUDDY_MODELS = [
2895
3000
  {
2896
3001
  id: "glm-5.3",
2897
3002
  name: "GLM-5.3",
2898
3003
  contextWindow: 2e5,
2899
3004
  maxOutputTokens: 128e3,
2900
- supportsImages: true
3005
+ supportsImages: true,
3006
+ multiplier: .79
2901
3007
  },
2902
3008
  {
2903
3009
  id: "glm-5.3-flash",
2904
3010
  name: "GLM-5.3-Flash",
2905
3011
  contextWindow: 2e5,
2906
3012
  maxOutputTokens: 128e3,
2907
- supportsImages: true
3013
+ supportsImages: true,
3014
+ multiplier: .06
2908
3015
  },
2909
3016
  {
2910
3017
  id: "glm-5.2",
2911
3018
  name: "GLM-5.2",
2912
3019
  contextWindow: 2e5,
2913
3020
  maxOutputTokens: 128e3,
2914
- supportsImages: true
3021
+ supportsImages: true,
3022
+ multiplier: .79
2915
3023
  },
2916
3024
  {
2917
3025
  id: "glm-5.1",
@@ -2939,7 +3047,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
2939
3047
  name: "Kimi-K3",
2940
3048
  contextWindow: 2e5,
2941
3049
  maxOutputTokens: 128e3,
2942
- supportsImages: true
3050
+ supportsImages: true,
3051
+ multiplier: 1.62
2943
3052
  },
2944
3053
  {
2945
3054
  id: "minimax-m3",
@@ -2953,14 +3062,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
2953
3062
  name: "Hy3",
2954
3063
  contextWindow: 32e3,
2955
3064
  maxOutputTokens: 8e3,
2956
- supportsImages: true
3065
+ supportsImages: true,
3066
+ multiplier: 0
2957
3067
  },
2958
3068
  {
2959
3069
  id: "hy4-preview",
2960
3070
  name: "Hy4-Preview",
2961
3071
  contextWindow: 1e6,
2962
3072
  maxOutputTokens: 128e3,
2963
- supportsImages: true
3073
+ supportsImages: true,
3074
+ multiplier: .29
2964
3075
  }
2965
3076
  ];
2966
3077
  /** Live catalog with a static fallback behind it. */
@@ -3038,7 +3149,8 @@ function toModelInfo(model) {
3038
3149
  maxOutputTokens: model.maxTokens,
3039
3150
  supportsImages: model.supportsImages ?? false,
3040
3151
  ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
3041
- ...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 }
3042
3154
  };
3043
3155
  }
3044
3156
  /** Map the live upstream list, falling back to the static list when empty. */
@@ -3148,7 +3260,8 @@ const TAG_LABEL = {
3148
3260
  * so we don't tack a trailing separator on a plain model. */
3149
3261
  function displaySuffix(info) {
3150
3262
  const parts = [];
3151
- 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)}`);
3152
3265
  for (const tag of info.tags ?? []) {
3153
3266
  const label = TAG_LABEL[tag];
3154
3267
  if (label !== void 0 && !parts.includes(label)) parts.push(label);
@@ -3647,6 +3760,65 @@ function expertChatEvents(expert, conversationId, requestId) {
3647
3760
  expert_industry_id: ""
3648
3761
  } : event);
3649
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
+ };
3650
3822
  /** How often the loop wakes to look for a due job. */
3651
3823
  /**
3652
3824
  * How long to wait for event scoring to land before re-reading the task list.
@@ -3705,6 +3877,23 @@ function isAutomationJobKind(value) {
3705
3877
  return typeof value === "string" && AUTOMATION_JOB_KINDS.includes(value);
3706
3878
  }
3707
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
+ }
3708
3897
  const EMPTY_JOB_STATE = {
3709
3898
  ok: 0,
3710
3899
  failed: 0,
@@ -3893,11 +4082,11 @@ var WorkBuddyScheduler = class {
3893
4082
  this.earningsDate = today;
3894
4083
  }
3895
4084
  this.enabled = options.enabled ?? false;
3896
- this.checkinHours = options.checkinHours ?? [9];
3897
- this.reportHours = options.reportHours ?? [10];
3898
- this.taskHours = options.taskHours ?? [11];
3899
- this.streakHours = options.streakHours ?? [12];
3900
- 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);
3901
4090
  }
3902
4091
  /** Apply a new configuration; safe to call while running. */
3903
4092
  /**
@@ -3925,11 +4114,11 @@ var WorkBuddyScheduler = class {
3925
4114
  }
3926
4115
  applyConfig(options) {
3927
4116
  if (options.enabled !== void 0) this.enabled = options.enabled;
3928
- if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
3929
- if (options.reportHours !== void 0) this.reportHours = options.reportHours;
3930
- if (options.taskHours !== void 0) this.taskHours = options.taskHours;
3931
- if (options.streakHours !== void 0) this.streakHours = options.streakHours;
3932
- 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);
3933
4122
  }
3934
4123
  /** Hours for one job, used by the loop and the status document. */
3935
4124
  hoursOf(kind) {
@@ -4160,12 +4349,16 @@ var WorkBuddyScheduler = class {
4160
4349
  */
4161
4350
  persistEarnings() {
4162
4351
  if (this.saveEarningsFn === void 0) return;
4352
+ const accounts = {};
4353
+ for (const [id, entry] of this.earnings) accounts[id] = entry;
4354
+ const ledger = {
4355
+ date: this.earningsDate,
4356
+ accounts
4357
+ };
4163
4358
  try {
4164
- const accounts = {};
4165
- for (const [id, entry] of this.earnings) accounts[id] = entry;
4166
- this.saveEarningsFn({
4167
- date: this.earningsDate,
4168
- accounts
4359
+ const saved = this.saveEarningsFn(ledger);
4360
+ if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
4361
+ this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
4169
4362
  });
4170
4363
  } catch (error) {
4171
4364
  this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
@@ -5398,36 +5591,6 @@ function formatRates(status) {
5398
5591
  return lines.join("\n");
5399
5592
  }
5400
5593
  //#endregion
5401
- //#region src/status-paths.ts
5402
- /**
5403
- * Node-free constants and types shared by the Host and browser halves of the
5404
- * WorkBuddy XD Pool settings card.
5405
- *
5406
- * Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
5407
- * `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
5408
- * JSON document into a shape that stays token-free and matches what the
5409
- * browser card renders. Route paths are plugin-owned and mounted on the Host's
5410
- * same-origin web server (see `src/web-status.ts`).
5411
- *
5412
- * @module dsh-workbuddy-xdpool/status-paths
5413
- */
5414
- /** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
5415
- const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
5416
- /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
5417
- const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
5418
- /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
5419
- const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
5420
- /** Plugin-owned daily check-in action endpoint (claim today's reward). */
5421
- const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
5422
- /** Plugin-owned model-selection save endpoint (writes the settings section). */
5423
- const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
5424
- /** Switch one account in or out of the pool (card toggle). */
5425
- const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
5426
- /** Run one automation job immediately, so the card can verify it on demand. */
5427
- const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
5428
- /** Set or clear one account's reserved-credit floor. */
5429
- const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
5430
- //#endregion
5431
5594
  //#region src/web-status.ts
5432
5595
  /** Redact token-like content before it crosses to the browser. */
5433
5596
  /**
@@ -5552,6 +5715,27 @@ function parseAccountToggle(body, known) {
5552
5715
  };
5553
5716
  }
5554
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
+ /**
5555
5739
  * Parse a manual-run request.
5556
5740
  *
5557
5741
  * The job name is checked against the real job list rather than passed through:
@@ -5723,7 +5907,8 @@ async function poolWebStatus(deps, region = "cn") {
5723
5907
  regions,
5724
5908
  shim,
5725
5909
  automation,
5726
- creditReserves: deps.pool.creditReservesInOrder()
5910
+ creditReserves: deps.pool.creditReservesInOrder(),
5911
+ ignored: deps.ignoredAccounts?.() ?? []
5727
5912
  };
5728
5913
  }
5729
5914
  /**
@@ -5847,6 +6032,42 @@ function registerPoolStatusRoute(ctx, deps) {
5847
6032
  }
5848
6033
  });
5849
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
+ /**
5850
6071
  * Run one automation job on demand.
5851
6072
  *
5852
6073
  * POST only, loopback origin only, and the job name must be one of the four
@@ -5906,6 +6127,7 @@ function registerPoolStatusRoute(ctx, deps) {
5906
6127
  return () => {
5907
6128
  disposeAutomationRun();
5908
6129
  disposeCreditReserve();
6130
+ disposeAccountIgnore();
5909
6131
  disposeCheckin();
5910
6132
  disposeAccountDisable();
5911
6133
  disposeModelsSave();
@@ -5916,6 +6138,153 @@ function registerPoolStatusRoute(ctx, deps) {
5916
6138
  }, "dsh-workbuddy-xdpool: Web status route");
5917
6139
  }
5918
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
5919
6288
  //#region src/index.ts
5920
6289
  /**
5921
6290
  * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
@@ -5962,6 +6331,56 @@ function automationOptions(automation) {
5962
6331
  * region that has never been saved stays absent so `applyConfigFromSource` can
5963
6332
  * fall back to the legacy flat keys.
5964
6333
  */
6334
+ function asVolatile(schema) {
6335
+ const candidate = schema;
6336
+ return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
6337
+ }
6338
+ /**
6339
+ * Peel one live volatile reference.
6340
+ *
6341
+ * On 0.1.7 a volatile field is handed back as `{ get(): T }` rather than a
6342
+ * plain value, so a running instance observes a settings edit without being
6343
+ * remounted. Every read of a marked field therefore has to unwrap: passing the
6344
+ * reference onward compares an object against a string and reports the field as
6345
+ * unset. On 0.1.5 the field is already a plain value, so this is a no-op.
6346
+ *
6347
+ * The test is duck-typed on purpose — importing `isVolatile` would add a
6348
+ * dependency the 0.1.5 line does not carry.
6349
+ */
6350
+ function unwrapVolatile(value) {
6351
+ if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
6352
+ return value;
6353
+ }
6354
+ /**
6355
+ * Deep copy of a config value with every live `{get(): T}` reference replaced
6356
+ * by the value it resolves to.
6357
+ *
6358
+ * {@link unwrapVolatile} peels only the one level an ordinary read needs. A
6359
+ * settings service is different: it validates and `structuredClone`s the WHOLE
6360
+ * object, so a reference surviving anywhere inside it fails schema validation
6361
+ * with a message that names the field but not the cause —
6362
+ * `$.authFile expected string but got [object Object]`. That is what makes the
6363
+ * namespace fail to register and the card silently disappear, which is why the
6364
+ * object handed to `installSection` goes through this first.
6365
+ */
6366
+ function unwrapVolatileDeep(value) {
6367
+ const peeled = unwrapVolatile(value);
6368
+ if (Array.isArray(peeled)) return peeled.map((entry) => unwrapVolatileDeep(entry));
6369
+ if (peeled !== null && typeof peeled === "object") {
6370
+ const out = {};
6371
+ for (const [key, entry] of Object.entries(peeled)) out[key] = unwrapVolatileDeep(entry);
6372
+ return out;
6373
+ }
6374
+ return peeled;
6375
+ }
6376
+ /**
6377
+ * One region's model-selection schema.
6378
+ *
6379
+ * Every field is optional on purpose: an absent field keeps its documented
6380
+ * meaning ("all enabled" / "follow the upstream image flag" / "no cap"), and a
6381
+ * region that has never been saved stays absent so `applyConfigFromSource` can
6382
+ * fall back to the legacy flat keys.
6383
+ */
5965
6384
  const modelSelectionSchema = z.object({
5966
6385
  enabledModelIds: z.array(z.string()).description("Model ids enabled in this region's picker (absent = all)"),
5967
6386
  imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
@@ -5999,24 +6418,32 @@ const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" :
5999
6418
  * also why `contextBudgets` is a real dictionary (`z.dict`) - an open object
6000
6419
  * schema reads as "an object with no fields" and the fold then throws while
6001
6420
  * the provider row is rendered.
6421
+ *
6422
+ * Every field is wrapped in {@link asVolatile}: on the 0.1.7 line the settings
6423
+ * write gate refuses an entry whose schema declares no volatile field at all
6424
+ * ("Plugin entry ... has no volatile fields") and `describe()` skips such an
6425
+ * entry — so an unmarked schema means the card can neither render nor save. On
6426
+ * the 0.1.5 line the wrapper degrades to an identity no-op (see its JSDoc), and
6427
+ * the value the running instance reads is a plain value either way once
6428
+ * unwrapped.
6002
6429
  */
6003
6430
  const Config = z.object({
6004
- authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
6005
- cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
6006
- distribution: z.union([
6431
+ authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app own location)")),
6432
+ cooldownMs: asVolatile(z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds")),
6433
+ distribution: asVolatile(z.union([
6007
6434
  "priority",
6008
6435
  "round-robin",
6009
6436
  "balanced"
6010
- ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
6011
- disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
6012
- creditReserves: z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value"),
6013
- enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
6014
- imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
6015
- contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
6016
- modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
6017
- modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
6018
- automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
6019
- automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
6437
+ ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
6438
+ disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
6439
+ creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
6440
+ enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
6441
+ imageModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet")),
6442
+ contextBudgets: asVolatile(z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet")),
6443
+ modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
6444
+ modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
6445
+ automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
6446
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
6020
6447
  });
6021
6448
  /** Live API, published for the CLI. */
6022
6449
  let api;
@@ -6060,6 +6487,14 @@ function createCore(logger) {
6060
6487
  */
6061
6488
  function apply(ctx, config = {}) {
6062
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
+ };
6063
6498
  /**
6064
6499
  * Invalidate the provider snapshot so the picker re-reads the catalog.
6065
6500
  *
@@ -6073,10 +6508,21 @@ function apply(ctx, config = {}) {
6073
6508
  /** The domestic adapter, published for the CLI after registration. */
6074
6509
  let builtAdapter;
6075
6510
  let invalidateCatalog = () => {};
6076
- let current = () => config;
6511
+ /**
6512
+ * The effective config with every live volatile reference peeled.
6513
+ *
6514
+ * On 0.1.7 a field marked volatile is handed to the plugin as `{get(): T}` so
6515
+ * a settings edit is observed without a remount, and the loader keeps the
6516
+ * reference live. Every read below therefore goes through this view: a raw
6517
+ * reference compared against a string reports the field as unset, which is
6518
+ * how a saved reserve would read back as "0" while the file holds the value.
6519
+ * On 0.1.5 there is nothing to peel and this is the identity.
6520
+ */
6521
+ let rawCurrent = () => config;
6522
+ const current = () => unwrapVolatileDeep(rawCurrent());
6077
6523
  const sectionHooks = {
6078
6524
  setSource(source) {
6079
- current = source;
6525
+ rawCurrent = source;
6080
6526
  },
6081
6527
  onChange() {
6082
6528
  applyConfigFromSource();
@@ -6097,13 +6543,21 @@ function apply(ctx, config = {}) {
6097
6543
  ...imageModelIds === void 0 ? {} : { imageModelIds },
6098
6544
  ...contextBudgets === void 0 ? {} : { contextBudgets }
6099
6545
  };
6100
- core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
6101
- 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"));
6102
6551
  core.scheduler.applyConfig(automationOptions(automation));
6103
6552
  };
6104
6553
  const settingsService = ctx.settings;
6105
- if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
6106
- else ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service has no installSection; card will not mount");
6554
+ if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, unwrapVolatileDeep(config), sectionHooks);
6555
+ if (typeof settingsService.configure === "function") ctx.effect(() => settingsService.configure?.({ auto: true }, ctx.fiber) ?? (() => {}));
6556
+ if (typeof settingsService.installSection !== "function" && typeof settingsService.configure !== "function") ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service exposes neither installSection nor configure; the card will not mount");
6557
+ ctx.on("loader/volatile-update", () => {
6558
+ applyConfigFromSource();
6559
+ });
6560
+ applyConfigFromSource();
6107
6561
  /**
6108
6562
  * Write one key of the plugin's own settings section. Only ever called with
6109
6563
  * the three model-selection keys, so the settings file cannot be steered from
@@ -6139,11 +6593,31 @@ function apply(ctx, config = {}) {
6139
6593
  if (typeof value === "object" && value !== null) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
6140
6594
  return JSON.stringify(value);
6141
6595
  }
6596
+ /**
6597
+ * The namespace `settings.update` expects on the running line.
6598
+ *
6599
+ * 0.1.5 resolves writers by the NAMESPACE the plugin registered through
6600
+ * `installSection`, so the plugin picks it. 0.1.7 resolves them by the HOST
6601
+ * PLUGIN ENTRY ID instead (`configEditor.entries().find(row => row.options.id
6602
+ * === ns)`), and the profile patch chooses that id — the live Desktop host
6603
+ * mounts this plugin as `llm-workbuddy-xdpool`, but a marketplace install can
6604
+ * wrap it (`mkt-...`) or mount it through an `include` row. Asking the fiber
6605
+ * for its own entry id is therefore the only correct answer there; guessing
6606
+ * our own namespace makes every save throw `No configurable plugin entry`.
6607
+ */
6608
+ const settingsWriteNs = typeof settingsService.installSection === "function" ? WORKBUDDY_POOL_SETTINGS_NS : (() => {
6609
+ try {
6610
+ const entryId = ctx.fiber?.entry?.options?.id;
6611
+ return entryId === void 0 || entryId === "" ? WORKBUDDY_POOL_SETTINGS_NS : entryId;
6612
+ } catch {
6613
+ return WORKBUDDY_POOL_SETTINGS_NS;
6614
+ }
6615
+ })();
6142
6616
  const setSetting = async (key, value, expected) => {
6143
6617
  if (value === void 0) return;
6144
6618
  const update = settingsService?.update;
6145
6619
  if (update === void 0) throw new Error(`settings service has no update(); ${key} was not saved`);
6146
- await update.call(settingsService, WORKBUDDY_POOL_SETTINGS_NS, { [key]: value });
6620
+ await update.call(settingsService, settingsWriteNs, { [key]: value });
6147
6621
  if (expected !== void 0) {
6148
6622
  const stored = current()[key];
6149
6623
  if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
@@ -6214,7 +6688,19 @@ function apply(ctx, config = {}) {
6214
6688
  if (reserve > 0) next[accountId] = reserve;
6215
6689
  else delete next[accountId];
6216
6690
  await setSetting("creditReserves", next, next);
6217
- }
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
6218
6704
  }));
6219
6705
  api = {
6220
6706
  ...core,
@@ -6339,10 +6825,12 @@ function apply(ctx, config = {}) {
6339
6825
  ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
6340
6826
  }
6341
6827
  invalidateCatalog();
6342
- })();
6828
+ })().catch((error) => {
6829
+ ctx.logger.warn("dsh-workbuddy-xdpool: account catalog seed failed", error);
6830
+ });
6343
6831
  }, (error) => {
6344
6832
  ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
6345
6833
  });
6346
6834
  }
6347
6835
  //#endregion
6348
- 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 };