@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 +22 -0
- package/lib/client.js +3 -3
- package/lib/cline-client.js +1 -2
- package/lib/http.js +1 -1
- package/lib/index.js +7 -5
- package/lib/models.js +4 -4
- package/lib/provider-sync.js +15 -2
- package/lib/routes/auth.js +1 -1
- package/lib/routes/proxy.js +35 -7
- package/lib/session-router.js +57 -9
- package/lib/slash-command.js +1 -3
- package/lib/stats-storage.js +26 -8
- package/package.json +1 -1
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-
|
|
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-
|
|
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-
|
|
509
|
+
color: isBad ? 'var(--dsw-alias-status-danger)' : 'var(--dsw-alias-label-secondary)',
|
|
510
510
|
whiteSpace: 'nowrap',
|
|
511
511
|
marginLeft: 'auto',
|
|
512
512
|
},
|
package/lib/cline-client.js
CHANGED
|
@@ -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
|
-
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
+
function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
|
|
452
452
|
return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
|
|
453
453
|
}
|
|
454
454
|
|
package/lib/provider-sync.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
}
|
package/lib/routes/auth.js
CHANGED
|
@@ -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
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 : []
|
package/lib/session-router.js
CHANGED
|
@@ -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
|
-
|
|
157
|
+
let existing = sessions.get(sessionId)
|
|
158
|
+
const now = Date.now()
|
|
124
159
|
if (existing) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
existing
|
|
129
|
-
|
|
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
|
-
|
|
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 :
|
|
141
|
-
lastUsedAt:
|
|
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]) => [
|
package/lib/slash-command.js
CHANGED
|
@@ -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 }) {
|
package/lib/stats-storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|