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/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createDecipheriv, createHash, randomUUID } from "node:crypto";
2
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { copyFile, mkdir, readFile, readdir, rename, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
3
3
  import { homedir, platform } from "node:os";
4
- import { basename, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { execFile, execFileSync } from "node:child_process";
6
6
  import { existsSync, readFileSync, readdirSync } from "node:fs";
7
7
  import z from "@deepseek-ai/schemastery";
@@ -427,6 +427,7 @@ function parseUpstreamModel(value) {
427
427
  const reasoning = parseReasoning(raw["reasoning"]);
428
428
  const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
429
429
  const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
430
+ const tags = Array.isArray(raw["tags"]) ? raw["tags"].filter((tag) => typeof tag === "string" && tag !== "") : void 0;
430
431
  return {
431
432
  id,
432
433
  name,
@@ -437,7 +438,8 @@ function parseUpstreamModel(value) {
437
438
  ...descriptionZh === void 0 ? {} : { descriptionZh },
438
439
  ...descriptionEn === void 0 ? {} : { descriptionEn },
439
440
  ...supportsToolCall === void 0 ? {} : { supportsToolCall },
440
- ...supportsImages === void 0 ? {} : { supportsImages }
441
+ ...supportsImages === void 0 ? {} : { supportsImages },
442
+ ...tags === void 0 || tags.length === 0 ? {} : { tags }
441
443
  };
442
444
  }
443
445
  /**
@@ -2321,6 +2323,41 @@ async function readCredential(path) {
2321
2323
  }
2322
2324
  }
2323
2325
  /**
2326
+ * The pool id a document would receive, WITHOUT decrypting anything.
2327
+ *
2328
+ * Used to honour the ignore list before the at-rest key lookup runs: `uin` and
2329
+ * `uid` are read as plain strings by {@link parseWorkBuddyAuth}, while
2330
+ * `nickname` is commonly encrypted — so this returns undefined for an account
2331
+ * whose identity lives only in the encrypted nickname, and the caller falls
2332
+ * back to the full parse for those.
2333
+ */
2334
+ function cheapIdentityId(text) {
2335
+ let parsed;
2336
+ try {
2337
+ parsed = JSON.parse(text);
2338
+ } catch {
2339
+ return;
2340
+ }
2341
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2342
+ const document = parsed;
2343
+ const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
2344
+ const uin = typeof identity["uin"] === "string" && identity["uin"] !== "" ? identity["uin"] : void 0;
2345
+ const uid = typeof identity["uid"] === "string" && identity["uid"] !== "" ? identity["uid"] : void 0;
2346
+ if (uin === void 0 && uid === void 0) return void 0;
2347
+ return workbuddyAccountId({
2348
+ ...uin === void 0 ? {} : { uin },
2349
+ ...uid === void 0 ? {} : { uid }
2350
+ });
2351
+ }
2352
+ /** {@link cheapIdentityId} for a file path; undefined when it cannot be read. */
2353
+ async function cheapIdentityIdFromFile(path) {
2354
+ try {
2355
+ return cheapIdentityId(await readFile(path, "utf8"));
2356
+ } catch {
2357
+ return;
2358
+ }
2359
+ }
2360
+ /**
2324
2361
  * Build the field opener, or undefined when the app cannot supply its key.
2325
2362
  *
2326
2363
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2399,6 +2436,21 @@ var WorkBuddyAccountPool = class {
2399
2436
  */
2400
2437
  disabledIds = /* @__PURE__ */ new Set();
2401
2438
  /**
2439
+ * Account ids the user threw out of the pool for good.
2440
+ *
2441
+ * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
2442
+ * identity is already ignored, so an ignored account costs no at-rest key
2443
+ * lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
2444
+ * when the app writes a fresh sign-in for it. That is the difference from
2445
+ * {@link disabledIds}, which only filters at pick time and leaves the account
2446
+ * listed, readable and re-discoverable.
2447
+ *
2448
+ * The set is supplied by the host from the plugin's own ignore file, and is
2449
+ * replaced wholesale on every {@link applyIgnored} so removing an entry takes
2450
+ * effect on the next scan without a restart.
2451
+ */
2452
+ ignoredIds = /* @__PURE__ */ new Set();
2453
+ /**
2402
2454
  * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
2403
2455
  * it all".
2404
2456
  *
@@ -2450,12 +2502,39 @@ var WorkBuddyAccountPool = class {
2450
2502
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
2451
2503
  if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
2452
2504
  }
2505
+ /**
2506
+ * Replace the permanent ignore list.
2507
+ *
2508
+ * Also drops any already-discovered account that is now ignored, so the change
2509
+ * is visible without waiting for the next scan: the card refreshes its status
2510
+ * document right after the write, and an account still sitting in `accounts`
2511
+ * would keep showing up there.
2512
+ */
2513
+ applyIgnored(ids) {
2514
+ this.ignoredIds = new Set(ids);
2515
+ if (this.ignoredIds.size === 0) return;
2516
+ this.accounts = this.accounts.filter((account) => !this.ignoredIds.has(account.id));
2517
+ }
2518
+ /** Whether this account has been thrown out of the pool for good. */
2519
+ isIgnored(accountId) {
2520
+ return this.ignoredIds.has(accountId);
2521
+ }
2522
+ /** Every ignored id currently in force, in insertion order. */
2523
+ ignoredIdsInOrder() {
2524
+ return [...this.ignoredIds];
2525
+ }
2453
2526
  /** Rescan the auth directories and merge newly discovered accounts. */
2454
2527
  async scan() {
2455
2528
  const found = [];
2456
2529
  for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
2530
+ if (this.ignoredIds.size > 0) {
2531
+ const cheapId = await cheapIdentityIdFromFile(file);
2532
+ if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
2533
+ }
2457
2534
  const credential = await readCredential(file);
2458
- if (credential !== void 0) found.push(credential);
2535
+ if (credential === void 0) continue;
2536
+ if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
2537
+ found.push(credential);
2459
2538
  }
2460
2539
  const byId = /* @__PURE__ */ new Map();
2461
2540
  for (const account of this.accounts) byId.set(account.id, account);
@@ -2796,28 +2875,44 @@ ${new Date(until).toISOString()}
2796
2875
  };
2797
2876
  //#endregion
2798
2877
  //#region src/catalog.ts
2799
- /** Static fallback used before the first live catalog fetch. */
2878
+ /**
2879
+ * Static fallback used before the first live catalog fetch, and whenever the
2880
+ * upstream cannot be reached.
2881
+ *
2882
+ * The multipliers are carried on purpose. Without them the provider's model
2883
+ * picker silently loses every rate and every free badge the moment the live
2884
+ * fetch fails — which reads to the user as "the plugin broke my model list"
2885
+ * rather than "the upstream is unreachable". The values are the ones the two
2886
+ * gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
2887
+ * on), so a fallback row looks the same as a live one.
2888
+ *
2889
+ * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
2890
+ * which is what turns on the free badge.
2891
+ */
2800
2892
  const FALLBACK_WORKBUDDY_MODELS = [
2801
2893
  {
2802
2894
  id: "glm-5.3",
2803
2895
  name: "GLM-5.3",
2804
2896
  contextWindow: 2e5,
2805
2897
  maxOutputTokens: 128e3,
2806
- supportsImages: true
2898
+ supportsImages: true,
2899
+ multiplier: .79
2807
2900
  },
2808
2901
  {
2809
2902
  id: "glm-5.3-flash",
2810
2903
  name: "GLM-5.3-Flash",
2811
2904
  contextWindow: 2e5,
2812
2905
  maxOutputTokens: 128e3,
2813
- supportsImages: true
2906
+ supportsImages: true,
2907
+ multiplier: .06
2814
2908
  },
2815
2909
  {
2816
2910
  id: "glm-5.2",
2817
2911
  name: "GLM-5.2",
2818
2912
  contextWindow: 2e5,
2819
2913
  maxOutputTokens: 128e3,
2820
- supportsImages: true
2914
+ supportsImages: true,
2915
+ multiplier: .79
2821
2916
  },
2822
2917
  {
2823
2918
  id: "glm-5.1",
@@ -2845,7 +2940,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
2845
2940
  name: "Kimi-K3",
2846
2941
  contextWindow: 2e5,
2847
2942
  maxOutputTokens: 128e3,
2848
- supportsImages: true
2943
+ supportsImages: true,
2944
+ multiplier: 1.62
2849
2945
  },
2850
2946
  {
2851
2947
  id: "minimax-m3",
@@ -2859,14 +2955,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
2859
2955
  name: "Hy3",
2860
2956
  contextWindow: 32e3,
2861
2957
  maxOutputTokens: 8e3,
2862
- supportsImages: true
2958
+ supportsImages: true,
2959
+ multiplier: 0
2863
2960
  },
2864
2961
  {
2865
2962
  id: "hy4-preview",
2866
2963
  name: "Hy4-Preview",
2867
2964
  contextWindow: 1e6,
2868
2965
  maxOutputTokens: 128e3,
2869
- supportsImages: true
2966
+ supportsImages: true,
2967
+ multiplier: .29
2870
2968
  }
2871
2969
  ];
2872
2970
  /** Live catalog with a static fallback behind it. */
@@ -2944,7 +3042,8 @@ function toModelInfo(model) {
2944
3042
  maxOutputTokens: model.maxTokens,
2945
3043
  supportsImages: model.supportsImages ?? false,
2946
3044
  ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
2947
- ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
3045
+ ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts },
3046
+ ...model.tags === void 0 || model.tags.length === 0 ? {} : { tags: model.tags }
2948
3047
  };
2949
3048
  }
2950
3049
  /** Map the live upstream list, falling back to the static list when empty. */
@@ -3358,6 +3457,30 @@ function expertChatEvents(expert, conversationId, requestId) {
3358
3457
  expert_industry_id: ""
3359
3458
  } : event);
3360
3459
  }
3460
+ //#endregion
3461
+ //#region src/status-paths.ts
3462
+ /**
3463
+ * The schedule every automation job falls back to.
3464
+ *
3465
+ * Shared by both halves on purpose. The host uses it when a configured hour
3466
+ * list arrives empty (the settings schema materializes "never configured" into
3467
+ * `[]`), and the card uses it when it writes the `automation` block back, so a
3468
+ * document that already holds an empty list is healed instead of being saved
3469
+ * back as an unrunnable schedule.
3470
+ *
3471
+ * This lives here rather than in `scheduler.ts` because the browser half cannot
3472
+ * import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
3473
+ * upstream client, none of which exists in the browser bundle. Two hand-written
3474
+ * copies would drift, and the drift is invisible — the card would write a
3475
+ * schedule the scheduler does not run.
3476
+ */
3477
+ const DEFAULT_AUTOMATION_HOURS = {
3478
+ checkin: [9],
3479
+ report: [10],
3480
+ tasks: [11],
3481
+ streak: [12],
3482
+ travel: [9, 21]
3483
+ };
3361
3484
  /**
3362
3485
  * The fallback record for the 腾讯轻量云 expert, used when the marketplace
3363
3486
  * listing cannot be read. The id is the one the task is scored against.
@@ -3401,6 +3524,23 @@ const JOB_KINDS = [
3401
3524
  "streak",
3402
3525
  "travel"
3403
3526
  ];
3527
+ /**
3528
+ * Pick the hour list to run on: the configured one, or the default.
3529
+ *
3530
+ * An EMPTY list is treated as "not configured" rather than as "never run".
3531
+ * That distinction is the whole point: the settings schema materializes an
3532
+ * absent list into `[]` (a bare `z.array` with no `.default()`), so a config
3533
+ * that never mentioned the schedule arrives here looking exactly like one
3534
+ * deliberately set to nothing — and honouring the empty reading silently
3535
+ * disabled every job while the card still showed the switch as ON.
3536
+ *
3537
+ * A user who genuinely wants a job skipped can leave it out of the schedule
3538
+ * they save; they cannot express "off" with an empty array through the card
3539
+ * either way, because the card always writes a non-empty list.
3540
+ */
3541
+ function hoursOrDefault(configured, fallback) {
3542
+ return configured !== void 0 && configured.length > 0 ? configured : fallback;
3543
+ }
3404
3544
  const EMPTY_JOB_STATE = {
3405
3545
  ok: 0,
3406
3546
  failed: 0,
@@ -3576,11 +3716,11 @@ var WorkBuddyScheduler = class {
3576
3716
  this.earningsDate = today;
3577
3717
  }
3578
3718
  this.enabled = options.enabled ?? false;
3579
- this.checkinHours = options.checkinHours ?? [9];
3580
- this.reportHours = options.reportHours ?? [10];
3581
- this.taskHours = options.taskHours ?? [11];
3582
- this.streakHours = options.streakHours ?? [12];
3583
- this.travelHours = options.travelHours ?? [9, 21];
3719
+ this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
3720
+ this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
3721
+ this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
3722
+ this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
3723
+ this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
3584
3724
  }
3585
3725
  /** Apply a new configuration; safe to call while running. */
3586
3726
  /**
@@ -3608,11 +3748,11 @@ var WorkBuddyScheduler = class {
3608
3748
  }
3609
3749
  applyConfig(options) {
3610
3750
  if (options.enabled !== void 0) this.enabled = options.enabled;
3611
- if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
3612
- if (options.reportHours !== void 0) this.reportHours = options.reportHours;
3613
- if (options.taskHours !== void 0) this.taskHours = options.taskHours;
3614
- if (options.streakHours !== void 0) this.streakHours = options.streakHours;
3615
- if (options.travelHours !== void 0) this.travelHours = options.travelHours;
3751
+ if (options.checkinHours !== void 0) this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
3752
+ if (options.reportHours !== void 0) this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
3753
+ if (options.taskHours !== void 0) this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
3754
+ if (options.streakHours !== void 0) this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
3755
+ if (options.travelHours !== void 0) this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
3616
3756
  }
3617
3757
  /** Hours for one job, used by the loop and the status document. */
3618
3758
  hoursOf(kind) {
@@ -3658,7 +3798,7 @@ var WorkBuddyScheduler = class {
3658
3798
  async runNow(kind, force = false) {
3659
3799
  const today = dayKey(this.now());
3660
3800
  const state = this.states[kind];
3661
- if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
3801
+ if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
3662
3802
  this.busy = true;
3663
3803
  try {
3664
3804
  await this.runJob(kind, today);
@@ -3756,13 +3896,46 @@ var WorkBuddyScheduler = class {
3756
3896
  * two hours still runs twice a day — but a job whose hour passed while DSH was
3757
3897
  * closed runs immediately on the next tick instead of waiting for tomorrow.
3758
3898
  */
3759
- isDue(kind, now) {
3899
+ /**
3900
+ * The configured slot `kind` should consume at `now`, or undefined when none.
3901
+ *
3902
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
3903
+ * same value it acted on. Returning a boolean was the original bug's enabler:
3904
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
3905
+ * it now" — and those two answers were almost never equal.
3906
+ *
3907
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
3908
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
3909
+ * 10:00 still runs the job when it wakes, on the same day.
3910
+ *
3911
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
3912
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
3913
+ * later one for its own hour.
3914
+ */
3915
+ dueSlot(kind, now) {
3760
3916
  const hours = this.hoursOf(kind);
3761
- if (hours.length === 0) return false;
3762
- const { hour } = zonedParts(now);
3763
- const current = Number(hour);
3917
+ if (hours.length === 0) return void 0;
3918
+ const current = Number(zonedParts(now).hour);
3764
3919
  const today = dayKey(now);
3765
- return hours.some((candidate) => candidate <= current && this.states[kind].lastRunSlot !== `${today}T${String(candidate).padStart(2, "0")}`);
3920
+ const fired = this.firedSlotsOf(kind);
3921
+ for (const candidate of [...hours].sort((a, b) => a - b)) {
3922
+ if (candidate > current) break;
3923
+ const slot = `${today}T${String(candidate).padStart(2, "0")}`;
3924
+ if (!fired.has(slot)) return slot;
3925
+ }
3926
+ }
3927
+ /** Today's consumed slots for one job, as a set. */
3928
+ firedSlotsOf(kind) {
3929
+ const stored = this.states[kind].firedSlots;
3930
+ if (stored === void 0) return /* @__PURE__ */ new Set();
3931
+ const today = dayKey(this.now());
3932
+ return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
3933
+ }
3934
+ /** Record one consumed slot on a job's state. */
3935
+ consumeSlot(kind, slot) {
3936
+ const state = this.states[kind];
3937
+ const today = dayKey(this.now());
3938
+ state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
3766
3939
  }
3767
3940
  async tick() {
3768
3941
  if (!this.enabled || this.stopped || this.busy) return;
@@ -3773,9 +3946,10 @@ var WorkBuddyScheduler = class {
3773
3946
  const today = dayKey(now);
3774
3947
  for (const kind of JOB_KINDS) {
3775
3948
  if (this.stopped) return;
3776
- if (this.states[kind].lastRunSlot === slot) continue;
3777
- if (!this.isDue(kind, now)) continue;
3778
- await this.runJob(kind, today);
3949
+ if (this.states[kind].lastFiredHour === slot) continue;
3950
+ const consumed = this.dueSlot(kind, now);
3951
+ if (consumed === void 0) continue;
3952
+ await this.runJob(kind, today, consumed);
3779
3953
  }
3780
3954
  } catch (error) {
3781
3955
  this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
@@ -3861,6 +4035,12 @@ var WorkBuddyScheduler = class {
3861
4035
  /**
3862
4036
  * Run one job against every eligible account and record the outcome.
3863
4037
  *
4038
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
4039
+ * It is passed IN rather than recomputed here so the value recorded is exactly
4040
+ * the value the schedule decided on — the defect this replaces wrote
4041
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
4042
+ * almost never equals the configured candidate the gate had compared against.
4043
+ *
3864
4044
  * The task job runs in TWO passes. The first sends the event chains that light
3865
4045
  * up client-scored tasks; the second collects rewards. They are separate
3866
4046
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -3868,7 +4048,7 @@ var WorkBuddyScheduler = class {
3868
4048
  * while claiming wants the whole pool to have been lit up first. Splitting
3869
4049
  * them costs one shared wait instead of one wait per account.
3870
4050
  */
3871
- async runJob(kind, today) {
4051
+ async runJob(kind, today, consumedSlot) {
3872
4052
  const accountWord = kind === "report" ? "report" : kind;
3873
4053
  let ok = 0;
3874
4054
  let failed = 0;
@@ -3933,7 +4113,8 @@ var WorkBuddyScheduler = class {
3933
4113
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3934
4114
  }
3935
4115
  const state = this.states[kind];
3936
- state.lastRunSlot = slotKey(this.now());
4116
+ if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
4117
+ state.lastFiredHour = slotKey(this.now());
3937
4118
  state.lastRunDate = today;
3938
4119
  state.lastRunAtMs = this.now().getTime();
3939
4120
  state.ok = ok;
@@ -4275,6 +4456,137 @@ function formatRates(status) {
4275
4456
  return lines.join("\n");
4276
4457
  }
4277
4458
  //#endregion
4459
+ //#region src/ignored.ts
4460
+ /**
4461
+ * The permanent ignore list: accounts the user has thrown out of the pool.
4462
+ *
4463
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
4464
+ * account stays on the card, can be switched back on, and its credential keeps
4465
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
4466
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
4467
+ * credential is decrypted, never appears among the pool's accounts, and does not
4468
+ * come back when the desktop app writes a fresh `.info` file for it.
4469
+ *
4470
+ * It lives in a plugin-owned file rather than in the settings document for one
4471
+ * practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
4472
+ * ignore <id>` runs without a host, so a settings-only list could be written by
4473
+ * the card and never by the CLI. One file, read by both halves, keeps the two
4474
+ * views of "which accounts are ignored" identical by construction.
4475
+ *
4476
+ * @module dsh-workbuddy-xdpool/ignored
4477
+ */
4478
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
4479
+ const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
4480
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
4481
+ const IGNORED_FILE_NAME = "ignored.json";
4482
+ /**
4483
+ * The DSH home directory, honouring the same override the host uses.
4484
+ *
4485
+ * Shared by the CLI and the host so both halves resolve the same file: an
4486
+ * `ignore` written from the terminal has to be visible to the running plugin,
4487
+ * which is only true if they agree on where "home" is.
4488
+ */
4489
+ function dshHome$1(env = process.env) {
4490
+ const fromEnv = env["DSH_HOME"];
4491
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
4492
+ return join(homedir(), ".dsh");
4493
+ }
4494
+ /** This plugin's own state directory. */
4495
+ function pluginDataDir(env = process.env) {
4496
+ return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
4497
+ }
4498
+ /** Absolute path of the ignore list. */
4499
+ function ignoredIdsPath(env = process.env) {
4500
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
4501
+ }
4502
+ /**
4503
+ * Normalize whatever the file holds into a clean list.
4504
+ *
4505
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
4506
+ * take the pool down. An entry without a usable id is dropped; a missing label
4507
+ * falls back to the id so the card always has something to print.
4508
+ */
4509
+ function normalize(raw) {
4510
+ if (typeof raw !== "object" || raw === null) return [];
4511
+ const entries = raw.accounts;
4512
+ if (!Array.isArray(entries)) return [];
4513
+ const out = [];
4514
+ const seen = /* @__PURE__ */ new Set();
4515
+ for (const entry of entries) {
4516
+ if (typeof entry !== "object" || entry === null) continue;
4517
+ const record = entry;
4518
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
4519
+ if (id === "" || seen.has(id)) continue;
4520
+ seen.add(id);
4521
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
4522
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
4523
+ out.push({
4524
+ id,
4525
+ label,
4526
+ ignoredAt
4527
+ });
4528
+ }
4529
+ return out;
4530
+ }
4531
+ /**
4532
+ * Read the ignore list, tolerating every "no list yet" shape.
4533
+ *
4534
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
4535
+ * the caller — nothing is ignored — so none of them throws. The pool must be
4536
+ * able to start on a machine that has never ignored anything.
4537
+ */
4538
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
4539
+ try {
4540
+ return normalize(JSON.parse(await readFile(path, "utf8")));
4541
+ } catch {
4542
+ return [];
4543
+ }
4544
+ }
4545
+ /**
4546
+ * Replace the ignore list, atomically.
4547
+ *
4548
+ * Written to a sibling temp file and renamed over the target so a crash (or a
4549
+ * concurrent reader) can never observe a half-written document — the ignore
4550
+ * list is the only thing standing between a dead account and the rotation, and
4551
+ * a truncated file reads as "nothing is ignored", which would quietly put every
4552
+ * discarded account back in the pool.
4553
+ */
4554
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
4555
+ const payload = JSON.stringify({
4556
+ version: 1,
4557
+ accounts
4558
+ }, null, 2);
4559
+ await mkdir(dirname(path), { recursive: true });
4560
+ const temp = `${path}.tmp`;
4561
+ await writeFile$1(temp, `${payload}\n`, "utf8");
4562
+ await rename(temp, path);
4563
+ }
4564
+ /**
4565
+ * Add one account to the ignore list, preserving the rest.
4566
+ *
4567
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
4568
+ * both be open, and each request names exactly one account, so re-writing the
4569
+ * whole list from a stale view would drop the other side's edits.
4570
+ */
4571
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
4572
+ const current = await readIgnoredAccounts(path);
4573
+ if (current.some((entry) => entry.id === account.id)) return current;
4574
+ const next = [...current, {
4575
+ id: account.id,
4576
+ label: account.label ?? account.id,
4577
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
4578
+ }];
4579
+ await writeIgnoredAccounts(next, path);
4580
+ return next;
4581
+ }
4582
+ /** Drop one account from the ignore list. Returns the resulting list. */
4583
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
4584
+ const current = await readIgnoredAccounts(path);
4585
+ const next = current.filter((entry) => entry.id !== accountId);
4586
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
4587
+ return next;
4588
+ }
4589
+ //#endregion
4278
4590
  //#region src/index.ts
4279
4591
  /**
4280
4592
  * One region's model-selection schema.
@@ -4376,6 +4688,20 @@ function createCore(logger) {
4376
4688
  */
4377
4689
  /** Directory holding imported account snapshots. */
4378
4690
  const ACCOUNT_DIR_NAME = ".workbuddy-xdpool";
4691
+ /**
4692
+ * Assemble the runtime for a CLI command, with the ignore list applied.
4693
+ *
4694
+ * Every command goes through here rather than calling `createCore()` directly,
4695
+ * so a command can never accidentally act on an account the user has thrown out
4696
+ * — `checkin all` collecting a reward for a discarded account would be exactly
4697
+ * the kind of silent surprise the ignore feature exists to prevent.
4698
+ */
4699
+ async function cliCore() {
4700
+ const core = createCore();
4701
+ const ignored = await readIgnoredAccounts();
4702
+ core.pool.applyIgnored(ignored.map((entry) => entry.id));
4703
+ return core;
4704
+ }
4379
4705
  /** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
4380
4706
  function snapshotPath(key, dir) {
4381
4707
  return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
@@ -4400,6 +4726,9 @@ function usage() {
4400
4726
  " accounts List discovered accounts (add --json)",
4401
4727
  " import <key> Snapshot the current desktop login as <key> (add --force)",
4402
4728
  " remove <key> Delete one imported snapshot",
4729
+ " ignore <acct> Drop an account from the pool for good (id or label)",
4730
+ " unignore <acct> Put an ignored account back into the pool",
4731
+ " ignored List the accounts dropped from the pool (add --json)",
4403
4732
  " login Guide for adding another account (desktop app is single-sign-in)",
4404
4733
  " checkin [all|<acct>] Daily check-in: report status, or collect with `all` / a label",
4405
4734
  " reset Clear all rate-limit cooldowns immediately",
@@ -4428,7 +4757,7 @@ async function commandStatus(args) {
4428
4757
  const asJson = args.includes("--json");
4429
4758
  const withCredits = args.includes("--credits");
4430
4759
  const withRates = args.includes("--rates");
4431
- const core = createCore();
4760
+ const core = await cliCore();
4432
4761
  const accounts = await core.pool.scan();
4433
4762
  const status = {
4434
4763
  ok: accounts.length > 0,
@@ -4489,7 +4818,7 @@ async function commandStatus(args) {
4489
4818
  async function commandCheckin(args) {
4490
4819
  const asJson = args.includes("--json");
4491
4820
  const target = args.find((arg) => !arg.startsWith("--"));
4492
- const core = createCore();
4821
+ const core = await cliCore();
4493
4822
  if ((await core.pool.scan()).length === 0) {
4494
4823
  console.error("No WorkBuddy account discovered. Sign in with the WorkBuddy desktop app first.");
4495
4824
  return 1;
@@ -4573,7 +4902,7 @@ async function commandDoctor() {
4573
4902
  lines.push(` ✓ ${dir} → ${snapshots.length} snapshot(s)`);
4574
4903
  }
4575
4904
  lines.push("");
4576
- const accounts = await createCore().pool.scan();
4905
+ const accounts = await (await cliCore()).pool.scan();
4577
4906
  lines.push(`Accounts discovered: ${accounts.length}`);
4578
4907
  if (accounts.length === 0) {
4579
4908
  healthy = false;
@@ -4651,6 +4980,66 @@ async function commandRemove(args) {
4651
4980
  return 1;
4652
4981
  }
4653
4982
  }
4983
+ /**
4984
+ * Throw one account out of the pool for good, or take it back.
4985
+ *
4986
+ * The target may be a full account id (as `accounts --json` prints it) or any
4987
+ * unambiguous fragment of the label, so the user does not have to copy a hash.
4988
+ * The id is what gets stored — a label can change when the account is renamed,
4989
+ * and an ignore list keyed by a mutable label would silently stop matching.
4990
+ */
4991
+ async function commandIgnore(args, ignored) {
4992
+ const verb = ignored ? "ignore" : "unignore";
4993
+ const target = args.filter((arg) => !arg.startsWith("--"))[0];
4994
+ if (target === void 0) {
4995
+ console.error(`usage: dsh-workbuddy-xdpool ${verb} <account-id|label>`);
4996
+ return 2;
4997
+ }
4998
+ const accounts = await (await cliCore()).pool.scan();
4999
+ const current = await readIgnoredAccounts();
5000
+ if (!ignored) {
5001
+ const match = current.find((entry) => entry.id === target) ?? current.find((entry) => entry.label === target) ?? current.find((entry) => entry.id.startsWith(target));
5002
+ if (match === void 0) {
5003
+ console.error(`No ignored account matches "${target}". Run \`ignored\` to list them.`);
5004
+ return 1;
5005
+ }
5006
+ await unignoreAccount(match.id);
5007
+ console.log(`Restored ${match.label} (${match.id}). It rejoins the pool on the next scan.`);
5008
+ return 0;
5009
+ }
5010
+ const matches = accounts.filter((account) => account.id === target || account.label === target || account.id.startsWith(target) || account.label.includes(target));
5011
+ if (matches.length === 0) {
5012
+ console.error(`No account matches "${target}". Run \`accounts\` to list them.`);
5013
+ return 1;
5014
+ }
5015
+ if (matches.length > 1) {
5016
+ console.error(`"${target}" matches ${matches.length} accounts; use the full id:\n` + matches.map((account) => ` ${account.id} ${account.label}`).join("\n"));
5017
+ return 1;
5018
+ }
5019
+ const account = matches[0];
5020
+ await ignoreAccount({
5021
+ id: account.id,
5022
+ label: account.label
5023
+ });
5024
+ console.log(`Ignored ${account.label} (${account.id}).\n Its credential is no longer read and it will not rejoin the pool, even if the
5025
+ desktop app signs it in again. Undo with: dsh-workbuddy-xdpool unignore ${account.id}`);
5026
+ return 0;
5027
+ }
5028
+ /** List the accounts currently thrown out of the pool. */
5029
+ async function commandIgnored(args) {
5030
+ const asJson = args.includes("--json");
5031
+ const ignored = await readIgnoredAccounts();
5032
+ if (asJson) {
5033
+ console.log(JSON.stringify(ignored, null, 2));
5034
+ return 0;
5035
+ }
5036
+ if (ignored.length === 0) {
5037
+ console.log("No accounts are ignored. Use `ignore <account-id|label>` to drop one.");
5038
+ return 0;
5039
+ }
5040
+ console.log(ignored.map((entry) => `⛔ ${entry.label} (${entry.id})`).join("\n"));
5041
+ return 0;
5042
+ }
4654
5043
  function commandLogin() {
4655
5044
  console.log([
4656
5045
  "Adding another WorkBuddy account",
@@ -4670,7 +5059,7 @@ function commandLogin() {
4670
5059
  return 0;
4671
5060
  }
4672
5061
  async function commandReset() {
4673
- const core = createCore();
5062
+ const core = await cliCore();
4674
5063
  await core.pool.scan();
4675
5064
  core.pool.resetCooldowns();
4676
5065
  console.log("Cleared all rate-limit cooldowns.");
@@ -4678,15 +5067,25 @@ async function commandReset() {
4678
5067
  }
4679
5068
  async function commandAccounts(args) {
4680
5069
  const asJson = args.includes("--json");
4681
- const accounts = await createCore().pool.scan();
4682
- if (asJson) console.log(JSON.stringify(accounts.map((account) => ({
5070
+ const accounts = await (await cliCore()).pool.scan();
5071
+ const ignored = await readIgnoredAccounts();
5072
+ if (asJson) console.log(JSON.stringify([...accounts.map((account) => ({
4683
5073
  id: account.id,
4684
5074
  label: account.label,
4685
5075
  cooling: account.cooldownUntilMs > Date.now(),
4686
5076
  rateLimitHits: account.rateLimitHits
4687
- })), null, 2));
4688
- else if (accounts.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
4689
- else console.log(accounts.map((account) => `${account.cooldownUntilMs > Date.now() ? "⏸" : "▶"} ${account.label}`).join("\n"));
5077
+ })), ...ignored.map((entry) => ({
5078
+ id: entry.id,
5079
+ label: entry.label,
5080
+ ignored: true,
5081
+ ignoredAt: entry.ignoredAt
5082
+ }))], null, 2));
5083
+ else if (accounts.length === 0 && ignored.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
5084
+ else {
5085
+ const lines = accounts.map((account) => `${account.cooldownUntilMs > Date.now() ? "⏸" : "▶"} ${account.label}`);
5086
+ if (ignored.length > 0) lines.push(...ignored.map((entry) => `⛔ ${entry.label} (removed; \`unignore ${entry.id}\` to restore)`));
5087
+ console.log(lines.join("\n"));
5088
+ }
4690
5089
  return 0;
4691
5090
  }
4692
5091
  /** Entry point; returns the process exit code. */
@@ -4704,6 +5103,9 @@ async function main(argv) {
4704
5103
  case "accounts": return commandAccounts(rest);
4705
5104
  case "import": return commandImport(rest);
4706
5105
  case "remove": return commandRemove(rest);
5106
+ case "ignore": return commandIgnore(rest, true);
5107
+ case "unignore": return commandIgnore(rest, false);
5108
+ case "ignored": return commandIgnored(rest);
4707
5109
  case "login": return commandLogin();
4708
5110
  case "logout": return commandRemove(["default", ...rest]);
4709
5111
  case "reset": return commandReset();