@goodandready/dsh-clinebot 0.3.16 → 0.3.17

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/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.17] - 2026-09-21
9
+
10
+ ### Fixed
11
+ - **Cache Resilience (#50)**: Fixed permanent `isRevalidating` flag lock in `usageCache` and `probeCache` upon background SWR revalidation failures. The flag is now guaranteed to reset in `finally` and `catch` blocks.
12
+ - **Immediate Abort Handling (#52)**: Eliminated artificial 250ms delay and futile retry upon `AbortError` or `signal.aborted` in `fetchUsageLimits`.
13
+ - **Lifecycle Timer Cleanup (#53)**: Wrapped `autoDiscover` timer in `ctx.effect` with cleanup handler to avoid orphaned background callbacks upon plugin context unload.
14
+ - **UI Version Hardcode Removal (#56)**: Replaced hardcoded legacy `'0.3.12'` string in initial React state with dynamic retrieval from `/update` endpoint and conditional badge rendering.
15
+
16
+ ### Performance
17
+ - **Single Account Pool Resolution (#51)**: Eliminated duplicate sequential IPC queries to DSH Credentials by passing the pre-resolved account pool into `resolveActiveAccountKey` within `buildStatus`.
18
+ - **Bounded Cache with Auto-Eviction (#57)**: Enforced `MAX_CACHE_ENTRIES = 50` and automatic eviction of expired entries in `usageCache` and `probeCache` to prevent long-term memory leaks.
19
+
20
+ ### Refactored
21
+ - **Active Route Guard (#54)**: Converted `assertTrustedSettingsRequest` into an active route guard writing `403 Forbidden` and applied it across all write endpoints in `lib/routes/`.
22
+ - **Dead Export Wiring (#55)**: Wired `getDefaultModelIds` helper in `lib/config.js` to compute `allDefaultIds`, clearing preflight dead-export warnings.
23
+
8
24
  ## [0.3.16] - 2026-09-20
9
25
 
10
26
  ### Fixed
package/lib/access.js CHANGED
@@ -1,9 +1,10 @@
1
- import { isTrustedSettingsRequest } from './http.js'
1
+ import { isTrustedSettingsRequest, writeJson } from './http.js'
2
2
 
3
3
  export { isTrustedSettingsRequest }
4
4
 
5
5
  export function assertTrustedSettingsRequest(req, res) {
6
6
  if (!isTrustedSettingsRequest(req)) {
7
+ writeJson(res, 403, { ok: false, error: 'Forbidden' })
7
8
  return false
8
9
  }
9
10
  return true
package/lib/client.js CHANGED
@@ -402,9 +402,11 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
402
402
  React.createElement(
403
403
  'div',
404
404
  { style: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' } },
405
- React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
406
- `v${updateState.currentVersion}`
407
- ),
405
+ updateState.currentVersion
406
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
407
+ `v${updateState.currentVersion}`
408
+ )
409
+ : null,
408
410
  updateState.checking
409
411
  ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
410
412
  t('update.checking')
@@ -882,7 +884,7 @@ function SettingsPage(props) {
882
884
  const [updateState, setUpdateState] = React.useState({
883
885
  checking: false,
884
886
  updating: false,
885
- currentVersion: '0.3.12',
887
+ currentVersion: '',
886
888
  latestVersion: '',
887
889
  updateAvailable: false,
888
890
  canAutoUpdate: true,
@@ -132,9 +132,26 @@ function abortAfter(ms) {
132
132
  }
133
133
 
134
134
  // In-memory caches for quota queries and host health probes
135
+ export const MAX_CACHE_ENTRIES = 50
135
136
  export const usageCache = new Map()
136
137
  export const probeCache = new Map()
137
138
 
139
+ export function setBoundedCache(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
140
+ if (map.size >= maxEntries) {
141
+ const now = Date.now()
142
+ for (const [k, v] of map.entries()) {
143
+ if (v.expiresAt && v.expiresAt < now) {
144
+ map.delete(k)
145
+ }
146
+ }
147
+ if (map.size >= maxEntries) {
148
+ const oldestKey = map.keys().next().value
149
+ if (oldestKey) map.delete(oldestKey)
150
+ }
151
+ }
152
+ map.set(key, value)
153
+ }
154
+
138
155
  export function clearProbeCache() {
139
156
  probeCache.clear()
140
157
  }
@@ -212,8 +229,10 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
212
229
  keepalive: true,
213
230
  })
214
231
  } catch (netErr) {
232
+ if (signal?.aborted || netErr?.name === 'AbortError') throw netErr
215
233
  // Quick 1-retry fallback on transient network drop
216
234
  await new Promise((r) => setTimeout(r, 250))
235
+ if (signal?.aborted || netErr?.name === 'AbortError') throw netErr
217
236
  limitsRes = await fetchImpl(`${base}/users/me/plan/usage-limits`, {
218
237
  method: 'GET',
219
238
  headers,
@@ -299,7 +318,7 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
299
318
  checkedAt: Date.now(),
300
319
  }
301
320
 
302
- usageCache.set(cacheKey, { data: result, expiresAt: Date.now() + ttlMs, isRevalidating: false })
321
+ setBoundedCache(usageCache, cacheKey, { data: result, expiresAt: Date.now() + ttlMs, isRevalidating: false })
303
322
  return result
304
323
  } catch (err) {
305
324
  const outcome = {
@@ -308,10 +327,15 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
308
327
  checkedAt: Date.now(),
309
328
  }
310
329
  if (!usageCache.has(cacheKey)) {
311
- usageCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
330
+ setBoundedCache(usageCache, cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
331
+ } else {
332
+ const existing = usageCache.get(cacheKey)
333
+ if (existing) existing.isRevalidating = false
312
334
  }
313
335
  return outcome
314
336
  } finally {
337
+ const existing = usageCache.get(cacheKey)
338
+ if (existing) existing.isRevalidating = false
315
339
  cancel()
316
340
  }
317
341
  }
@@ -362,7 +386,7 @@ export async function probeHealth(baseUrl, {
362
386
  error: reachable ? null : `HTTP status ${res.status}`,
363
387
  checkedAt: Date.now(),
364
388
  }
365
- probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + ttlMs, isRevalidating: false })
389
+ setBoundedCache(probeCache, cacheKey, { data: outcome, expiresAt: Date.now() + ttlMs, isRevalidating: false })
366
390
  return outcome
367
391
  } catch (err) {
368
392
  const latencyMs = Date.now() - start
@@ -372,9 +396,16 @@ export async function probeHealth(baseUrl, {
372
396
  error: String(err?.message || err),
373
397
  checkedAt: Date.now(),
374
398
  }
375
- probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
399
+ if (!probeCache.has(cacheKey)) {
400
+ setBoundedCache(probeCache, cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
401
+ } else {
402
+ const existing = probeCache.get(cacheKey)
403
+ if (existing) existing.isRevalidating = false
404
+ }
376
405
  return outcome
377
406
  } finally {
407
+ const existing = probeCache.get(cacheKey)
408
+ if (existing) existing.isRevalidating = false
378
409
  cancel()
379
410
  }
380
411
  }
package/lib/config.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  DEFAULT_MODEL_ID,
11
11
  getAllModels,
12
+ getDefaultModelIds,
12
13
  getActiveModelIds,
13
14
  } from './models.js'
14
15
 
@@ -56,8 +57,7 @@ export const Config = z.object({
56
57
 
57
58
  export function publicConfig(cfg) {
58
59
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
59
- const allModels = getAllModels(dynamic)
60
- const allDefaultIds = allModels.map((m) => m.id)
60
+ const allDefaultIds = getDefaultModelIds(dynamic)
61
61
 
62
62
  let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
63
63
  if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
package/lib/index.js CHANGED
@@ -83,7 +83,14 @@ export function apply(ctx, config) {
83
83
  }
84
84
 
85
85
  syncProviderState(live())
86
- setTimeout(triggerAutoDiscover, 500)
86
+ if (typeof ctx.effect === 'function') {
87
+ ctx.effect(() => {
88
+ const timer = setTimeout(triggerAutoDiscover, 500)
89
+ return () => clearTimeout(timer)
90
+ }, 'dsh-clinebot: auto-discover')
91
+ } else {
92
+ setTimeout(triggerAutoDiscover, 500)
93
+ }
87
94
 
88
95
  if (ctx.webServer?.register) {
89
96
  const unregisterUpdater = registerPluginUpdater(ctx, {
@@ -37,9 +37,9 @@ export async function checkRegisteredInPiAi(ctx) {
37
37
  }
38
38
  }
39
39
 
40
- export async function resolveActiveAccountKey(ctx, cfg) {
41
- const pool = await resolveAccountPool(ctx, cfg)
42
- const configured = pool.filter((acc) => acc.present && acc.value)
40
+ export async function resolveActiveAccountKey(ctx, cfg, pool = null) {
41
+ const accountPool = pool || await resolveAccountPool(ctx, cfg)
42
+ const configured = accountPool.filter((acc) => acc.present && acc.value)
43
43
  if (!configured.length) {
44
44
  return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
45
45
  }
@@ -62,7 +62,7 @@ export async function buildStatus(ctx, cfg) {
62
62
  probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
63
63
  ])
64
64
 
65
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
65
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
66
66
  const allModels = getAllModels(pub.dynamicModels)
67
67
 
68
68
  let usage = null
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest } from '../access.js'
2
+ import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, Config } from '../config.js'
4
4
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
5
5
  import { clearUsageCache, clearProbeCache } from '../cline-client.js'
@@ -11,9 +11,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
11
11
  path: '/dsh-clinebot/accounts/active',
12
12
  handler: async (req, res) => {
13
13
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
14
- if (!isTrustedSettingsRequest(req)) {
15
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
16
- }
14
+ if (!assertTrustedSettingsRequest(req, res)) return
17
15
  try {
18
16
  const bodyBuf = await readBody(req)
19
17
  let body = {}
@@ -43,9 +41,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
43
41
  path: '/dsh-clinebot/register',
44
42
  handler: async (req, res) => {
45
43
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
46
- if (!isTrustedSettingsRequest(req)) {
47
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
48
- }
44
+ if (!assertTrustedSettingsRequest(req, res)) return
49
45
  try {
50
46
  const bodyBuf = await readBody(req)
51
47
  let body = {}
@@ -65,9 +61,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
65
61
  path: '/dsh-clinebot/unregister',
66
62
  handler: async (req, res) => {
67
63
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
68
- if (!isTrustedSettingsRequest(req)) {
69
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
70
- }
64
+ if (!assertTrustedSettingsRequest(req, res)) return
71
65
  try {
72
66
  await removePiAiProvider(ctx)
73
67
  writeJson(res, 200, { ok: true })
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest } from '../access.js'
2
+ import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig } from '../config.js'
4
4
  import { resolveActiveAccountKey } from '../provider-sync.js'
5
5
  import { smokeChat, recordSessionRequest, rotateToNextAccount, DEFAULT_MODEL_ID } from '../cline-client.js'
@@ -13,9 +13,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
13
13
  path: '/dsh-clinebot/auth/begin',
14
14
  handler: async (req, res) => {
15
15
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
16
- if (!isTrustedSettingsRequest(req)) {
17
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
18
- }
16
+ if (!assertTrustedSettingsRequest(req, res)) return
19
17
  try {
20
18
  const authUrl = 'https://app.cline.bot'
21
19
  authSession = {
@@ -54,9 +52,7 @@ export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderStat
54
52
  path: '/dsh-clinebot/smoke',
55
53
  handler: async (req, res) => {
56
54
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
57
- if (!isTrustedSettingsRequest(req)) {
58
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
59
- }
55
+ if (!assertTrustedSettingsRequest(req, res)) return
60
56
  try {
61
57
  const bodyBuf = await readBody(req)
62
58
  let body = {}
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest } from '../access.js'
2
+ import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, Config } from '../config.js'
4
4
  import { resolveActiveAccountKey, resolvePathWithHome } from '../provider-sync.js'
5
5
  import { getAllModels, saveModelsDiskCache } from '../models.js'
@@ -11,9 +11,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
11
11
  kind: 'exact',
12
12
  path: '/dsh-clinebot/models/sync',
13
13
  handler: async (req, res) => {
14
- if (!isTrustedSettingsRequest(req)) {
15
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
16
- }
14
+ if (!assertTrustedSettingsRequest(req, res)) return
17
15
  if (req.method !== 'POST') {
18
16
  return writeJson(res, 405, { ok: false, error: 'POST only' })
19
17
  }
@@ -69,9 +67,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
69
67
  path: '/dsh-clinebot/models/toggle',
70
68
  handler: async (req, res) => {
71
69
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
72
- if (!isTrustedSettingsRequest(req)) {
73
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
74
- }
70
+ if (!assertTrustedSettingsRequest(req, res)) return
75
71
  try {
76
72
  const bodyBuf = await readBody(req)
77
73
  let body = {}
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { isTrustedSettingsRequest } from '../access.js'
2
+ import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
3
  import { publicConfig, Config } from '../config.js'
4
4
  import { buildStatus } from '../provider-sync.js'
5
5
  import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
@@ -62,9 +62,7 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
62
62
  path: '/dsh-clinebot/save-key',
63
63
  handler: async (req, res) => {
64
64
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
65
- if (!isTrustedSettingsRequest(req)) {
66
- return writeJson(res, 403, { ok: false, error: 'Forbidden' })
67
- }
65
+ if (!assertTrustedSettingsRequest(req, res)) return
68
66
  try {
69
67
  const bodyBuf = await readBody(req)
70
68
  let body = {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",