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/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) {
@@ -4275,6 +4415,137 @@ function formatRates(status) {
4275
4415
  return lines.join("\n");
4276
4416
  }
4277
4417
  //#endregion
4418
+ //#region src/ignored.ts
4419
+ /**
4420
+ * The permanent ignore list: accounts the user has thrown out of the pool.
4421
+ *
4422
+ * Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
4423
+ * account stays on the card, can be switched back on, and its credential keeps
4424
+ * being read on every scan. Ignoring is a statement about the ACCOUNT — "this
4425
+ * one is not mine any more" — so an ignored account is skipped BEFORE its
4426
+ * credential is decrypted, never appears among the pool's accounts, and does not
4427
+ * come back when the desktop app writes a fresh `.info` file for it.
4428
+ *
4429
+ * It lives in a plugin-owned file rather than in the settings document for one
4430
+ * practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
4431
+ * ignore <id>` runs without a host, so a settings-only list could be written by
4432
+ * the card and never by the CLI. One file, read by both halves, keeps the two
4433
+ * views of "which accounts are ignored" identical by construction.
4434
+ *
4435
+ * @module dsh-workbuddy-xdpool/ignored
4436
+ */
4437
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
4438
+ const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
4439
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
4440
+ const IGNORED_FILE_NAME = "ignored.json";
4441
+ /**
4442
+ * The DSH home directory, honouring the same override the host uses.
4443
+ *
4444
+ * Shared by the CLI and the host so both halves resolve the same file: an
4445
+ * `ignore` written from the terminal has to be visible to the running plugin,
4446
+ * which is only true if they agree on where "home" is.
4447
+ */
4448
+ function dshHome$1(env = process.env) {
4449
+ const fromEnv = env["DSH_HOME"];
4450
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
4451
+ return join(homedir(), ".dsh");
4452
+ }
4453
+ /** This plugin's own state directory. */
4454
+ function pluginDataDir(env = process.env) {
4455
+ return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
4456
+ }
4457
+ /** Absolute path of the ignore list. */
4458
+ function ignoredIdsPath(env = process.env) {
4459
+ return join(pluginDataDir(env), IGNORED_FILE_NAME);
4460
+ }
4461
+ /**
4462
+ * Normalize whatever the file holds into a clean list.
4463
+ *
4464
+ * Tolerant on purpose: this file is hand-editable and a malformed entry must not
4465
+ * take the pool down. An entry without a usable id is dropped; a missing label
4466
+ * falls back to the id so the card always has something to print.
4467
+ */
4468
+ function normalize(raw) {
4469
+ if (typeof raw !== "object" || raw === null) return [];
4470
+ const entries = raw.accounts;
4471
+ if (!Array.isArray(entries)) return [];
4472
+ const out = [];
4473
+ const seen = /* @__PURE__ */ new Set();
4474
+ for (const entry of entries) {
4475
+ if (typeof entry !== "object" || entry === null) continue;
4476
+ const record = entry;
4477
+ const id = typeof record["id"] === "string" ? record["id"].trim() : "";
4478
+ if (id === "" || seen.has(id)) continue;
4479
+ seen.add(id);
4480
+ const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
4481
+ const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
4482
+ out.push({
4483
+ id,
4484
+ label,
4485
+ ignoredAt
4486
+ });
4487
+ }
4488
+ return out;
4489
+ }
4490
+ /**
4491
+ * Read the ignore list, tolerating every "no list yet" shape.
4492
+ *
4493
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
4494
+ * the caller — nothing is ignored — so none of them throws. The pool must be
4495
+ * able to start on a machine that has never ignored anything.
4496
+ */
4497
+ async function readIgnoredAccounts(path = ignoredIdsPath()) {
4498
+ try {
4499
+ return normalize(JSON.parse(await readFile(path, "utf8")));
4500
+ } catch {
4501
+ return [];
4502
+ }
4503
+ }
4504
+ /**
4505
+ * Replace the ignore list, atomically.
4506
+ *
4507
+ * Written to a sibling temp file and renamed over the target so a crash (or a
4508
+ * concurrent reader) can never observe a half-written document — the ignore
4509
+ * list is the only thing standing between a dead account and the rotation, and
4510
+ * a truncated file reads as "nothing is ignored", which would quietly put every
4511
+ * discarded account back in the pool.
4512
+ */
4513
+ async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
4514
+ const payload = JSON.stringify({
4515
+ version: 1,
4516
+ accounts
4517
+ }, null, 2);
4518
+ await mkdir(dirname(path), { recursive: true });
4519
+ const temp = `${path}.tmp`;
4520
+ await writeFile$1(temp, `${payload}\n`, "utf8");
4521
+ await rename(temp, path);
4522
+ }
4523
+ /**
4524
+ * Add one account to the ignore list, preserving the rest.
4525
+ *
4526
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
4527
+ * both be open, and each request names exactly one account, so re-writing the
4528
+ * whole list from a stale view would drop the other side's edits.
4529
+ */
4530
+ async function ignoreAccount(account, path = ignoredIdsPath()) {
4531
+ const current = await readIgnoredAccounts(path);
4532
+ if (current.some((entry) => entry.id === account.id)) return current;
4533
+ const next = [...current, {
4534
+ id: account.id,
4535
+ label: account.label ?? account.id,
4536
+ ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
4537
+ }];
4538
+ await writeIgnoredAccounts(next, path);
4539
+ return next;
4540
+ }
4541
+ /** Drop one account from the ignore list. Returns the resulting list. */
4542
+ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
4543
+ const current = await readIgnoredAccounts(path);
4544
+ const next = current.filter((entry) => entry.id !== accountId);
4545
+ if (next.length !== current.length) await writeIgnoredAccounts(next, path);
4546
+ return next;
4547
+ }
4548
+ //#endregion
4278
4549
  //#region src/index.ts
4279
4550
  /**
4280
4551
  * One region's model-selection schema.
@@ -4376,6 +4647,20 @@ function createCore(logger) {
4376
4647
  */
4377
4648
  /** Directory holding imported account snapshots. */
4378
4649
  const ACCOUNT_DIR_NAME = ".workbuddy-xdpool";
4650
+ /**
4651
+ * Assemble the runtime for a CLI command, with the ignore list applied.
4652
+ *
4653
+ * Every command goes through here rather than calling `createCore()` directly,
4654
+ * so a command can never accidentally act on an account the user has thrown out
4655
+ * — `checkin all` collecting a reward for a discarded account would be exactly
4656
+ * the kind of silent surprise the ignore feature exists to prevent.
4657
+ */
4658
+ async function cliCore() {
4659
+ const core = createCore();
4660
+ const ignored = await readIgnoredAccounts();
4661
+ core.pool.applyIgnored(ignored.map((entry) => entry.id));
4662
+ return core;
4663
+ }
4379
4664
  /** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
4380
4665
  function snapshotPath(key, dir) {
4381
4666
  return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
@@ -4400,6 +4685,9 @@ function usage() {
4400
4685
  " accounts List discovered accounts (add --json)",
4401
4686
  " import <key> Snapshot the current desktop login as <key> (add --force)",
4402
4687
  " remove <key> Delete one imported snapshot",
4688
+ " ignore <acct> Drop an account from the pool for good (id or label)",
4689
+ " unignore <acct> Put an ignored account back into the pool",
4690
+ " ignored List the accounts dropped from the pool (add --json)",
4403
4691
  " login Guide for adding another account (desktop app is single-sign-in)",
4404
4692
  " checkin [all|<acct>] Daily check-in: report status, or collect with `all` / a label",
4405
4693
  " reset Clear all rate-limit cooldowns immediately",
@@ -4428,7 +4716,7 @@ async function commandStatus(args) {
4428
4716
  const asJson = args.includes("--json");
4429
4717
  const withCredits = args.includes("--credits");
4430
4718
  const withRates = args.includes("--rates");
4431
- const core = createCore();
4719
+ const core = await cliCore();
4432
4720
  const accounts = await core.pool.scan();
4433
4721
  const status = {
4434
4722
  ok: accounts.length > 0,
@@ -4489,7 +4777,7 @@ async function commandStatus(args) {
4489
4777
  async function commandCheckin(args) {
4490
4778
  const asJson = args.includes("--json");
4491
4779
  const target = args.find((arg) => !arg.startsWith("--"));
4492
- const core = createCore();
4780
+ const core = await cliCore();
4493
4781
  if ((await core.pool.scan()).length === 0) {
4494
4782
  console.error("No WorkBuddy account discovered. Sign in with the WorkBuddy desktop app first.");
4495
4783
  return 1;
@@ -4573,7 +4861,7 @@ async function commandDoctor() {
4573
4861
  lines.push(` ✓ ${dir} → ${snapshots.length} snapshot(s)`);
4574
4862
  }
4575
4863
  lines.push("");
4576
- const accounts = await createCore().pool.scan();
4864
+ const accounts = await (await cliCore()).pool.scan();
4577
4865
  lines.push(`Accounts discovered: ${accounts.length}`);
4578
4866
  if (accounts.length === 0) {
4579
4867
  healthy = false;
@@ -4651,6 +4939,66 @@ async function commandRemove(args) {
4651
4939
  return 1;
4652
4940
  }
4653
4941
  }
4942
+ /**
4943
+ * Throw one account out of the pool for good, or take it back.
4944
+ *
4945
+ * The target may be a full account id (as `accounts --json` prints it) or any
4946
+ * unambiguous fragment of the label, so the user does not have to copy a hash.
4947
+ * The id is what gets stored — a label can change when the account is renamed,
4948
+ * and an ignore list keyed by a mutable label would silently stop matching.
4949
+ */
4950
+ async function commandIgnore(args, ignored) {
4951
+ const verb = ignored ? "ignore" : "unignore";
4952
+ const target = args.filter((arg) => !arg.startsWith("--"))[0];
4953
+ if (target === void 0) {
4954
+ console.error(`usage: dsh-workbuddy-xdpool ${verb} <account-id|label>`);
4955
+ return 2;
4956
+ }
4957
+ const accounts = await (await cliCore()).pool.scan();
4958
+ const current = await readIgnoredAccounts();
4959
+ if (!ignored) {
4960
+ const match = current.find((entry) => entry.id === target) ?? current.find((entry) => entry.label === target) ?? current.find((entry) => entry.id.startsWith(target));
4961
+ if (match === void 0) {
4962
+ console.error(`No ignored account matches "${target}". Run \`ignored\` to list them.`);
4963
+ return 1;
4964
+ }
4965
+ await unignoreAccount(match.id);
4966
+ console.log(`Restored ${match.label} (${match.id}). It rejoins the pool on the next scan.`);
4967
+ return 0;
4968
+ }
4969
+ const matches = accounts.filter((account) => account.id === target || account.label === target || account.id.startsWith(target) || account.label.includes(target));
4970
+ if (matches.length === 0) {
4971
+ console.error(`No account matches "${target}". Run \`accounts\` to list them.`);
4972
+ return 1;
4973
+ }
4974
+ if (matches.length > 1) {
4975
+ console.error(`"${target}" matches ${matches.length} accounts; use the full id:\n` + matches.map((account) => ` ${account.id} ${account.label}`).join("\n"));
4976
+ return 1;
4977
+ }
4978
+ const account = matches[0];
4979
+ await ignoreAccount({
4980
+ id: account.id,
4981
+ label: account.label
4982
+ });
4983
+ console.log(`Ignored ${account.label} (${account.id}).\n Its credential is no longer read and it will not rejoin the pool, even if the
4984
+ desktop app signs it in again. Undo with: dsh-workbuddy-xdpool unignore ${account.id}`);
4985
+ return 0;
4986
+ }
4987
+ /** List the accounts currently thrown out of the pool. */
4988
+ async function commandIgnored(args) {
4989
+ const asJson = args.includes("--json");
4990
+ const ignored = await readIgnoredAccounts();
4991
+ if (asJson) {
4992
+ console.log(JSON.stringify(ignored, null, 2));
4993
+ return 0;
4994
+ }
4995
+ if (ignored.length === 0) {
4996
+ console.log("No accounts are ignored. Use `ignore <account-id|label>` to drop one.");
4997
+ return 0;
4998
+ }
4999
+ console.log(ignored.map((entry) => `⛔ ${entry.label} (${entry.id})`).join("\n"));
5000
+ return 0;
5001
+ }
4654
5002
  function commandLogin() {
4655
5003
  console.log([
4656
5004
  "Adding another WorkBuddy account",
@@ -4670,7 +5018,7 @@ function commandLogin() {
4670
5018
  return 0;
4671
5019
  }
4672
5020
  async function commandReset() {
4673
- const core = createCore();
5021
+ const core = await cliCore();
4674
5022
  await core.pool.scan();
4675
5023
  core.pool.resetCooldowns();
4676
5024
  console.log("Cleared all rate-limit cooldowns.");
@@ -4678,15 +5026,25 @@ async function commandReset() {
4678
5026
  }
4679
5027
  async function commandAccounts(args) {
4680
5028
  const asJson = args.includes("--json");
4681
- const accounts = await createCore().pool.scan();
4682
- if (asJson) console.log(JSON.stringify(accounts.map((account) => ({
5029
+ const accounts = await (await cliCore()).pool.scan();
5030
+ const ignored = await readIgnoredAccounts();
5031
+ if (asJson) console.log(JSON.stringify([...accounts.map((account) => ({
4683
5032
  id: account.id,
4684
5033
  label: account.label,
4685
5034
  cooling: account.cooldownUntilMs > Date.now(),
4686
5035
  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"));
5036
+ })), ...ignored.map((entry) => ({
5037
+ id: entry.id,
5038
+ label: entry.label,
5039
+ ignored: true,
5040
+ ignoredAt: entry.ignoredAt
5041
+ }))], null, 2));
5042
+ else if (accounts.length === 0 && ignored.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
5043
+ else {
5044
+ const lines = accounts.map((account) => `${account.cooldownUntilMs > Date.now() ? "⏸" : "▶"} ${account.label}`);
5045
+ if (ignored.length > 0) lines.push(...ignored.map((entry) => `⛔ ${entry.label} (removed; \`unignore ${entry.id}\` to restore)`));
5046
+ console.log(lines.join("\n"));
5047
+ }
4690
5048
  return 0;
4691
5049
  }
4692
5050
  /** Entry point; returns the process exit code. */
@@ -4704,6 +5062,9 @@ async function main(argv) {
4704
5062
  case "accounts": return commandAccounts(rest);
4705
5063
  case "import": return commandImport(rest);
4706
5064
  case "remove": return commandRemove(rest);
5065
+ case "ignore": return commandIgnore(rest, true);
5066
+ case "unignore": return commandIgnore(rest, false);
5067
+ case "ignored": return commandIgnored(rest);
4707
5068
  case "login": return commandLogin();
4708
5069
  case "logout": return commandRemove(["default", ...rest]);
4709
5070
  case "reset": return commandReset();