@iasiv5/dsh-quota-watch 0.1.28 → 0.1.30

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/README.md CHANGED
@@ -48,7 +48,7 @@ Web 与 Desktop profile 均已实测适配。按 DSH 插件管理器提示完成
48
48
 
49
49
  ## 配置
50
50
 
51
- 本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60 秒,只读额度轮询节奏)。
51
+ 本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60 秒,只读额度轮询节奏;无任何插件路由请求持续 2 个轮询周期时宿主暂停计划轮询——空闲暂停,下一次请求立即恢复,先答缓存、后台补一轮新数据;暂停不算刷新失败、不标记数据过期)。
52
52
 
53
53
  ## 重置卡与手动用卡
54
54
 
package/lib/client.js CHANGED
@@ -198,7 +198,7 @@ function parseInset(computedValue) {
198
198
 
199
199
  // src/client.mjs
200
200
  var name = "quota-watch-client";
201
- var inject = [];
201
+ var inject = ["slots"];
202
202
  var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
203
203
  var FLOAT_SELECTOR = "[data-dsh-quota-watch-float]";
204
204
  var FETCH_TIMEOUT_MS = 15e3;
@@ -655,7 +655,10 @@ function mountQuotaCard({
655
655
  retryBackoff = 2,
656
656
  // Q16: a confirmed SUCCESS notice lingers about five seconds before the
657
657
  // panel returns to the latest inventory. Injectable for tests.
658
- outcomeNoticeMs = 5e3
658
+ outcomeNoticeMs = 5e3,
659
+ // Shell-takeover sentinel (2026-10-13 ds-bot): inject `ctx.slots` to arm it.
660
+ // Injectable so DOM tests and exotic hosts keep the legacy behavior.
661
+ slots = null
659
662
  } = {}) {
660
663
  if (!doc?.body || typeof fetchImpl !== "function") return () => {
661
664
  };
@@ -1459,12 +1462,22 @@ function mountQuotaCard({
1459
1462
  let currentFoot = null;
1460
1463
  let footTooNarrow = false;
1461
1464
  let noDataHidden = false;
1465
+ let shellTakeover = false;
1466
+ const syncShellTakeover = () => {
1467
+ try {
1468
+ shellTakeover = (slots?.entries?.("sidebar.workspaces") ?? []).some(
1469
+ (entry) => (entry.options.priority ?? 0) < 0
1470
+ );
1471
+ } catch {
1472
+ shellTakeover = false;
1473
+ }
1474
+ };
1462
1475
  const syncFootNarrowness = () => {
1463
1476
  footTooNarrow = currentFoot !== null && currentFoot.clientWidth > 0 && currentFoot.clientWidth < CARD_MIN_WIDTH;
1464
1477
  };
1465
1478
  const syncCardVisibility = () => {
1466
1479
  syncFootNarrowness();
1467
- const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden;
1480
+ const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden && !shellTakeover;
1468
1481
  container.hidden = !showCard;
1469
1482
  card.style.display = showCard ? "" : "none";
1470
1483
  };
@@ -1566,7 +1579,21 @@ function mountQuotaCard({
1566
1579
  if (!disposed) place();
1567
1580
  });
1568
1581
  };
1569
- const observer = new win.MutationObserver(() => {
1582
+ const SEAT_MARKERS = ["sidebarCol", "footArea"];
1583
+ const seatValid = () => currentFoot !== null && currentFoot.isConnected && container.parentElement === currentFoot && currentFoot.firstElementChild === container;
1584
+ const nodeTouchesSeatDiscovery = (node) => {
1585
+ if (node?.nodeType !== 1) return false;
1586
+ const cls = node.getAttribute("class") ?? "";
1587
+ if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true;
1588
+ return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null;
1589
+ };
1590
+ const batchTouchesSeatDiscovery = (records) => records.some((record) => {
1591
+ const touched = [...record.addedNodes ?? [], ...record.removedNodes ?? []];
1592
+ return touched.some(nodeTouchesSeatDiscovery);
1593
+ });
1594
+ const observer = new win.MutationObserver((records) => {
1595
+ const noLiveFoot = currentFoot === null || !currentFoot.isConnected;
1596
+ if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return;
1570
1597
  schedulePlace();
1571
1598
  });
1572
1599
  observer.observe(doc.body, { childList: true, subtree: true });
@@ -1574,6 +1601,18 @@ function mountQuotaCard({
1574
1601
  if (!disposed) renderAll();
1575
1602
  }) : null;
1576
1603
  langObserver?.observe(doc.documentElement, { attributeFilter: ["lang"] });
1604
+ let offShellTakeover = null;
1605
+ try {
1606
+ if (slots && typeof slots.subscribe === "function") {
1607
+ offShellTakeover = slots.subscribe("sidebar.workspaces", () => {
1608
+ if (disposed) return;
1609
+ syncShellTakeover();
1610
+ syncCardVisibility();
1611
+ });
1612
+ }
1613
+ } catch {
1614
+ }
1615
+ syncShellTakeover();
1577
1616
  syncFloatVisibility();
1578
1617
  place();
1579
1618
  const nextPollDelay = () => {
@@ -1721,6 +1760,7 @@ function mountQuotaCard({
1721
1760
  outcomeNoticeTimer = void 0;
1722
1761
  observer.disconnect();
1723
1762
  langObserver?.disconnect();
1763
+ if (offShellTakeover) offShellTakeover();
1724
1764
  doc.removeEventListener("visibilitychange", onVisibilityChange);
1725
1765
  doc.removeEventListener("pointerdown", onDocPointerDown, true);
1726
1766
  doc.removeEventListener("scroll", onDocScroll, true);
@@ -1739,7 +1779,7 @@ function mountQuotaCard({
1739
1779
  };
1740
1780
  }
1741
1781
  function apply(ctx) {
1742
- ctx.effect(() => mountQuotaCard(), "dsh-quota-watch: sidebar");
1782
+ ctx.effect(() => mountQuotaCard({ slots: ctx.slots ?? null }), "dsh-quota-watch: sidebar");
1743
1783
  }
1744
1784
 
1745
1785
  return module.exports;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
@@ -49,7 +49,9 @@
49
49
  },
50
50
  "client": {
51
51
  "platform": "web",
52
- "inject": []
52
+ "inject": [
53
+ "slots"
54
+ ]
53
55
  }
54
56
  },
55
57
  "scripts": {
package/src/client.mjs CHANGED
@@ -5,7 +5,12 @@ import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosit
5
5
  import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, parseInset, clampFrame, dragSlop, grabOffset } from './client/drag.mjs'
6
6
 
7
7
  export const name = 'quota-watch-client'
8
- export const inject = []
8
+ // 2026-10-13: the shell-takeover sentinel reads the OFFICIAL slots ledger, so
9
+ // the client now touches ctx.slots — cordis denies undeclared service access
10
+ // ("cannot get property 'slots' without inject") and the whole activation
11
+ // dies. Declare BEFORE using; the bundle + package.json dsh.client.inject
12
+ // must stay in lockstep (locked by tests/package-shape.test.mjs).
13
+ export const inject = ['slots']
9
14
 
10
15
  const CARD_SELECTOR = '[data-dsh-quota-watch-card]'
11
16
  const FLOAT_SELECTOR = '[data-dsh-quota-watch-float]'
@@ -556,6 +561,9 @@ export function mountQuotaCard({
556
561
  // Q16: a confirmed SUCCESS notice lingers about five seconds before the
557
562
  // panel returns to the latest inventory. Injectable for tests.
558
563
  outcomeNoticeMs = 5_000,
564
+ // Shell-takeover sentinel (2026-10-13 ds-bot): inject `ctx.slots` to arm it.
565
+ // Injectable so DOM tests and exotic hosts keep the legacy behavior.
566
+ slots = null,
559
567
  } = {}) {
560
568
  if (!doc?.body || typeof fetchImpl !== 'function') return () => {}
561
569
  if (doc.querySelector(CARD_SELECTOR) || doc.querySelector(FLOAT_SELECTOR)) return () => {}
@@ -1552,6 +1560,23 @@ export function mountQuotaCard({
1552
1560
  let currentFoot = null
1553
1561
  let footTooNarrow = false
1554
1562
  let noDataHidden = false
1563
+ // The sidebar foot belongs to whoever registers sidebar.workspaces
1564
+ // (ui-sidebar doc: "the foot is the sidebar.workspaces registrant's"). The
1565
+ // official workspace tree registers at default priority 0 (ui-workspace); a
1566
+ // NEGATIVE-priority occupant is a takeover shell (ds-bot takes the seat at
1567
+ // -10 for its native bot sidebar) — while one is live the injected card
1568
+ // yields the whole foot, same contract as the dsh-skins/dsh-m footer rows.
1569
+ // The signal is the OFFICIAL slot ledger, not any ds-bot internal.
1570
+ let shellTakeover = false
1571
+ const syncShellTakeover = () => {
1572
+ try {
1573
+ shellTakeover = (slots?.entries?.('sidebar.workspaces') ?? []).some(
1574
+ (entry) => (entry.options.priority ?? 0) < 0,
1575
+ )
1576
+ } catch {
1577
+ shellTakeover = false
1578
+ }
1579
+ }
1555
1580
  const syncFootNarrowness = () => {
1556
1581
  footTooNarrow = currentFoot !== null && currentFoot.clientWidth > 0 && currentFoot.clientWidth < CARD_MIN_WIDTH
1557
1582
  }
@@ -1564,7 +1589,7 @@ export function mountQuotaCard({
1564
1589
  // The sidebar card shows ONLY in an expanded sidebar: the width guard hides
1565
1590
  // it in narrow rails / squeezed containers (works on every profile), and no
1566
1591
  // host collapse signal is consulted at all (they proved host-specific).
1567
- const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden
1592
+ const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden && !shellTakeover
1568
1593
  container.hidden = !showCard
1569
1594
  card.style.display = showCard ? '' : 'none'
1570
1595
  }
@@ -1685,7 +1710,42 @@ export function mountQuotaCard({
1685
1710
  })
1686
1711
  }
1687
1712
 
1688
- const observer = new win.MutationObserver(() => {
1713
+ // Seat-gate (2026-10-15 perf): the body observer sees EVERY childList batch
1714
+ // in the whole app — during chat streaming that is a near-continuous stream —
1715
+ // and each scheduled place() used to open with an O(document) discovery scan
1716
+ // (`[class*="sidebarCol"]` cannot use a class index). The gate skips the
1717
+ // schedule entirely when this batch cannot change the seating:
1718
+ // • Seat valid (card still first child of a connected foot): only a batch
1719
+ // carrying a sidebarCol/footArea node — added OR removed, the element
1720
+ // itself or anywhere inside it — can change WHICH foot is the doc-order
1721
+ // first one or create a competing one. No such node ⇒ place() would be a
1722
+ // measured no-op.
1723
+ // • No live foot (null, or stale after the sidebar was torn down): a seat
1724
+ // can only appear through a sidebar-introducing node, and class-only
1725
+ // flips were already invisible to this childList-only observer.
1726
+ // A displacement inside a live foot fails seatValid() and re-arms place().
1727
+ // Invariant for the ResizeObserver rebind in place(): any batch that could
1728
+ // change which element IS the foot necessarily carries a marker node, so the
1729
+ // gate never masks a real foot swap (jsdom has no ResizeObserver; this
1730
+ // invariant is argued here, not exercised by tests). Marker-carrying batches
1731
+ // that change nothing still pay one discovery scan — the false-positive cost.
1732
+ const SEAT_MARKERS = ['sidebarCol', 'footArea']
1733
+ const seatValid = () => currentFoot !== null && currentFoot.isConnected
1734
+ && container.parentElement === currentFoot
1735
+ && currentFoot.firstElementChild === container
1736
+ const nodeTouchesSeatDiscovery = (node) => {
1737
+ if (node?.nodeType !== 1) return false
1738
+ const cls = node.getAttribute('class') ?? ''
1739
+ if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true
1740
+ return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null
1741
+ }
1742
+ const batchTouchesSeatDiscovery = (records) => records.some((record) => {
1743
+ const touched = [...(record.addedNodes ?? []), ...(record.removedNodes ?? [])]
1744
+ return touched.some(nodeTouchesSeatDiscovery)
1745
+ })
1746
+ const observer = new win.MutationObserver((records) => {
1747
+ const noLiveFoot = currentFoot === null || !currentFoot.isConnected
1748
+ if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return
1689
1749
  schedulePlace()
1690
1750
  })
1691
1751
  observer.observe(doc.body, { childList: true, subtree: true })
@@ -1696,6 +1756,21 @@ export function mountQuotaCard({
1696
1756
  ? new win.MutationObserver(() => { if (!disposed) renderAll() })
1697
1757
  : null
1698
1758
  langObserver?.observe(doc.documentElement, { attributeFilter: ['lang'] })
1759
+ // Arm the shell-takeover sentinel: re-probe on every workspaces-ledger
1760
+ // mutation (microtask-batched by the slot core) and once up front. Without
1761
+ // an injected slots service (DOM tests, exotic hosts) this is a no-op and
1762
+ // the card behaves exactly as before.
1763
+ let offShellTakeover = null
1764
+ try {
1765
+ if (slots && typeof slots.subscribe === 'function') {
1766
+ offShellTakeover = slots.subscribe('sidebar.workspaces', () => {
1767
+ if (disposed) return
1768
+ syncShellTakeover()
1769
+ syncCardVisibility()
1770
+ })
1771
+ }
1772
+ } catch {}
1773
+ syncShellTakeover()
1699
1774
  // Cold until data lands: keep the shell hidden so a slow or failed first
1700
1775
  // probe never flashes an empty capsule (startPolling arms the retry chain).
1701
1776
  syncFloatVisibility()
@@ -1870,6 +1945,7 @@ export function mountQuotaCard({
1870
1945
  outcomeNoticeTimer = undefined
1871
1946
  observer.disconnect()
1872
1947
  langObserver?.disconnect()
1948
+ if (offShellTakeover) offShellTakeover()
1873
1949
  doc.removeEventListener('visibilitychange', onVisibilityChange)
1874
1950
  doc.removeEventListener('pointerdown', onDocPointerDown, true)
1875
1951
  doc.removeEventListener('scroll', onDocScroll, true)
@@ -1890,5 +1966,5 @@ export function mountQuotaCard({
1890
1966
  }
1891
1967
 
1892
1968
  export function apply(ctx) {
1893
- ctx.effect(() => mountQuotaCard(), 'dsh-quota-watch: sidebar')
1969
+ ctx.effect(() => mountQuotaCard({ slots: ctx.slots ?? null }), 'dsh-quota-watch: sidebar')
1894
1970
  }
package/src/host.mjs CHANGED
@@ -17,6 +17,7 @@ import { retireLegacyAutoResetSettings } from './core/legacy-auto-settings.mjs'
17
17
  import { createManualOutcomeJournal } from './core/manual-outcome.mjs'
18
18
  import {
19
19
  DEFAULT_POLL_INTERVAL_SEC,
20
+ IDLE_AFTER_POLL_INTERVALS,
20
21
  MAX_POLL_INTERVAL_SEC,
21
22
  MIN_POLL_INTERVAL_SEC,
22
23
  PROBE_TIMEOUT_MS,
@@ -128,6 +129,15 @@ function requestAllowed(ctx, req, res) {
128
129
  return false
129
130
  }
130
131
 
132
+ /** The access fence plus the idle-pause watcher stamp: any allowed request
133
+ * proves a browser is watching, so it feeds lastClientSeenAt. The call is
134
+ * defensive because route tests may pass minimal service doubles. */
135
+ function requestAllowedAndSeen(ctx, req, res, service) {
136
+ if (!requestAllowed(ctx, req, res)) return false
137
+ service?.noteClientSeen?.()
138
+ return true
139
+ }
140
+
131
141
  /**
132
142
  * Where the legacy automatic-use switches persisted. The start()-time one-shot
133
143
  * retirement keeps this file overwritten all-off, so a rollback to an old
@@ -171,6 +181,20 @@ export function createQuotaService(ctx, options = {}) {
171
181
  let coldAttempts = 0
172
182
  let legacyRetirement
173
183
 
184
+ // --- Idle polling pause -----------------------------------------------------
185
+ // The scheduled poll serves browsers only. Once idleAfterMs passes with no
186
+ // request to any plugin route (every allowed route hit stamps
187
+ // lastClientSeenAt), timer fires skip the round entirely; the next client
188
+ // request resumes with a stale-while-revalidate kick. start() stamps once so
189
+ // the boot round always runs (the first client GET then meets data, not
190
+ // 'loading'). An undefined stamp (service built but never started, as in
191
+ // tests) never counts as idle, so direct refresh() calls behave as before.
192
+ const idleAfterMs = options.idleAfterMs ?? pollIntervalSec * 1000 * IDLE_AFTER_POLL_INTERVALS
193
+ let lastClientSeenAt
194
+ let idleActive = false
195
+ const idleNow = () => lastClientSeenAt !== undefined && now() - lastClientSeenAt >= idleAfterMs
196
+ const snapshotStale = () => updatedAt === undefined || now() - updatedAt >= pollIntervalSec * 1000
197
+
174
198
  /** The usage slice a browser may see: old values survive only while the failure episode is short. */
175
199
  function usageView(usage) {
176
200
  if (usage === undefined) return undefined
@@ -444,6 +468,17 @@ export function createQuotaService(ctx, options = {}) {
444
468
  // --- Reset cards -----------------------------------------------------------
445
469
 
446
470
  function refresh(forceCards = false) {
471
+ // Idle pause: a timer-driven round with no recent client serves the cache
472
+ // and skips every upstream probe. A forced refresh is exempt, and any
473
+ // client-driven path has just stamped lastClientSeenAt, so it never lands
474
+ // here (one check covers both callers).
475
+ if (forceCards !== true && idleNow()) {
476
+ if (!idleActive) {
477
+ idleActive = true
478
+ logAuto('info', `quota-watch: idle — no client request for ${Math.round(idleAfterMs / 1000)}s, scheduled poll paused`)
479
+ }
480
+ return Promise.resolve(view())
481
+ }
447
482
  if (pollPromise !== undefined) {
448
483
  // A manual refresh must still re-list cards when it overlaps a scheduled
449
484
  // round whose ten-minute gate skipped the list.
@@ -796,6 +831,19 @@ export function createQuotaService(ctx, options = {}) {
796
831
  }
797
832
  }
798
833
 
834
+ /** Any allowed route hit: stamp the watcher clock, and — if the snapshot
835
+ * went stale during a pause — kick a background round. Stale-while-
836
+ * revalidate: the caller serves the cached view immediately and never waits
837
+ * for the kick. Returns the kicked round's promise for tests. */
838
+ function noteClientSeen() {
839
+ const resuming = idleActive
840
+ lastClientSeenAt = now()
841
+ idleActive = false
842
+ if (resuming) logAuto('info', 'quota-watch: client request — scheduled poll resumed')
843
+ if (disposed || pollPromise !== undefined || !snapshotStale()) return undefined
844
+ return refresh()
845
+ }
846
+
799
847
  function schedule(delayMs) {
800
848
  if (disposed) return
801
849
  timer = setTimeout(async () => {
@@ -825,6 +873,7 @@ export function createQuotaService(ctx, options = {}) {
825
873
  return {
826
874
  overview: view,
827
875
  refresh,
876
+ noteClientSeen,
828
877
  listResetCards,
829
878
  useResetCard,
830
879
  acknowledgeManualOutcome,
@@ -842,6 +891,9 @@ export function createQuotaService(ctx, options = {}) {
842
891
  } else {
843
892
  logAuto('warn', `quota-watch: could not overwrite the legacy auto-reset settings (${legacyRetirement.error}); a rollback to an old version could revive its switches, but this host has no automatic spending path`)
844
893
  }
894
+ // Boot counts as watched for the first idle window: the initial round
895
+ // always runs, so the first client GET meets data instead of 'loading'.
896
+ lastClientSeenAt = now()
845
897
  // Routes register synchronously in apply() before start(), so probing
846
898
  // immediately is safe; the legacy 2s delay only padded the cold start.
847
899
  schedule(0)
@@ -861,7 +913,7 @@ export function makeOverviewRoute(ctx, service) {
861
913
  kind: 'exact',
862
914
  path: ROUTES.overview,
863
915
  handler: (req, res) => {
864
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'GET')) return
916
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
865
917
  responseJson(res, 200, service.overview())
866
918
  },
867
919
  }
@@ -872,7 +924,7 @@ export function makeRefreshRoute(ctx, service) {
872
924
  kind: 'exact',
873
925
  path: ROUTES.refresh,
874
926
  handler: async (req, res) => {
875
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
927
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
876
928
  try {
877
929
  await service.refresh(true)
878
930
  responseJson(res, 200, service.overview())
@@ -889,7 +941,7 @@ export function makeResetCardsRoute(ctx, service) {
889
941
  kind: 'exact',
890
942
  path: ROUTES.resetCards,
891
943
  handler: async (req, res) => {
892
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'GET')) return
944
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
893
945
  try {
894
946
  const result = await service.listResetCards()
895
947
  responseJson(res, result.ok ? 200 : 502, result)
@@ -912,7 +964,7 @@ export function makeResetUseRoute(ctx, service) {
912
964
  kind: 'exact',
913
965
  path: ROUTES.resetUse,
914
966
  handler: async (req, res) => {
915
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
967
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
916
968
  const payload = await readJsonBody(req)
917
969
  if (payload === undefined) {
918
970
  responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
@@ -948,7 +1000,7 @@ export function makeResetAcknowledgeRoute(ctx, service) {
948
1000
  kind: 'exact',
949
1001
  path: ROUTES.resetAcknowledge,
950
1002
  handler: async (req, res) => {
951
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
1003
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
952
1004
  const payload = await readJsonBody(req)
953
1005
  if (payload === undefined) {
954
1006
  responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
@@ -975,7 +1027,7 @@ export function makeResetReceiptRoute(ctx, service) {
975
1027
  kind: 'exact',
976
1028
  path: ROUTES.resetReceipt,
977
1029
  handler: async (req, res) => {
978
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
1030
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
979
1031
  const payload = await readJsonBody(req)
980
1032
  if (payload === undefined) {
981
1033
  responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
@@ -1000,12 +1052,12 @@ const RETIRED_AUTO_RESET_PATH = '/api/dsh-quota-watch/auto-reset'
1000
1052
  * The path is a private host constant precisely so it never re-enters the
1001
1053
  * shared client contract in src/shared.mjs.
1002
1054
  */
1003
- export function makeRetiredAutoResetRoute(ctx) {
1055
+ export function makeRetiredAutoResetRoute(ctx, service) {
1004
1056
  return {
1005
1057
  kind: 'exact',
1006
1058
  path: RETIRED_AUTO_RESET_PATH,
1007
1059
  handler: async (req, res) => {
1008
- if (!requestAllowed(ctx, req, res) || !methodAllowed(req, res, 'POST')) return
1060
+ if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
1009
1061
  responseJson(res, 410, { ok: false, error: 'retired', message: '自动用卡功能已退役;重置卡请在面板手动确认使用' })
1010
1062
  },
1011
1063
  }
@@ -1039,7 +1091,7 @@ export function apply(ctx, config = {}) {
1039
1091
  ctx.webServer.register(makeResetUseRoute(ctx, service)),
1040
1092
  ctx.webServer.register(makeResetAcknowledgeRoute(ctx, service)),
1041
1093
  ctx.webServer.register(makeResetReceiptRoute(ctx, service)),
1042
- ctx.webServer.register(makeRetiredAutoResetRoute(ctx)),
1094
+ ctx.webServer.register(makeRetiredAutoResetRoute(ctx, service)),
1043
1095
  ]
1044
1096
  service.start()
1045
1097
  ctx.effect(() => () => {
package/src/shared.mjs CHANGED
@@ -31,3 +31,7 @@ export const MIN_POLL_INTERVAL_SEC = 30
31
31
  export const MAX_POLL_INTERVAL_SEC = 3600
32
32
  export const PROBE_TIMEOUT_MS = 10_000
33
33
  export const CLIENT_POLL_INTERVAL_MS = 30_000
34
+ // Idle polling pause: a scheduled round is skipped once this many poll
35
+ // intervals have passed without any request to a plugin route (every allowed
36
+ // route hit stamps the watcher clock).
37
+ export const IDLE_AFTER_POLL_INTERVALS = 2