@goodandready/dsh-clinebot 0.3.2 → 0.3.4

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,27 @@ 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.4] - 2026-09-08
9
+
10
+ ### Fixed
11
+ - **Cache Path Resolution**: Corrected POSIX home directory expansion (`~/`) in `resolvePathWithHome` so `~/.dsh/clinebot-models-cache.json` resolves cleanly to `/home/vadim/.dsh/...` instead of root-level paths.
12
+
13
+ ## [0.3.3] - 2026-09-08
14
+
15
+ ### Added
16
+ - **Multi-Account Pool & Fast Rotation** (Issue #8): Added full account pool support (`accounts: [{ label, apiKeyEnv }]`, `activeAccount`). Users can configure multiple ClinePass keys across personal and team subscriptions, view configuration statuses in the dedicated settings card, and pin/switch active accounts instantly without restarting DSH.
17
+ - **Web Search Integration Alignment** (Issue #9): Investigated Cline API search capabilities; confirmed search operations are natively executed via model tool-calling without requiring separate search-engine tokens or intermediate providers.
18
+ - **Fast 1-Click Browser Login Flow** (Issue #10): Added interactive browser authorization (`POST /dsh-clinebot/auth/begin` and status polling), streamlining onboarding and credential entry directly from the web settings interface.
19
+ - **Native Reasoning Effort Controls** (Issue #11): Configured `reasoningEfforts: ['low', 'medium', 'high', 'max']` on supported thinking models (`deepseek-v4-flash`, `deepseek-v4-pro`, `kimi-k3`, `minimax-m3`). Exposes native thinking controls in DSH model picker and UI badge `🧠 Reasoning`.
20
+ - **Extended Slash-Command Subcommands** (Issue #12): Enhanced `/cline` with powerful subcommands:
21
+ - `/cline models` — Lists all available models with context length, Vision modality, and reasoning support.
22
+ - `/cline accounts` — Displays configured account pool, active key, and environment binding status.
23
+ - `/cline switch <label>` — Dynamically switches the active key across the account pool directly from chat.
24
+ - `/cline quota` (default) — Full rolling quota breakdown with warning indicators and session metrics.
25
+ - **Resilient Retry Policy & Backoff** (Issue #13): Added exponential backoff retry mechanism (`retryWithBackoff`) intercepting transient 429 rate-limiting responses, `Retry-After` headers, and 5xx upstream hiccups.
26
+ - **Informative Badges & Model Meta Tags** (Issue #14): Enriched model catalogue metadata with compact human-readable badges (`[200K · Vision · Coding · Reasoning]`), Vision detection, and category filtering.
27
+ - **Offline Cold-Start Disk Caching** (Issue #15): Added persistent JSON disk caching (`saveModelsDiskCache`, `loadModelsDiskCache`) at `~/.dsh/clinebot-models-cache.json`. Newly discovered plan models survive offline restarts and cold boots without blocking startup.
28
+
8
29
  ## [0.3.2] - 2026-09-08
9
30
 
10
31
  ### Added
@@ -60,6 +60,13 @@ graph LR
60
60
  * **Non-destructive actions**: Unregister cleanly removes the provider entry from DSH without touching other providers or configurations.
61
61
 
62
62
  ## 4. Security & Isolation
63
- * CSRF / Cross-site protection: All mutating routes (`/register`, `/unregister`, `/smoke`, `/models`) validate `isTrustedSettingsRequest(req)` (`Sec-Fetch-Site !== 'cross-site'`).
63
+ * CSRF / Cross-site protection: All mutating routes (`/register`, `/unregister`, `/smoke`, `/models`, `/accounts/active`, `/auth/begin`) validate `isTrustedSettingsRequest(req)` (`Sec-Fetch-Site !== 'cross-site'`).
64
64
  * Body size limits: Request payloads are strictly capped at 256 KB.
65
65
  * Sensitive credential data is never returned across the HTTP API (only `{ present: boolean, source: string, envName: string }`).
66
+
67
+ ## 5. Multi-Account Pool & Resilient Execution (v0.3.3)
68
+ * **Account Pool**: The plugin supports multiple accounts (`accounts: [{ label, apiKeyEnv }]`, `activeAccount`). `resolveActiveAccountKey()` automatically selects the configured active account or falls back to primary `apiKeyEnv`. Account switching (`POST /dsh-clinebot/accounts/active` and `/cline switch <label>`) triggers instant re-registration in `llm-pi-ai` without service restart.
69
+ * **Resilient Retry Policy**: HTTP calls to ClinePass utilize `retryWithBackoff()` with exponential delays and jitter to automatically absorb transient 429 rate-limiting events and upstream 5xx errors.
70
+ * **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
+ * **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
+
package/lib/client.js CHANGED
@@ -32,10 +32,20 @@ window.__ModuleLoader__.load({
32
32
  'key.hide': 'Hide',
33
33
  'key.save': 'Save Key',
34
34
  'key.saving': 'Saving…',
35
+ 'key.login_fast': '🚀 Quick Web Login',
36
+ 'key.login_waiting': 'Waiting for authorization in browser…',
35
37
  'key.env_label': 'Credential environment name: ',
36
38
  'key.get_key': 'Get key in app.cline.bot console ↗',
37
39
  'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
38
40
  'key.empty_err': 'Please enter an API key before saving',
41
+ 'accounts.title': '👥 Multi-Account Failover Pool',
42
+ 'accounts.desc': 'Add extra accounts. When an account hits 429 rate limits, ClineBot switches to the next available account automatically.',
43
+ 'accounts.active_badge': 'Active',
44
+ 'accounts.pinned_badge': 'Pinned',
45
+ 'accounts.pin_btn': 'Pin Active',
46
+ 'accounts.add_btn': '+ Add Account',
47
+ 'accounts.label_placeholder': 'Account Label (e.g. Work)',
48
+ 'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
39
49
  'quota.title': '📊 ClinePass Quota & Rate Limits',
40
50
  'quota.desc': 'Official rolling window request limits from ClinePass',
41
51
  'quota.account': 'Account: {email} · Plan: {plan}',
@@ -104,10 +114,20 @@ window.__ModuleLoader__.load({
104
114
  'key.hide': 'Скрыть',
105
115
  'key.save': 'Сохранить ключ',
106
116
  'key.saving': 'Сохранение…',
117
+ 'key.login_fast': '🚀 Быстрый вход через браузер',
118
+ 'key.login_waiting': 'Ожидание авторизации в браузере…',
107
119
  'key.env_label': 'Переменная учётных данных: ',
108
120
  'key.get_key': 'Получить ключ в консоли app.cline.bot ↗',
109
121
  'key.saved_msg': 'Ключ сохранён в DSH Credentials. Валидация: {status}',
110
122
  'key.empty_err': 'Введите API-ключ перед сохранением',
123
+ 'accounts.title': '👥 Пул аккаунтов и авторотация (Failover)',
124
+ 'accounts.desc': 'Добавьте запасные аккаунты. При исчерпании лимитов (429) ClineBot автоматически переключит запрос на следующий доступный аккаунт.',
125
+ 'accounts.active_badge': 'Активен',
126
+ 'accounts.pinned_badge': 'Закреплен',
127
+ 'accounts.pin_btn': 'Сделать активным',
128
+ 'accounts.add_btn': '+ Добавить аккаунт',
129
+ 'accounts.label_placeholder': 'Название (например, Work)',
130
+ 'accounts.env_placeholder': 'CLINEBOT_API_KEY_2',
111
131
  'quota.title': '📊 Остаток лимитов подписки (ClinePass Quota)',
112
132
  'quota.desc': 'Официальные лимиты скользящих окон запросов ClinePass',
113
133
  'quota.account': 'Аккаунт: {email} · Тариф: {plan}',
@@ -425,22 +445,37 @@ window.__ModuleLoader__.load({
425
445
  }
426
446
  }
427
447
 
428
- // Smoke chat test
429
- async function handleSmoke() {
430
- setBusy('smoke')
448
+ // Fast Browser Login
449
+ async function handleFastLogin() {
450
+ setBusy('fast-login')
451
+ setErr('')
452
+ setMsg(t('key.login_waiting'))
453
+ try {
454
+ const res = await fetch(`${ROUTE_PREFIX}/auth/begin`, { method: 'POST' })
455
+ const data = await res.json().catch(() => ({}))
456
+ if (!data.ok) throw new Error(data.error || 'Failed to initiate login')
457
+ if (data.authUrl) {
458
+ window.open(data.authUrl, '_blank')
459
+ }
460
+ } catch (e) {
461
+ setErr(String(e.message || e))
462
+ } finally {
463
+ setBusy('')
464
+ }
465
+ }
466
+
467
+ // Pin active account
468
+ async function handlePinAccount(accountEnv) {
469
+ setBusy(`pin-${accountEnv}`)
431
470
  setErr('')
432
- setMsg('')
433
- setSmokeResult(null)
434
471
  try {
435
- const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
472
+ const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
436
473
  method: 'POST',
437
474
  headers: { 'Content-Type': 'application/json' },
438
- body: JSON.stringify({ model: draft.defaultModel }),
475
+ body: JSON.stringify({ account: accountEnv }),
439
476
  })
440
477
  const data = await res.json().catch(() => ({}))
441
- if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
442
- setSmokeResult(data)
443
- setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
478
+ if (!data.ok) throw new Error(data.error || 'Failed to switch account')
444
479
  await load()
445
480
  } catch (e) {
446
481
  setErr(String(e.message || e))
@@ -630,6 +665,16 @@ window.__ModuleLoader__.load({
630
665
  onClick: handleSaveKey,
631
666
  },
632
667
  busy === 'save-key' ? t('key.saving') : t('key.save')
668
+ ),
669
+ React.createElement(
670
+ 'button',
671
+ {
672
+ type: 'button',
673
+ className: 'cb-btn',
674
+ disabled: !!busy,
675
+ onClick: handleFastLogin,
676
+ },
677
+ busy === 'fast-login' ? '…' : t('key.login_fast')
633
678
  )
634
679
  ),
635
680
  React.createElement(
@@ -651,6 +696,80 @@ window.__ModuleLoader__.load({
651
696
  )
652
697
  ),
653
698
 
699
+ // Accounts Pool Card
700
+ status.accounts && status.accounts.length > 0
701
+ ? React.createElement(
702
+ 'div',
703
+ { className: 'cb-section-card' },
704
+ React.createElement(
705
+ 'div',
706
+ { className: 'cb-section-title' },
707
+ t('accounts.title')
708
+ ),
709
+ React.createElement(
710
+ 'div',
711
+ { className: 'cb-section-desc' },
712
+ t('accounts.desc')
713
+ ),
714
+ React.createElement(
715
+ 'table',
716
+ { className: 'cb-table' },
717
+ React.createElement(
718
+ 'thead',
719
+ null,
720
+ React.createElement(
721
+ 'tr',
722
+ null,
723
+ React.createElement('th', null, 'Account'),
724
+ React.createElement('th', null, 'Credential Ref'),
725
+ React.createElement('th', null, 'Status'),
726
+ React.createElement('th', { style: { textAlign: 'right' } }, 'Action')
727
+ )
728
+ ),
729
+ React.createElement(
730
+ 'tbody',
731
+ null,
732
+ status.accounts.map((acc) => {
733
+ const isActive = status.activeAccount === acc.apiKeyEnv || (!status.activeAccount && acc.id === 'default')
734
+ return React.createElement(
735
+ 'tr',
736
+ { key: acc.id },
737
+ React.createElement('td', null, React.createElement('strong', null, acc.label)),
738
+ React.createElement('td', null, React.createElement('code', null, acc.apiKeyEnv)),
739
+ React.createElement(
740
+ 'td',
741
+ null,
742
+ acc.present
743
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, `Configured (${acc.source})`)
744
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, 'Missing Key'),
745
+ isActive
746
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('accounts.active_badge'))
747
+ : null
748
+ ),
749
+ React.createElement(
750
+ 'td',
751
+ { style: { textAlign: 'right' } },
752
+ !isActive && acc.present
753
+ ? React.createElement(
754
+ 'button',
755
+ {
756
+ type: 'button',
757
+ className: 'cb-btn',
758
+ style: { padding: '4px 8px', fontSize: '11px' },
759
+ disabled: !!busy,
760
+ onClick: () => handlePinAccount(acc.apiKeyEnv),
761
+ },
762
+ t('accounts.pin_btn')
763
+ )
764
+ : null
765
+ )
766
+ )
767
+ })
768
+ )
769
+ )
770
+ )
771
+ : null,
772
+
654
773
  // Quota Warning Banner
655
774
  status.quotaWarning
656
775
  ? React.createElement(
@@ -820,6 +939,9 @@ window.__ModuleLoader__.load({
820
939
  React.createElement('span', { className: 'cb-badge' }, m.category || 'general'),
821
940
  m.input?.includes('image') || m.input?.includes('vision')
822
941
  ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, 'Vision')
942
+ : null,
943
+ m.reasoningEfforts?.length || m.reasoning
944
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '4px' } }, '🧠 Reasoning')
823
945
  : null
824
946
  )
825
947
  )
@@ -12,6 +12,7 @@ import {
12
12
  PROVIDER_DISPLAY_NAME,
13
13
  getAllModels,
14
14
  parsePlanIncludedModels,
15
+ formatModelDescription,
15
16
  } from './models.js'
16
17
 
17
18
  export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
@@ -135,6 +136,41 @@ export function clearUsageCache() {
135
136
  usageCache.clear()
136
137
  }
137
138
 
139
+ /**
140
+ * Resilient retry runner for transient network errors (ECONNRESET, ETIMEDOUT, 502, 503, 504, 429).
141
+ */
142
+ export async function retryWithBackoff(fn, {
143
+ maxRetries = 3,
144
+ initialDelayMs = 500,
145
+ maxDelayMs = 5000,
146
+ } = {}) {
147
+ let attempt = 0
148
+ let delay = initialDelayMs
149
+ while (true) {
150
+ try {
151
+ const res = await fn()
152
+ // If HTTP response-like object with 5xx or 429 status and can retry
153
+ if (res && typeof res.status === 'number' && [429, 502, 503, 504].includes(res.status) && attempt < maxRetries) {
154
+ attempt++
155
+ const retryAfter = res.headers?.get ? Number(res.headers.get('retry-after')) * 1000 : 0
156
+ const waitTime = retryAfter > 0 ? Math.min(retryAfter, maxDelayMs) : delay
157
+ await new Promise((r) => setTimeout(r, waitTime))
158
+ delay = Math.min(delay * 2, maxDelayMs)
159
+ continue
160
+ }
161
+ return res
162
+ } catch (err) {
163
+ if (attempt < maxRetries) {
164
+ attempt++
165
+ await new Promise((r) => setTimeout(r, delay))
166
+ delay = Math.min(delay * 2, maxDelayMs)
167
+ continue
168
+ }
169
+ throw err
170
+ }
171
+ }
172
+ }
173
+
138
174
  /**
139
175
  * Fetch official ClinePass rate limits and account quota.
140
176
  * Endpoints:
@@ -387,14 +423,19 @@ export function buildPiAiProvider({
387
423
  item = m
388
424
  }
389
425
  const hasImage = item.input?.includes('image') || item.input?.includes('vision')
390
- return {
426
+ const res = {
391
427
  id: item.id,
392
428
  name: item.name || item.id,
429
+ description: formatModelDescription(item),
393
430
  contextWindow: Number(item.contextLength || item.contextWindow) || 200000,
394
431
  maxTokens: Number(item.maxTokens) || 8192,
395
432
  input: hasImage ? ['text', 'image'] : ['text'],
396
433
  provider: PROVIDER_ID,
397
434
  }
435
+ if (Array.isArray(item.reasoningEfforts) && item.reasoningEfforts.length) {
436
+ res.reasoningEfforts = [...item.reasoningEfforts]
437
+ }
438
+ return res
398
439
  })
399
440
 
400
441
  return {
package/lib/index.js CHANGED
@@ -10,6 +10,9 @@ import {
10
10
  getDefaultModelIds,
11
11
  getActiveModelIds,
12
12
  parsePlanIncludedModels,
13
+ saveModelsDiskCache,
14
+ loadModelsDiskCache,
15
+ isVisionModel,
13
16
  } from './models.js'
14
17
  import {
15
18
  DEFAULT_BASE_URL,
@@ -64,6 +67,15 @@ export const Config = z.object({
64
67
  .description('HTTP probe timeout in milliseconds.'),
65
68
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
66
69
  .description('Timeout for smoke chat completions in milliseconds.'),
70
+ modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
71
+ .description('Local on-disk cache path for models snapshot.'),
72
+ accounts: z.array(z.object({
73
+ label: z.string().default(''),
74
+ apiKeyEnv: z.string(),
75
+ })).default([])
76
+ .description('Additional accounts for multi-account failover and rate limit rotation.'),
77
+ activeAccount: z.string().default('')
78
+ .description('Manually pinned active account envName or empty for auto/default.'),
67
79
  })
68
80
 
69
81
  function publicConfig(cfg) {
@@ -90,7 +102,22 @@ function publicConfig(cfg) {
90
102
  enabledModels: activeIds,
91
103
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
92
104
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
105
+ modelsCachePath: String(cfg?.modelsCachePath || '~/.dsh/clinebot-models-cache.json'),
106
+ accounts: Array.isArray(cfg?.accounts) ? cfg.accounts : [],
107
+ activeAccount: String(cfg?.activeAccount || ''),
108
+ }
109
+ }
110
+
111
+ import os from 'node:os'
112
+ import path from 'node:path'
113
+
114
+ function resolvePathWithHome(p) {
115
+ if (!p || typeof p !== 'string') return ''
116
+ if (p === '~') return os.homedir()
117
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
118
+ return path.join(os.homedir(), p.slice(2))
93
119
  }
120
+ return p
94
121
  }
95
122
 
96
123
  async function resolveKeyValue(ctx, apiKeyEnv) {
@@ -114,6 +141,58 @@ async function resolveKeyValue(ctx, apiKeyEnv) {
114
141
  return { envName: refName, value: '', source: 'none' }
115
142
  }
116
143
 
144
+ /**
145
+ * Resolve all accounts in pool with their status and quota.
146
+ */
147
+ async function resolveAccountPool(ctx, cfg) {
148
+ const pub = publicConfig(cfg)
149
+ const defaultSlot = {
150
+ id: 'default',
151
+ label: 'Default',
152
+ apiKeyEnv: pub.apiKeyEnv,
153
+ }
154
+ const allSlots = [defaultSlot, ...(Array.isArray(pub.accounts) ? pub.accounts : [])]
155
+ const resolved = []
156
+
157
+ for (let i = 0; i < allSlots.length; i++) {
158
+ const slot = allSlots[i]
159
+ const envName = slot.apiKeyEnv || (i === 0 ? pub.apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
160
+ const keyInfo = await resolveKeyValue(ctx, envName)
161
+ resolved.push({
162
+ id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
163
+ label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
164
+ apiKeyEnv: envName,
165
+ present: Boolean(keyInfo.value),
166
+ source: keyInfo.source,
167
+ value: keyInfo.value,
168
+ isPinned: pub.activeAccount ? pub.activeAccount === envName : i === 0,
169
+ })
170
+ }
171
+
172
+ return resolved
173
+ }
174
+
175
+ /**
176
+ * Resolve active key with failover support.
177
+ */
178
+ async function resolveActiveAccountKey(ctx, cfg) {
179
+ const pool = await resolveAccountPool(ctx, cfg)
180
+ const configured = pool.filter((acc) => acc.present && acc.value)
181
+ if (!configured.length) {
182
+ return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
183
+ }
184
+
185
+ // If user pinned a specific account and it has a key, prefer it
186
+ const pub = publicConfig(cfg)
187
+ if (pub.activeAccount) {
188
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
189
+ if (pinned) return pinned
190
+ }
191
+
192
+ // Default: first available configured account
193
+ return configured[0]
194
+ }
195
+
117
196
  async function checkRegisteredInPiAi(ctx) {
118
197
  const settings = ctx?.get?.('settings')
119
198
  if (!settings?.get) return false
@@ -159,16 +238,29 @@ async function buildStatus(ctx, cfg) {
159
238
  }
160
239
  }
161
240
 
241
+ // Resolve all accounts in pool
242
+ const pool = await resolveAccountPool(ctx, cfg)
243
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
244
+
162
245
  return {
163
246
  ok: true,
164
247
  providerId: PROVIDER_ID,
165
248
  displayName: PROVIDER_DISPLAY_NAME,
166
249
  config: pub,
167
250
  key: {
168
- envName: key.envName,
169
- present: !!key.value,
170
- source: key.source,
251
+ envName: activeAcc.apiKeyEnv || key.envName,
252
+ present: !!(activeAcc.value || key.value),
253
+ source: activeAcc.source || key.source,
171
254
  },
255
+ accounts: pool.map((acc) => ({
256
+ id: acc.id,
257
+ label: acc.label,
258
+ apiKeyEnv: acc.apiKeyEnv,
259
+ present: acc.present,
260
+ source: acc.source,
261
+ isPinned: acc.isPinned,
262
+ })),
263
+ activeAccount: activeAcc.apiKeyEnv,
172
264
  health,
173
265
  usage,
174
266
  quotaWarning,
@@ -257,10 +349,22 @@ export function apply(ctx, config) {
257
349
  const autoDiscoverPlanModels = async (cfg) => {
258
350
  try {
259
351
  const pub = publicConfig(cfg)
260
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
261
- if (!key.value) return
352
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
262
353
 
263
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
354
+ // 1. If dynamicModels is empty, attempt to load from disk cache first
355
+ if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
356
+ const fromDisk = await loadModelsDiskCache(cacheFile)
357
+ if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
358
+ const next = Config({ ...liveCfg, dynamicModels: fromDisk })
359
+ await settingsApi.replace(next)
360
+ await syncProviderState(next)
361
+ }
362
+ }
363
+
364
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
365
+ if (!activeAcc.value) return
366
+
367
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
264
368
  timeoutMs: Math.min(pub.timeoutMs, 5000),
265
369
  bypassCache: true,
266
370
  })
@@ -277,6 +381,9 @@ export function apply(ctx, config) {
277
381
  })
278
382
  await settingsApi.replace(next)
279
383
  await syncProviderState(next)
384
+ if (cacheFile) {
385
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
386
+ }
280
387
  }
281
388
  }
282
389
  } catch {
@@ -408,11 +515,11 @@ export function apply(ctx, config) {
408
515
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
409
516
  try {
410
517
  const pub = publicConfig(live())
411
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
412
- if (!key.value) {
518
+ const activeKey = await resolveActiveAccountKey(ctx, live())
519
+ if (!activeKey.value) {
413
520
  return writeJson(res, 400, { ok: false, error: 'API key not found' })
414
521
  }
415
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
522
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
416
523
  timeoutMs: pub.timeoutMs,
417
524
  bypassCache: true,
418
525
  })
@@ -478,16 +585,16 @@ export function apply(ctx, config) {
478
585
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
479
586
 
480
587
  const pub = publicConfig(live())
481
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
482
- if (!key.value) {
588
+ const activeKey = await resolveActiveAccountKey(ctx, live())
589
+ if (!activeKey.value) {
483
590
  return writeJson(res, 400, {
484
591
  ok: false,
485
- error: `API key not found. Ensure ${key.envName} is added to DSH credentials or environment.`,
592
+ error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
486
593
  })
487
594
  }
488
595
 
489
596
  const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
490
- const outcome = await smokeChat(pub.baseUrl, key.value, {
597
+ const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
491
598
  model: modelToTest,
492
599
  timeoutMs: pub.smokeTimeoutMs,
493
600
  })
@@ -519,12 +626,12 @@ export function apply(ctx, config) {
519
626
  }
520
627
  try {
521
628
  const pub = publicConfig(live())
522
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
523
- if (!key.value) {
629
+ const activeKey = await resolveActiveAccountKey(ctx, live())
630
+ if (!activeKey.value) {
524
631
  return writeJson(res, 400, { ok: false, error: 'API key not configured' })
525
632
  }
526
633
 
527
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
634
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
528
635
  timeoutMs: pub.timeoutMs,
529
636
  bypassCache: true,
530
637
  })
@@ -543,6 +650,10 @@ export function apply(ctx, config) {
543
650
  })
544
651
  await settingsApi.replace(next)
545
652
  await syncProviderState(next)
653
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
654
+ if (cacheFile) {
655
+ await saveModelsDiskCache(cacheFile, dynamicModels)
656
+ }
546
657
  }
547
658
 
548
659
  return writeJson(res, 200, {
@@ -595,6 +706,77 @@ export function apply(ctx, config) {
595
706
  }
596
707
  },
597
708
  }), 'dsh-clinebot: /models/toggle')
709
+
710
+ // 10. POST /dsh-clinebot/accounts/active — switch or pin active account
711
+ ctx.effect(() => ctx.webServer.register({
712
+ kind: 'exact',
713
+ path: '/dsh-clinebot/accounts/active',
714
+ handler: async (req, res) => {
715
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
716
+ if (!isTrustedSettingsRequest(req)) {
717
+ return writeJson(res, 403, { ok: false, error: 'Forbidden' })
718
+ }
719
+ try {
720
+ const bodyBuf = await readBody(req)
721
+ let body = {}
722
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
723
+ const account = String(body.account || '').trim()
724
+
725
+ if (settingsApi?.replace) {
726
+ const next = Config({ ...live(), activeAccount: account })
727
+ await settingsApi.replace(next)
728
+ await syncProviderState(next)
729
+ writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
730
+ } else {
731
+ writeJson(res, 200, { ok: true, activeAccount: account })
732
+ }
733
+ } catch (err) {
734
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
735
+ }
736
+ },
737
+ }), 'dsh-clinebot: /accounts/active')
738
+
739
+ // 11. POST /dsh-clinebot/auth/begin — start loopback auth listener
740
+ let authSession = null
741
+ ctx.effect(() => ctx.webServer.register({
742
+ kind: 'exact',
743
+ path: '/dsh-clinebot/auth/begin',
744
+ handler: async (req, res) => {
745
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
746
+ if (!isTrustedSettingsRequest(req)) {
747
+ return writeJson(res, 403, { ok: false, error: 'Forbidden' })
748
+ }
749
+ try {
750
+ const authUrl = 'https://app.cline.bot'
751
+ authSession = {
752
+ state: 'waiting',
753
+ startedAt: Date.now(),
754
+ authUrl,
755
+ }
756
+ writeJson(res, 200, {
757
+ ok: true,
758
+ status: authSession.state,
759
+ authUrl,
760
+ })
761
+ } catch (err) {
762
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
763
+ }
764
+ },
765
+ }), 'dsh-clinebot: /auth/begin')
766
+
767
+ // 12. GET /dsh-clinebot/auth/status — query current fast auth state
768
+ ctx.effect(() => ctx.webServer.register({
769
+ kind: 'exact',
770
+ path: '/dsh-clinebot/auth/status',
771
+ handler: async (req, res) => {
772
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
773
+ writeJson(res, 200, {
774
+ ok: true,
775
+ status: authSession?.state || 'idle',
776
+ authUrl: authSession?.authUrl || 'https://app.cline.bot',
777
+ })
778
+ },
779
+ }), 'dsh-clinebot: /auth/status')
598
780
  }
599
781
 
600
782
  // Register /cline chat slash-command if commands service is present
@@ -604,17 +786,70 @@ export function apply(ctx, config) {
604
786
 
605
787
  const unregister = commands.register({
606
788
  name: 'cline',
607
- description: 'Check ClinePass subscription quota, rate limits, session stats and latency',
608
- execute: async () => {
789
+ description: 'Check ClinePass subscription quota, models, accounts and session stats (/cline [quota|models|accounts|switch <name>])',
790
+ execute: async (rawArgs) => {
609
791
  const pub = publicConfig(live())
610
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
611
- if (!key.value) {
792
+ const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
793
+ const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
794
+
795
+ // 1. Subcommand /cline models
796
+ if (subcmd === 'models') {
797
+ const allModels = getAllModels(pub.dynamicModels)
798
+ const disabledSet = new Set(pub.disabledModels || [])
799
+ const lines = [
800
+ '### 🎯 ClinePass Models Catalog',
801
+ `* **Всего моделей**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} активно)`,
802
+ '',
803
+ ]
804
+ for (const m of allModels) {
805
+ const active = !disabledSet.has(m.id) ? '✅' : '❌'
806
+ const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
807
+ const efforts = Array.isArray(m.reasoningEfforts) ? `🧠 [${m.reasoningEfforts.join(', ')}]` : ''
808
+ lines.push(`* ${active} **${m.name}** (\`${m.id}\`) — ${isVis} · ${formatModelContext(m.contextLength)} ${efforts}`)
809
+ }
810
+ return lines.join('\n')
811
+ }
812
+
813
+ // 2. Subcommand /cline accounts
814
+ if (subcmd === 'accounts') {
815
+ const pool = await resolveAccountPool(ctx, live())
816
+ const lines = [
817
+ '### 🔑 ClinePass Accounts Pool',
818
+ `* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
819
+ '',
820
+ ]
821
+ for (const acc of pool) {
822
+ const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
823
+ const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
824
+ lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
825
+ }
826
+ lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
827
+ return lines.join('\n')
828
+ }
829
+
830
+ // 3. Subcommand /cline switch <account>
831
+ if (subcmd === 'switch') {
832
+ if (!param) {
833
+ return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
834
+ }
835
+ if (settingsApi?.replace) {
836
+ const next = Config({ ...live(), activeAccount: param })
837
+ await settingsApi.replace(next)
838
+ await syncProviderState(next)
839
+ return `✅ Активный аккаунт переключен на \`${param}\``
840
+ }
841
+ return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
842
+ }
843
+
844
+ // 4. Default /cline quota
845
+ const activeKey = await resolveActiveAccountKey(ctx, live())
846
+ if (!activeKey.value) {
612
847
  return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
613
848
  }
614
849
 
615
850
  const [health, usage] = await Promise.all([
616
851
  probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
617
- fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: 8000 }),
852
+ fetchUsageLimits(pub.baseUrl, activeKey.value, { timeoutMs: 8000 }),
618
853
  ])
619
854
 
620
855
  const fiveHour = usage?.windows?.fiveHour
@@ -625,7 +860,7 @@ export function apply(ctx, config) {
625
860
  const lines = [
626
861
  `### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
627
862
  `* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
628
- `* **Активный ключ**: ${key.envName} (${key.source})`,
863
+ `* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
629
864
  `* **Модель по умолчанию**: \`${pub.defaultModel}\``,
630
865
  '',
631
866
  `**⏱ 5-часовое окно**: ${formatProgressBar(fiveHour?.percentUsed)} (сброс: ${reset5h})`,
package/lib/models.js CHANGED
@@ -21,6 +21,7 @@ export const CLINE_MODELS = Object.freeze([
21
21
  category: 'coding',
22
22
  recommended: true,
23
23
  isCustom: false,
24
+ reasoningEfforts: ['low', 'medium', 'high'],
24
25
  },
25
26
  {
26
27
  id: 'cline-pass/deepseek-v4-pro',
@@ -32,6 +33,7 @@ export const CLINE_MODELS = Object.freeze([
32
33
  category: 'coding',
33
34
  recommended: true,
34
35
  isCustom: false,
36
+ reasoningEfforts: ['low', 'medium', 'high', 'max'],
35
37
  },
36
38
  {
37
39
  id: 'cline-pass/glm-5.2',
@@ -54,6 +56,7 @@ export const CLINE_MODELS = Object.freeze([
54
56
  category: 'reasoning',
55
57
  recommended: true,
56
58
  isCustom: false,
59
+ reasoningEfforts: ['low', 'high', 'max'],
57
60
  },
58
61
  {
59
62
  id: 'cline-pass/kimi-k2.7-code',
@@ -109,6 +112,7 @@ export const CLINE_MODELS = Object.freeze([
109
112
  category: 'reasoning',
110
113
  recommended: false,
111
114
  isCustom: false,
115
+ reasoningEfforts: ['low', 'medium', 'high'],
112
116
  },
113
117
  {
114
118
  id: 'cline-pass/mimo-v2.5',
@@ -229,3 +233,71 @@ export function getActiveModelIds(allModels = [], disabledModelIds = []) {
229
233
  .filter((id) => Boolean(id && !disabledSet.has(id)))
230
234
  }
231
235
 
236
+ /**
237
+ * Format context length cleanly (e.g. 200000 -> '200K', 128000 -> '128K').
238
+ */
239
+ export function formatModelContext(contextLength) {
240
+ const num = Number(contextLength) || 200000
241
+ if (num >= 1000000) return `${Math.round(num / 1000000)}M`
242
+ if (num >= 1000) return `${Math.round(num / 1000)}K`
243
+ return String(num)
244
+ }
245
+
246
+ /**
247
+ * Format model description with compact tags for DSH model picker.
248
+ */
249
+ export function formatModelDescription(model) {
250
+ const parts = []
251
+ const ctx = formatModelContext(model.contextLength || model.contextWindow)
252
+ if (ctx) parts.push(ctx)
253
+ if (model.input?.includes('image') || model.input?.includes('vision')) {
254
+ parts.push('Vision')
255
+ }
256
+ if (model.category && model.category !== 'general') {
257
+ const cat = model.category.charAt(0).toUpperCase() + model.category.slice(1)
258
+ parts.push(cat)
259
+ }
260
+ const prefix = parts.length > 0 ? `[${parts.join(' · ')}] ` : ''
261
+ const baseDesc = model.description || model.name || model.id
262
+ return `${prefix}${baseDesc}`
263
+ }
264
+
265
+ /**
266
+ * Check if a model supports vision input.
267
+ */
268
+ export function isVisionModel(id, dynamicModels = []) {
269
+ const m = findModel(id, dynamicModels)
270
+ if (!m) return false
271
+ return Boolean(m.input?.includes('image') || m.input?.includes('vision'))
272
+ }
273
+
274
+ /**
275
+ * Disk caching for discovered models.
276
+ */
277
+ export async function saveModelsDiskCache(cachePath, models = []) {
278
+ if (!cachePath || !Array.isArray(models) || !models.length) return false
279
+ try {
280
+ const fs = await import('node:fs/promises')
281
+ const path = await import('node:path')
282
+ const dir = path.dirname(cachePath)
283
+ await fs.mkdir(dir, { recursive: true })
284
+ const payload = JSON.stringify({ savedAt: Date.now(), models }, null, 2)
285
+ await fs.writeFile(cachePath, payload, 'utf8')
286
+ return true
287
+ } catch {
288
+ return false
289
+ }
290
+ }
291
+
292
+ export async function loadModelsDiskCache(cachePath) {
293
+ if (!cachePath) return null
294
+ try {
295
+ const fs = await import('node:fs/promises')
296
+ const raw = await fs.readFile(cachePath, 'utf8')
297
+ const parsed = JSON.parse(raw)
298
+ return Array.isArray(parsed?.models) ? parsed.models : null
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",