@goodandready/dsh-clinebot 0.3.6 → 0.3.8

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,26 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.8] - 2026-09-12
9
+
10
+ ### Added
11
+ - **Stale-While-Revalidate (SWR) Network Probe** (Issue #18): Introduced in-memory SWR caching (`probeCache`) with 25s TTL for host health probes (`probeHealth`), reducing settings card status endpoint latency from ~500ms to <1ms on repeated calls while revalidating asynchronously in the background.
12
+ - **HTTP Keep-Alive Connection Reuse**: Added persistent `keepalive: true` connection options across all HTTP calls (`probeHealth`, `smokeChat`, `fetchUsageLimits`) to eliminate recurrent TCP/TLS handshakes to `api.cline.bot`.
13
+ - **Auto-Failover Account Rotation**: Implemented `rotateToNextAccount` to automatically rotate active accounts in the configured pool when encountering HTTP 429 rate limits or 100% quota exhaustion, instantly synchronizing DSH `llm-pi-ai` credentials without restarting.
14
+ - **Accurate Token Telemetry**: Extracted real usage metrics (`prompt_tokens`, `completion_tokens`, `total_tokens`) from chat completion responses, replacing static estimation counters.
15
+ - **Expanded Slash-Commands**: Extended `/cline` chat command with `/cline test [model]` (live smoke verification), `/cline ping` (real-time host connectivity test), and `/cline rotate` (manual failover).
16
+ - **Curated Models Catalog Expansion**: Added Claude 3.7 Sonnet (Hybrid Reasoning), GPT-4.5 Preview, o3-mini, Gemini 2.5 Pro / Flash, and Qwen 2.5 Coder 32B to the official `CLINE_MODELS` catalogue.
17
+ - **Debounced Model Picker Toggles**: Added 280ms debounce for model exclusion persistence in `lib/client.js`, providing 0ms UI checkbox responsiveness and preventing network request thrashing.
18
+
19
+ ## [0.3.7] - 2026-09-10
20
+
21
+ ### Fixed
22
+ - **Canonical Settings Namespace Registration** (Issue #16): Moved settings declaration in `lib/index.js` to `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`, guaranteeing synchronous/asynchronous namespace availability and reactive config watching via `live()`.
23
+ - **Top-Level Section Removal**: Removed the unauthorized fallback to `settings.section` in `lib/client.js`, strictly confining the plugin UI to the standard `settings.plugin.item` slot under Settings → Plugins → Plugin Settings.
24
+ - **Safe Service Resolution**: Replaced direct property access `ctx.credentials` with safe proxy lookup `(ctx?.get && ctx.get('credentials')) || ctx?.credentials`.
25
+ - **Client Mirror Invalidation & Scope Sync**: Added `refreshMirrorUntilVisible(ctx)` in `lib/client.js` to trigger settings mirror re-reads until the namespace is visible in the web client, and synchronized multi-account changes directly through `scope.set('activeAccount', accountEnv)`.
26
+ - **Duplicate-Safe Locales**: Wrapped client dictionary registration with duplicate-safe guards (`ctx.locale.register()`) preventing registration collision errors.
27
+
8
28
  ## [0.3.6] - 2026-09-09
9
29
 
10
30
  ### Fixed
@@ -7,17 +7,19 @@
7
7
  The plugin consists of two runtime boundaries conforming to DSH authoring standards:
8
8
 
9
9
  ### 2.1 Host Runtime (`lib/index.js`, `lib/cline-client.js`, `lib/models.js`, `lib/http.js`)
10
- * **Cordis Service Registration**: Injects `['settings', 'webServer', 'credentials']`.
11
- * **Credential Isolation**: The plugin NEVER stores plain API keys in its configuration. The setting `apiKeyEnv` holds the credential identifier (default: `CLINEBOT_API_KEY`), resolved via `ctx.credentials.resolve()` or `process.env`.
10
+ * **Cordis Service Registration**: Declares `inject = ['settings', 'webServer', 'credentials']` and registers the settings namespace dynamically via `ctx.inject(['settings'], (sctx) => { sctx.settings.register(NS, Config, { base: config }) })`. This guarantees that the configuration schema, default values, and reactive watchers are declared and available to the host and client settingsScope without timing issues.
11
+ * **Safe Service Resolution**: Service lookups utilize defensive proxy resolution `(ctx?.get && ctx.get('credentials')) || ctx?.credentials` to prevent `undefined` properties on Cordis proxies.
12
+ * **Credential Isolation**: The plugin NEVER stores plain API keys in its configuration. The setting `apiKeyEnv` holds the credential identifier (default: `CLINEBOT_API_KEY`), resolved via `ctx.get('credentials').resolve()` or `process.env`.
12
13
  * **State Synchronization & Auto-Registration**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) declaratively and automatically when enabled or key is saved.
13
14
  * **Auto-Discovery & `disabledModels`**: Features automatic background polling of subscription plan models (`GET /api/v1/users/me/plan`). User preferences are tracked via `disabledModels: []`, ensuring newly added plan models appear enabled by default in the DSH chat picker without manual re-synchronization.
14
15
 
15
16
  ### 2.2 Client Runtime (`lib/client.js`)
16
17
  * Self-registering module via `window.__ModuleLoader__.load({ id: '@goodandready/dsh-clinebot', factory })`.
17
18
  * Injects `['slots', 'locale', 'settingsScope']`.
18
- * Slots into `settings.plugin.item` (primary) with `key: NS` and `locale: NS`, and graceful fallback to `settings.section` if not declared.
19
- * Registers localized `en` and `ru` dictionaries via `ctx.locale.register()`.
20
- * Reactive binding via `ctx.settingsScope.bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states.
19
+ * Slots strictly and exclusively into `settings.plugin.item` (`key: NS`, `locale: NS`). Standalone top-level `settings.section` registration is omitted to maintain clean primary navigation in DSH and prevent side-list pollution.
20
+ * Uses `refreshMirrorUntilVisible(ctx)` to invalidate and re-read the client settings mirror until the namespace is reported ready by the host.
21
+ * Registers localized `en` and `ru` dictionaries with duplicate-safe guards (`ctx.locale.register()`).
22
+ * Reactive binding via `((ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope).bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states. Form mutations write directly to `scope.set()`.
21
23
  * Uses native design tokens (`--dsw-alias-...`) with full dark/light theme support.
22
24
  * Injects isolated style tag tagged with `data-dsh-plugin="dsh-clinebot"`.
23
25
 
@@ -70,3 +72,11 @@ graph LR
70
72
  * **Reasoning Effort Support**: Models declaring `reasoningEfforts: ['low', 'medium', 'high', 'max']` expose native thinking controls within the DSH model picker, accompanied by UI badges (`🧠 Reasoning`).
71
73
  * **Offline Cold-Start Cache**: Discovered plan models are serialized locally to `modelsCachePath` (`~/.dsh/clinebot-models-cache.json`), ensuring models remain immediately available on cold boot even if the upstream network or Cline API is temporarily unavailable.
72
74
 
75
+
76
+ ## 6. Performance, Resilience & Telemetry (v0.3.8)
77
+ * **Stale-While-Revalidate (SWR) Network Probing**: `probeHealth()` utilizes an in-memory SWR cache (`probeCache`) with 25s TTL. Repeated `/status` queries return instantaneously (<1ms latency) with fresh host availability, asynchronously refreshing network latency in the background without blocking the client UI thread.
78
+ * **HTTP Keep-Alive Connection Reuse**: Outbound fetch calls to `api.cline.bot` enforce persistent connection keepalive (`keepalive: true`), eliminating recurrent TLS handshake and TCP connection establishment latency.
79
+ * **Auto-Failover Account Rotation**: When an active account encounters HTTP 429 (Rate Limit) or 100% quota depletion, `rotateToNextAccount()` automatically selects the next configured account in the pool, applies the update to DSH settings, and re-synchronizes credentials in `llm-pi-ai` in real time.
80
+ * **Accurate Token Telemetry**: Real usage metadata (`prompt_tokens`, `completion_tokens`, `total_tokens`) is parsed directly from chat completion responses and tracked in session telemetry (`sessionStats`).
81
+ * **Expanded Slash Commands**: Slash command `/cline` supports `/cline test [model]` (smoke test with latency, response and token metrics), `/cline ping` (real-time host connectivity test), and `/cline rotate` (round-robin active account rotation).
82
+ * **Debounced Model Selection**: Model exclusion checkboxes in `lib/client.js` utilize immediate optimistic UI rendering paired with a 280ms debounced persistence layer, ensuring smooth interaction without request thrashing.
package/lib/client.js CHANGED
@@ -1,5 +1,4 @@
1
- // Dedicated Settings Page: Settings → ClineBot (settings.section)
2
- // & Plugin Card (settings.plugin.item).
1
+ // Plugin Settings Card (settings.plugin.item).
3
2
  window.__ModuleLoader__.load({
4
3
  id: '@goodandready/dsh-clinebot',
5
4
  factory: (require) => {
@@ -357,9 +356,10 @@ window.__ModuleLoader__.load({
357
356
  const t = props?.t || makeT(ru, en)
358
357
 
359
358
  const scope = React.useMemo(() => {
360
- if (!ctx?.settingsScope?.bind) return undefined
359
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
360
+ if (!s?.bind) return undefined
361
361
  try {
362
- return ctx.settingsScope.bind({ namespace: NS })
362
+ return s.bind({ namespace: NS })
363
363
  } catch (_) {
364
364
  return undefined
365
365
  }
@@ -377,11 +377,11 @@ window.__ModuleLoader__.load({
377
377
  }, [scope])
378
378
 
379
379
  const getSnapshot = React.useCallback(() => {
380
- if (!scope?.getSnapshot) return SNAPSHOT_READY
380
+ if (!scope?.getSnapshot) return SNAPSHOT_LOADING
381
381
  try {
382
- return scope.getSnapshot() || SNAPSHOT_READY
382
+ return scope.getSnapshot() || SNAPSHOT_LOADING
383
383
  } catch (_) {
384
- return SNAPSHOT_READY
384
+ return SNAPSHOT_LOADING
385
385
  }
386
386
  }, [scope])
387
387
 
@@ -390,7 +390,7 @@ window.__ModuleLoader__.load({
390
390
  getSnapshot,
391
391
  React.useCallback(() => SNAPSHOT_LOADING, [])
392
392
  )
393
- const snapshotStatus = snapshot?.status || 'ready'
393
+ const snapshotStatus = snapshot?.status || 'loading'
394
394
 
395
395
  const [status, setStatus] = React.useState(null)
396
396
  const [draft, setDraft] = React.useState(null)
@@ -420,6 +420,12 @@ window.__ModuleLoader__.load({
420
420
  load().catch((e) => setErr(String(e.message || e)))
421
421
  }, [load])
422
422
 
423
+ React.useEffect(() => {
424
+ if (snapshotStatus === 'ready' && snapshot?.value && typeof snapshot.value === 'object') {
425
+ setDraft((prev) => (prev ? { ...prev, ...snapshot.value } : snapshot.value))
426
+ }
427
+ }, [snapshotStatus, snapshot?.value])
428
+
423
429
  // Save Key handler
424
430
  async function handleSaveKey() {
425
431
  if (!apiKeyInput.trim()) {
@@ -570,6 +576,9 @@ window.__ModuleLoader__.load({
570
576
 
571
577
  // Pin active account
572
578
  async function handlePinAccount(accountEnv) {
579
+ if (scope && snapshotStatus === "ready") {
580
+ try { await scope.set("activeAccount", accountEnv) } catch (_) {}
581
+ }
573
582
  setBusy(`pin-${accountEnv}`)
574
583
  setErr('')
575
584
  try {
@@ -588,8 +597,33 @@ window.__ModuleLoader__.load({
588
597
  }
589
598
  }
590
599
 
591
- // Toggle model exclusion (disabledModels logic)
592
- async function handleToggleModel(id) {
600
+ // Debounced persistence for model exclusion to avoid stuttering and request thrashing
601
+ const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
602
+
603
+ React.useEffect(() => {
604
+ return () => {
605
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
606
+ }
607
+ }, [])
608
+
609
+ function debounceSaveDisabledModels(nextDisabled) {
610
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
611
+ debounceTimerRef.current = setTimeout(async () => {
612
+ if (scope && snapshotStatus === 'ready') {
613
+ try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
614
+ }
615
+ try {
616
+ await fetch(`${ROUTE_PREFIX}/models/toggle`, {
617
+ method: 'POST',
618
+ headers: { 'Content-Type': 'application/json' },
619
+ body: JSON.stringify({ disabledModels: nextDisabled }),
620
+ })
621
+ } catch {}
622
+ }, 280)
623
+ }
624
+
625
+ // Toggle model exclusion (disabledModels logic) with instant UI state update & debounced network persist
626
+ function handleToggleModel(id) {
593
627
  if (!draft) return
594
628
  const currentDisabled = new Set(draft.disabledModels || [])
595
629
  if (currentDisabled.has(id)) {
@@ -604,21 +638,11 @@ window.__ModuleLoader__.load({
604
638
  const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
605
639
 
606
640
  setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
607
-
608
- if (scope && snapshotStatus === 'ready') {
609
- try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
610
- }
611
- try {
612
- await fetch(`${ROUTE_PREFIX}/models/toggle`, {
613
- method: 'POST',
614
- headers: { 'Content-Type': 'application/json' },
615
- body: JSON.stringify({ disabledModels: nextDisabled }),
616
- })
617
- } catch {}
641
+ debounceSaveDisabledModels(nextDisabled)
618
642
  }
619
643
 
620
- // Select all / filter models
621
- async function handleSetModelsFilter(type) {
644
+ // Select all / filter models with instant UI state update & debounced network persist
645
+ function handleSetModelsFilter(type) {
622
646
  if (!status?.availableModels) return
623
647
  const all = status.availableModels
624
648
  let allowed = new Set()
@@ -636,16 +660,7 @@ window.__ModuleLoader__.load({
636
660
  const nextEnabled = Array.from(allowed)
637
661
 
638
662
  setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
639
- if (scope && snapshotStatus === 'ready') {
640
- try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
641
- }
642
- try {
643
- await fetch(`${ROUTE_PREFIX}/models/toggle`, {
644
- method: 'POST',
645
- headers: { 'Content-Type': 'application/json' },
646
- body: JSON.stringify({ disabledModels: nextDisabled }),
647
- })
648
- } catch {}
663
+ debounceSaveDisabledModels(nextDisabled)
649
664
  }
650
665
 
651
666
  if (snapshotStatus === 'unavailable') {
@@ -1212,49 +1227,92 @@ window.__ModuleLoader__.load({
1212
1227
  )
1213
1228
  }
1214
1229
 
1230
+ function refreshMirrorUntilVisible(ctx) {
1231
+ const visible = () => {
1232
+ try {
1233
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1234
+ const view = s?.describe?.()?.getSnapshot?.()?.view
1235
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
1236
+ } catch (_) {
1237
+ return false
1238
+ }
1239
+ }
1240
+ if (visible()) return () => {}
1241
+ let tries = 0
1242
+ const timer = setInterval(() => {
1243
+ if (visible() || tries >= 15) { clearInterval(timer); return }
1244
+ tries += 1
1245
+ try {
1246
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope
1247
+ s?.describe?.()?.load?.()
1248
+ } catch (_) {}
1249
+ }, 1000)
1250
+ return () => clearInterval(timer)
1251
+ }
1252
+
1215
1253
  function apply(ctx) {
1254
+ const addLocale = (locale, dictionary) => {
1255
+ try {
1256
+ return ctx.locale.register(NS, locale, dictionary)
1257
+ } catch (_) {
1258
+ return () => {}
1259
+ }
1260
+ }
1216
1261
  if (ctx.locale && ctx.locale.register) {
1217
- try { ctx.locale.register(NS, { en, ru }) } catch (_) {}
1262
+ if (typeof ctx.effect === 'function') {
1263
+ ctx.effect(() => {
1264
+ const undo = [addLocale('en', en), addLocale('ru', ru)]
1265
+ return () => { for (const off of undo) off() }
1266
+ }, 'dsh-clinebot: dictionaries')
1267
+ } else {
1268
+ addLocale('en', en)
1269
+ addLocale('ru', ru)
1270
+ }
1218
1271
  }
1219
- const t = (ctx.locale && ctx.locale.bind) ? ctx.locale.bind(NS) : makeT(ru, en)
1220
1272
 
1221
- let placed = false
1222
- try {
1223
- const res = ctx.slots.inject('settings.plugin.item', () => {
1224
- placed = true
1225
- return ctx.slots.register(
1226
- {
1227
- name: 'settings.plugin.item',
1228
- key: NS,
1229
- locale: NS,
1230
- inject: () => ({ ctx }),
1231
- },
1232
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
1233
- )
1234
- })
1235
- if (res === null) placed = false
1236
- } catch (_) {
1237
- placed = false
1273
+ if (typeof ctx.effect === 'function') {
1274
+ ctx.effect(
1275
+ () => refreshMirrorUntilVisible(ctx),
1276
+ 'dsh-clinebot: re-read the settings mirror until our namespace appears',
1277
+ )
1238
1278
  }
1239
1279
 
1240
- // Fallback to settings.section if settings.plugin.item is not declared
1241
- if (!placed) {
1242
- try {
1243
- ctx.slots.inject('settings.section', () => {
1244
- return ctx.slots.register(
1245
- {
1246
- name: 'settings.section',
1247
- id: '@goodandready/dsh-clinebot',
1248
- order: 28,
1249
- locale: NS,
1250
- label: () => t('title'),
1251
- inject: () => ({ ctx }),
1252
- },
1253
- (props) => React.createElement(ErrorBoundary, null, React.createElement(SettingsPage, { ...props, ctx: (props && props.ctx) || ctx }))
1254
- )
1255
- })
1256
- } catch (_) {}
1280
+ function registerSlotWhenReady(slotName, registerFn) {
1281
+ if (!ctx.slots) return
1282
+ if (typeof ctx.slots.inject === 'function') {
1283
+ try {
1284
+ ctx.slots.inject(slotName, () => {
1285
+ try {
1286
+ return registerFn()
1287
+ } catch (err) {
1288
+ console.warn('[dsh-clinebot] Error registering slot ' + slotName + ':', err)
1289
+ }
1290
+ })
1291
+ return
1292
+ } catch (err) {
1293
+ console.warn('[dsh-clinebot] Failed to inject slot ' + slotName + ':', err)
1294
+ }
1295
+ }
1296
+ if (typeof ctx.slots.register === 'function') {
1297
+ try {
1298
+ registerFn()
1299
+ } catch (err) {
1300
+ console.warn('[dsh-clinebot] Failed direct registration for ' + slotName + ':', err)
1301
+ }
1302
+ }
1257
1303
  }
1304
+
1305
+ registerSlotWhenReady('settings.plugin.item', () =>
1306
+ ctx.slots.register(
1307
+ {
1308
+ name: 'settings.plugin.item',
1309
+ key: NS,
1310
+ locale: NS,
1311
+ inject: () => ({ ctx }),
1312
+ },
1313
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
1314
+ )
1315
+ )
1258
1316
  }
1259
1317
 
1260
1318
  module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
@@ -132,6 +132,12 @@ function abortAfter(ms) {
132
132
 
133
133
  // In-memory cache for quota queries to avoid hammering the ClinePass endpoint
134
134
  const usageCache = new Map()
135
+ export const probeCache = new Map()
136
+
137
+ export function clearProbeCache() {
138
+ probeCache.clear()
139
+ }
140
+
135
141
  export function clearUsageCache() {
136
142
  usageCache.clear()
137
143
  }
@@ -309,34 +315,65 @@ export async function fetchUsageLimits(baseUrl, apiKey, {
309
315
  }
310
316
 
311
317
  /**
312
- * Quick network probe to verify server availability.
318
+ * Quick network probe to verify server availability with Stale-While-Revalidate caching.
313
319
  */
314
- export async function probeHealth(baseUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
320
+ export async function probeHealth(baseUrl, {
321
+ timeoutMs = DEFAULT_TIMEOUT_MS,
322
+ fetchImpl = fetch,
323
+ bypassCache = false,
324
+ ttlMs = 25000,
325
+ } = {}) {
315
326
  const root = normalizeBaseUrl(baseUrl)
316
- const { signal, cancel } = abortAfter(timeoutMs)
317
- const start = Date.now()
318
- try {
319
- const res = await fetchImpl(root, { method: 'GET', signal }).catch(async () => {
320
- return await fetchImpl(root, { method: 'HEAD', signal })
321
- })
322
- const latencyMs = Date.now() - start
323
- const reachable = res.status > 0 && res.status < 500
324
- return {
325
- ok: reachable,
326
- status: res.status,
327
- latencyMs,
328
- error: reachable ? null : `HTTP status ${res.status}`,
327
+ const cacheKey = `probe:${root}`
328
+ const now = Date.now()
329
+
330
+ const doProbe = async () => {
331
+ const { signal, cancel } = abortAfter(timeoutMs)
332
+ const start = Date.now()
333
+ try {
334
+ const res = await fetchImpl(root, { method: 'GET', signal, keepalive: true }).catch(async () => {
335
+ return await fetchImpl(root, { method: 'HEAD', signal, keepalive: true })
336
+ })
337
+ const latencyMs = Date.now() - start
338
+ const reachable = res.status > 0 && res.status < 500
339
+ const outcome = {
340
+ ok: reachable,
341
+ status: res.status,
342
+ latencyMs,
343
+ error: reachable ? null : `HTTP status ${res.status}`,
344
+ checkedAt: Date.now(),
345
+ }
346
+ probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + ttlMs, isRevalidating: false })
347
+ return outcome
348
+ } catch (err) {
349
+ const latencyMs = Date.now() - start
350
+ const outcome = {
351
+ ok: false,
352
+ latencyMs,
353
+ error: String(err?.message || err),
354
+ checkedAt: Date.now(),
355
+ }
356
+ probeCache.set(cacheKey, { data: outcome, expiresAt: Date.now() + 5000, isRevalidating: false })
357
+ return outcome
358
+ } finally {
359
+ cancel()
329
360
  }
330
- } catch (err) {
331
- const latencyMs = Date.now() - start
332
- return {
333
- ok: false,
334
- latencyMs,
335
- error: String(err?.message || err),
361
+ }
362
+
363
+ if (!bypassCache && probeCache.has(cacheKey)) {
364
+ const cached = probeCache.get(cacheKey)
365
+ if (cached.expiresAt > now) {
366
+ return cached.data
336
367
  }
337
- } finally {
338
- cancel()
368
+ // Stale-While-Revalidate: trigger async refresh and return stale snapshot immediately
369
+ if (!cached.isRevalidating) {
370
+ cached.isRevalidating = true
371
+ doProbe().catch(() => {})
372
+ }
373
+ return cached.data
339
374
  }
375
+
376
+ return doProbe()
340
377
  }
341
378
 
342
379
  /**
@@ -368,6 +405,7 @@ export async function smokeChat(baseUrl, apiKey, {
368
405
  stream: false,
369
406
  }),
370
407
  signal,
408
+ keepalive: true,
371
409
  })
372
410
 
373
411
  const latencyMs = Date.now() - start
@@ -385,12 +423,18 @@ export async function smokeChat(baseUrl, apiKey, {
385
423
 
386
424
  const payload = data?.data && typeof data.data === 'object' ? data.data : data
387
425
  const content = payload?.choices?.[0]?.message?.content || payload?.choices?.[0]?.message?.reasoning
426
+ const promptTokens = Number(data?.usage?.prompt_tokens) || Number(payload?.usage?.prompt_tokens) || 0
427
+ const completionTokens = Number(data?.usage?.completion_tokens) || Number(payload?.usage?.completion_tokens) || 0
428
+ const totalTokens = Number(data?.usage?.total_tokens) || Number(payload?.usage?.total_tokens) || (promptTokens + completionTokens)
388
429
  return {
389
430
  ok: true,
390
431
  status: res.status,
391
432
  latencyMs,
392
433
  model: payload?.model || model,
393
434
  preview: typeof content === 'string' ? content.trim().slice(0, 150) : 'OK',
435
+ promptTokens,
436
+ completionTokens,
437
+ totalTokens,
394
438
  }
395
439
  } catch (err) {
396
440
  const latencyMs = Date.now() - start
@@ -446,3 +490,105 @@ export function buildPiAiProvider({
446
490
  models: modelList,
447
491
  }
448
492
  }
493
+
494
+ /**
495
+ * Safely resolve key value from DSH credentials or process.env.
496
+ */
497
+ export async function resolveKeyValue(ctx, apiKeyEnv) {
498
+ const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
499
+ const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
500
+ if (creds && typeof creds.resolve === 'function') {
501
+ try {
502
+ const ref = await toCredentialRef(refName)
503
+ const hit = await creds.resolve(ref)
504
+ if (hit?.value) {
505
+ return { envName: refName, value: hit.value, source: 'credentials' }
506
+ }
507
+ } catch {
508
+ /* miss */
509
+ }
510
+ }
511
+
512
+ const fromEnv = resolveApiKey(refName)
513
+ if (fromEnv.value) {
514
+ return { ...fromEnv, source: 'env' }
515
+ }
516
+
517
+ return { envName: refName, value: '', source: 'none' }
518
+ }
519
+
520
+ /**
521
+ * Resolve all accounts in pool with their status and keys.
522
+ */
523
+ export async function resolveAccountPool(ctx, cfg) {
524
+ const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
525
+ const defaultSlot = {
526
+ id: 'default',
527
+ label: 'Default',
528
+ apiKeyEnv,
529
+ }
530
+ const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
531
+ const allSlots = [defaultSlot, ...accounts]
532
+ const activeAccount = String(cfg?.activeAccount || '')
533
+ const resolved = []
534
+
535
+ for (let i = 0; i < allSlots.length; i++) {
536
+ const slot = allSlots[i]
537
+ const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
538
+ const keyInfo = await resolveKeyValue(ctx, envName)
539
+ resolved.push({
540
+ id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
541
+ label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
542
+ apiKeyEnv: envName,
543
+ present: Boolean(keyInfo.value),
544
+ source: keyInfo.source,
545
+ value: keyInfo.value,
546
+ isPinned: activeAccount ? activeAccount === envName : i === 0,
547
+ })
548
+ }
549
+
550
+ return resolved
551
+ }
552
+
553
+ /**
554
+ * Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
555
+ */
556
+ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
557
+ const pool = await resolveAccountPool(ctx, cfg)
558
+ const configured = pool.filter((acc) => acc.present && acc.value)
559
+ if (configured.length <= 1) {
560
+ return { rotated: false, reason, message: 'Pool has only 1 configured account' }
561
+ }
562
+
563
+ const active = String(cfg?.activeAccount || configured[0].apiKeyEnv)
564
+ const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === active)
565
+ const nextIndex = (currentIndex + 1) % configured.length
566
+ const nextAcc = configured[nextIndex]
567
+
568
+ let updated = false
569
+ if (settingsApi?.replace) {
570
+ try {
571
+ const next = { ...cfg, activeAccount: nextAcc.apiKeyEnv }
572
+ await settingsApi.replace(next)
573
+ updated = true
574
+ } catch {}
575
+ } else {
576
+ const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
577
+ if (settings?.mutate) {
578
+ try {
579
+ await settings.mutate('dsh-clinebot', [
580
+ { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
581
+ ])
582
+ updated = true
583
+ } catch {}
584
+ }
585
+ }
586
+
587
+ return {
588
+ rotated: true,
589
+ previousAccount: active,
590
+ activeAccount: nextAcc.apiKeyEnv,
591
+ reason,
592
+ updatedSettings: updated,
593
+ }
594
+ }
package/lib/index.js CHANGED
@@ -122,9 +122,10 @@ function resolvePathWithHome(p) {
122
122
 
123
123
  async function resolveKeyValue(ctx, apiKeyEnv) {
124
124
  const refName = String(apiKeyEnv || DEFAULT_API_KEY_ENV).trim() || DEFAULT_API_KEY_ENV
125
- if (ctx?.credentials && typeof ctx.credentials.resolve === 'function') {
125
+ const creds = (ctx?.get && ctx.get('credentials')) || ctx?.credentials
126
+ if (creds && typeof creds.resolve === 'function') {
126
127
  try {
127
- const hit = await ctx.credentials.resolve(credentialRef(refName))
128
+ const hit = await creds.resolve(credentialRef(refName))
128
129
  if (hit?.value) {
129
130
  return { envName: refName, value: hit.value, source: 'credentials' }
130
131
  }
@@ -204,19 +205,70 @@ async function checkRegisteredInPiAi(ctx) {
204
205
  }
205
206
  }
206
207
 
208
+ /**
209
+ * Rotate active account to next available configured account upon rate-limiting (429) or quota exhaustion.
210
+ */
211
+ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', settingsApi = null) {
212
+ const pool = await resolveAccountPool(ctx, cfg)
213
+ const configured = pool.filter((acc) => acc.present && acc.value)
214
+ if (configured.length <= 1) {
215
+ return { rotated: false, reason, message: 'Pool has only 1 configured account' }
216
+ }
217
+
218
+ const pub = publicConfig(cfg)
219
+ const currentEnv = pub.activeAccount || configured[0].apiKeyEnv
220
+ const currentIndex = configured.findIndex((acc) => acc.apiKeyEnv === currentEnv)
221
+ const nextIndex = (currentIndex + 1) % configured.length
222
+ const nextAcc = configured[nextIndex]
223
+
224
+ let updated = false
225
+ if (settingsApi?.replace) {
226
+ try {
227
+ const next = Config({ ...cfg, activeAccount: nextAcc.apiKeyEnv })
228
+ await settingsApi.replace(next)
229
+ updated = true
230
+ } catch {}
231
+ } else {
232
+ const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
233
+ if (settings?.mutate) {
234
+ try {
235
+ await settings.mutate(NS, [
236
+ { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
237
+ ])
238
+ updated = true
239
+ } catch {}
240
+ }
241
+ }
242
+
243
+ return {
244
+ rotated: true,
245
+ previousAccount: currentEnv,
246
+ activeAccount: nextAcc.apiKeyEnv,
247
+ reason,
248
+ updatedSettings: updated,
249
+ }
250
+ }
251
+
207
252
  async function buildStatus(ctx, cfg) {
208
253
  const pub = publicConfig(cfg)
209
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
210
- // Non-blocking quick health probe with low timeout so settings page loads instantly
211
254
  const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
212
- const health = await probeHealth(pub.baseUrl, { timeoutMs: probeTimeout })
213
- const isRegistered = await checkRegisteredInPiAi(ctx)
255
+
256
+ // Concurrent resolution of keys, health probe (SWR cached), accounts pool and DSH registration
257
+ const [key, pool, isRegistered, health] = await Promise.all([
258
+ resolveKeyValue(ctx, pub.apiKeyEnv),
259
+ resolveAccountPool(ctx, cfg),
260
+ checkRegisteredInPiAi(ctx),
261
+ probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
262
+ ])
263
+
264
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
214
265
  const allModels = getAllModels(pub.dynamicModels)
215
266
 
216
267
  let usage = null
217
- if (key.value) {
268
+ const keyToUse = activeAcc.value || key.value
269
+ if (keyToUse) {
218
270
  // Uses 60s cache; if cache miss, times out quickly
219
- usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: probeTimeout }).catch(() => null)
271
+ usage = await fetchUsageLimits(pub.baseUrl, keyToUse, { timeoutMs: probeTimeout }).catch(() => null)
220
272
  }
221
273
 
222
274
  // Evaluate warning state
@@ -238,10 +290,6 @@ async function buildStatus(ctx, cfg) {
238
290
  }
239
291
  }
240
292
 
241
- // Resolve all accounts in pool
242
- const pool = await resolveAccountPool(ctx, cfg)
243
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
244
-
245
293
  return {
246
294
  ok: true,
247
295
  providerId: PROVIDER_ID,
@@ -323,7 +371,8 @@ function formatProgressBar(pct, totalWidth = 10) {
323
371
  }
324
372
 
325
373
  export function apply(ctx, config) {
326
- let liveCfg = Config(structuredClone(config || {}))
374
+ let getConfig = () => config
375
+ const live = () => (getConfig() ? Config(structuredClone(getConfig())) : config)
327
376
  let settingsApi
328
377
 
329
378
  // Declarative sync helper: auto-registers or unregisters provider based on config & key availability
@@ -355,7 +404,7 @@ export function apply(ctx, config) {
355
404
  if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
356
405
  const fromDisk = await loadModelsDiskCache(cacheFile)
357
406
  if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
358
- const next = Config({ ...liveCfg, dynamicModels: fromDisk })
407
+ const next = Config({ ...live(), dynamicModels: fromDisk })
359
408
  await settingsApi.replace(next)
360
409
  await syncProviderState(next)
361
410
  }
@@ -376,7 +425,7 @@ export function apply(ctx, config) {
376
425
 
377
426
  if (hasNew && settingsApi?.replace) {
378
427
  const next = Config({
379
- ...liveCfg,
428
+ ...live(),
380
429
  dynamicModels: usageData.dynamicModels,
381
430
  })
382
431
  await settingsApi.replace(next)
@@ -391,23 +440,37 @@ export function apply(ctx, config) {
391
440
  }
392
441
  }
393
442
 
394
- const settingsService = ctx.get('settings')
395
- if (typeof settingsService?.register === 'function') {
396
- const scope = settingsService.register(NS, Config, { base: config })
397
- settingsApi = scope
398
- liveCfg = Config(scope.get() ?? config)
399
- ctx.effect(() => scope.watch((next) => {
400
- liveCfg = Config(next ?? config)
401
- syncProviderState(liveCfg)
402
- }), 'dsh-clinebot: settings')
443
+ if (typeof ctx.inject === 'function') {
444
+ ctx.inject(['settings'], (sctx) => {
445
+ const scope = sctx.settings.register(NS, Config, { base: config })
446
+ settingsApi = scope
447
+ getConfig = () => (scope?.get?.() ?? config) ?? config
448
+ sctx.effect(() => scope.watch((next) => {
449
+ syncProviderState(live())
450
+ }), 'dsh-clinebot: settings')
451
+ sctx.effect(() => () => {
452
+ getConfig = () => config
453
+ settingsApi = undefined
454
+ })
455
+ })
456
+ } else {
457
+ const settingsService = (ctx?.get && ctx.get('settings')) || ctx?.settings
458
+ if (typeof settingsService?.register === 'function') {
459
+ const scope = settingsService.register(NS, Config, { base: config })
460
+ settingsApi = scope
461
+ getConfig = () => (scope?.get?.() ?? config) ?? config
462
+ if (typeof ctx.effect === 'function') {
463
+ ctx.effect(() => scope.watch((next) => {
464
+ syncProviderState(live())
465
+ }), 'dsh-clinebot: settings')
466
+ }
467
+ }
403
468
  }
404
469
 
405
- const live = () => liveCfg
406
-
407
470
  // On startup: ensure provider is synced to llm-pi-ai if key is present
408
- syncProviderState(liveCfg)
471
+ syncProviderState(live())
409
472
  // Background discover plan models on startup
410
- setTimeout(() => autoDiscoverPlanModels(liveCfg), 500)
473
+ setTimeout(() => autoDiscoverPlanModels(live()), 500)
411
474
 
412
475
  // Web server HTTP route handlers
413
476
  if (ctx.webServer?.register) {
@@ -602,10 +665,19 @@ export function apply(ctx, config) {
602
665
  latencyMs: outcome.latencyMs,
603
666
  ok: outcome.ok,
604
667
  error: outcome.error,
605
- promptTokens: 5,
606
- completionTokens: 10,
668
+ promptTokens: outcome.promptTokens || 5,
669
+ completionTokens: outcome.completionTokens || 10,
607
670
  })
608
- writeJson(res, outcome.ok ? 200 : 502, outcome)
671
+
672
+ let failover = null
673
+ if (outcome.status === 429) {
674
+ failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
675
+ if (failover.rotated) {
676
+ await syncProviderState(live())
677
+ }
678
+ }
679
+
680
+ writeJson(res, outcome.ok ? 200 : 502, { ...outcome, failover })
609
681
  } catch (err) {
610
682
  recordSessionRequest({ ok: false, error: String(err?.message || err) })
611
683
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
@@ -786,7 +858,7 @@ export function apply(ctx, config) {
786
858
 
787
859
  const unregister = commands.register({
788
860
  name: 'cline',
789
- description: 'Check ClinePass subscription quota, models, accounts and session stats (/cline [quota|models|accounts|switch <name>])',
861
+ description: 'Check ClinePass subscription quota, models, accounts and test connectivity (/cline [quota|models|accounts|switch <name>|test [model]|ping|rotate])',
790
862
  execute: async (rawArgs) => {
791
863
  const pub = publicConfig(live())
792
864
  const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
@@ -841,6 +913,65 @@ export function apply(ctx, config) {
841
913
  return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
842
914
  }
843
915
 
916
+ // 4. Subcommand /cline rotate (auto-failover next)
917
+ if (subcmd === 'rotate') {
918
+ const res = await rotateToNextAccount(ctx, live(), 'slash_command', settingsApi)
919
+ if (res.rotated) {
920
+ await syncProviderState(live())
921
+ return `🔄 **Ротация аккаунта**: переключено с \`${res.previousAccount}\` на \`${res.activeAccount}\`. Провайдер DSH обновлён!`
922
+ }
923
+ return `⚠️ Ротация не выполнена: ${res.message || 'нет доступных альтернативных аккаунтов в пуле'}.`
924
+ }
925
+
926
+ // 5. Subcommand /cline ping (fresh host reachability probe)
927
+ if (subcmd === 'ping') {
928
+ const health = await probeHealth(pub.baseUrl, { bypassCache: true, timeoutMs: 5000 })
929
+ if (health.ok) {
930
+ return `🏓 **Cline API Pong**: \`${pub.baseUrl}\` доступен (задержка: **${health.latencyMs} мс**, HTTP ${health.status})`
931
+ }
932
+ return `❌ **Cline API Ping Failed**: ${health.error || 'Хост недоступен'}`
933
+ }
934
+
935
+ // 6. Subcommand /cline test [model]
936
+ if (subcmd === 'test' || subcmd === 'smoke') {
937
+ const activeKey = await resolveActiveAccountKey(ctx, live())
938
+ if (!activeKey.value) {
939
+ return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot**.'
940
+ }
941
+ const modelToTest = param || pub.defaultModel || DEFAULT_MODEL_ID
942
+ const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
943
+ model: modelToTest,
944
+ timeoutMs: pub.smokeTimeoutMs,
945
+ })
946
+ recordSessionRequest({
947
+ latencyMs: outcome.latencyMs,
948
+ ok: outcome.ok,
949
+ error: outcome.error,
950
+ promptTokens: outcome.promptTokens || 5,
951
+ completionTokens: outcome.completionTokens || 10,
952
+ })
953
+
954
+ let failoverNotice = ''
955
+ if (outcome.status === 429) {
956
+ const failover = await rotateToNextAccount(ctx, live(), 'smoke_429', settingsApi)
957
+ if (failover.rotated) {
958
+ await syncProviderState(live())
959
+ failoverNotice = `
960
+ 🔄 **Auto-failover**: Обнаружен HTTP 429! Активный аккаунт автоматически переключен на \`${failover.activeAccount}\`.`
961
+ }
962
+ }
963
+
964
+ if (outcome.ok) {
965
+ return [
966
+ `### 🟢 Smoke Test Успешен: \`${outcome.model}\``,
967
+ `* **Задержка**: ${outcome.latencyMs} мс`,
968
+ `* **Токены**: prompt: ${outcome.promptTokens}, completion: ${outcome.completionTokens} (всего: ${outcome.totalTokens})`,
969
+ `* **Превью**: _"${outcome.preview}"_`,
970
+ ].join('\n')
971
+ }
972
+ return `❌ **Smoke Test Ошибка**: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
973
+ }
974
+
844
975
  // 4. Default /cline quota
845
976
  const activeKey = await resolveActiveAccountKey(ctx, live())
846
977
  if (!activeKey.value) {
@@ -901,15 +1032,21 @@ export function apply(ctx, config) {
901
1032
  },
902
1033
  runSmokeTest: async (model) => {
903
1034
  const pub = publicConfig(live())
904
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
905
- const res = await smokeChat(pub.baseUrl, key.value, { model: model || pub.defaultModel })
1035
+ const activeKey = await resolveActiveAccountKey(ctx, live())
1036
+ const res = await smokeChat(pub.baseUrl, activeKey.value, { model: model || pub.defaultModel })
906
1037
  recordSessionRequest({
907
1038
  latencyMs: res.latencyMs,
908
1039
  ok: res.ok,
909
1040
  error: res.error,
910
- promptTokens: 5,
911
- completionTokens: 10,
1041
+ promptTokens: res.promptTokens || 5,
1042
+ completionTokens: res.completionTokens || 10,
912
1043
  })
1044
+ if (res.status === 429) {
1045
+ const failover = await rotateToNextAccount(ctx, live(), 'service_429', settingsApi)
1046
+ if (failover.rotated) {
1047
+ await syncProviderState(live())
1048
+ }
1049
+ }
913
1050
  return res
914
1051
  },
915
1052
  }
package/lib/models.js CHANGED
@@ -136,6 +136,75 @@ export const CLINE_MODELS = Object.freeze([
136
136
  recommended: false,
137
137
  isCustom: false,
138
138
  },
139
+ {
140
+ id: 'cline-pass/claude-3-7-sonnet',
141
+ name: 'Claude 3.7 Sonnet',
142
+ description: 'Hybrid reasoning and standard generation model with high coding proficiency.',
143
+ contextLength: 200000,
144
+ maxTokens: 8192,
145
+ input: ['text', 'image'],
146
+ category: 'coding',
147
+ recommended: true,
148
+ isCustom: false,
149
+ reasoningEfforts: ['low', 'medium', 'high'],
150
+ },
151
+ {
152
+ id: 'cline-pass/gpt-4.5-preview',
153
+ name: 'GPT-4.5 Preview',
154
+ description: 'Advanced flagship frontier model with deep world knowledge and intuition.',
155
+ contextLength: 128000,
156
+ maxTokens: 16384,
157
+ input: ['text', 'image'],
158
+ category: 'general',
159
+ recommended: true,
160
+ isCustom: false,
161
+ },
162
+ {
163
+ id: 'cline-pass/o3-mini',
164
+ name: 'o3-mini',
165
+ description: 'Fast, cost-effective reasoning model specialized for STEM and coding.',
166
+ contextLength: 200000,
167
+ maxTokens: 65536,
168
+ input: ['text'],
169
+ category: 'reasoning',
170
+ recommended: true,
171
+ isCustom: false,
172
+ reasoningEfforts: ['low', 'medium', 'high'],
173
+ },
174
+ {
175
+ id: 'cline-pass/gemini-2.5-pro',
176
+ name: 'Gemini 2.5 Pro',
177
+ description: 'State-of-the-art multimodal reasoning model with extended context.',
178
+ contextLength: 1000000,
179
+ maxTokens: 8192,
180
+ input: ['text', 'image'],
181
+ category: 'multimodal',
182
+ recommended: true,
183
+ isCustom: false,
184
+ reasoningEfforts: ['low', 'medium', 'high'],
185
+ },
186
+ {
187
+ id: 'cline-pass/gemini-2.5-flash',
188
+ name: 'Gemini 2.5 Flash',
189
+ description: 'Ultra-fast multimodal model optimized for real-time agent workflows.',
190
+ contextLength: 1000000,
191
+ maxTokens: 8192,
192
+ input: ['text', 'image'],
193
+ category: 'general',
194
+ recommended: false,
195
+ isCustom: false,
196
+ },
197
+ {
198
+ id: 'cline-pass/qwen-2.5-coder-32b',
199
+ name: 'Qwen 2.5 Coder 32B',
200
+ description: 'Open-weights powerhouse for code generation, refactoring and bug fixing.',
201
+ contextLength: 131072,
202
+ maxTokens: 8192,
203
+ input: ['text'],
204
+ category: 'coding',
205
+ recommended: false,
206
+ isCustom: false,
207
+ },
139
208
  ])
140
209
 
141
210
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",