@goodandready/dsh-clinebot 0.5.4 → 0.5.6
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 +14 -5
- package/lib/config.js +1 -1
- package/lib/index.js +56 -20
- package/lib/provider-sync.js +6 -6
- package/lib/routes/accounts.js +37 -8
- package/lib/routes/models.js +5 -5
- package/lib/routes/proxy.js +1 -1
- package/lib/routes/settings.js +1 -1
- package/lib/stats-storage.js +26 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.4
|
|
4
|
-
|
|
5
|
-
### Fixed
|
|
6
|
-
- **Peer gate on DSH 0.2.0-rc.1** (#58): DSH skips a profile bundle whose `peerDependencies` exclude the running version, so this plugin was absent from the profile with no error in the UI. Every `@deepseek-ai/dsh-*` peer now names both the 0.1.7-rc.2 and 0.2.0-rc.1 lines, because semver does not admit a prerelease of the next minor into a range that does not name it.
|
|
7
|
-
|
|
8
3
|
All notable changes to `@goodandready/dsh-clinebot` will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
7
|
|
|
8
|
+
## [0.5.6] - 2026-09-30
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **Credential Deletion Scope Enforcement (Issue #155)**: Restricted credential deletion on `DELETE /dsh-clinebot/accounts` and `POST /dsh-clinebot/accounts/delete` strictly to configured secondary accounts matching `^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`. Prohibited deletion of the primary account. Unconfigured or invalid keys receive HTTP 400/404 without calling credentials store. Credential deletion errors return HTTP 500.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Atomic Settings Persistence & Volatile Lifecycle (Issue #157)**: Re-architected `settingsApi.replace` and `settingsApi.update` to persist changes via DSH `SettingsForms` *before* mutating live state. If settings service is unavailable or non-writable, explicit errors are thrown. Mutating in-memory configuration now preserves dynamic Volatile references and getters across updates without severance.
|
|
15
|
+
- **Cold Start Persistent Stats Loading & Flush on Dispose (Issue #158)**: Connected `loadStats(statsPath)` to plugin startup lifecycle in `apply()`, preventing cold starts from overwriting historical token metrics with empty counters. Added `flushStats()` effect hook on plugin disposal and automatic corrupted file preservation (`*.corrupt.<timestamp>`) for diagnostics.
|
|
16
|
+
|
|
17
|
+
## [0.5.5] - 2026-09-30
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Volatile Boxes Unwrapping in Config Validation (Issue #153)**: Corrected deep unwrapping of Schemastery Volatile boxes and getters when passing dynamic configuration objects through `Config()` validation, preventing schema validation failures.
|
|
21
|
+
|
|
13
22
|
## [0.5.3] - 2026-09-28
|
|
14
23
|
|
|
15
24
|
### Fixed
|
package/lib/config.js
CHANGED
|
@@ -87,7 +87,7 @@ export const Config = z.object({
|
|
|
87
87
|
.description('Manually pinned active account envName or empty for auto/default.').volatile(),
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
function isVolatileRef(value) {
|
|
90
|
+
export function isVolatileRef(value) {
|
|
91
91
|
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
92
92
|
}
|
|
93
93
|
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Config, publicConfig, plainConfig, volatileConfig, NS, LLM_PI_AI_NS } from './config.js'
|
|
1
|
+
import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
|
|
2
2
|
import { registerPluginUpdater } from './updater.js'
|
|
3
3
|
import {
|
|
4
4
|
sessionStats,
|
|
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
|
|
|
22
22
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
23
23
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
24
24
|
import { registerSlashCommand } from './slash-command.js'
|
|
25
|
-
import { recordUsage } from './stats-storage.js'
|
|
25
|
+
import { recordUsage, loadStats, flushStats } from './stats-storage.js'
|
|
26
26
|
|
|
27
27
|
export const name = '@goodandready/dsh-clinebot'
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
@@ -67,35 +67,62 @@ export function apply(ctx, config) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const applyConfigMutations = (target, source) => {
|
|
71
|
+
const targetConfig = typeof target === 'function' ? target() : target
|
|
72
|
+
if (targetConfig && typeof targetConfig === 'object' && !Object.isFrozen(targetConfig)) {
|
|
73
|
+
for (const [key, value] of Object.entries(source)) {
|
|
74
|
+
try {
|
|
75
|
+
if (isVolatileRef(targetConfig[key]) && typeof targetConfig[key].set === 'function') {
|
|
76
|
+
targetConfig[key].set(value)
|
|
77
|
+
} else if (Object.getOwnPropertyDescriptor(targetConfig, key)?.set) {
|
|
78
|
+
targetConfig[key] = value
|
|
79
|
+
} else if (!Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
|
|
80
|
+
targetConfig[key] = value
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore read-only */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
currentConfig = source
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
settingsApi = {
|
|
71
92
|
get: () => live(),
|
|
72
93
|
replace: async (next) => {
|
|
73
|
-
const parsed = Config(structuredClone(plainConfig(next)))
|
|
74
|
-
currentConfig = parsed
|
|
75
94
|
const svc = ctx?.get?.('settings')
|
|
76
|
-
if (svc) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
|
|
96
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
97
|
+
}
|
|
98
|
+
const parsed = Config(structuredClone(plainConfig(next)))
|
|
99
|
+
const payload = volatileConfig(parsed)
|
|
100
|
+
const rev = getRevision(svc)
|
|
101
|
+
if (typeof svc.replace === 'function') {
|
|
102
|
+
await svc.replace(NS, payload, rev)
|
|
103
|
+
} else {
|
|
104
|
+
await svc.update(NS, payload, rev)
|
|
83
105
|
}
|
|
106
|
+
applyConfigMutations(currentConfig, parsed)
|
|
107
|
+
loadStats(publicConfig(live()).statsPath)
|
|
84
108
|
await syncProviderState(live())
|
|
85
109
|
return parsed
|
|
86
110
|
},
|
|
87
111
|
update: async (patch) => {
|
|
88
|
-
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
89
|
-
currentConfig = merged
|
|
90
112
|
const svc = ctx?.get?.('settings')
|
|
91
|
-
if (svc) {
|
|
92
|
-
|
|
93
|
-
if (typeof svc.update === 'function') {
|
|
94
|
-
await svc.update(NS, payload, getRevision(svc))
|
|
95
|
-
} else if (typeof svc.replace === 'function') {
|
|
96
|
-
await svc.replace(NS, payload, getRevision(svc))
|
|
97
|
-
}
|
|
113
|
+
if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
|
|
114
|
+
throw new Error('DSH settings service is unavailable or non-writable')
|
|
98
115
|
}
|
|
116
|
+
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
117
|
+
const payload = volatileConfig(merged)
|
|
118
|
+
const rev = getRevision(svc)
|
|
119
|
+
if (typeof svc.update === 'function') {
|
|
120
|
+
await svc.update(NS, payload, rev)
|
|
121
|
+
} else {
|
|
122
|
+
await svc.replace(NS, payload, rev)
|
|
123
|
+
}
|
|
124
|
+
applyConfigMutations(currentConfig, merged)
|
|
125
|
+
loadStats(publicConfig(live()).statsPath)
|
|
99
126
|
await syncProviderState(live())
|
|
100
127
|
return merged
|
|
101
128
|
},
|
|
@@ -106,6 +133,15 @@ export function apply(ctx, config) {
|
|
|
106
133
|
},
|
|
107
134
|
}
|
|
108
135
|
|
|
136
|
+
loadStats(publicConfig(live()).statsPath)
|
|
137
|
+
if (typeof ctx.effect === 'function') {
|
|
138
|
+
ctx.effect(() => {
|
|
139
|
+
return () => {
|
|
140
|
+
flushStats(publicConfig(live()).statsPath)
|
|
141
|
+
}
|
|
142
|
+
}, 'dsh-clinebot: stats-storage')
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
syncProviderState(live())
|
|
110
146
|
if (typeof ctx.effect === 'function') {
|
|
111
147
|
ctx.effect(() => {
|
package/lib/provider-sync.js
CHANGED
|
@@ -274,10 +274,10 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
274
274
|
const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
|
|
275
275
|
if (hasObsolete && settingsApi?.replace) {
|
|
276
276
|
const migrated = existingDynamic.map(migrateModelEntry)
|
|
277
|
-
const next = Config({
|
|
277
|
+
const next = plainConfig(Config({
|
|
278
278
|
...plainConfig(live()),
|
|
279
279
|
dynamicModels: migrated,
|
|
280
|
-
})
|
|
280
|
+
}))
|
|
281
281
|
await settingsApi.replace(next)
|
|
282
282
|
await syncProviderState(next)
|
|
283
283
|
}
|
|
@@ -286,11 +286,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
286
286
|
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
287
287
|
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
288
288
|
const migratedDisk = fromDisk.map(migrateModelEntry)
|
|
289
|
-
const next = Config({
|
|
289
|
+
const next = plainConfig(Config({
|
|
290
290
|
...plainConfig(live()),
|
|
291
291
|
dynamicModels: migratedDisk,
|
|
292
292
|
planSyncedAt: fromDisk.planSyncedAt || Date.now(),
|
|
293
|
-
})
|
|
293
|
+
}))
|
|
294
294
|
await settingsApi.replace(next)
|
|
295
295
|
await syncProviderState(next)
|
|
296
296
|
}
|
|
@@ -311,11 +311,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
311
311
|
|
|
312
312
|
if (hasNew && settingsApi?.replace) {
|
|
313
313
|
const now = Date.now()
|
|
314
|
-
const next = Config({
|
|
314
|
+
const next = plainConfig(Config({
|
|
315
315
|
...plainConfig(live()),
|
|
316
316
|
dynamicModels: usageData.dynamicModels,
|
|
317
317
|
planSyncedAt: now,
|
|
318
|
-
})
|
|
318
|
+
}))
|
|
319
319
|
await settingsApi.replace(next)
|
|
320
320
|
await syncProviderState(next)
|
|
321
321
|
if (cacheFile) {
|
package/lib/routes/accounts.js
CHANGED
|
@@ -78,7 +78,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
78
78
|
existingAccounts.push(accountEntry)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const next = Config({ ...curr, accounts: existingAccounts })
|
|
81
|
+
const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
|
|
82
82
|
await settingsApi.replace(next)
|
|
83
83
|
await syncProviderState(next)
|
|
84
84
|
|
|
@@ -127,31 +127,60 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
127
127
|
body = {}
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
let queryApiKeyEnv = ''
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
133
|
+
queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
|
|
134
|
+
} catch {
|
|
135
|
+
queryApiKeyEnv = ''
|
|
136
|
+
}
|
|
137
|
+
const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
|
|
131
138
|
if (!targetEnv) {
|
|
132
139
|
return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
|
|
143
|
+
if (!isDefaultPattern) {
|
|
144
|
+
return writeJson(res, 400, {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
135
150
|
const curr = plainConfig(live())
|
|
136
151
|
if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
|
|
137
152
|
return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
156
|
+
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
157
|
+
if (!accountToDelete) {
|
|
158
|
+
return writeJson(res, 404, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (body.deleteSecret) {
|
|
165
|
+
const delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
166
|
+
if (!delRes || !delRes.ok) {
|
|
167
|
+
return writeJson(res, 500, {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
141
174
|
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
142
175
|
let activeAccount = curr.activeAccount
|
|
143
176
|
if (activeAccount === targetEnv) {
|
|
144
177
|
activeAccount = ''
|
|
145
178
|
}
|
|
146
179
|
|
|
147
|
-
const next = Config({ ...curr, accounts: nextAccounts, activeAccount })
|
|
180
|
+
const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
|
|
148
181
|
await settingsApi.replace(next)
|
|
149
182
|
await syncProviderState(next)
|
|
150
183
|
|
|
151
|
-
if (body.deleteSecret) {
|
|
152
|
-
await deleteCredentialKey(ctx, targetEnv)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
184
|
clearUsageCache()
|
|
156
185
|
clearProbeCache()
|
|
157
186
|
|
|
@@ -193,7 +222,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
193
222
|
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
194
223
|
}
|
|
195
224
|
|
|
196
|
-
const next = Config({ ...plainConfig(live()), activeAccount: account })
|
|
225
|
+
const next = plainConfig(Config({ ...plainConfig(live()), activeAccount: account }))
|
|
197
226
|
await settingsApi.replace(next)
|
|
198
227
|
await syncProviderState(next)
|
|
199
228
|
writeJson(res, 200, { ok: true, activeAccount: account })
|
package/lib/routes/models.js
CHANGED
|
@@ -40,11 +40,11 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const now = Date.now()
|
|
43
|
-
const next = Config({
|
|
43
|
+
const next = plainConfig(Config({
|
|
44
44
|
...plainConfig(live()),
|
|
45
45
|
dynamicModels,
|
|
46
46
|
planSyncedAt: now,
|
|
47
|
-
})
|
|
47
|
+
}))
|
|
48
48
|
await settingsApi.replace(next)
|
|
49
49
|
await syncProviderState(next)
|
|
50
50
|
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
@@ -92,7 +92,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
92
92
|
patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
|
|
93
93
|
}
|
|
94
94
|
if (body.defaultModel) patch.defaultModel = body.defaultModel
|
|
95
|
-
const next = Config({ ...plainConfig(live()), ...patch })
|
|
95
|
+
const next = plainConfig(Config({ ...plainConfig(live()), ...patch }))
|
|
96
96
|
await settingsApi.replace(next)
|
|
97
97
|
await syncProviderState(next)
|
|
98
98
|
writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
|
|
@@ -164,10 +164,10 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
164
164
|
return writeJson(res, 400, { ok: false, error: 'Missing modelId, mode, or overrides array' })
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
const next = Config({
|
|
167
|
+
const next = plainConfig(Config({
|
|
168
168
|
...plainConfig(live()),
|
|
169
169
|
modelContextOverrides: currentOverrides,
|
|
170
|
-
})
|
|
170
|
+
}))
|
|
171
171
|
await settingsApi.replace(next)
|
|
172
172
|
await syncProviderState(next)
|
|
173
173
|
|
package/lib/routes/proxy.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getLocalProxyToken } from '../proxy-token.js'
|
|
|
26
26
|
const require = createRequire(import.meta.url)
|
|
27
27
|
let packageVersion = '0.5.3'
|
|
28
28
|
try {
|
|
29
|
-
packageVersion = require('../../package.json').version || '0.5.
|
|
29
|
+
packageVersion = require('../../package.json').version || '0.5.6'
|
|
30
30
|
} catch (err) {
|
|
31
31
|
/* fallback to default version if manifest is unreadable */
|
|
32
32
|
}
|
package/lib/routes/settings.js
CHANGED
|
@@ -93,7 +93,7 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
93
93
|
const base = plainConfig(live()) || {}
|
|
94
94
|
delete base.enabledModels
|
|
95
95
|
const merged = { ...base, ...rawPayload }
|
|
96
|
-
const parsed = Config(merged)
|
|
96
|
+
const parsed = plainConfig(Config(merged))
|
|
97
97
|
await settingsApi.replace(parsed)
|
|
98
98
|
await syncProviderState(parsed)
|
|
99
99
|
writeJson(res, 200, { ok: true, config: publicConfig(live()) })
|
package/lib/stats-storage.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
@@ -84,9 +84,9 @@ export function resolvePath(targetPath) {
|
|
|
84
84
|
* @returns {Object} Loaded stats
|
|
85
85
|
*/
|
|
86
86
|
export function loadStats(customPath) {
|
|
87
|
+
const filePath = resolvePath(customPath)
|
|
88
|
+
activeStatsPath = filePath
|
|
87
89
|
try {
|
|
88
|
-
const filePath = resolvePath(customPath)
|
|
89
|
-
activeStatsPath = filePath
|
|
90
90
|
if (existsSync(filePath)) {
|
|
91
91
|
const raw = readFileSync(filePath, 'utf8')
|
|
92
92
|
const parsed = JSON.parse(raw)
|
|
@@ -103,14 +103,35 @@ export function loadStats(customPath) {
|
|
|
103
103
|
return inMemoryStats
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
// If reading fails or file is corrupted,
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// If reading fails or file is corrupted, preserve corrupted file for diagnostics
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(filePath)) {
|
|
110
|
+
const corruptBackup = `${filePath}.corrupt.${Date.now()}`
|
|
111
|
+
copyFileSync(filePath, corruptBackup)
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore backup failure */
|
|
115
|
+
}
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
inMemoryStats = createEmptyStats()
|
|
111
119
|
return inMemoryStats
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Flush any debounced pending writes immediately to disk.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} [customPath]
|
|
126
|
+
*/
|
|
127
|
+
export function flushStats(customPath) {
|
|
128
|
+
if (saveTimer) {
|
|
129
|
+
clearTimeout(saveTimer)
|
|
130
|
+
saveTimer = null
|
|
131
|
+
}
|
|
132
|
+
saveStatsSync(customPath)
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
/**
|
|
115
136
|
* Synchronously write stats to disk using atomic rename.
|
|
116
137
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|