@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 +16 -0
- package/lib/access.js +2 -1
- package/lib/client.js +6 -4
- package/lib/cline-client.js +35 -4
- package/lib/config.js +2 -2
- package/lib/index.js +8 -1
- package/lib/provider-sync.js +4 -4
- package/lib/routes/accounts.js +4 -10
- package/lib/routes/auth.js +3 -7
- package/lib/routes/models.js +3 -7
- package/lib/routes/settings.js +2 -4
- package/package.json +1 -1
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
|
-
|
|
406
|
-
|
|
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: '
|
|
887
|
+
currentVersion: '',
|
|
886
888
|
latestVersion: '',
|
|
887
889
|
updateAvailable: false,
|
|
888
890
|
canAutoUpdate: true,
|
package/lib/cline-client.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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, {
|
package/lib/provider-sync.js
CHANGED
|
@@ -37,9 +37,9 @@ export async function checkRegisteredInPiAi(ctx) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function resolveActiveAccountKey(ctx, cfg) {
|
|
41
|
-
const
|
|
42
|
-
const configured =
|
|
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
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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 })
|
package/lib/routes/auth.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 = {}
|
package/lib/routes/models.js
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 = {}
|
package/lib/routes/settings.js
CHANGED
|
@@ -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 (!
|
|
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.
|
|
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",
|