@iasiv5/dsh-quota-watch 0.1.29 → 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
@@ -1579,7 +1579,21 @@ function mountQuotaCard({
1579
1579
  if (!disposed) place();
1580
1580
  });
1581
1581
  };
1582
- 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;
1583
1597
  schedulePlace();
1584
1598
  });
1585
1599
  observer.observe(doc.body, { childList: true, subtree: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.29",
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",
package/src/client.mjs CHANGED
@@ -1710,7 +1710,42 @@ export function mountQuotaCard({
1710
1710
  })
1711
1711
  }
1712
1712
 
1713
- 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
1714
1749
  schedulePlace()
1715
1750
  })
1716
1751
  observer.observe(doc.body, { childList: true, subtree: true })
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