dsh-homepage 0.1.4 → 0.1.5

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/docs/card-api.md CHANGED
@@ -80,7 +80,7 @@ including a built-in one — requires `override: true`, otherwise it throws.
80
80
  | `effects` | `{outline, ripple, sheen, float, spotlight}` | all off | Animation defaults (see Effects). |
81
81
  | `refreshMs` | number | `0` | Refresh hint for polling bodies; the card still owns its timer. |
82
82
  | `expandable` / `draggable` / `resizable` | boolean | `true` | Turn that interaction off. |
83
- | `onMount` / `onExpand` / `onCollapse` | `(api) => void` | `null` | Lifecycle callbacks. |
83
+ | `onMount` / `onExpand` / `onCollapse` | `(api) => void` | `null` | Lifecycle callbacks. `onMount` fires the first time the card is actually on the wall — a hidden card runs nothing until it is shown. |
84
84
  | `tags` / `order` / `version` | string[] / number / string | `[]` / `0` / `null` | Manifest tags, initial position hint (drag order wins) and registrant version. |
85
85
  | `dict` | `{zh, en}` | `null` | Card-local strings merged into the locale service. |
86
86
  | `override` | boolean | `false` | Required to replace another plugin's card. |
@@ -105,7 +105,9 @@ including a built-in one — requires `override: true`, otherwise it throws.
105
105
 
106
106
  Framework hooks are on `hooks`: `useStore()`, `useRemote(endpoint, args, {refreshMs, enabled})`
107
107
  (returns `{data, error, busy, reload, at, failures}`), `useClock(ms)`, `useNoticeOnce(api, key, level, text)`
108
- and `useAnnounce(api, key, notice)`.
108
+ and `useAnnounce(api, key, notice)`. A poll is scheduled one tick at a time with a little
109
+ jitter (about ±10%) so cards mounted together do not keep refreshing together, and
110
+ `enabled: false` (or `refreshMs: 0`) stops the timer entirely.
109
111
 
110
112
  ## Controls (`actions` / `detailActions`)
111
113
 
@@ -139,6 +139,12 @@
139
139
 
140
140
  /**
141
141
  * The polling interval, in ms, that the card's own control asks for.
142
+ *
143
+ * Five seconds is the original cadence and stays: what makes a fast poll
144
+ * cheap is the host, which reads CPU and memory in-process and refreshes
145
+ * the GPU/temperature probes on their own slower, cached schedule instead
146
+ * of paying for one on every request. A slower control is still offered for
147
+ * readers who want it.
142
148
  * @param api - the card's API bag.
143
149
  * @returns the interval in ms, at least one second.
144
150
  */
package/lib/client.js CHANGED
@@ -575,6 +575,9 @@ window.__ModuleLoader__.load({
575
575
  card: next,
576
576
  previous: existing || null,
577
577
  })
578
+ // A card registered after the wall booted is mounted only if it is on
579
+ // the wall; a hidden one waits until it is shown.
580
+ if (bootstrapped && store.hostReady && cardVisible(next)) mountCard(next)
578
581
  return next
579
582
  }
580
583
 
@@ -589,6 +592,7 @@ window.__ModuleLoader__.load({
589
592
  registry.splice(registry.indexOf(card), 1)
590
593
  delete registryById[id]
591
594
  delete store.notifications[id]
595
+ delete mountedCards[id]
592
596
  if (store.expanded === id) store.expanded = null
593
597
  if (store.expanded === null) store.focusFrom = null
594
598
  bump()
@@ -1310,14 +1314,35 @@ window.__ModuleLoader__.load({
1310
1314
  }, [endpoint, argsKey])
1311
1315
 
1312
1316
  React.useEffect(function () {
1317
+ if (settings.enabled === false) {
1318
+ alive.current = false
1319
+ return undefined
1320
+ }
1313
1321
  alive.current = true
1314
- if (settings.enabled === false) return undefined
1315
1322
  void load(false)
1316
1323
  if (!settings.refreshMs) return function () { alive.current = false }
1317
- var timer = setInterval(function () { void load(true) }, settings.refreshMs)
1324
+ var base = Math.max(1000, Number(settings.refreshMs) || 0)
1325
+ var timer = null
1326
+ // Re-arm one timer at a time with a little jitter. Cards that mounted
1327
+ // together would otherwise refresh together forever, and every peak is
1328
+ // paid by the host at once; ±10% spreads them without changing the
1329
+ // cadence a reader sees.
1330
+ var arm = function () {
1331
+ var delay = Math.round(base * (0.9 + Math.random() * 0.2))
1332
+ timer = setTimeout(function () {
1333
+ timer = null
1334
+ if (!alive.current) return
1335
+ void load(true)
1336
+ arm()
1337
+ }, delay)
1338
+ // A card test (or a headless host) must not be kept alive by a poll
1339
+ // it never sees fire; a browser timer has no unref and ignores this.
1340
+ if (timer && typeof timer.unref === 'function') timer.unref()
1341
+ }
1342
+ arm()
1318
1343
  return function () {
1319
1344
  alive.current = false
1320
- clearInterval(timer)
1345
+ if (timer !== null) clearTimeout(timer)
1321
1346
  }
1322
1347
  }, [load, settings.refreshMs, settings.enabled])
1323
1348
 
@@ -1584,6 +1609,8 @@ window.__ModuleLoader__.load({
1584
1609
  if (!visible) hidden.push(card.id)
1585
1610
  if (!visible && store.expanded === card.id) collapseCard()
1586
1611
  patchState({ hidden: hidden })
1612
+ // A card is mounted when it reaches the wall, not when it was defined.
1613
+ if (visible) mountCard(card)
1587
1614
  }
1588
1615
 
1589
1616
  function textureLayer(texture, card) {
@@ -2928,11 +2955,33 @@ window.__ModuleLoader__.load({
2928
2955
  // -------------------------------------------------------------- bootstrap
2929
2956
 
2930
2957
  var bootstrapped = false
2958
+ /** Cards whose `onMount` already ran; one entry per card id. */
2959
+ var mountedCards = Object.create(null)
2960
+
2961
+ /**
2962
+ * Run a card's `onMount` the first time it is actually on the wall.
2963
+ *
2964
+ * A hidden card is disabled: it is not rendered, it polls nothing, and it
2965
+ * must not get lifecycle work either. The old bootstrap ran every
2966
+ * registered card's `onMount` before the persisted visibility was known,
2967
+ * so a disabled card still armed whatever its hook armed.
2968
+ * @param card - a registered card.
2969
+ */
2970
+ function mountCard(card) {
2971
+ if (mountedCards[card.id] === true) return
2972
+ mountedCards[card.id] = true
2973
+ runOnMount(card)
2974
+ }
2975
+
2976
+ /** Mount every card the persisted layout puts on the wall. */
2977
+ function mountVisibleCards() {
2978
+ orderedCards().filter(cardVisible).forEach(mountCard)
2979
+ }
2980
+
2931
2981
  /** Load the persisted layout and install card hooks exactly once. */
2932
2982
  function bootstrap() {
2933
2983
  if (bootstrapped) return Promise.resolve()
2934
2984
  bootstrapped = true
2935
- registry.forEach(function (card) { runOnMount(card) })
2936
2985
  return remoteCall('homepage/state', {}).then(function (persisted) {
2937
2986
  if (persisted && typeof persisted === 'object') {
2938
2987
  store.state = stateDocumentOf(persisted)
@@ -2946,6 +2995,11 @@ window.__ModuleLoader__.load({
2946
2995
  store.hostReady = true
2947
2996
  bump()
2948
2997
  return null
2998
+ }).then(function (value) {
2999
+ // Only after the visibility document is in hand: a card the user hid
3000
+ // is not mounted and runs nothing.
3001
+ mountVisibleCards()
3002
+ return value
2949
3003
  })
2950
3004
  }
2951
3005
 
@@ -6222,6 +6276,12 @@ var STYLES = [
6222
6276
 
6223
6277
  /**
6224
6278
  * The polling interval, in ms, that the card's own control asks for.
6279
+ *
6280
+ * Five seconds is the original cadence and stays: what makes a fast poll
6281
+ * cheap is the host, which reads CPU and memory in-process and refreshes
6282
+ * the GPU/temperature probes on their own slower, cached schedule instead
6283
+ * of paying for one on every request. A slower control is still offered for
6284
+ * readers who want it.
6225
6285
  * @param api - the card's API bag.
6226
6286
  * @returns the interval in ms, at least one second.
6227
6287
  */
@@ -563,6 +563,9 @@ window.__ModuleLoader__.load({
563
563
  card: next,
564
564
  previous: existing || null,
565
565
  })
566
+ // A card registered after the wall booted is mounted only if it is on
567
+ // the wall; a hidden one waits until it is shown.
568
+ if (bootstrapped && store.hostReady && cardVisible(next)) mountCard(next)
566
569
  return next
567
570
  }
568
571
 
@@ -577,6 +580,7 @@ window.__ModuleLoader__.load({
577
580
  registry.splice(registry.indexOf(card), 1)
578
581
  delete registryById[id]
579
582
  delete store.notifications[id]
583
+ delete mountedCards[id]
580
584
  if (store.expanded === id) store.expanded = null
581
585
  if (store.expanded === null) store.focusFrom = null
582
586
  bump()
@@ -1298,14 +1302,35 @@ window.__ModuleLoader__.load({
1298
1302
  }, [endpoint, argsKey])
1299
1303
 
1300
1304
  React.useEffect(function () {
1305
+ if (settings.enabled === false) {
1306
+ alive.current = false
1307
+ return undefined
1308
+ }
1301
1309
  alive.current = true
1302
- if (settings.enabled === false) return undefined
1303
1310
  void load(false)
1304
1311
  if (!settings.refreshMs) return function () { alive.current = false }
1305
- var timer = setInterval(function () { void load(true) }, settings.refreshMs)
1312
+ var base = Math.max(1000, Number(settings.refreshMs) || 0)
1313
+ var timer = null
1314
+ // Re-arm one timer at a time with a little jitter. Cards that mounted
1315
+ // together would otherwise refresh together forever, and every peak is
1316
+ // paid by the host at once; ±10% spreads them without changing the
1317
+ // cadence a reader sees.
1318
+ var arm = function () {
1319
+ var delay = Math.round(base * (0.9 + Math.random() * 0.2))
1320
+ timer = setTimeout(function () {
1321
+ timer = null
1322
+ if (!alive.current) return
1323
+ void load(true)
1324
+ arm()
1325
+ }, delay)
1326
+ // A card test (or a headless host) must not be kept alive by a poll
1327
+ // it never sees fire; a browser timer has no unref and ignores this.
1328
+ if (timer && typeof timer.unref === 'function') timer.unref()
1329
+ }
1330
+ arm()
1306
1331
  return function () {
1307
1332
  alive.current = false
1308
- clearInterval(timer)
1333
+ if (timer !== null) clearTimeout(timer)
1309
1334
  }
1310
1335
  }, [load, settings.refreshMs, settings.enabled])
1311
1336
 
@@ -1572,6 +1597,8 @@ window.__ModuleLoader__.load({
1572
1597
  if (!visible) hidden.push(card.id)
1573
1598
  if (!visible && store.expanded === card.id) collapseCard()
1574
1599
  patchState({ hidden: hidden })
1600
+ // A card is mounted when it reaches the wall, not when it was defined.
1601
+ if (visible) mountCard(card)
1575
1602
  }
1576
1603
 
1577
1604
  function textureLayer(texture, card) {
@@ -2916,11 +2943,33 @@ window.__ModuleLoader__.load({
2916
2943
  // -------------------------------------------------------------- bootstrap
2917
2944
 
2918
2945
  var bootstrapped = false
2946
+ /** Cards whose `onMount` already ran; one entry per card id. */
2947
+ var mountedCards = Object.create(null)
2948
+
2949
+ /**
2950
+ * Run a card's `onMount` the first time it is actually on the wall.
2951
+ *
2952
+ * A hidden card is disabled: it is not rendered, it polls nothing, and it
2953
+ * must not get lifecycle work either. The old bootstrap ran every
2954
+ * registered card's `onMount` before the persisted visibility was known,
2955
+ * so a disabled card still armed whatever its hook armed.
2956
+ * @param card - a registered card.
2957
+ */
2958
+ function mountCard(card) {
2959
+ if (mountedCards[card.id] === true) return
2960
+ mountedCards[card.id] = true
2961
+ runOnMount(card)
2962
+ }
2963
+
2964
+ /** Mount every card the persisted layout puts on the wall. */
2965
+ function mountVisibleCards() {
2966
+ orderedCards().filter(cardVisible).forEach(mountCard)
2967
+ }
2968
+
2919
2969
  /** Load the persisted layout and install card hooks exactly once. */
2920
2970
  function bootstrap() {
2921
2971
  if (bootstrapped) return Promise.resolve()
2922
2972
  bootstrapped = true
2923
- registry.forEach(function (card) { runOnMount(card) })
2924
2973
  return remoteCall('homepage/state', {}).then(function (persisted) {
2925
2974
  if (persisted && typeof persisted === 'object') {
2926
2975
  store.state = stateDocumentOf(persisted)
@@ -2934,6 +2983,11 @@ window.__ModuleLoader__.load({
2934
2983
  store.hostReady = true
2935
2984
  bump()
2936
2985
  return null
2986
+ }).then(function (value) {
2987
+ // Only after the visibility document is in hand: a card the user hid
2988
+ // is not mounted and runs nothing.
2989
+ mountVisibleCards()
2990
+ return value
2937
2991
  })
2938
2992
  }
2939
2993
 
@@ -14,19 +14,25 @@
14
14
  * - CPU usage needs two readings, so the previous `os.cpus()` snapshot is kept
15
15
  * in module state. The first call of a process waits ~250 ms and reads again
16
16
  * rather than reporting a meaningless 0;
17
- * - a sample taken less than a second ago is memoized, because several
18
- * subscribers poll the same endpoint at once;
17
+ * - a sample taken less than a second ago is memoized, and a sample still in
18
+ * flight is shared by every caller, because several subscribers poll the
19
+ * same endpoint at once;
19
20
  * - the temperature probe costs a subprocess, so it is cached for
20
- * `temperatureTtlMs` and sampled on its own schedule. `temperature: false`
21
- * skips it outright, which is what the periodic history sampler passes;
21
+ * `temperatureTtlMs`. `temperature: false` skips it outright;
22
22
  * - the GPU counters are the expensive read: Windows needs one PowerShell
23
23
  * spawn and Linux needs one `nvidia-smi` spawn when no sysfs card answers,
24
- * so the verdict is cached for `gpuTtlMs`. A Linux machine whose amdgpu
25
- * publishes `gpu_busy_percent` reads plain files and spawns nothing at all.
26
- * `gpu: false` skips the block outright — the same cheap path the periodic
27
- * history sampler takes;
28
- * - `startDeviceSampler()` drives that cheap path on an unref-ed interval so
29
- * the history buffer fills before anyone opens the panel;
24
+ * so the verdict is cached for `gpuTtlMs`, and a failed verdict for
25
+ * `GPU_FAILURE_TTL_MS`. A Linux machine whose amdgpu publishes
26
+ * `gpu_busy_percent` reads plain files and spawns nothing at all. `gpu: false`
27
+ * skips the block outright;
28
+ * - a stale temperature or GPU cache is served immediately while its probe
29
+ * refreshes in the background; only the first request (a cold cache) waits.
30
+ * That is what keeps a fast poll as cheap as a performance panel: CPU,
31
+ * memory and disk are read in-process, and the subprocess blocks are
32
+ * refreshed on their own slower cadence;
33
+ * - the plugin arms no background sampler: every sample is on demand, so an
34
+ * installed page nobody opened probes nothing. `startDeviceSampler()` exists
35
+ * for a caller that wants the cheap history path, and it never spawns;
30
36
  * - history is an in-memory ring buffer per process only — it is deliberately
31
37
  * NOT persisted anywhere;
32
38
  * - every source is injectable through `options`, which is what lets the unit
@@ -58,7 +64,17 @@ const WINDOWS_PLATFORMS = new Set(['win32', 'windows', 'win'])
58
64
  const PERMISSION_CODES = new Set(['EACCES', 'EPERM'])
59
65
 
60
66
  /** How long one GPU probe's verdict is reused; that probe costs a subprocess. */
61
- const GPU_TTL_MS = 5000
67
+ const GPU_TTL_MS = 30000
68
+
69
+ /**
70
+ * How long a failed GPU verdict is reused.
71
+ *
72
+ * A machine whose counters are disabled, unreadable or slow kept paying a
73
+ * PowerShell spawn on every poll. Reusing the failure for a minute turns a
74
+ * per-poll subprocess into one a minute without hiding a recovered machine for
75
+ * long.
76
+ */
77
+ const GPU_FAILURE_TTL_MS = 60000
62
78
 
63
79
  /**
64
80
  * How long the GPU probe may run before it is killed.
@@ -165,8 +181,16 @@ let lastSample = null
165
181
  /** Cached temperature verdict plus the epoch ms it was taken at. */
166
182
  let temperatureCache = null
167
183
 
168
- /** Cached GPU block plus the epoch ms the probe was run at. */
184
+ /** Cached GPU block plus the epoch ms the probe finished at. */
169
185
  let gpuCache = null
186
+ /** The GPU probe currently in flight, shared by concurrent readers. */
187
+ let gpuPending = null
188
+ /** The temperature probe currently in flight, shared by concurrent readers. */
189
+ let temperaturePending = null
190
+ /** The sample currently in flight, shared by concurrent readers. */
191
+ let samplePending = null
192
+ /** Bumped by `resetDeviceCache()`; a stale probe must not refill a reset cache. */
193
+ let cacheEpoch = 0
170
194
  /** Appended per real sample; in memory for this process only. */
171
195
  let history = []
172
196
  /** Set once the first call has paid for its second CPU reading. */
@@ -721,16 +745,53 @@ async function readTemperature(deps) {
721
745
  }
722
746
 
723
747
  /**
724
- * Hand back the cached temperature verdict while its TTL holds.
748
+ * Run one temperature probe, sharing it with any caller already waiting.
749
+ *
750
+ * The cache is stamped when the probe finishes rather than when it started, so
751
+ * a slow subprocess is not immediately stale. `resetDeviceCache()` bumps the
752
+ * epoch, which keeps a probe that lands after a reset from repopulating it.
753
+ * @param deps - the resolved dependency bag.
754
+ * @returns `{ celsius, source, reason }`.
755
+ */
756
+ async function probeTemperature(deps) {
757
+ if (temperaturePending !== null) return temperaturePending
758
+ const epoch = cacheEpoch
759
+ const run = (async () => {
760
+ const value = await readTemperature(deps)
761
+ if (epoch === cacheEpoch) {
762
+ temperatureCache = { at: deps.now(), value: value, ttlMs: deps.temperatureTtlMs }
763
+ }
764
+ return value
765
+ })()
766
+ temperaturePending = run
767
+ try {
768
+ return await run
769
+ } finally {
770
+ if (temperaturePending === run) temperaturePending = null
771
+ }
772
+ }
773
+
774
+ /**
775
+ * The temperature block for one sample.
776
+ *
777
+ * A performance panel must stay cheap at a fast cadence, so this never blocks a
778
+ * poll on the subprocess probe:
779
+ * - a warm cache answers directly;
780
+ * - a stale cache answers with its last value at once and refreshes in the
781
+ * background — the next poll picks the new reading up;
782
+ * - a cold cache (the first request after start) waits once, so the card is
783
+ * never blank.
725
784
  * @param deps - the resolved dependency bag.
726
785
  * @returns `{ celsius, source, reason }`.
727
786
  */
728
787
  async function temperature(deps) {
729
788
  const at = deps.now()
730
- if (temperatureCache && at - temperatureCache.at < deps.temperatureTtlMs) return temperatureCache.value
731
- const value = await readTemperature(deps)
732
- temperatureCache = { at: at, value: value }
733
- return value
789
+ if (temperatureCache && at - temperatureCache.at < temperatureCache.ttlMs) return temperatureCache.value
790
+ const stale = temperatureCache ? temperatureCache.value : null
791
+ const run = probeTemperature(deps)
792
+ if (stale === null) return run
793
+ run.catch(() => {})
794
+ return stale
734
795
  }
735
796
 
736
797
  // -------------------------------------------------------------------- gpu
@@ -1262,16 +1323,56 @@ async function readGpu(deps) {
1262
1323
  }
1263
1324
 
1264
1325
  /**
1265
- * Hand back the cached GPU block while its TTL holds.
1326
+ * Run one GPU probe, sharing it with any caller already waiting.
1327
+ *
1328
+ * The cache is stamped when the probe finishes, and a failed verdict gets the
1329
+ * longer `GPU_FAILURE_TTL_MS` so a broken counter provider is not retried at
1330
+ * poll speed. The epoch guard keeps a late probe out of a reset cache.
1331
+ * @param deps - the resolved dependency bag.
1332
+ * @returns the `gpu` block; never rejects.
1333
+ */
1334
+ async function probeGpu(deps) {
1335
+ if (gpuPending !== null) return gpuPending
1336
+ const epoch = cacheEpoch
1337
+ const run = (async () => {
1338
+ const value = await readGpu(deps)
1339
+ if (epoch === cacheEpoch) {
1340
+ const failed = !value || value.usage === null
1341
+ gpuCache = {
1342
+ at: deps.now(),
1343
+ value: value,
1344
+ ttlMs: failed ? GPU_FAILURE_TTL_MS : deps.gpuTtlMs,
1345
+ }
1346
+ }
1347
+ return value
1348
+ })()
1349
+ gpuPending = run
1350
+ try {
1351
+ return await run
1352
+ } finally {
1353
+ if (gpuPending === run) gpuPending = null
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * The GPU block for one sample.
1359
+ *
1360
+ * The probe is the most expensive read the surface makes, so a poll never waits
1361
+ * for a refresh: a warm cache answers directly, a stale cache answers with its
1362
+ * last value while the probe runs in the background, and only a cold cache (the
1363
+ * first request) waits. That is what lets the card poll quickly for CPU and
1364
+ * memory without making the GPU a per-poll subprocess.
1266
1365
  * @param deps - the resolved dependency bag.
1267
1366
  * @returns the `gpu` block; never rejects.
1268
1367
  */
1269
1368
  async function gpuReading(deps) {
1270
1369
  const at = deps.now()
1271
- if (gpuCache && at - gpuCache.at < deps.gpuTtlMs) return gpuCache.value
1272
- const value = await readGpu(deps)
1273
- gpuCache = { at: at, value: value }
1274
- return value
1370
+ if (gpuCache && at - gpuCache.at < gpuCache.ttlMs) return gpuCache.value
1371
+ const stale = gpuCache ? gpuCache.value : null
1372
+ const run = probeGpu(deps)
1373
+ if (stale === null) return run
1374
+ run.catch(() => {})
1375
+ return stale
1275
1376
  }
1276
1377
 
1277
1378
  // ------------------------------------------------------------------ cache
@@ -1282,11 +1383,15 @@ async function gpuReading(deps) {
1282
1383
  * @returns nothing.
1283
1384
  */
1284
1385
  export function resetDeviceCache() {
1386
+ cacheEpoch += 1
1285
1387
  prevTimes = null
1286
1388
  prevAt = null
1287
1389
  lastSample = null
1288
1390
  temperatureCache = null
1391
+ temperaturePending = null
1289
1392
  gpuCache = null
1393
+ gpuPending = null
1394
+ samplePending = null
1290
1395
  history = []
1291
1396
  firstReadDone = false
1292
1397
  }
@@ -1314,7 +1419,7 @@ function resolve(options) {
1314
1419
  sleep: typeof input.sleep === 'function' ? input.sleep : defaultSleep,
1315
1420
  temperatureTtlMs: toFinite(input.temperatureTtlMs) !== null && input.temperatureTtlMs >= 0
1316
1421
  ? input.temperatureTtlMs
1317
- : 60000,
1422
+ : 120000,
1318
1423
  temperature: input.temperature !== false,
1319
1424
  gpuTtlMs: toFinite(input.gpuTtlMs) !== null && input.gpuTtlMs >= 0
1320
1425
  ? input.gpuTtlMs
@@ -1361,8 +1466,15 @@ async function gather(deps) {
1361
1466
  const previousAt = prevAt
1362
1467
  prevAt = at
1363
1468
  const memory = readMemory(deps.os)
1364
- const thermal = deps.temperature ? await temperature(deps) : { celsius: null, source: null, reason: 'skipped' }
1365
- const gpu = deps.gpu ? await gpuReading(deps) : emptyGpu('skipped')
1469
+ // The two hardware blocks are independent: a cold start pays for them in
1470
+ // parallel rather than one after the other. Once warm, both resolve from
1471
+ // cache in the same tick, so a fast poll never waits on a subprocess.
1472
+ const blocks = await Promise.all([
1473
+ deps.temperature ? temperature(deps) : { celsius: null, source: null, reason: 'skipped' },
1474
+ deps.gpu ? gpuReading(deps) : emptyGpu('skipped'),
1475
+ ])
1476
+ const thermal = blocks[0]
1477
+ const gpu = blocks[1]
1366
1478
  const drive = systemDrive(deps.platform)
1367
1479
 
1368
1480
  const cpu = {
@@ -1416,40 +1528,51 @@ async function gather(deps) {
1416
1528
  *
1417
1529
  * Never rejects: every source that fails degrades to `null`, to a `reason`
1418
1530
  * code, or to zeros, and the rest of the document still comes back. A call
1419
- * inside the one-second dedupe window returns the memoized document unchanged.
1531
+ * inside the one-second dedupe window returns the memoized document unchanged,
1532
+ * and a call made while a sample is still gathering shares that sample instead
1533
+ * of starting a second set of probes.
1420
1534
  * @param options - injectable sources and limits; see the module docblock.
1421
1535
  * @returns the sample document.
1422
1536
  */
1423
1537
  export async function sampleDevice(options = {}) {
1424
1538
  const deps = resolve(options)
1425
- try {
1426
- const at = deps.now()
1427
- if (lastSample && at - lastSample.at < MIN_SAMPLE_INTERVAL_MS) return lastSample
1428
- const sample = await gather(deps)
1429
- lastSample = sample
1430
- return sample
1431
- } catch (error) {
1432
- return {
1433
- at: deps.now(),
1434
- sampleSeconds: 0,
1435
- cpu: { usage: 0, cores: [], count: 0, model: '', speedMhz: 0, loadavg: null },
1436
- memory: { total: 0, free: 0, used: 0, usage: 0, swapTotal: 0, swapFree: 0, swapUsed: 0 },
1437
- temperature: { celsius: null, source: null, reason: 'error' },
1438
- gpu: emptyGpu('error'),
1439
- disk: null,
1440
- system: {
1441
- platform: deps.platform,
1442
- arch: deps.platform,
1443
- release: '',
1444
- hostname: '',
1445
- uptime: 0,
1446
- node: process.version,
1447
- cpuModel: '',
1448
- drive: null,
1449
- },
1450
- history: history.slice(),
1451
- error: error && error.message ? String(error.message) : 'sample failed',
1539
+ const at = deps.now()
1540
+ if (lastSample && at - lastSample.at < MIN_SAMPLE_INTERVAL_MS) return lastSample
1541
+ if (samplePending !== null) return samplePending
1542
+ const run = (async () => {
1543
+ try {
1544
+ const sample = await gather(deps)
1545
+ lastSample = sample
1546
+ return sample
1547
+ } catch (error) {
1548
+ return {
1549
+ at: deps.now(),
1550
+ sampleSeconds: 0,
1551
+ cpu: { usage: 0, cores: [], count: 0, model: '', speedMhz: 0, loadavg: null },
1552
+ memory: { total: 0, free: 0, used: 0, usage: 0, swapTotal: 0, swapFree: 0, swapUsed: 0 },
1553
+ temperature: { celsius: null, source: null, reason: 'error' },
1554
+ gpu: emptyGpu('error'),
1555
+ disk: null,
1556
+ system: {
1557
+ platform: deps.platform,
1558
+ arch: deps.platform,
1559
+ release: '',
1560
+ hostname: '',
1561
+ uptime: 0,
1562
+ node: process.version,
1563
+ cpuModel: '',
1564
+ drive: null,
1565
+ },
1566
+ history: history.slice(),
1567
+ error: error && error.message ? String(error.message) : 'sample failed',
1568
+ }
1452
1569
  }
1570
+ })()
1571
+ samplePending = run
1572
+ try {
1573
+ return await run
1574
+ } finally {
1575
+ if (samplePending === run) samplePending = null
1453
1576
  }
1454
1577
  }
1455
1578
 
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@ import { createActions } from './host/actions.js'
25
25
  import { versionFromEntry } from './host/audit.js'
26
26
  import { createAuditRunner } from './host/audit-runner.js'
27
27
  import { createCollector } from './host/collector.js'
28
- import { sampleDevice, startDeviceSampler } from './host/device.js'
28
+ import { sampleDevice } from './host/device.js'
29
29
  import { sampleDshRelease } from './host/dsh.js'
30
30
  import { defaultInstallRoot, listInstalled, startInstall } from './host/dsh-install.js'
31
31
  import { createHygiene } from './host/hygiene.js'
@@ -127,20 +127,6 @@ const MICRO = 1000000
127
127
  const DAY_KEEP = 180
128
128
  const HISTORY_DAYS = 21
129
129
  const MIN_SAMPLE_INTERVAL_MS = 15000
130
- /** Delay before the start-up observation, long enough for the account service to mount. */
131
- const BOOT_SAMPLE_DELAY_MS = 4000
132
- /**
133
- * Background observation cadence. The ledger's per-day spend is the drop between
134
- * the first and the latest sample of the day, so a day nobody looks at still
135
- * needs more than one point. Thirty minutes is 48 free reads a day — enough to
136
- * show when the spending happened without hammering the Platform.
137
- */
138
- const BACKGROUND_SAMPLE_MS = 30 * 60 * 1000
139
- // The device card's history only exists while something samples it. A tick this
140
- // slow costs one `os.cpus()` read and keeps a usable window in front of whoever
141
- // opens the card; the tick deliberately does not touch the temperature, which
142
- // would shell out to the system on a timer nobody is watching.
143
- const DEVICE_SAMPLE_MS = 15000
144
130
 
145
131
  // --------------------------------------------------------------- storage
146
132
 
@@ -558,6 +544,13 @@ async function loadLedger() {
558
544
  return value
559
545
  }
560
546
 
547
+ /**
548
+ * The account read currently in flight, shared by every concurrent on-demand
549
+ * overview. A page that is closed never reaches this module: the plugin arms no
550
+ * background account timer, so an idle instance performs no Platform reads.
551
+ */
552
+ let balanceReadPending = null
553
+
561
554
  /**
562
555
  * Sample the account, fold the result into the ledger and persist it.
563
556
  * @param ctx - plugin context.
@@ -565,22 +558,34 @@ async function loadLedger() {
565
558
  * @returns the read outcome, with the ledger attached.
566
559
  */
567
560
  async function sample(ctx, force) {
568
- const ledger = await loadLedger()
569
- const now = Date.now()
570
- if (!force && now - Number(ledger.lastAttemptAt || 0) < MIN_SAMPLE_INTERVAL_MS) {
571
- return { ledger: ledger, read: ledger.lastOutcome || null, sampled: false, at: Number(ledger.lastAttemptAt || 0) }
572
- }
573
- const read = await readBalance(ctx)
574
- ledger.lastAttemptAt = now
575
- ledger.lastOutcome = { ok: read.ok, reason: read.reason || null, message: read.message || null, view: read.view || null }
576
- if (read.ok) {
577
- applySample(ledger, read.wallets, now)
578
- ledger.wallet = read.wallets
579
- ledger.bonusWallets = read.bonusWallets
580
- ledger.walletAt = now
561
+ // Two card instances asking at the same moment (the wall preview and the
562
+ // expanded page) share one read instead of spending two.
563
+ if (!force && balanceReadPending !== null) return balanceReadPending
564
+ const run = (async () => {
565
+ const ledger = await loadLedger()
566
+ const now = Date.now()
567
+ if (!force && now - Number(ledger.lastAttemptAt || 0) < MIN_SAMPLE_INTERVAL_MS) {
568
+ return { ledger: ledger, read: ledger.lastOutcome || null, sampled: false, at: Number(ledger.lastAttemptAt || 0) }
569
+ }
570
+ const read = await readBalance(ctx)
571
+ ledger.lastAttemptAt = now
572
+ ledger.lastOutcome = { ok: read.ok, reason: read.reason || null, message: read.message || null, view: read.view || null }
573
+ if (read.ok) {
574
+ applySample(ledger, read.wallets, now)
575
+ ledger.wallet = read.wallets
576
+ ledger.bonusWallets = read.bonusWallets
577
+ ledger.walletAt = now
578
+ }
579
+ try { await writeJson(ledgerPath(), ledger) } catch { /* a failed cache write must not fail the read */ }
580
+ return { ledger: ledger, read: read, sampled: true, at: now }
581
+ })()
582
+ if (force) return run
583
+ balanceReadPending = run
584
+ try {
585
+ return await run
586
+ } finally {
587
+ if (balanceReadPending === run) balanceReadPending = null
581
588
  }
582
- try { await writeJson(ledgerPath(), ledger) } catch { /* a failed cache write must not fail the read */ }
583
- return { ledger: ledger, read: read, sampled: true, at: now }
584
589
  }
585
590
 
586
591
  /**
@@ -2125,37 +2130,11 @@ export function apply(ctx, config) {
2125
2130
  }
2126
2131
  }
2127
2132
 
2128
- // Take one observation shortly after start, then keep observing on a slow
2129
- // interval. The ledger then carries a real balance before anyone opens the
2130
- // panel, and a day left alone still gets a curve instead of a single sample
2131
- // (one sample cannot show when the spend happened, and `today.partial` says
2132
- // so). The delay lets the account service finish mounting, and a failure is
2133
- // recorded in the ledger rather than thrown at start-up.
2134
- ctx.effect(() => {
2135
- const take = () => { sample(ctx, false).catch(() => {}) }
2136
- const timer = service(ctx, 'timer')
2137
- if (timer && typeof timer.timeout === 'function' && typeof timer.interval === 'function') {
2138
- const stopTimeout = timer.timeout(take, BOOT_SAMPLE_DELAY_MS)
2139
- const stopInterval = timer.interval(take, BACKGROUND_SAMPLE_MS)
2140
- return () => {
2141
- if (typeof stopTimeout === 'function') stopTimeout()
2142
- if (typeof stopInterval === 'function') stopInterval()
2143
- }
2144
- }
2145
- const timeout = setTimeout(take, BOOT_SAMPLE_DELAY_MS)
2146
- const interval = setInterval(take, BACKGROUND_SAMPLE_MS)
2147
- return () => { clearTimeout(timeout); clearInterval(interval) }
2148
- }, name + ': balance sampling')
2149
-
2150
- // Keep the device card's history warm, so the chart has a window the moment
2151
- // somebody opens it. The tick is deliberately cheap: two `os` reads and never
2152
- // a temperature probe, which is cached and only runs while the card polls.
2153
- ctx.effect(() => {
2154
- const take = () => { sampleDevice({ temperature: false }).catch(() => {}) }
2155
- const timer = service(ctx, 'timer')
2156
- if (timer && typeof timer.interval === 'function') return timer.interval(take, DEVICE_SAMPLE_MS)
2157
- return startDeviceSampler({ intervalMs: DEVICE_SAMPLE_MS })
2158
- }, name + ': device sampling')
2133
+ // No background sampling lives past this point. The account ledger and the
2134
+ // device history are filled only by an on-demand read (an open card or the
2135
+ // model-facing tool), so an installed-but-unused plugin runs nothing: no
2136
+ // Platform requests, no subprocesses, no timers. Disabled cards never mount,
2137
+ // so they never call an endpoint either.
2159
2138
  }
2160
2139
 
2161
2140
  /** @returns one error carrying the plugin's own prefix, without doubling it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-homepage",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DeepSeek Harness plugin: a personal home page in the sidebar for a DSH instance. Review a plugin before installing it, and read sessions, projects, tool usage, device, storage, DSH cores, Skills and cost from one composable, registrable card wall.",
5
5
  "keywords": [
6
6
  "dsh-plugin",