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/client.js CHANGED
@@ -27,10 +27,39 @@ window.__ModuleLoader__.load({
27
27
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
28
28
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
29
29
  const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
30
+ /**
31
+ * Throw one account out of the pool for good, or take it back.
32
+ *
33
+ * Separate from the disable route because the semantics differ: disabling is a
34
+ * rotation preference the account survives, ignoring survives the account.
35
+ */
36
+ const POOL_ACCOUNT_IGNORE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/ignored";
30
37
  /** Run one automation job immediately, so the card can verify it on demand. */
31
38
  const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
32
39
  /** Set or clear one account's reserved-credit floor. */
33
40
  const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
41
+ /**
42
+ * The schedule every automation job falls back to.
43
+ *
44
+ * Shared by both halves on purpose. The host uses it when a configured hour
45
+ * list arrives empty (the settings schema materializes "never configured" into
46
+ * `[]`), and the card uses it when it writes the `automation` block back, so a
47
+ * document that already holds an empty list is healed instead of being saved
48
+ * back as an unrunnable schedule.
49
+ *
50
+ * This lives here rather than in `scheduler.ts` because the browser half cannot
51
+ * import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
52
+ * upstream client, none of which exists in the browser bundle. Two hand-written
53
+ * copies would drift, and the drift is invisible — the card would write a
54
+ * schedule the scheduler does not run.
55
+ */
56
+ const DEFAULT_AUTOMATION_HOURS = {
57
+ checkin: [9],
58
+ report: [10],
59
+ tasks: [11],
60
+ streak: [12],
61
+ travel: [9, 21]
62
+ };
34
63
  //#endregion
35
64
  //#region src/client/icon.ts
36
65
  /**
@@ -168,6 +197,23 @@ window.__ModuleLoader__.load({
168
197
  .dsm-workbuddy-xdpool-account-toggle:disabled{cursor:default;opacity:.6}
169
198
  .dsm-workbuddy-xdpool-account-toggle-on{background:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 14%, transparent);border-color:color-mix(in oklab, var(--dsw-alias-state-success-primary,#22a06b) 55%, transparent);color:var(--dsw-alias-state-success-primary,#22a06b);font-weight:600}
170
199
  .dsm-workbuddy-xdpool-account-toggle-dot{width:6px;height:6px;border-radius:50%;flex:none;background:currentColor;opacity:.9}
200
+ /* "Remove" button: destructive and reversible, so it is quiet until hovered. */
201
+ .dsm-workbuddy-xdpool-account-ignore{appearance:none;font:inherit;flex:none;cursor:pointer;border:1px solid transparent;border-radius:999px;padding:3px 10px;font-size:11px;line-height:17px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s}
202
+ .dsm-workbuddy-xdpool-account-ignore:hover:not(:disabled){color:var(--dsw-alias-state-error-primary,#ef4444);border-color:color-mix(in oklab, var(--dsw-alias-state-error-primary,#ef4444) 45%, transparent);background:color-mix(in oklab, var(--dsw-alias-state-error-primary,#ef4444) 10%, transparent)}
203
+ .dsm-workbuddy-xdpool-account-ignore:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
204
+ .dsm-workbuddy-xdpool-account-ignore:disabled{cursor:default;opacity:.6}
205
+ /* Ignored-account list: visible proof that "remove" can be undone. */
206
+ .dsm-workbuddy-xdpool-ignored-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:12px 16px;border-bottom:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 45%, transparent)}
207
+ .dsm-workbuddy-xdpool-ignored-title{margin:0;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:13px;font-weight:600;line-height:19px}
208
+ .dsm-workbuddy-xdpool-ignored-summary{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:17px}
209
+ .dsm-workbuddy-xdpool-ignored-list{display:flex;flex-direction:column}
210
+ .dsm-workbuddy-xdpool-ignored-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 16px}
211
+ .dsm-workbuddy-xdpool-ignored-row+.dsm-workbuddy-xdpool-ignored-row{border-top:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 30%, transparent)}
212
+ .dsm-workbuddy-xdpool-ignored-label{color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
213
+ .dsm-workbuddy-xdpool-ignored-restore{appearance:none;font:inherit;flex:none;cursor:pointer;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 70%, transparent);border-radius:999px;padding:3px 10px;font-size:11px;line-height:17px;background:transparent;color:var(--dsw-alias-label-secondary,#c6c9d0);transition:color .16s,border-color .16s}
214
+ .dsm-workbuddy-xdpool-ignored-restore:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e6e6e6);border-color:var(--dsw-alias-label-dimmed,#777)}
215
+ .dsm-workbuddy-xdpool-ignored-restore:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
216
+ .dsm-workbuddy-xdpool-ignored-restore:disabled{cursor:default;opacity:.6}
171
217
  .dsm-workbuddy-xdpool-account-tags{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
172
218
  .dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
173
219
  .dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
@@ -391,6 +437,17 @@ window.__ModuleLoader__.load({
391
437
  "streak",
392
438
  "travel"
393
439
  ];
440
+ /**
441
+ * The hour list to save for one job: what the card holds, or the default.
442
+ *
443
+ * Mirrors the host's own fallback (`hoursOrDefault` in `scheduler.ts`). An
444
+ * empty list must resolve to the default on BOTH sides, or the card would save
445
+ * a schedule the scheduler then refuses to run — the exact silent standstill
446
+ * this pair of fixes exists to remove.
447
+ */
448
+ function hoursOrDefault(configured, fallback) {
449
+ return configured !== void 0 && configured.length > 0 ? [...configured] : [...fallback];
450
+ }
394
451
  /** Read one job's configured hours off the status document. */
395
452
  function automationHours(status, kind) {
396
453
  const automation = status.automation;
@@ -473,9 +530,21 @@ window.__ModuleLoader__.load({
473
530
  const days = daysUntil(pack.expiresAtMs);
474
531
  return days !== void 0 && days <= 3;
475
532
  }
533
+ /**
534
+ * The promo badge for one model, or undefined when it has none.
535
+ *
536
+ * `free` is read off the CREDIT MULTIPLIER, not off a tag. Neither gateway ever
537
+ * sends a literal `free` tag: the global roster marks its zero-cost models
538
+ * (`hy3`, `hy4-preview-f`, `deepseek-v4.1-flash`) as `credits: "x0.00"`, so
539
+ * waiting for a tag meant the badge NEVER appeared — including on models that
540
+ * genuinely cost nothing.
541
+ *
542
+ * An explicit free-ish tag still wins when present, so a gateway that starts
543
+ * tagging them keeps working without another change.
544
+ */
476
545
  function tagFor(model) {
477
546
  const tags = model.tags ?? [];
478
- if (tags.includes("free")) return "free";
547
+ if (tags.includes("free") || model.multiplier === 0) return "free";
479
548
  if (tags.includes("limited-free")) return "limited";
480
549
  if (tags.includes("night-discount")) return "night";
481
550
  }
@@ -687,6 +756,37 @@ window.__ModuleLoader__.load({
687
756
  if (mounted.current) setAccountBusyId(void 0);
688
757
  }
689
758
  };
759
+ /**
760
+ * Throw one account out of the pool for good, or take it back.
761
+ *
762
+ * The host owns the ignore file, so this is a plain route call: no settings
763
+ * scope is involved, which is also why it keeps working on a profile whose
764
+ * settings section is read-only.
765
+ */
766
+ const setAccountIgnored = async (accountId, ignored) => {
767
+ setAccountBusyId(accountId);
768
+ setFlash(void 0);
769
+ try {
770
+ const response = await fetch(POOL_ACCOUNT_IGNORE_PATH, {
771
+ method: "POST",
772
+ headers: { "Content-Type": "application/json" },
773
+ body: JSON.stringify({
774
+ accountId,
775
+ ignored
776
+ })
777
+ });
778
+ if (!response.ok) {
779
+ const detail = await response.json().catch(() => ({}));
780
+ throw new Error(detail.error ?? `HTTP ${response.status}`);
781
+ }
782
+ await refresh(activeRegion);
783
+ } catch (cause) {
784
+ const message = cause instanceof Error ? cause.message : String(cause);
785
+ if (mounted.current) setError(t?.("row.accountIgnoreError", { message }) ?? "Could not change the ignore list: " + message);
786
+ } finally {
787
+ if (mounted.current) setAccountBusyId(void 0);
788
+ }
789
+ };
690
790
  /** Keep the draft in step with the server copy while nothing is dirty. */
691
791
  const modelDraft = draft ?? (status === void 0 ? {} : draftFromStatus(status));
692
792
  /** Model edits need a writable settings scope; otherwise the rows are read-only. */
@@ -790,12 +890,11 @@ window.__ModuleLoader__.load({
790
890
  setFlash(void 0);
791
891
  try {
792
892
  await write.call(settingsScope, "automation", {
793
- ...existing === void 0 ? {} : {
794
- checkinHours: [...existing.checkinHours],
795
- reportHours: [...existing.reportHours],
796
- taskHours: [...existing.taskHours],
797
- streakHours: [...existing.streakHours]
798
- },
893
+ checkinHours: hoursOrDefault(existing?.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin),
894
+ reportHours: hoursOrDefault(existing?.reportHours, DEFAULT_AUTOMATION_HOURS.report),
895
+ taskHours: hoursOrDefault(existing?.taskHours, DEFAULT_AUTOMATION_HOURS.tasks),
896
+ streakHours: hoursOrDefault(existing?.streakHours, DEFAULT_AUTOMATION_HOURS.streak),
897
+ travelHours: hoursOrDefault(existing?.travelHours, DEFAULT_AUTOMATION_HOURS.travel),
799
898
  enabled
800
899
  });
801
900
  await refresh(activeRegion);
@@ -1171,7 +1270,7 @@ window.__ModuleLoader__.load({
1171
1270
  }),
1172
1271
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1173
1272
  className: "dsm-workbuddy-xdpool-auto-job-last",
1174
- children: job?.lastRunDate === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${job.lastRunDate} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1273
+ children: job?.lastRunAtMs === void 0 ? t?.("row.autoNever") ?? "not run yet" : `${formatTime(job.lastRunAtMs)} · ${job.ok}${job.failed > 0 ? `/${job.failed}` : ""}`
1175
1274
  }),
1176
1275
  job?.progress === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1177
1276
  className: "dsm-workbuddy-xdpool-auto-job-note",
@@ -1272,11 +1371,46 @@ window.__ModuleLoader__.load({
1272
1371
  onToggleDisabled: (accountId, disabled) => {
1273
1372
  toggleAccountDisabled(accountId, disabled);
1274
1373
  },
1374
+ onIgnoreAccount: (accountId) => {
1375
+ setAccountIgnored(accountId, true);
1376
+ },
1275
1377
  ...accountBusyId === void 0 ? {} : { accountBusyId },
1276
1378
  t
1277
1379
  }, account.id))
1278
1380
  ]
1279
1381
  }) : null,
1382
+ (status?.ignored.length ?? 0) > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1383
+ className: "dsm-workbuddy-xdpool-card dsm-workbuddy-xdpool-ignored",
1384
+ "aria-label": t?.("row.ignoredTitle") ?? "Removed accounts",
1385
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1386
+ className: "dsm-workbuddy-xdpool-ignored-head",
1387
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1388
+ className: "dsm-workbuddy-xdpool-ignored-title",
1389
+ children: t?.("row.ignoredTitle") ?? "Removed accounts"
1390
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1391
+ className: "dsm-workbuddy-xdpool-ignored-summary",
1392
+ children: t?.("row.ignoredSummary", { count: status?.ignored.length ?? 0 }) ?? `${status?.ignored.length ?? 0} account(s) no longer in the pool`
1393
+ })]
1394
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1395
+ className: "dsm-workbuddy-xdpool-ignored-list",
1396
+ children: (status?.ignored ?? []).map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1397
+ className: "dsm-workbuddy-xdpool-ignored-row",
1398
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1399
+ className: "dsm-workbuddy-xdpool-ignored-label",
1400
+ children: entry.label
1401
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1402
+ type: "button",
1403
+ className: "dsm-workbuddy-xdpool-ignored-restore",
1404
+ title: t?.("row.ignoredRestoreHint") ?? "Put this account back into the pool",
1405
+ disabled: accountBusyId === entry.id,
1406
+ onClick: () => {
1407
+ setAccountIgnored(entry.id, false);
1408
+ },
1409
+ children: t?.("row.ignoredRestore") ?? "Restore"
1410
+ })]
1411
+ }, entry.id))
1412
+ })]
1413
+ }) : null,
1280
1414
  (status?.models.length ?? 0) > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1281
1415
  className: "dsm-workbuddy-xdpool-card dsm-workbuddy-xdpool-models",
1282
1416
  "aria-label": t?.("row.modelsTitle") ?? "Models",
@@ -1332,7 +1466,7 @@ window.__ModuleLoader__.load({
1332
1466
  });
1333
1467
  }
1334
1468
  /** One account block: label + status tag + meta + optional credit panels. */
1335
- function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onSaveCreditReserve, reserveBusyId }) {
1469
+ function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
1336
1470
  const isDisabled = account.disabled === true;
1337
1471
  const isCooling = account.cooling === true;
1338
1472
  const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
@@ -1365,6 +1499,16 @@ window.__ModuleLoader__.load({
1365
1499
  onToggleDisabled(account.id, !isDisabled);
1366
1500
  },
1367
1501
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dsm-workbuddy-xdpool-account-toggle-dot" }), isDisabled ? t?.("row.accountOff") ?? "Disabled" : t?.("row.accountInRotation") ?? "Enabled"]
1502
+ }),
1503
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1504
+ type: "button",
1505
+ className: "dsm-workbuddy-xdpool-account-ignore",
1506
+ title: t?.("row.accountIgnoreHint") ?? "Remove this account from the pool for good",
1507
+ disabled: accountBusyId === account.id,
1508
+ onClick: () => {
1509
+ onIgnoreAccount(account.id);
1510
+ },
1511
+ children: t?.("row.accountIgnore") ?? "Remove"
1368
1512
  })
1369
1513
  ]
1370
1514
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1682,7 +1826,7 @@ window.__ModuleLoader__.load({
1682
1826
  className: "dsm-workbuddy-xdpool-model-copy",
1683
1827
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1684
1828
  className: "dsm-workbuddy-xdpool-model-name",
1685
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), model.multiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1829
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: model.name }), model.multiplier === void 0 || model.multiplier === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1686
1830
  className: "dsm-workbuddy-xdpool-model-name-rate",
1687
1831
  children: t?.("row.rate", { rate: model.multiplier.toFixed(2) }) ?? `${model.multiplier.toFixed(2)}x`
1688
1832
  })]
@@ -1829,6 +1973,13 @@ window.__ModuleLoader__.load({
1829
1973
  "row.accountOff": "Disabled",
1830
1974
  "row.accountToggleHint": "Enable this account (uncheck to keep it out of the pool)",
1831
1975
  "row.accountToggleError": "Could not switch the account: {message}",
1976
+ "row.accountIgnore": "Remove",
1977
+ "row.accountIgnoreHint": "Remove this account from the pool for good — its credential stops being read and it will not come back",
1978
+ "row.accountIgnoreError": "Could not change the ignore list: {message}",
1979
+ "row.ignoredTitle": "Removed accounts",
1980
+ "row.ignoredSummary": "{count} account(s) no longer in the pool",
1981
+ "row.ignoredRestore": "Restore",
1982
+ "row.ignoredRestoreHint": "Put this account back into the pool",
1832
1983
  "row.currentAccount": "In use now",
1833
1984
  "row.currentAccountDisabled": "switched off — the next request moves on",
1834
1985
  "row.cooling": "Cooling (rate-limited)",
@@ -1961,6 +2112,13 @@ window.__ModuleLoader__.load({
1961
2112
  "row.accountOff": "已停用",
1962
2113
  "row.accountToggleHint": "启用该账号(取消勾选则不参与池子)",
1963
2114
  "row.accountToggleError": "切换账号失败:{message}",
2115
+ "row.accountIgnore": "移出池子",
2116
+ "row.accountIgnoreHint": "把这个账号永久移出池子 —— 不再读取它的凭据,即使重新登录也不会回来",
2117
+ "row.accountIgnoreError": "修改忽略列表失败:{message}",
2118
+ "row.ignoredTitle": "已移出的账号",
2119
+ "row.ignoredSummary": "{count} 个账号已不在池中",
2120
+ "row.ignoredRestore": "恢复",
2121
+ "row.ignoredRestoreHint": "把这个账号放回池子",
1964
2122
  "row.currentAccount": "当前使用",
1965
2123
  "row.currentAccountDisabled": "已停用 —— 下次请求会换号",
1966
2124
  "row.cooling": "冷却中(被限流)",
package/lib/index.d.ts CHANGED
@@ -221,6 +221,21 @@ interface WorkBuddyUpstreamModel {
221
221
  descriptionZh?: string;
222
222
  descriptionEn?: string;
223
223
  supportsToolCall?: boolean;
224
+ /**
225
+ * Promo tags the upstream attaches to a model.
226
+ *
227
+ * Both gateways send a `tags` array, but the vocabularies are NOT the same
228
+ * and neither uses the words this plugin used to expect:
229
+ *
230
+ * - Global sends `["craft"]`, `["text-to-image"]`, `["text-to-video"]`, `[]`
231
+ * — capability/grouping labels, never `free`.
232
+ * - Zero cost is expressed as `credits: "x0.00"` instead.
233
+ *
234
+ * So this field is a passthrough of what the upstream really said, and
235
+ * `free` is DERIVED from the multiplier rather than awaited as a tag (see
236
+ * {@link isFreeModel}).
237
+ */
238
+ tags?: readonly string[];
224
239
  }
225
240
  /** One billing package, already normalised. */
226
241
  interface WorkBuddyCreditPackage {
@@ -830,6 +845,21 @@ export declare class WorkBuddyAccountPool {
830
845
  * lives on the pool and is re-applied from settings after each scan.
831
846
  */
832
847
  private disabledIds;
848
+ /**
849
+ * Account ids the user threw out of the pool for good.
850
+ *
851
+ * Enforced BEFORE the credential is parsed: `scan()` skips a file whose
852
+ * identity is already ignored, so an ignored account costs no at-rest key
853
+ * lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
854
+ * when the app writes a fresh sign-in for it. That is the difference from
855
+ * {@link disabledIds}, which only filters at pick time and leaves the account
856
+ * listed, readable and re-discoverable.
857
+ *
858
+ * The set is supplied by the host from the plugin's own ignore file, and is
859
+ * replaced wholesale on every {@link applyIgnored} so removing an entry takes
860
+ * effect on the next scan without a restart.
861
+ */
862
+ private ignoredIds;
833
863
  /**
834
864
  * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
835
865
  * it all".
@@ -875,6 +905,19 @@ export declare class WorkBuddyAccountPool {
875
905
  /** Per-account credit floor, keyed by account id. Absent keeps the current map. */
876
906
  creditReserves?: Readonly<Record<string, number>>;
877
907
  }): void;
908
+ /**
909
+ * Replace the permanent ignore list.
910
+ *
911
+ * Also drops any already-discovered account that is now ignored, so the change
912
+ * is visible without waiting for the next scan: the card refreshes its status
913
+ * document right after the write, and an account still sitting in `accounts`
914
+ * would keep showing up there.
915
+ */
916
+ applyIgnored(ids: Iterable<string>): void;
917
+ /** Whether this account has been thrown out of the pool for good. */
918
+ isIgnored(accountId: string): boolean;
919
+ /** Every ignored id currently in force, in insertion order. */
920
+ ignoredIdsInOrder(): string[];
878
921
  /** Rescan the auth directories and merge newly discovered accounts. */
879
922
  scan(): Promise<WorkBuddyAccount[]>;
880
923
  /** All accounts, cooldown state included. */
@@ -1036,7 +1079,20 @@ interface WorkBuddyModelInfo {
1036
1079
  /** Upstream tags: free / limited-free / night-discount. */
1037
1080
  tags?: readonly string[];
1038
1081
  }
1039
- /** Static fallback used before the first live catalog fetch. */
1082
+ /**
1083
+ * Static fallback used before the first live catalog fetch, and whenever the
1084
+ * upstream cannot be reached.
1085
+ *
1086
+ * The multipliers are carried on purpose. Without them the provider's model
1087
+ * picker silently loses every rate and every free badge the moment the live
1088
+ * fetch fails — which reads to the user as "the plugin broke my model list"
1089
+ * rather than "the upstream is unreachable". The values are the ones the two
1090
+ * gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
1091
+ * on), so a fallback row looks the same as a live one.
1092
+ *
1093
+ * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
1094
+ * which is what turns on the free badge.
1095
+ */
1040
1096
  export declare const FALLBACK_WORKBUDDY_MODELS: readonly WorkBuddyModelInfo[];
1041
1097
  /** Live catalog with a static fallback behind it. */
1042
1098
  export declare class WorkBuddyCatalog {
@@ -1209,6 +1265,34 @@ interface AutomationJobState {
1209
1265
  * repeat tick inside the same hour is still refused.
1210
1266
  */
1211
1267
  lastRunSlot?: string;
1268
+ /**
1269
+ * Every configured slot consumed TODAY, as `YYYY-MM-DDTHH`.
1270
+ *
1271
+ * This is what actually gates a re-run, and it is a SET rather than a single
1272
+ * slot because one field could not express the contract: a job configured for
1273
+ * `[9, 21]` runs twice, and a single "last slot" value can only remember one
1274
+ * of them — the second run erased the first, so the 9 o'clock candidate looked
1275
+ * unconsumed again and the job re-fired every hour after 21:00.
1276
+ *
1277
+ * The values are the CONFIGURED hours that were spent, never the clock time
1278
+ * the run happened to finish at. Storing the finish time was the original
1279
+ * defect: the reader compared it against a configured candidate, so the two
1280
+ * almost never matched and the gate stayed open all day.
1281
+ *
1282
+ * In-memory like the rest of `states`: this is a per-process record, and the
1283
+ * catch-up design deliberately re-runs an hour that passed while DSH was
1284
+ * closed. See `automationEarnings` for the ledger that DOES persist.
1285
+ */
1286
+ firedSlots?: readonly string[];
1287
+ /**
1288
+ * The clock slot the last run STARTED in (`YYYY-MM-DDTHH`).
1289
+ *
1290
+ * Separate from {@link firedSlots} on purpose: this is a throttle ("do not
1291
+ * start twice inside the same hour"), while `firedSlots` is the schedule
1292
+ * ledger. Conflating the two is what let a catch-up run at 13:00 erase the
1293
+ * record of the 9 o'clock slot.
1294
+ */
1295
+ lastFiredHour?: string;
1212
1296
  /** Epoch ms of the last completed run. */
1213
1297
  lastRunAtMs?: number;
1214
1298
  /** Accounts that completed without throwing. */
@@ -1479,7 +1563,27 @@ export declare class WorkBuddyScheduler {
1479
1563
  * two hours still runs twice a day — but a job whose hour passed while DSH was
1480
1564
  * closed runs immediately on the next tick instead of waiting for tomorrow.
1481
1565
  */
1482
- private isDue;
1566
+ /**
1567
+ * The configured slot `kind` should consume at `now`, or undefined when none.
1568
+ *
1569
+ * Returns the SLOT STRING (not a boolean) because the caller must record the
1570
+ * same value it acted on. Returning a boolean was the original bug's enabler:
1571
+ * the tick asked "is it due", then `runJob` independently wrote "what time is
1572
+ * it now" — and those two answers were almost never equal.
1573
+ *
1574
+ * CATCH-UP semantics are deliberate: a candidate fires once its hour has
1575
+ * PASSED and its slot is still unconsumed, so a laptop that slept through
1576
+ * 10:00 still runs the job when it wakes, on the same day.
1577
+ *
1578
+ * The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
1579
+ * (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
1580
+ * later one for its own hour.
1581
+ */
1582
+ private dueSlot;
1583
+ /** Today's consumed slots for one job, as a set. */
1584
+ private firedSlotsOf;
1585
+ /** Record one consumed slot on a job's state. */
1586
+ private consumeSlot;
1483
1587
  private tick;
1484
1588
  /** Run one job against every eligible account and record the outcome. */
1485
1589
  /**
@@ -1512,6 +1616,12 @@ export declare class WorkBuddyScheduler {
1512
1616
  /**
1513
1617
  * Run one job against every eligible account and record the outcome.
1514
1618
  *
1619
+ * `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
1620
+ * It is passed IN rather than recomputed here so the value recorded is exactly
1621
+ * the value the schedule decided on — the defect this replaces wrote
1622
+ * `slotKey(now)` instead, i.e. the clock time the run finished at, which
1623
+ * almost never equals the configured candidate the gate had compared against.
1624
+ *
1515
1625
  * The task job runs in TWO passes. The first sends the event chains that light
1516
1626
  * up client-scored tasks; the second collects rewards. They are separate
1517
1627
  * because scoring lands asynchronously — a chain sent and claimed within the
@@ -1692,11 +1802,18 @@ export declare const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/c
1692
1802
  export declare const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
1693
1803
  /** Plugin-owned model-selection save endpoint (writes the settings section). */
1694
1804
  export declare const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
1805
+ /**
1806
+ * Throw one account out of the pool for good, or take it back.
1807
+ *
1808
+ * Separate from the disable route because the semantics differ: disabling is a
1809
+ * rotation preference the account survives, ignoring survives the account.
1810
+ */
1811
+ export declare const POOL_ACCOUNT_IGNORE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/ignored";
1695
1812
  /** Run one automation job immediately, so the card can verify it on demand. */
1696
1813
  export declare const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
1697
1814
  /** Set or clear one account's reserved-credit floor. */
1698
1815
  export declare const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
1699
- /** One pool account's row, token-free. */
1816
+ /** One account's row, token-free. */
1700
1817
  interface PoolWebAccount {
1701
1818
  id: string;
1702
1819
  label: string;
@@ -1826,6 +1943,34 @@ interface PoolWebModel {
1826
1943
  /** Whether this model is currently enabled in the picker. */
1827
1944
  enabled: boolean;
1828
1945
  }
1946
+ /**
1947
+ * Body of the account ignore/unignore route: exactly one account per request.
1948
+ *
1949
+ * `ignored: true` throws the account out of the pool for good (its credential is
1950
+ * not even read on the next scan, and a fresh desktop sign-in will not bring it
1951
+ * back). `false` restores it, at which point the next scan discovers it again.
1952
+ */
1953
+ interface PoolWebAccountIgnore {
1954
+ /** Pool account id, as reported in `PoolWebAccount.id`. */
1955
+ accountId: string;
1956
+ /** `true` ignores the account permanently; `false` takes it back. */
1957
+ ignored: boolean;
1958
+ }
1959
+ /**
1960
+ * One account the user has thrown out of the pool.
1961
+ *
1962
+ * Kept on the status document so the card can list what was ignored and offer a
1963
+ * way back: without that, "ignored" is a one-way door the user cannot inspect or
1964
+ * undo from the UI, which is how a hidden list becomes a support burden.
1965
+ */
1966
+ interface PoolWebIgnoredAccount {
1967
+ /** Pool account id, the same key `PoolWebAccount.id` uses. */
1968
+ id: string;
1969
+ /** Human label captured at ignore time, so the row reads without a rescan. */
1970
+ label: string;
1971
+ /** ISO timestamp of when it was ignored. */
1972
+ ignoredAt: string;
1973
+ }
1829
1974
  interface PoolWebModelSelection {
1830
1975
  /** Absent = every model is enabled. */
1831
1976
  enabledModelIds?: readonly string[];
@@ -1861,11 +2006,34 @@ interface PoolWebStatus {
1861
2006
  automation: PoolWebAutomation;
1862
2007
  /** Per-account credit floors currently in force, keyed by account id. */
1863
2008
  creditReserves: Readonly<Record<string, number>>;
2009
+ /**
2010
+ * Accounts thrown out of the pool, in the order they were ignored.
2011
+ *
2012
+ * Reported so the card can show the list and offer a way back. These accounts
2013
+ * are NOT in `accounts`: they are filtered out before their credentials are
2014
+ * read, which is the whole point of the feature.
2015
+ */
2016
+ ignored: readonly PoolWebIgnoredAccount[];
1864
2017
  }
1865
2018
  /** One automation job's last run, as shown on the card. */
1866
2019
  interface PoolWebAutomationJob {
1867
2020
  /** `YYYY-MM-DD` of the last run in this process, if it has run. */
1868
2021
  lastRunDate?: string;
2022
+ /**
2023
+ * Epoch ms of the last run, so the card can show the TIME.
2024
+ *
2025
+ * Carried because a date-only stamp cannot tell one run from eight: every
2026
+ * repeat inside the same day rendered as the identical `2026-09-28 · 2`,
2027
+ * which is what kept a "re-runs every hour" defect invisible on the card.
2028
+ */
2029
+ lastRunAtMs?: number;
2030
+ /**
2031
+ * Configured slots consumed today, as `YYYY-MM-DDTHH`.
2032
+ *
2033
+ * Shown so "which of today's hours already ran" is answerable at a glance
2034
+ * rather than inferred from a counter.
2035
+ */
2036
+ firedSlots?: readonly string[];
1869
2037
  /** Accounts that finished without error on the last run. */
1870
2038
  ok: number;
1871
2039
  /** Accounts that failed on the last run (each one skipped, the run continued). */
@@ -1952,6 +2120,89 @@ interface PoolWebAutomationEarnings {
1952
2120
  type PoolRegion = 'cn' | 'global';
1953
2121
  /** How the pool spreads requests across its accounts. */
1954
2122
  type PoolDistribution = 'priority' | 'round-robin' | 'balanced';
2123
+ /**
2124
+ * The schedule every automation job falls back to.
2125
+ *
2126
+ * Shared by both halves on purpose. The host uses it when a configured hour
2127
+ * list arrives empty (the settings schema materializes "never configured" into
2128
+ * `[]`), and the card uses it when it writes the `automation` block back, so a
2129
+ * document that already holds an empty list is healed instead of being saved
2130
+ * back as an unrunnable schedule.
2131
+ *
2132
+ * This lives here rather than in `scheduler.ts` because the browser half cannot
2133
+ * import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
2134
+ * upstream client, none of which exists in the browser bundle. Two hand-written
2135
+ * copies would drift, and the drift is invisible — the card would write a
2136
+ * schedule the scheduler does not run.
2137
+ */
2138
+ export declare const DEFAULT_AUTOMATION_HOURS: {
2139
+ readonly checkin: readonly [9];
2140
+ readonly report: readonly [10];
2141
+ readonly tasks: readonly [11];
2142
+ readonly streak: readonly [12];
2143
+ readonly travel: readonly [9, 21];
2144
+ };
2145
+ //#endregion
2146
+ //#region src/ignored.d.ts
2147
+ /** Directory holding this plugin's own state (imported snapshots, ignore list). */
2148
+ export declare const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
2149
+ /** File holding the permanent ignore list, inside {@link pluginDataDir}. */
2150
+ export declare const IGNORED_FILE_NAME = "ignored.json";
2151
+ /** One ignored account, as stored on disk and shown on the card. */
2152
+ type IgnoredAccount = PoolWebIgnoredAccount;
2153
+ /**
2154
+ * The DSH home directory, honouring the same override the host uses.
2155
+ *
2156
+ * Shared by the CLI and the host so both halves resolve the same file: an
2157
+ * `ignore` written from the terminal has to be visible to the running plugin,
2158
+ * which is only true if they agree on where "home" is.
2159
+ */
2160
+ export declare function dshHome(env?: NodeJS.ProcessEnv): string;
2161
+ /** This plugin's own state directory. */
2162
+ export declare function pluginDataDir(env?: NodeJS.ProcessEnv): string;
2163
+ /** Absolute path of the ignore list. */
2164
+ export declare function ignoredIdsPath(env?: NodeJS.ProcessEnv): string;
2165
+ /**
2166
+ * Read the ignore list, tolerating every "no list yet" shape.
2167
+ *
2168
+ * A missing file, unreadable file, or invalid JSON all mean the same thing to
2169
+ * the caller — nothing is ignored — so none of them throws. The pool must be
2170
+ * able to start on a machine that has never ignored anything.
2171
+ */
2172
+ export declare function readIgnoredAccounts(path?: string): Promise<IgnoredAccount[]>;
2173
+ /**
2174
+ * Synchronous read, for startup.
2175
+ *
2176
+ * The host applies the ignore list from inside `apply()`, which is synchronous,
2177
+ * and doing it there removes a startup race: an async load could resolve AFTER
2178
+ * the first account scan, which would let an ignored account slip into the pool
2179
+ * once per boot. The file is a few hundred bytes, so a blocking read at startup
2180
+ * costs nothing measurable.
2181
+ */
2182
+ export declare function readIgnoredAccountsSync(path?: string): IgnoredAccount[];
2183
+ /**
2184
+ * Replace the ignore list, atomically.
2185
+ *
2186
+ * Written to a sibling temp file and renamed over the target so a crash (or a
2187
+ * concurrent reader) can never observe a half-written document — the ignore
2188
+ * list is the only thing standing between a dead account and the rotation, and
2189
+ * a truncated file reads as "nothing is ignored", which would quietly put every
2190
+ * discarded account back in the pool.
2191
+ */
2192
+ export declare function writeIgnoredAccounts(accounts: readonly IgnoredAccount[], path?: string): Promise<void>;
2193
+ /**
2194
+ * Add one account to the ignore list, preserving the rest.
2195
+ *
2196
+ * A read-modify-write rather than a wholesale replace: the card and the CLI can
2197
+ * both be open, and each request names exactly one account, so re-writing the
2198
+ * whole list from a stale view would drop the other side's edits.
2199
+ */
2200
+ export declare function ignoreAccount(account: {
2201
+ id: string;
2202
+ label?: string;
2203
+ }, path?: string): Promise<IgnoredAccount[]>;
2204
+ /** Drop one account from the ignore list. Returns the resulting list. */
2205
+ export declare function unignoreAccount(accountId: string, path?: string): Promise<IgnoredAccount[]>;
1955
2206
  //#endregion
1956
2207
  //#region src/web-status.d.ts
1957
2208
  /** Constructor dependencies — a narrow slice of the pool runtime. */
@@ -2005,6 +2256,21 @@ interface PoolStatusRouteOptions {
2005
2256
  * Absent without a settings service: the route then answers 503.
2006
2257
  */
2007
2258
  setAccountDisabled?: (accountId: string, disabled: boolean) => Promise<void> | void;
2259
+ /**
2260
+ * Throw one account out of the pool for good, or take it back.
2261
+ *
2262
+ * Backed by the plugin's own ignore file rather than the settings document,
2263
+ * because the CLI writes the same list and has no settings service. Absent
2264
+ * when the host did not wire it: the route then answers 503.
2265
+ */
2266
+ setAccountIgnored?: (accountId: string, ignored: boolean) => Promise<void> | void;
2267
+ /**
2268
+ * The accounts currently ignored, for the card's "ignored" list.
2269
+ *
2270
+ * A thunk rather than a snapshot so the document always reflects the file on
2271
+ * disk, including edits made by the CLI while the card is open.
2272
+ */
2273
+ ignoredAccounts?: () => readonly PoolWebIgnoredAccount[];
2008
2274
  }
2009
2275
  /**
2010
2276
  * Assemble the card's status document. Per-account credits and check-in state
@@ -2214,4 +2480,4 @@ export declare function createCore(logger?: {
2214
2480
  */
2215
2481
  export declare function apply(ctx: Context, config?: Config): void;
2216
2482
  //#endregion
2217
- export type { AccountStatus, AutomationLedger, AutomationRunSummary, AutomationStatus, Context, ExpertUseMode, MarketExpert, ModelSelection, PoolStatusRouteOptions, PoolWebCheckin, PoolWebCheckinClaim, PoolWebModel, PoolWebModelSelection, PoolWebStatus, SchedulerLogger, TaskEventChain, TaskEventTransport, UpstreamErrorKind, WorkBuddyAccount, WorkBuddyAdapter, WorkBuddyCredential, WorkBuddyModelInfo, WorkBuddyShim, WorkBuddyStatus };
2483
+ export type { AccountStatus, AutomationLedger, AutomationRunSummary, AutomationStatus, Context, ExpertUseMode, IgnoredAccount, MarketExpert, ModelSelection, PoolStatusRouteOptions, PoolWebAccountIgnore, PoolWebCheckin, PoolWebCheckinClaim, PoolWebIgnoredAccount, PoolWebModel, PoolWebModelSelection, PoolWebStatus, SchedulerLogger, TaskEventChain, TaskEventTransport, UpstreamErrorKind, WorkBuddyAccount, WorkBuddyAdapter, WorkBuddyCredential, WorkBuddyModelInfo, WorkBuddyShim, WorkBuddyStatus };