@goodandready/dsh-clinebot 0.4.8 → 0.5.1

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,28 @@ 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.5.1] - 2026-09-28
9
+
10
+ ### Fixed
11
+ - **Host Process Crash on Stats Save (Issue #141, GitHub #9)**: Safely unwrap volatile Schemastery `{ get }` references, getter functions, and non-string inputs in `resolvePath`. Wrapped `saveStatsSync` in `try/catch` inside `queueSaveStats` timer callback to guarantee disk persistence failures never become uncaught exceptions terminating the DSH host process. Updated `lib/index.js` to pass `publicConfig(live()).statsPath`.
12
+ - **Large Context / Multimodal Proxy Rejection (Issue #142, GitHub #10)**: Increased request body reading limit on `/dsh-clinebot/v1/chat/completions` from 256 KB to 64 MB, preventing HTTP 400 `"body too large"` errors on long conversational sessions and multimodal requests.
13
+ - **Model Cache Path Resolution (Issue #143)**: Added safe volatile reference and getter unwrapping to `resolvePathWithHome` in `lib/provider-sync.js`, preventing `TypeError [ERR_INVALID_ARG_TYPE]` on disk cache operations.
14
+
15
+ ## [0.5.0] - 2026-09-27
16
+
17
+ ### Security
18
+ - **Loopback Proxy Access Protection (#133)**: Strictly restricted OpenAI-compatible proxy endpoints (`POST /dsh-clinebot/v1/chat/completions` and `GET /dsh-clinebot/v1/models`) to loopback callers (`127.0.0.1`, `::1`, `localhost`). External network requests are rejected immediately with HTTP 403 Forbidden (`Loopback access only`).
19
+
20
+ ### Added & Improved
21
+ - **Session Router Bounded Memory & LRU Eviction (#134)**: Introduced hard session limit `MAX_SESSIONS = 1000` and session expiration TTL `SESSION_TTL_MS = 24h` in `lib/session-router.js`. Prevents unbounded Map growth under high concurrency by pruning expired sessions and evicting least-recently-used sessions.
22
+
23
+ ### Fixed & Cleaned
24
+ - **Dead & Test-Only Exports Elimination (#135)**: Pruned dead and internal-only exports across server modules (`USAGE_CACHE_TTL_MS` in `lib/cline-client.js`, `queueSaveStats` in `lib/stats-storage.js`, and `migrateModelId`, `getOriginalModelProvider`, `findModel`, `getDefaultModelIds` in `lib/models.js`), aligning module boundaries with public contract.
25
+ - **Design Token Purity in Progress Bar (#136)**: Replaced hardcoded fallback hex `#f87171` in `src/client/progress-bar.js` with semantic design system token `var(--dsw-alias-status-danger)`, eliminating color hardcodes across all client components.
26
+ - **Proxy Client Abort Stream Cancellation (#138)**: Connected `req.on('close')` in streaming SSE completions (`POST /dsh-clinebot/v1/chat/completions`) to immediately cancel upstream response reader via `reader.cancel()`, preventing orphaned background inference and quota drain when clients disconnect.
27
+ - **Circular Module Dependency Removal (#139)**: Eradicated artificial circular import cycle between `lib/cline-client.js` and `lib/account-pool.js` by removing legacy re-exports from `cline-client.js` and routing consumers directly to `lib/account-pool.js`.
28
+ - **Proxy Model Fallback (#140)**: Implemented seamless fallback to `pub.defaultModel` in `/dsh-clinebot/v1/chat/completions` when `model` parameter is omitted, preventing upstream HTTP 400 Bad Request.
29
+
8
30
  ## [0.4.8] - 2026-09-27
9
31
 
10
32
  ### Added
package/lib/client.js CHANGED
@@ -478,7 +478,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
478
478
  gap: '8px',
479
479
  padding: '12px 14px',
480
480
  borderRadius: '8px',
481
- border: isBad ? '1px solid var(--dsw-alias-error, #f87171)' : '1px solid var(--dsw-alias-border-l2)',
481
+ border: isBad ? '1px solid var(--dsw-alias-status-danger)' : '1px solid var(--dsw-alias-border-l2)',
482
482
  background: 'var(--dsw-alias-bg-layer-2)',
483
483
  },
484
484
  },
@@ -498,7 +498,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
498
498
  'span',
499
499
  { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
500
500
  label,
501
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-error, #f87171)' } }, '⚠️ <10%') : null
501
+ isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-status-danger)' } }, '⚠️ <10%') : null
502
502
  ),
503
503
  React.createElement(
504
504
  'span',
@@ -506,7 +506,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
506
506
  className: 'cb-progress-meta cb-bar-meta',
507
507
  style: {
508
508
  fontSize: '12px',
509
- color: isBad ? 'var(--dsw-alias-error, #f87171)' : 'var(--dsw-alias-label-secondary)',
509
+ color: isBad ? 'var(--dsw-alias-status-danger)' : 'var(--dsw-alias-label-secondary)',
510
510
  whiteSpace: 'nowrap',
511
511
  marginLeft: 'auto',
512
512
  },
@@ -26,7 +26,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
26
26
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
27
27
  export const DEFAULT_TIMEOUT_MS = 15000
28
28
  export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
29
- export const USAGE_CACHE_TTL_MS = 60000
29
+ const USAGE_CACHE_TTL_MS = 60000
30
30
 
31
31
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
32
32
 
@@ -508,4 +508,3 @@ export function buildPiAiProvider({
508
508
 
509
509
 
510
510
 
511
- export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
package/lib/http.js CHANGED
@@ -46,7 +46,7 @@ function header(request, name) {
46
46
  return Array.isArray(value) ? value[0] : value
47
47
  }
48
48
 
49
- function isLoopbackAddress(value) {
49
+ export function isLoopbackAddress(value) {
50
50
  const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
51
51
  return address === 'localhost' || address === 'localhost.' || address === '::1'
52
52
  || address.startsWith('127.')
package/lib/index.js CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  sessionStats,
5
5
  recordSessionRequest,
6
6
  resetSessionStats,
7
+ } from './cline-client.js'
8
+ import {
7
9
  rotateToNextAccount,
8
10
  isQuotaExceededError,
9
- } from './cline-client.js'
11
+ } from './account-pool.js'
10
12
  import { PROVIDER_ID } from './models.js'
11
13
  import {
12
14
  upsertPiAiProvider,
@@ -208,7 +210,7 @@ export function apply(ctx, config) {
208
210
  totalTokens: promptTokens + completionTokens,
209
211
  isError: true,
210
212
  is429,
211
- statsPath: live().statsPath,
213
+ statsPath: publicConfig(live()).statsPath,
212
214
  })
213
215
  if (is429 || isExceeded) {
214
216
  handleStream429()
@@ -235,7 +237,7 @@ export function apply(ctx, config) {
235
237
  promptTokens,
236
238
  completionTokens,
237
239
  totalTokens: promptTokens + completionTokens,
238
- statsPath: live().statsPath,
240
+ statsPath: publicConfig(live()).statsPath,
239
241
  })
240
242
  }
241
243
  }
@@ -254,7 +256,7 @@ export function apply(ctx, config) {
254
256
  promptTokens,
255
257
  completionTokens,
256
258
  totalTokens: promptTokens + completionTokens,
257
- statsPath: live().statsPath,
259
+ statsPath: publicConfig(live()).statsPath,
258
260
  })
259
261
  }
260
262
  } catch (err) {
@@ -277,7 +279,7 @@ export function apply(ctx, config) {
277
279
  totalTokens: promptTokens + completionTokens,
278
280
  isError: true,
279
281
  is429: status === 429,
280
- statsPath: live().statsPath,
282
+ statsPath: publicConfig(live()).statsPath,
281
283
  })
282
284
  if (status === 429 || isQuotaExceededError(msg)) {
283
285
  handleStream429()
package/lib/models.js CHANGED
@@ -20,7 +20,7 @@ export const OBSOLETE_MODEL_ID_MAP = Object.freeze({
20
20
  'cline-pass/muse-spark-13-contributor': 'cline-pass/muse-spark-1.3-contributor',
21
21
  })
22
22
 
23
- export function migrateModelId(id) {
23
+ function migrateModelId(id) {
24
24
  if (typeof id !== 'string') return id
25
25
  return OBSOLETE_MODEL_ID_MAP[id] || id
26
26
  }
@@ -57,7 +57,7 @@ export function getOriginalModelContext(nameOrId) {
57
57
  return 200000
58
58
  }
59
59
 
60
- export function getOriginalModelProvider(nameOrId) {
60
+ function getOriginalModelProvider(nameOrId) {
61
61
  const norm = String(nameOrId || '').toLowerCase()
62
62
  if (norm.includes('kimi')) return 'Moonshot AI'
63
63
  if (norm.includes('qwen')) return 'Alibaba Cloud'
@@ -437,7 +437,7 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
437
437
  return baseModels
438
438
  }
439
439
 
440
- export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
440
+ function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
441
  const all = getAllModels(dynamicModels, modelContextOverrides)
442
442
  const found = all.find((m) => m.id === id)
443
443
  if (found) return found
@@ -448,7 +448,7 @@ export function isSupportedModel(id, dynamicModels = [], modelContextOverrides =
448
448
  return findModel(id, dynamicModels, modelContextOverrides) !== null
449
449
  }
450
450
 
451
- export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
451
+ function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
452
  return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
453
453
  }
454
454
 
@@ -23,8 +23,21 @@ import { resolveKeyValue } from './credential-refs.js'
23
23
  import { resolveAccountPool, getLastRotation } from './account-pool.js'
24
24
 
25
25
  export function resolvePathWithHome(p) {
26
- if (typeof p === 'string' && p.startsWith('~/')) {
27
- return path.join(os.homedir(), p.slice(2))
26
+ let guard = 0
27
+ while (guard < 5 && p) {
28
+ if (typeof p === 'function') {
29
+ try { p = p() } catch { p = ''; break }
30
+ guard += 1
31
+ } else if (typeof p === 'object' && typeof p.get === 'function') {
32
+ try { p = p.get() } catch { p = ''; break }
33
+ guard += 1
34
+ } else {
35
+ break
36
+ }
37
+ }
38
+ if (typeof p !== 'string') return ''
39
+ if (p.startsWith('~/') || p === '~') {
40
+ return path.join(os.homedir(), p.slice(1))
28
41
  }
29
42
  return p
30
43
  }
@@ -5,10 +5,10 @@ import { resolveActiveAccountKey } from '../provider-sync.js'
5
5
  import {
6
6
  smokeChat,
7
7
  recordSmokeTest,
8
- rotateToNextAccount,
9
8
  DEFAULT_MODEL_ID,
10
9
  normalizeBaseUrl,
11
10
  } from '../cline-client.js'
11
+ import { rotateToNextAccount } from '../account-pool.js'
12
12
 
13
13
  export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
14
14
  // POST /dsh-clinebot/key/verify — on-the-fly verification of Cline API key
@@ -12,12 +12,12 @@
12
12
  * 4. Model reasoning_effort default injection from config.
13
13
  */
14
14
 
15
- import { writeJson, readBody } from '../http.js'
15
+ import { writeJson, readBody, isLoopbackAddress } from '../http.js'
16
16
  import { publicConfig, plainConfig } from '../config.js'
17
17
  import { resolveAccountPool } from '../account-pool.js'
18
18
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
19
19
  import { recordUsage } from '../stats-storage.js'
20
- import { getAllModels } from '../models.js'
20
+ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
21
21
 
22
22
  export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
23
  // 1. POST /dsh-clinebot/v1/chat/completions
@@ -29,9 +29,13 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
29
29
  return writeJson(res, 405, { ok: false, error: 'POST only' })
30
30
  }
31
31
 
32
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
33
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
34
+ }
35
+
32
36
  let bodyBuf
33
37
  try {
34
- bodyBuf = await readBody(req)
38
+ bodyBuf = await readBody(req, 64 * 1024 * 1024)
35
39
  } catch (err) {
36
40
  return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
37
41
  }
@@ -56,10 +60,12 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
56
60
  ''
57
61
  ).trim()
58
62
 
63
+ // Ensure model is set, fallback to defaultModel (#140)
64
+ body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
65
+
59
66
  // Inject model reasoning effort if model has default configured and payload omitted it
60
67
  if (
61
68
  pub.modelReasoningDefaults &&
62
- body.model &&
63
69
  pub.modelReasoningDefaults[body.model] &&
64
70
  !body.reasoning_effort
65
71
  ) {
@@ -95,7 +101,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
95
101
  'Content-Type': 'application/json',
96
102
  'Authorization': `Bearer ${targetAccount.value}`,
97
103
  'Accept': isStreaming ? 'text/event-stream' : 'application/json',
98
- 'User-Agent': '@goodandready/dsh-clinebot/0.4.8'
104
+ 'User-Agent': '@goodandready/dsh-clinebot/0.5.0'
99
105
  }
100
106
 
101
107
  let upstreamRes
@@ -145,10 +151,23 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
145
151
  const reader = upstreamRes.body.getReader()
146
152
  const decoder = new TextDecoder('utf8')
147
153
 
154
+ let streamAborted = false
155
+ const onClientAbort = () => {
156
+ streamAborted = true
157
+ try {
158
+ reader.cancel().catch(() => {})
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ }
163
+ if (typeof req.on === 'function') {
164
+ req.on('close', onClientAbort)
165
+ }
166
+
148
167
  try {
149
168
  while (true) {
150
169
  const { done, value } = await reader.read()
151
- if (done) break
170
+ if (done || streamAborted) break
152
171
 
153
172
  const chunkStr = decoder.decode(value, { stream: true })
154
173
  res.write(value)
@@ -175,8 +194,13 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
175
194
  }
176
195
  }
177
196
  } catch (streamErr) {
178
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
197
+ if (!streamAborted) {
198
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
199
+ }
179
200
  } finally {
201
+ if (typeof req.off === 'function') {
202
+ req.off('close', onClientAbort)
203
+ }
180
204
  res.end()
181
205
  recordUsage({
182
206
  model: body.model,
@@ -237,6 +261,10 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
237
261
  if (req.method !== 'GET') {
238
262
  return writeJson(res, 405, { ok: false, error: 'GET only' })
239
263
  }
264
+
265
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
266
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
267
+ }
240
268
  const pub = publicConfig(live())
241
269
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
242
270
  const custom = Array.isArray(pub.customModels) ? pub.customModels : []
@@ -14,6 +14,40 @@ const sessions = new Map()
14
14
  // In-memory cooldowns: accountId -> { until: timestamp, reason: string }
15
15
  const cooldowns = new Map()
16
16
 
17
+ export const MAX_SESSIONS = 1000
18
+ export const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
19
+
20
+ /**
21
+ * Evict expired sessions and trim capacity to maxLimit via LRU.
22
+ *
23
+ * @param {number} [now=Date.now()] Current timestamp
24
+ * @param {number} [maxLimit=MAX_SESSIONS] Capacity threshold
25
+ * @param {number} [ttlMs=SESSION_TTL_MS] Expiration TTL
26
+ */
27
+ export function pruneSessions(now = Date.now(), maxLimit = MAX_SESSIONS, ttlMs = SESSION_TTL_MS) {
28
+ for (const [id, s] of sessions.entries()) {
29
+ if (now - s.lastUsedAt > ttlMs) {
30
+ sessions.delete(id)
31
+ }
32
+ }
33
+
34
+ while (sessions.size > maxLimit) {
35
+ let oldestId = null
36
+ let oldestTime = Infinity
37
+ for (const [id, s] of sessions.entries()) {
38
+ if (s.lastUsedAt < oldestTime) {
39
+ oldestTime = s.lastUsedAt
40
+ oldestId = id
41
+ }
42
+ }
43
+ if (oldestId) {
44
+ sessions.delete(oldestId)
45
+ } else {
46
+ break
47
+ }
48
+ }
49
+ }
50
+
17
51
  /**
18
52
  * Check if an account is currently in cooldown or rate-limited.
19
53
  * Performs automatic recovery if the cooldown duration has passed.
@@ -120,15 +154,27 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
120
154
  return { account: best, isReassigned: false, reason: 'no_session_id' }
121
155
  }
122
156
 
123
- const existing = sessions.get(sessionId)
157
+ let existing = sessions.get(sessionId)
158
+ const now = Date.now()
124
159
  if (existing) {
125
- const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
126
- // If assigned account is still valid and NOT in cooldown, keep sticky pin
127
- if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
128
- existing.lastUsedAt = Date.now()
129
- return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
160
+ // TTL eviction check
161
+ if (now - existing.lastUsedAt > SESSION_TTL_MS) {
162
+ sessions.delete(sessionId)
163
+ existing = null
164
+ } else {
165
+ const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
166
+ // If assigned account is still valid and NOT in cooldown, keep sticky pin
167
+ if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
168
+ existing.lastUsedAt = now
169
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
170
+ }
171
+ // Failover: account was deleted or entered cooldown mid-session
130
172
  }
131
- // Failover: account was deleted or entered cooldown mid-session
173
+ }
174
+
175
+ // Prune before adding a brand new session if capacity reached
176
+ if (!existing && sessions.size >= MAX_SESSIONS) {
177
+ pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
132
178
  }
133
179
 
134
180
  // New session or mid-session failover: pick least-used account
@@ -137,8 +183,8 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
137
183
  const chosenId = chosen.id || chosen.label
138
184
  sessions.set(sessionId, {
139
185
  accountId: chosenId,
140
- assignedAt: existing ? existing.assignedAt : Date.now(),
141
- lastUsedAt: Date.now()
186
+ assignedAt: existing ? existing.assignedAt : now,
187
+ lastUsedAt: now
142
188
  })
143
189
  }
144
190
 
@@ -174,6 +220,8 @@ export function resetSessionRouter() {
174
220
  export function getSessionRouterStatus() {
175
221
  return {
176
222
  activeSessionsCount: sessions.size,
223
+ maxSessions: MAX_SESSIONS,
224
+ sessionTtlMs: SESSION_TTL_MS,
177
225
  cooldownsCount: cooldowns.size,
178
226
  cooldowns: Object.fromEntries(
179
227
  Array.from(cooldowns.entries()).map(([k, v]) => [
@@ -1,5 +1,6 @@
1
1
  import { publicConfig, plainConfig, Config } from './config.js'
2
2
  import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
3
+ import { resolveAccountPool, rotateToNextAccount, getLastRotation } from './account-pool.js'
3
4
  import {
4
5
  getAllModels,
5
6
  isVisionModel,
@@ -8,8 +9,6 @@ import {
8
9
  DEFAULT_MODEL_ID,
9
10
  } from './models.js'
10
11
  import {
11
- resolveAccountPool,
12
- rotateToNextAccount,
13
12
  probeHealth,
14
13
  smokeChat,
15
14
  fetchUsageLimits,
@@ -18,7 +17,6 @@ import {
18
17
  clearProbeCache,
19
18
  usageCache,
20
19
  sessionStats,
21
- getLastRotation,
22
20
  } from './cline-client.js'
23
21
 
24
22
  export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderState }) {
@@ -41,6 +41,21 @@ export function createEmptyStats() {
41
41
  * Resolve '~' in paths to user's home directory.
42
42
  */
43
43
  export function resolvePath(targetPath) {
44
+ let unwrapGuard = 0
45
+ while (unwrapGuard < 5 && targetPath) {
46
+ if (typeof targetPath === 'function') {
47
+ try { targetPath = targetPath() } catch { targetPath = ''; break }
48
+ unwrapGuard += 1
49
+ } else if (typeof targetPath === 'object' && typeof targetPath.get === 'function') {
50
+ try { targetPath = targetPath.get() } catch { targetPath = ''; break }
51
+ unwrapGuard += 1
52
+ } else {
53
+ break
54
+ }
55
+ }
56
+ if (typeof targetPath !== 'string') {
57
+ targetPath = ''
58
+ }
44
59
  if (!targetPath) {
45
60
  return path.join(homedir(), '.dsh', 'clinebot-stats.json')
46
61
  }
@@ -57,10 +72,9 @@ export function resolvePath(targetPath) {
57
72
  * @returns {Object} Loaded stats
58
73
  */
59
74
  export function loadStats(customPath) {
60
- const filePath = resolvePath(customPath)
61
- activeStatsPath = filePath
62
-
63
75
  try {
76
+ const filePath = resolvePath(customPath)
77
+ activeStatsPath = filePath
64
78
  if (existsSync(filePath)) {
65
79
  const raw = readFileSync(filePath, 'utf8')
66
80
  const parsed = JSON.parse(raw)
@@ -91,10 +105,10 @@ export function loadStats(customPath) {
91
105
  * @param {string} [customPath]
92
106
  */
93
107
  export function saveStatsSync(customPath) {
94
- const filePath = resolvePath(customPath || activeStatsPath)
95
- activeStatsPath = filePath
96
-
97
108
  try {
109
+ const filePath = resolvePath(customPath || activeStatsPath)
110
+ activeStatsPath = filePath
111
+
98
112
  const dir = path.dirname(filePath)
99
113
  if (!existsSync(dir)) {
100
114
  mkdirSync(dir, { recursive: true })
@@ -117,13 +131,17 @@ export function saveStatsSync(customPath) {
117
131
  * @param {string} [customPath]
118
132
  * @param {number} [debounceMs=500]
119
133
  */
120
- export function queueSaveStats(customPath, debounceMs = 500) {
134
+ function queueSaveStats(customPath, debounceMs = 500) {
121
135
  if (saveTimer) {
122
136
  clearTimeout(saveTimer)
123
137
  }
124
138
  saveTimer = setTimeout(() => {
125
139
  saveTimer = null
126
- saveStatsSync(customPath)
140
+ try {
141
+ saveStatsSync(customPath)
142
+ } catch {
143
+ // Best-effort disk persistence, never crash host process
144
+ }
127
145
  }, debounceMs)
128
146
  }
129
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.4.8",
3
+ "version": "0.5.1",
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",