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/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
  /**
@@ -1640,8 +1642,20 @@ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
1640
1642
  const SYMMETRIC_SCHEME = "sym-v1";
1641
1643
  /** Env override pointing at the WorkBuddy desktop executable. */
1642
1644
  const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
1643
- /** How long the app is given to answer with its key payload. */
1644
- const KEY_FETCH_TIMEOUT_MS = 1e4;
1645
+ /**
1646
+ * How long the app is given to answer with its key payload.
1647
+ *
1648
+ * 30s, not 10s: the child is the WorkBuddy Electron binary running as plain
1649
+ * Node, and its FIRST spawn on a cold machine costs several seconds on its own
1650
+ * (measured 4.5s here) before the endpoint security stack has warmed its scan
1651
+ * cache. Under load — a concurrent `pnpm install` from the market, a running
1652
+ * full-disk scan — that first spawn crosses a 10s budget, the fetch rejects,
1653
+ * `readAtRestKey` returns undefined, and every encrypted credential then reads
1654
+ * as `WorkBuddyEncryptedCredentialError` until the 60s negative cache expires.
1655
+ * A successful fetch is cached for the process lifetime, so the longer budget
1656
+ * is only ever paid once per process, and only when the app is present but slow.
1657
+ */
1658
+ const KEY_FETCH_TIMEOUT_MS = 3e4;
1645
1659
  /**
1646
1660
  * Executable file names the desktop app ships under, in probe order.
1647
1661
  *
@@ -1896,6 +1910,7 @@ function windowsFallbackAppPaths(env) {
1896
1910
  }
1897
1911
  roots.add(join(drive, "Program Files"));
1898
1912
  roots.add(join(drive, "Program Files (x86)"));
1913
+ roots.add(drive);
1899
1914
  }
1900
1915
  for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
1901
1916
  out.push(join(root, "WorkBuddy", name));
@@ -2308,6 +2323,41 @@ async function readCredential(path) {
2308
2323
  }
2309
2324
  }
2310
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
+ /**
2311
2361
  * Build the field opener, or undefined when the app cannot supply its key.
2312
2362
  *
2313
2363
  * Split out so the key lookup is testable without a real desktop install, and so
@@ -2386,6 +2436,21 @@ var WorkBuddyAccountPool = class {
2386
2436
  */
2387
2437
  disabledIds = /* @__PURE__ */ new Set();
2388
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
+ /**
2389
2454
  * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
2390
2455
  * it all".
2391
2456
  *
@@ -2437,12 +2502,39 @@ var WorkBuddyAccountPool = class {
2437
2502
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
2438
2503
  if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
2439
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
+ }
2440
2526
  /** Rescan the auth directories and merge newly discovered accounts. */
2441
2527
  async scan() {
2442
2528
  const found = [];
2443
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
+ }
2444
2534
  const credential = await readCredential(file);
2445
- 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);
2446
2538
  }
2447
2539
  const byId = /* @__PURE__ */ new Map();
2448
2540
  for (const account of this.accounts) byId.set(account.id, account);
@@ -2783,28 +2875,44 @@ ${new Date(until).toISOString()}
2783
2875
  };
2784
2876
  //#endregion
2785
2877
  //#region src/catalog.ts
2786
- /** 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
+ */
2787
2892
  const FALLBACK_WORKBUDDY_MODELS = [
2788
2893
  {
2789
2894
  id: "glm-5.3",
2790
2895
  name: "GLM-5.3",
2791
2896
  contextWindow: 2e5,
2792
2897
  maxOutputTokens: 128e3,
2793
- supportsImages: true
2898
+ supportsImages: true,
2899
+ multiplier: .79
2794
2900
  },
2795
2901
  {
2796
2902
  id: "glm-5.3-flash",
2797
2903
  name: "GLM-5.3-Flash",
2798
2904
  contextWindow: 2e5,
2799
2905
  maxOutputTokens: 128e3,
2800
- supportsImages: true
2906
+ supportsImages: true,
2907
+ multiplier: .06
2801
2908
  },
2802
2909
  {
2803
2910
  id: "glm-5.2",
2804
2911
  name: "GLM-5.2",
2805
2912
  contextWindow: 2e5,
2806
2913
  maxOutputTokens: 128e3,
2807
- supportsImages: true
2914
+ supportsImages: true,
2915
+ multiplier: .79
2808
2916
  },
2809
2917
  {
2810
2918
  id: "glm-5.1",
@@ -2832,7 +2940,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
2832
2940
  name: "Kimi-K3",
2833
2941
  contextWindow: 2e5,
2834
2942
  maxOutputTokens: 128e3,
2835
- supportsImages: true
2943
+ supportsImages: true,
2944
+ multiplier: 1.62
2836
2945
  },
2837
2946
  {
2838
2947
  id: "minimax-m3",
@@ -2846,14 +2955,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
2846
2955
  name: "Hy3",
2847
2956
  contextWindow: 32e3,
2848
2957
  maxOutputTokens: 8e3,
2849
- supportsImages: true
2958
+ supportsImages: true,
2959
+ multiplier: 0
2850
2960
  },
2851
2961
  {
2852
2962
  id: "hy4-preview",
2853
2963
  name: "Hy4-Preview",
2854
2964
  contextWindow: 1e6,
2855
2965
  maxOutputTokens: 128e3,
2856
- supportsImages: true
2966
+ supportsImages: true,
2967
+ multiplier: .29
2857
2968
  }
2858
2969
  ];
2859
2970
  /** Live catalog with a static fallback behind it. */
@@ -2931,7 +3042,8 @@ function toModelInfo(model) {
2931
3042
  maxOutputTokens: model.maxTokens,
2932
3043
  supportsImages: model.supportsImages ?? false,
2933
3044
  ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
2934
- ...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 }
2935
3047
  };
2936
3048
  }
2937
3049
  /** Map the live upstream list, falling back to the static list when empty. */
@@ -3345,6 +3457,30 @@ function expertChatEvents(expert, conversationId, requestId) {
3345
3457
  expert_industry_id: ""
3346
3458
  } : event);
3347
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
+ };
3348
3484
  /**
3349
3485
  * The fallback record for the 腾讯轻量云 expert, used when the marketplace
3350
3486
  * listing cannot be read. The id is the one the task is scored against.
@@ -3388,6 +3524,23 @@ const JOB_KINDS = [
3388
3524
  "streak",
3389
3525
  "travel"
3390
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
+ }
3391
3544
  const EMPTY_JOB_STATE = {
3392
3545
  ok: 0,
3393
3546
  failed: 0,
@@ -3563,11 +3716,11 @@ var WorkBuddyScheduler = class {
3563
3716
  this.earningsDate = today;
3564
3717
  }
3565
3718
  this.enabled = options.enabled ?? false;
3566
- this.checkinHours = options.checkinHours ?? [9];
3567
- this.reportHours = options.reportHours ?? [10];
3568
- this.taskHours = options.taskHours ?? [11];
3569
- this.streakHours = options.streakHours ?? [12];
3570
- 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);
3571
3724
  }
3572
3725
  /** Apply a new configuration; safe to call while running. */
3573
3726
  /**
@@ -3595,11 +3748,11 @@ var WorkBuddyScheduler = class {
3595
3748
  }
3596
3749
  applyConfig(options) {
3597
3750
  if (options.enabled !== void 0) this.enabled = options.enabled;
3598
- if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
3599
- if (options.reportHours !== void 0) this.reportHours = options.reportHours;
3600
- if (options.taskHours !== void 0) this.taskHours = options.taskHours;
3601
- if (options.streakHours !== void 0) this.streakHours = options.streakHours;
3602
- 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);
3603
3756
  }
3604
3757
  /** Hours for one job, used by the loop and the status document. */
3605
3758
  hoursOf(kind) {
@@ -3830,12 +3983,16 @@ var WorkBuddyScheduler = class {
3830
3983
  */
3831
3984
  persistEarnings() {
3832
3985
  if (this.saveEarningsFn === void 0) return;
3986
+ const accounts = {};
3987
+ for (const [id, entry] of this.earnings) accounts[id] = entry;
3988
+ const ledger = {
3989
+ date: this.earningsDate,
3990
+ accounts
3991
+ };
3833
3992
  try {
3834
- const accounts = {};
3835
- for (const [id, entry] of this.earnings) accounts[id] = entry;
3836
- this.saveEarningsFn({
3837
- date: this.earningsDate,
3838
- accounts
3993
+ const saved = this.saveEarningsFn(ledger);
3994
+ if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
3995
+ this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
3839
3996
  });
3840
3997
  } catch (error) {
3841
3998
  this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
@@ -4258,6 +4415,137 @@ function formatRates(status) {
4258
4415
  return lines.join("\n");
4259
4416
  }
4260
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
4261
4549
  //#region src/index.ts
4262
4550
  /**
4263
4551
  * One region's model-selection schema.
@@ -4267,6 +4555,18 @@ function formatRates(status) {
4267
4555
  * region that has never been saved stays absent so `applyConfigFromSource` can
4268
4556
  * fall back to the legacy flat keys.
4269
4557
  */
4558
+ function asVolatile(schema) {
4559
+ const candidate = schema;
4560
+ return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
4561
+ }
4562
+ /**
4563
+ * One region's model-selection schema.
4564
+ *
4565
+ * Every field is optional on purpose: an absent field keeps its documented
4566
+ * meaning ("all enabled" / "follow the upstream image flag" / "no cap"), and a
4567
+ * region that has never been saved stays absent so `applyConfigFromSource` can
4568
+ * fall back to the legacy flat keys.
4569
+ */
4270
4570
  const modelSelectionSchema = z.object({
4271
4571
  enabledModelIds: z.array(z.string()).description("Model ids enabled in this region's picker (absent = all)"),
4272
4572
  imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
@@ -4294,22 +4594,22 @@ const automationSchema = z.object({
4294
4594
  exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
4295
4595
  });
4296
4596
  z.object({
4297
- authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
4298
- cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
4299
- distribution: z.union([
4597
+ authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app own location)")),
4598
+ cooldownMs: asVolatile(z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds")),
4599
+ distribution: asVolatile(z.union([
4300
4600
  "priority",
4301
4601
  "round-robin",
4302
4602
  "balanced"
4303
- ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
4304
- disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
4305
- 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"),
4306
- enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
4307
- imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
4308
- 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"),
4309
- modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
4310
- modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
4311
- automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
4312
- automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
4603
+ ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
4604
+ disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
4605
+ 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")),
4606
+ enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
4607
+ imageModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet")),
4608
+ 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")),
4609
+ modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
4610
+ modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
4611
+ automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
4612
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
4313
4613
  });
4314
4614
  /** Assemble the runtime objects without registering anything. */
4315
4615
  /**
@@ -4347,6 +4647,20 @@ function createCore(logger) {
4347
4647
  */
4348
4648
  /** Directory holding imported account snapshots. */
4349
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
+ }
4350
4664
  /** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
4351
4665
  function snapshotPath(key, dir) {
4352
4666
  return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
@@ -4371,6 +4685,9 @@ function usage() {
4371
4685
  " accounts List discovered accounts (add --json)",
4372
4686
  " import <key> Snapshot the current desktop login as <key> (add --force)",
4373
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)",
4374
4691
  " login Guide for adding another account (desktop app is single-sign-in)",
4375
4692
  " checkin [all|<acct>] Daily check-in: report status, or collect with `all` / a label",
4376
4693
  " reset Clear all rate-limit cooldowns immediately",
@@ -4399,7 +4716,7 @@ async function commandStatus(args) {
4399
4716
  const asJson = args.includes("--json");
4400
4717
  const withCredits = args.includes("--credits");
4401
4718
  const withRates = args.includes("--rates");
4402
- const core = createCore();
4719
+ const core = await cliCore();
4403
4720
  const accounts = await core.pool.scan();
4404
4721
  const status = {
4405
4722
  ok: accounts.length > 0,
@@ -4460,7 +4777,7 @@ async function commandStatus(args) {
4460
4777
  async function commandCheckin(args) {
4461
4778
  const asJson = args.includes("--json");
4462
4779
  const target = args.find((arg) => !arg.startsWith("--"));
4463
- const core = createCore();
4780
+ const core = await cliCore();
4464
4781
  if ((await core.pool.scan()).length === 0) {
4465
4782
  console.error("No WorkBuddy account discovered. Sign in with the WorkBuddy desktop app first.");
4466
4783
  return 1;
@@ -4544,7 +4861,7 @@ async function commandDoctor() {
4544
4861
  lines.push(` ✓ ${dir} → ${snapshots.length} snapshot(s)`);
4545
4862
  }
4546
4863
  lines.push("");
4547
- const accounts = await createCore().pool.scan();
4864
+ const accounts = await (await cliCore()).pool.scan();
4548
4865
  lines.push(`Accounts discovered: ${accounts.length}`);
4549
4866
  if (accounts.length === 0) {
4550
4867
  healthy = false;
@@ -4622,6 +4939,66 @@ async function commandRemove(args) {
4622
4939
  return 1;
4623
4940
  }
4624
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
+ }
4625
5002
  function commandLogin() {
4626
5003
  console.log([
4627
5004
  "Adding another WorkBuddy account",
@@ -4641,7 +5018,7 @@ function commandLogin() {
4641
5018
  return 0;
4642
5019
  }
4643
5020
  async function commandReset() {
4644
- const core = createCore();
5021
+ const core = await cliCore();
4645
5022
  await core.pool.scan();
4646
5023
  core.pool.resetCooldowns();
4647
5024
  console.log("Cleared all rate-limit cooldowns.");
@@ -4649,15 +5026,25 @@ async function commandReset() {
4649
5026
  }
4650
5027
  async function commandAccounts(args) {
4651
5028
  const asJson = args.includes("--json");
4652
- const accounts = await createCore().pool.scan();
4653
- 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) => ({
4654
5032
  id: account.id,
4655
5033
  label: account.label,
4656
5034
  cooling: account.cooldownUntilMs > Date.now(),
4657
5035
  rateLimitHits: account.rateLimitHits
4658
- })), null, 2));
4659
- else if (accounts.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
4660
- 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
+ }
4661
5048
  return 0;
4662
5049
  }
4663
5050
  /** Entry point; returns the process exit code. */
@@ -4675,6 +5062,9 @@ async function main(argv) {
4675
5062
  case "accounts": return commandAccounts(rest);
4676
5063
  case "import": return commandImport(rest);
4677
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);
4678
5068
  case "login": return commandLogin();
4679
5069
  case "logout": return commandRemove(["default", ...rest]);
4680
5070
  case "reset": return commandReset();