@goodandready/dsh-clinebot 0.5.6 → 0.5.7

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,15 @@ 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.7] - 2026-09-30
9
+
10
+ ### Fixed
11
+ - **Active Account Pinning & Account Identity Unification (Issue #161)**: Resolved issue where proxy router ignored manually pinned active account and routed requests to secondary keys. Unified account identification across `id`, `apiKeyEnv`, and `label` in session routing, round-robin fallback, and cooldown tracking.
12
+ - **Account Pool Quota Metadata Enrichment (Issue #162)**: Provided real-time quota window utilization metadata (`remainingPercent`, `percentUsed`, `resetsAt`) to least-used account routing strategy. Handled unknown/unprobed quotas safely to prevent routing bias towards exhausted accounts.
13
+ - **Pi-AI Provider Session Affinity Header Propagation (Issue #163)**: Enabled `sendSessionAffinityHeaders: true` and `sessionAffinityFormat: 'openrouter'` in `@earendil-works/pi-ai` provider and model compatibility options. Expanded proxy session identification to extract session IDs from `x-session-id`, `x-dsh-session-id`, `session-id`, `x-session-affinity`, and client body parameters, preserving conversation affinity.
14
+ - **Client SSE Disconnection Upstream Abort (Issue #164)**: Monitored client response `close` events during streaming. Aborts upstream fetch and cancels ReadableStream reader immediately when a client disconnects prematurely, avoiding resource waste and orphaned upstream requests. Guarded writes and cleaned up listeners reliably.
15
+ - **Decoupled Connect Timeout & Streaming Idle Watchdog (Issue #165)**: Separated initial HTTP connect / header timeout (`connectTimeoutMs`, default 15s) from active streaming idle timeout (`streamIdleTimeoutMs`, default 30s). Prevented healthy long SSE streams from being cut off by monolithic timeouts. Added rearmable idle watchdog that signals client errors on stalls and accurately accounts failed streams with `isError: true` in token analytics instead of false successes.
16
+
8
17
  ## [0.5.6] - 2026-09-30
9
18
 
10
19
  ### Security
@@ -32,6 +32,40 @@ function toPlainSnapshot(cfg) {
32
32
  return out
33
33
  }
34
34
 
35
+ export function getAccountQuotaSnapshot(accValue) {
36
+ if (!accValue || typeof accValue !== 'string') return null
37
+ const cacheKey = `cline:usage:${accValue.slice(-8)}`
38
+ const cached = usageCache.get(cacheKey)?.data
39
+ if (!cached?.windows) return null
40
+
41
+ const now = Date.now()
42
+ const evaluateWindow = (w) => {
43
+ if (!w || typeof w.percentUsed !== 'number') return null
44
+ if (w.resetsAt) {
45
+ const resetTime = new Date(w.resetsAt).getTime()
46
+ if (!Number.isNaN(resetTime) && now >= resetTime) {
47
+ return 0
48
+ }
49
+ }
50
+ return w.percentUsed
51
+ }
52
+
53
+ const fh = evaluateWindow(cached.windows.fiveHour)
54
+ const wk = evaluateWindow(cached.windows.weekly)
55
+ const mo = evaluateWindow(cached.windows.monthly)
56
+
57
+ const activePcts = [fh, wk, mo].filter((p) => typeof p === 'number')
58
+ const percentUsed = activePcts.length > 0 ? Math.max(...activePcts) : null
59
+
60
+ return {
61
+ percentUsed,
62
+ fiveHour: fh,
63
+ weekly: wk,
64
+ monthly: mo,
65
+ raw: cached,
66
+ }
67
+ }
68
+
35
69
  export async function resolveAccountPool(ctx, cfg) {
36
70
  const apiKeyEnv = textSetting(cfg?.apiKeyEnv) || DEFAULT_API_KEY_ENV
37
71
  const defaultSlot = {
@@ -48,14 +82,21 @@ export async function resolveAccountPool(ctx, cfg) {
48
82
  const slot = allSlots[i]
49
83
  const envName = textSetting(slot.apiKeyEnv) || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
50
84
  const keyInfo = await resolveKeyValue(ctx, envName)
85
+ const slotId = slot.id || (i === 0 ? 'default' : `account-${i + 1}`)
86
+ const isPinned = activeAccount === 'auto'
87
+ ? false
88
+ : (activeAccount ? (activeAccount === envName || activeAccount === slotId) : i === 0)
89
+ const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value) : null
51
90
  resolved.push({
52
- id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
91
+ id: slotId,
53
92
  label: textSetting(slot.label) || (i === 0 ? 'Default' : `Account ${i + 1}`),
54
93
  apiKeyEnv: envName,
55
94
  present: Boolean(keyInfo.value),
56
95
  source: keyInfo.source,
57
96
  value: keyInfo.value,
58
- isPinned: activeAccount ? activeAccount === envName : i === 0,
97
+ isPinned,
98
+ percentUsed: typeof quota?.percentUsed === 'number' ? quota.percentUsed : null,
99
+ quota,
59
100
  })
60
101
  }
61
102
 
@@ -26,6 +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 DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
29
30
  const USAGE_CACHE_TTL_MS = 60000
30
31
 
31
32
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
@@ -486,13 +487,18 @@ export function buildPiAiProvider({
486
487
  if (validEfforts.includes(key)) efforts[key] = key === 'off' ? (v === undefined ? null : v) : (typeof v === 'string' && v.trim() ? v.trim() : key)
487
488
  }
488
489
  }
490
+ const modelCompat = {
491
+ sendSessionAffinityHeaders: true,
492
+ sessionAffinityFormat: 'openrouter',
493
+ }
489
494
  if (efforts && Object.keys(efforts).length) {
490
495
  if (!('off' in efforts)) efforts.off = null
491
496
  res.reasoningEfforts = efforts
492
- res.compat = { supportsReasoningEffort: true }
497
+ modelCompat.supportsReasoningEffort = true
493
498
  } else {
494
499
  res.reasoningEfforts = false
495
500
  }
501
+ res.compat = modelCompat
496
502
  return res
497
503
  })
498
504
 
@@ -502,7 +508,11 @@ export function buildPiAiProvider({
502
508
  baseURL: normalizeBaseUrl(baseUrl),
503
509
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
504
510
  ...(apiKey ? { apiKey } : {}),
505
- compat: { supportsReasoningEffort: true },
511
+ compat: {
512
+ supportsReasoningEffort: true,
513
+ sendSessionAffinityHeaders: true,
514
+ sessionAffinityFormat: 'openrouter',
515
+ },
506
516
  models: modelList,
507
517
  }
508
518
  }
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_API_KEY_ENV,
5
5
  DEFAULT_TIMEOUT_MS,
6
6
  DEFAULT_SMOKE_TIMEOUT_MS,
7
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
7
8
  normalizeBaseUrl,
8
9
  } from './cline-client.js'
9
10
  import {
@@ -76,6 +77,8 @@ export const Config = z.object({
76
77
  .description('HTTP probe timeout in milliseconds.').volatile(),
77
78
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
78
79
  .description('Timeout for smoke chat completions in milliseconds.').volatile(),
80
+ streamIdleTimeoutMs: z.number().default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
81
+ .description('Idle timeout between SSE stream chunks in milliseconds.').volatile(),
79
82
  modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
80
83
  .description('Local on-disk cache path for models snapshot.').volatile(),
81
84
  accounts: z.array(z.object({
@@ -170,6 +173,7 @@ export function publicConfig(cfg) {
170
173
  enabledModels: activeIds,
171
174
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
172
175
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
176
+ streamIdleTimeoutMs: Number(cfg?.streamIdleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS,
173
177
  modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
174
178
  accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
175
179
  activeAccount: String(cfg?.activeAccount || ''),
@@ -161,9 +161,6 @@ export async function buildStatus(ctx, cfg) {
161
161
  source: activeAcc.source || key.source,
162
162
  },
163
163
  accounts: pool.map((acc) => {
164
- const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
165
- const cached = usageCache.get(cacheKey)?.data
166
- const pct = cached?.windows?.weekly?.percentUsed ?? cached?.windows?.monthly?.percentUsed ?? cached?.windows?.fiveHour?.percentUsed ?? null
167
164
  return {
168
165
  id: acc.id,
169
166
  label: acc.label,
@@ -171,7 +168,7 @@ export async function buildStatus(ctx, cfg) {
171
168
  present: acc.present,
172
169
  source: acc.source,
173
170
  isPinned: acc.isPinned,
174
- percentUsed: typeof pct === 'number' ? pct : null,
171
+ percentUsed: typeof acc.percentUsed === 'number' ? acc.percentUsed : null,
175
172
  }
176
173
  }),
177
174
  activeAccount: activeAcc.apiKeyEnv,
@@ -106,6 +106,11 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
106
106
  const sessionId = String(
107
107
  req.headers['x-dsh-session-id'] ||
108
108
  req.headers['x-session-id'] ||
109
+ req.headers['session-id'] ||
110
+ req.headers['session_id'] ||
111
+ req.headers['x-session-affinity'] ||
112
+ req.headers['x-client-request-id'] ||
113
+ body?.session_id ||
109
114
  body?.conversation_id ||
110
115
  body?.chat_id ||
111
116
  body?.user ||
@@ -127,6 +132,24 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
127
132
  const isStreaming = Boolean(body.stream)
128
133
  const configuredAccounts = pool.filter(a => a.present && a.value)
129
134
 
135
+ const clientAbortCtrl = new AbortController()
136
+ let clientClosed = false
137
+ const onClientClose = () => {
138
+ clientClosed = true
139
+ if (!res.writableFinished) {
140
+ clientAbortCtrl.abort()
141
+ }
142
+ }
143
+ if (typeof res.on === 'function') res.on('close', onClientClose)
144
+ if (typeof req.on === 'function') req.on('close', onClientClose)
145
+
146
+ const cleanupClientListeners = () => {
147
+ if (typeof res.off === 'function') res.off('close', onClientClose)
148
+ else if (typeof res.removeListener === 'function') res.removeListener('close', onClientClose)
149
+ if (typeof req.off === 'function') req.off('close', onClientClose)
150
+ else if (typeof req.removeListener === 'function') req.removeListener('close', onClientClose)
151
+ }
152
+
130
153
  const maxRetries = Math.max(1, configuredAccounts.length)
131
154
  let attempt = 0
132
155
  let lastError = null
@@ -135,10 +158,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
135
158
  while (attempt < maxRetries) {
136
159
  attempt += 1
137
160
 
161
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
162
+ cleanupClientListeners()
163
+ return
164
+ }
165
+
138
166
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
139
167
  const targetAccount = routeResult.account
140
168
 
141
169
  if (!targetAccount || !targetAccount.value) {
170
+ cleanupClientListeners()
142
171
  return writeJson(res, 503, {
143
172
  error: {
144
173
  message: 'No available ClinePass API keys configured in pool',
@@ -156,16 +185,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
156
185
  'User-Agent': USER_AGENT
157
186
  }
158
187
 
159
- const upstreamTimeoutMs = Math.max(1000, Number(pub.timeoutMs) || 60000)
160
- const timeoutSignal = AbortSignal.timeout(upstreamTimeoutMs)
161
- const clientAbortCtrl = new AbortController()
162
- const onReqClose = () => {
163
- clientAbortCtrl.abort()
164
- }
165
- if (typeof req.on === 'function') {
166
- req.on('close', onReqClose)
167
- }
168
- const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, timeoutSignal])
188
+ const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
189
+ const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
190
+
191
+ const connectAbortCtrl = new AbortController()
192
+ let connectTimedOut = false
193
+ const connectTimer = setTimeout(() => {
194
+ connectTimedOut = true
195
+ connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
196
+ }, connectTimeoutMs)
197
+
198
+ const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal])
169
199
 
170
200
  let upstreamRes
171
201
  try {
@@ -177,25 +207,29 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
177
207
  keepalive: true
178
208
  })
179
209
  } catch (fetchErr) {
180
- if (clientAbortCtrl.signal.aborted) {
181
- // Client closed connection, stop processing
210
+ clearTimeout(connectTimer)
211
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
212
+ cleanupClientListeners()
182
213
  return
183
214
  }
184
215
  lastError = fetchErr
185
- const isTimeout = timeoutSignal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
216
+ const isTimeout = connectTimedOut || connectAbortCtrl.signal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
186
217
  if (isTimeout) {
187
218
  hadTimeout = true
188
- const accountId = targetAccount.id || targetAccount.label
219
+ const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
189
220
  markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
190
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream timed out after ${upstreamTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
221
+ if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_timeout')
222
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream connect timed out after ${connectTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
191
223
  }
192
224
  continue
193
225
  }
226
+ clearTimeout(connectTimer)
194
227
 
195
228
  // If upstream rejected with 429 or 402 quota exhaustion before streaming, failover!
196
229
  if (upstreamRes.status === 429 || upstreamRes.status === 402) {
197
- const accountId = targetAccount.id || targetAccount.label
230
+ const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
198
231
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
232
+ if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_429')
199
233
  recordUsage({
200
234
  model: body.model,
201
235
  accountId,
@@ -221,6 +255,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
221
255
  let completionTokens = 0
222
256
  let totalTokens = 0
223
257
  let buffer = ''
258
+ let streamError = null
259
+ let streamCompletedCleanly = false
224
260
 
225
261
  const reader = upstreamRes.body.getReader()
226
262
  const decoder = new TextDecoder('utf8')
@@ -229,22 +265,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
229
265
  const onStreamAbort = () => {
230
266
  streamAborted = true
231
267
  try {
232
- reader.cancel().catch(() => {})
233
- } catch {
234
- /* ignore */
268
+ reader.cancel('client closed response').catch(() => {})
269
+ } catch { /* reader already cancelled or closed */ }
270
+ }
271
+
272
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
273
+ onStreamAbort()
274
+ } else {
275
+ clientAbortCtrl.signal.addEventListener('abort', onStreamAbort, { once: true })
276
+ }
277
+
278
+ let idleTimer = null
279
+ let idleTimedOut = false
280
+ const resetIdleTimer = () => {
281
+ if (idleTimer) clearTimeout(idleTimer)
282
+ idleTimer = setTimeout(() => {
283
+ idleTimedOut = true
284
+ streamError = new Error(`Stream idle timeout after ${streamIdleTimeoutMs}ms`)
285
+ try {
286
+ reader.cancel('stream idle timeout').catch(() => {})
287
+ } catch { /* reader already cancelled or closed */ }
288
+ }, streamIdleTimeoutMs)
289
+ }
290
+
291
+ const clearIdleTimer = () => {
292
+ if (idleTimer) {
293
+ clearTimeout(idleTimer)
294
+ idleTimer = null
235
295
  }
236
296
  }
237
- if (typeof req.on === 'function') {
238
- req.on('close', onStreamAbort)
297
+
298
+ let totalTimer = null
299
+ if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
300
+ const totalMs = Number(pub.streamTotalTimeoutMs)
301
+ totalTimer = setTimeout(() => {
302
+ streamError = new Error(`Stream total timeout after ${totalMs}ms`)
303
+ try {
304
+ reader.cancel('stream total timeout').catch(() => {})
305
+ } catch { /* reader already cancelled or closed */ }
306
+ }, totalMs)
239
307
  }
240
308
 
241
309
  try {
310
+ resetIdleTimer()
242
311
  while (true) {
312
+ if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
243
313
  const { done, value } = await reader.read()
244
- if (done || streamAborted) break
314
+ if (idleTimedOut || streamError) break
315
+ if (done || streamAborted) {
316
+ if (done && !streamAborted && !clientClosed && !idleTimedOut && !streamError) {
317
+ streamCompletedCleanly = true
318
+ }
319
+ break
320
+ }
321
+
322
+ resetIdleTimer()
245
323
 
246
324
  const chunkStr = decoder.decode(value, { stream: true })
247
- res.write(value)
325
+ if (!res.destroyed && !res.writableEnded) {
326
+ res.write(value)
327
+ }
328
+ if (streamAborted || clientAbortCtrl.signal.aborted || clientClosed) break
248
329
 
249
330
  // Parse SSE lines for usage metrics
250
331
  buffer += chunkStr
@@ -268,14 +349,34 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
268
349
  }
269
350
  }
270
351
  } catch (streamErr) {
352
+ streamError = streamErr
271
353
  ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
272
354
  } finally {
355
+ clearIdleTimer()
356
+ if (totalTimer) clearTimeout(totalTimer)
357
+ clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
358
+ cleanupClientListeners()
273
359
  try {
274
360
  reader.releaseLock()
275
- } catch (err) {
276
- /* ignore lock release error if reader already closed */
361
+ } catch (err) { /* ignore lock release error if reader closed */ }
362
+
363
+ const isStreamFailed = Boolean(streamError || idleTimedOut || clientClosed || clientAbortCtrl.signal.aborted || !streamCompletedCleanly)
364
+
365
+ if (!res.destroyed && !res.writableEnded) {
366
+ if (isStreamFailed && (idleTimedOut || streamError)) {
367
+ try {
368
+ const errPayload = JSON.stringify({
369
+ error: {
370
+ message: streamError?.message || 'Stream interrupted or timed out',
371
+ type: 'stream_error',
372
+ code: idleTimedOut ? 'stream_idle_timeout' : 'stream_error'
373
+ }
374
+ })
375
+ res.write(`data: ${errPayload}\n\n`)
376
+ } catch { /* reader already cancelled or closed */ }
377
+ }
378
+ res.end()
277
379
  }
278
- res.end()
279
380
 
280
381
  recordUsage({
281
382
  model: body.model,
@@ -283,6 +384,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
283
384
  promptTokens,
284
385
  completionTokens,
285
386
  totalTokens: totalTokens || (promptTokens + completionTokens),
387
+ isError: isStreamFailed,
286
388
  statsPath: pub.statsPath
287
389
  })
288
390
  }
@@ -298,17 +400,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
298
400
  resJson = null
299
401
  }
300
402
 
301
- if (resJson?.usage) {
302
- recordUsage({
303
- model: body.model,
304
- accountId: targetAccount.id || targetAccount.label,
305
- promptTokens: resJson.usage.prompt_tokens,
306
- completionTokens: resJson.usage.completion_tokens,
307
- totalTokens: resJson.usage.total_tokens,
308
- isError: upstreamRes.status >= 400,
309
- statsPath: pub.statsPath
310
- })
311
- }
403
+ cleanupClientListeners()
404
+ recordUsage({
405
+ model: body.model,
406
+ accountId: targetAccount.id || targetAccount.label,
407
+ promptTokens: resJson?.usage?.prompt_tokens || 0,
408
+ completionTokens: resJson?.usage?.completion_tokens || 0,
409
+ totalTokens: resJson?.usage?.total_tokens || 0,
410
+ isError: upstreamRes.status >= 400,
411
+ statsPath: pub.statsPath
412
+ })
312
413
 
313
414
  res.statusCode = upstreamRes.status
314
415
  res.setHeader('Content-Type', 'application/json; charset=utf-8')
@@ -316,6 +417,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
316
417
  return
317
418
  }
318
419
 
420
+ cleanupClientListeners()
319
421
  // Exhausted all retries
320
422
  if (hadTimeout) {
321
423
  return writeJson(res, 504, {
@@ -101,6 +101,20 @@ export function clearAccountCooldown(accountId) {
101
101
  }
102
102
  }
103
103
 
104
+ export function matchesAccount(acc, identifier) {
105
+ if (!acc || !identifier) return false
106
+ return acc.id === identifier || acc.apiKeyEnv === identifier || acc.label === identifier
107
+ }
108
+
109
+ export function isAccountBlocked(acc) {
110
+ if (!acc) return false
111
+ return Boolean(
112
+ (acc.id && isAccountInCooldown(acc.id)) ||
113
+ (acc.apiKeyEnv && isAccountInCooldown(acc.apiKeyEnv)) ||
114
+ (acc.label && isAccountInCooldown(acc.label))
115
+ )
116
+ }
117
+
104
118
  /**
105
119
  * Select the best account from the pool based on least-used quota.
106
120
  * Lowest percentUsed is prioritized.
@@ -108,6 +122,14 @@ export function clearAccountCooldown(accountId) {
108
122
  * @param {Array<Object>} accounts Pool of accounts
109
123
  * @returns {Object|null} Selected account or null
110
124
  */
125
+ export function getAccountUsageMetric(acc) {
126
+ if (typeof acc?.percentUsed === 'number') {
127
+ return acc.percentUsed
128
+ }
129
+ // Unknown quota is treated as 50% neutral midpoint rather than assuming 0% completely free
130
+ return 50
131
+ }
132
+
111
133
  export function selectLeastUsedAccount(accounts = []) {
112
134
  if (!Array.isArray(accounts) || accounts.length === 0) {
113
135
  return null
@@ -116,7 +138,7 @@ export function selectLeastUsedAccount(accounts = []) {
116
138
  // Filter available accounts that have keys present and are not in cooldown
117
139
  const available = accounts.filter(acc => {
118
140
  if (!acc || acc.present === false) return false
119
- return !isAccountInCooldown(acc.id || acc.label)
141
+ return !isAccountBlocked(acc)
120
142
  })
121
143
 
122
144
  const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
@@ -124,10 +146,10 @@ export function selectLeastUsedAccount(accounts = []) {
124
146
  return accounts[0] || null
125
147
  }
126
148
 
127
- // Sort by lowest percentUsed (least used). If percentUsed is null/undefined, treat as 0% used
149
+ // Sort by lowest percentUsed (least used). Unknown quota is treated as neutral 50%
128
150
  const sorted = [...candidatePool].sort((a, b) => {
129
- const aUsed = typeof a.percentUsed === 'number' ? a.percentUsed : 0
130
- const bUsed = typeof b.percentUsed === 'number' ? b.percentUsed : 0
151
+ const aUsed = getAccountUsageMetric(a)
152
+ const bUsed = getAccountUsageMetric(b)
131
153
  return aUsed - bUsed
132
154
  })
133
155
 
@@ -139,50 +161,85 @@ export function selectLeastUsedAccount(accounts = []) {
139
161
  *
140
162
  * @param {string|null} sessionId Unique session or conversation identifier
141
163
  * @param {Array<Object>} accounts Configured accounts pool
142
- * @param {string} [defaultActiveId] Fallback active account ID
164
+ * @param {string} [defaultActiveId] Explicit active account ID, envName or 'auto'
143
165
  * @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
144
166
  */
145
167
  export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
146
- const safeAccounts = Array.isArray(accounts) ? accounts : []
168
+ const safeAccounts = Array.isArray(accounts) ? accounts.filter(a => a && a.present !== false) : []
147
169
  if (safeAccounts.length === 0) {
148
- return { account: null, isReassigned: false, reason: 'empty_pool' }
170
+ const fallback = Array.isArray(accounts) && accounts.length > 0 ? accounts[0] : null
171
+ return { account: fallback, isReassigned: false, reason: 'empty_pool' }
149
172
  }
150
173
 
151
- // If no sessionId is provided, use least-used account or default active
174
+ // Determine explicit pin / active account policy
175
+ const isAutoMode = defaultActiveId === 'auto'
176
+ let pinnedAccount = null
177
+ if (!isAutoMode) {
178
+ if (defaultActiveId) {
179
+ pinnedAccount = safeAccounts.find(a => matchesAccount(a, defaultActiveId)) || null
180
+ }
181
+ if (!pinnedAccount) {
182
+ pinnedAccount = safeAccounts.find(a => a.isPinned) || null
183
+ }
184
+ }
185
+
186
+ const pinnedAvailable = pinnedAccount && !isAccountBlocked(pinnedAccount) ? pinnedAccount : null
187
+
188
+ // If no sessionId provided:
152
189
  if (!sessionId) {
153
- const best = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
154
- return { account: best, isReassigned: false, reason: 'no_session_id' }
190
+ if (pinnedAvailable) {
191
+ return { account: pinnedAvailable, isReassigned: false, reason: 'pinned_active' }
192
+ }
193
+ const best = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
194
+ return { account: best, isReassigned: false, reason: pinnedAccount ? 'pinned_in_cooldown_fallback' : 'no_session_id' }
155
195
  }
156
196
 
157
- let existing = sessions.get(sessionId)
158
197
  const now = Date.now()
198
+ let existing = sessions.get(sessionId)
199
+
159
200
  if (existing) {
160
- // TTL eviction check
201
+ // Check TTL
161
202
  if (now - existing.lastUsedAt > SESSION_TTL_MS) {
162
203
  sessions.delete(sessionId)
163
204
  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
172
205
  }
173
206
  }
174
207
 
175
- // Prune before adding a brand new session if capacity reached
208
+ // If manual pin is configured and available:
209
+ if (pinnedAvailable) {
210
+ const reassigned = Boolean(existing && !matchesAccount(pinnedAvailable, existing.accountId))
211
+ sessions.set(sessionId, {
212
+ accountId: pinnedAvailable.apiKeyEnv || pinnedAvailable.id,
213
+ assignedAt: existing ? existing.assignedAt : now,
214
+ lastUsedAt: now
215
+ })
216
+ return {
217
+ account: pinnedAvailable,
218
+ isReassigned: reassigned,
219
+ reason: reassigned ? 'pinned_override' : (existing ? 'sticky_pinned' : 'pinned_active')
220
+ }
221
+ }
222
+
223
+ // If in auto mode or pinned account is unavailable:
224
+ if (existing) {
225
+ const assigned = safeAccounts.find(a => matchesAccount(a, existing.accountId))
226
+ if (assigned && !isAccountBlocked(assigned)) {
227
+ existing.lastUsedAt = now
228
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
229
+ }
230
+ // Otherwise, failover is needed
231
+ }
232
+
233
+ // Prune if needed
176
234
  if (!existing && sessions.size >= MAX_SESSIONS) {
177
235
  pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
178
236
  }
179
237
 
180
- // New session or mid-session failover: pick least-used account
181
- const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
238
+ // Pick least-used available account
239
+ const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
182
240
  if (chosen) {
183
- const chosenId = chosen.id || chosen.label
184
241
  sessions.set(sessionId, {
185
- accountId: chosenId,
242
+ accountId: chosen.apiKeyEnv || chosen.id,
186
243
  assignedAt: existing ? existing.assignedAt : now,
187
244
  lastUsedAt: now
188
245
  })
@@ -191,7 +248,7 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
191
248
  return {
192
249
  account: chosen,
193
250
  isReassigned: Boolean(existing),
194
- reason: existing ? 'mid_session_failover' : 'new_session_assigned'
251
+ reason: existing ? 'mid_session_failover' : (pinnedAccount ? 'pinned_in_cooldown_fallback' : 'new_session_assigned')
195
252
  }
196
253
  }
197
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
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",