@goodandready/dsh-clinebot 0.3.2 → 0.3.3

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,22 @@ 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.3] - 2026-09-08
9
+
10
+ ### Added
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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`.
15
+ - **Extended Slash-Command Subcommands** (Issue #12): Enhanced `/cline` with powerful subcommands:
16
+ - `/cline models` — Lists all available models with context length, Vision modality, and reasoning support.
17
+ - `/cline accounts` — Displays configured account pool, active key, and environment binding status.
18
+ - `/cline switch <label>` — Dynamically switches the active key across the account pool directly from chat.
19
+ - `/cline quota` (default) — Full rolling quota breakdown with warning indicators and session metrics.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+
8
24
  ## [0.3.2] - 2026-09-08
9
25
 
10
26
  ### 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,21 @@ 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.startsWith('~/') || p === '~') {
117
+ return path.join(os.homedir(), p.slice(1))
93
118
  }
119
+ return p
94
120
  }
95
121
 
96
122
  async function resolveKeyValue(ctx, apiKeyEnv) {
@@ -114,6 +140,58 @@ async function resolveKeyValue(ctx, apiKeyEnv) {
114
140
  return { envName: refName, value: '', source: 'none' }
115
141
  }
116
142
 
143
+ /**
144
+ * Resolve all accounts in pool with their status and quota.
145
+ */
146
+ async function resolveAccountPool(ctx, cfg) {
147
+ const pub = publicConfig(cfg)
148
+ const defaultSlot = {
149
+ id: 'default',
150
+ label: 'Default',
151
+ apiKeyEnv: pub.apiKeyEnv,
152
+ }
153
+ const allSlots = [defaultSlot, ...(Array.isArray(pub.accounts) ? pub.accounts : [])]
154
+ const resolved = []
155
+
156
+ for (let i = 0; i < allSlots.length; i++) {
157
+ const slot = allSlots[i]
158
+ const envName = slot.apiKeyEnv || (i === 0 ? pub.apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
159
+ const keyInfo = await resolveKeyValue(ctx, envName)
160
+ resolved.push({
161
+ id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
162
+ label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
163
+ apiKeyEnv: envName,
164
+ present: Boolean(keyInfo.value),
165
+ source: keyInfo.source,
166
+ value: keyInfo.value,
167
+ isPinned: pub.activeAccount ? pub.activeAccount === envName : i === 0,
168
+ })
169
+ }
170
+
171
+ return resolved
172
+ }
173
+
174
+ /**
175
+ * Resolve active key with failover support.
176
+ */
177
+ async function resolveActiveAccountKey(ctx, cfg) {
178
+ const pool = await resolveAccountPool(ctx, cfg)
179
+ const configured = pool.filter((acc) => acc.present && acc.value)
180
+ if (!configured.length) {
181
+ return { envName: publicConfig(cfg).apiKeyEnv, value: '', source: 'none', id: 'default' }
182
+ }
183
+
184
+ // If user pinned a specific account and it has a key, prefer it
185
+ const pub = publicConfig(cfg)
186
+ if (pub.activeAccount) {
187
+ const pinned = configured.find((acc) => acc.apiKeyEnv === pub.activeAccount || acc.id === pub.activeAccount)
188
+ if (pinned) return pinned
189
+ }
190
+
191
+ // Default: first available configured account
192
+ return configured[0]
193
+ }
194
+
117
195
  async function checkRegisteredInPiAi(ctx) {
118
196
  const settings = ctx?.get?.('settings')
119
197
  if (!settings?.get) return false
@@ -159,16 +237,29 @@ async function buildStatus(ctx, cfg) {
159
237
  }
160
238
  }
161
239
 
240
+ // Resolve all accounts in pool
241
+ const pool = await resolveAccountPool(ctx, cfg)
242
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
243
+
162
244
  return {
163
245
  ok: true,
164
246
  providerId: PROVIDER_ID,
165
247
  displayName: PROVIDER_DISPLAY_NAME,
166
248
  config: pub,
167
249
  key: {
168
- envName: key.envName,
169
- present: !!key.value,
170
- source: key.source,
250
+ envName: activeAcc.apiKeyEnv || key.envName,
251
+ present: !!(activeAcc.value || key.value),
252
+ source: activeAcc.source || key.source,
171
253
  },
254
+ accounts: pool.map((acc) => ({
255
+ id: acc.id,
256
+ label: acc.label,
257
+ apiKeyEnv: acc.apiKeyEnv,
258
+ present: acc.present,
259
+ source: acc.source,
260
+ isPinned: acc.isPinned,
261
+ })),
262
+ activeAccount: activeAcc.apiKeyEnv,
172
263
  health,
173
264
  usage,
174
265
  quotaWarning,
@@ -257,10 +348,22 @@ export function apply(ctx, config) {
257
348
  const autoDiscoverPlanModels = async (cfg) => {
258
349
  try {
259
350
  const pub = publicConfig(cfg)
260
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
261
- if (!key.value) return
351
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
262
352
 
263
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
353
+ // 1. If dynamicModels is empty, attempt to load from disk cache first
354
+ if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
355
+ const fromDisk = await loadModelsDiskCache(cacheFile)
356
+ if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
357
+ const next = Config({ ...liveCfg, dynamicModels: fromDisk })
358
+ await settingsApi.replace(next)
359
+ await syncProviderState(next)
360
+ }
361
+ }
362
+
363
+ const activeAcc = await resolveActiveAccountKey(ctx, cfg)
364
+ if (!activeAcc.value) return
365
+
366
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
264
367
  timeoutMs: Math.min(pub.timeoutMs, 5000),
265
368
  bypassCache: true,
266
369
  })
@@ -277,6 +380,9 @@ export function apply(ctx, config) {
277
380
  })
278
381
  await settingsApi.replace(next)
279
382
  await syncProviderState(next)
383
+ if (cacheFile) {
384
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels)
385
+ }
280
386
  }
281
387
  }
282
388
  } catch {
@@ -408,11 +514,11 @@ export function apply(ctx, config) {
408
514
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
409
515
  try {
410
516
  const pub = publicConfig(live())
411
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
412
- if (!key.value) {
517
+ const activeKey = await resolveActiveAccountKey(ctx, live())
518
+ if (!activeKey.value) {
413
519
  return writeJson(res, 400, { ok: false, error: 'API key not found' })
414
520
  }
415
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
521
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
416
522
  timeoutMs: pub.timeoutMs,
417
523
  bypassCache: true,
418
524
  })
@@ -478,16 +584,16 @@ export function apply(ctx, config) {
478
584
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
479
585
 
480
586
  const pub = publicConfig(live())
481
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
482
- if (!key.value) {
587
+ const activeKey = await resolveActiveAccountKey(ctx, live())
588
+ if (!activeKey.value) {
483
589
  return writeJson(res, 400, {
484
590
  ok: false,
485
- error: `API key not found. Ensure ${key.envName} is added to DSH credentials or environment.`,
591
+ error: `API key not found. Ensure ${activeKey.envName} is added to DSH credentials or environment.`,
486
592
  })
487
593
  }
488
594
 
489
595
  const modelToTest = body.model || pub.defaultModel || DEFAULT_MODEL_ID
490
- const outcome = await smokeChat(pub.baseUrl, key.value, {
596
+ const outcome = await smokeChat(pub.baseUrl, activeKey.value, {
491
597
  model: modelToTest,
492
598
  timeoutMs: pub.smokeTimeoutMs,
493
599
  })
@@ -519,12 +625,12 @@ export function apply(ctx, config) {
519
625
  }
520
626
  try {
521
627
  const pub = publicConfig(live())
522
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
523
- if (!key.value) {
628
+ const activeKey = await resolveActiveAccountKey(ctx, live())
629
+ if (!activeKey.value) {
524
630
  return writeJson(res, 400, { ok: false, error: 'API key not configured' })
525
631
  }
526
632
 
527
- const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
633
+ const usageData = await fetchUsageLimits(pub.baseUrl, activeKey.value, {
528
634
  timeoutMs: pub.timeoutMs,
529
635
  bypassCache: true,
530
636
  })
@@ -543,6 +649,10 @@ export function apply(ctx, config) {
543
649
  })
544
650
  await settingsApi.replace(next)
545
651
  await syncProviderState(next)
652
+ const cacheFile = resolvePathWithHome(pub.modelsCachePath)
653
+ if (cacheFile) {
654
+ await saveModelsDiskCache(cacheFile, dynamicModels)
655
+ }
546
656
  }
547
657
 
548
658
  return writeJson(res, 200, {
@@ -595,6 +705,77 @@ export function apply(ctx, config) {
595
705
  }
596
706
  },
597
707
  }), 'dsh-clinebot: /models/toggle')
708
+
709
+ // 10. POST /dsh-clinebot/accounts/active — switch or pin active account
710
+ ctx.effect(() => ctx.webServer.register({
711
+ kind: 'exact',
712
+ path: '/dsh-clinebot/accounts/active',
713
+ handler: async (req, res) => {
714
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
715
+ if (!isTrustedSettingsRequest(req)) {
716
+ return writeJson(res, 403, { ok: false, error: 'Forbidden' })
717
+ }
718
+ try {
719
+ const bodyBuf = await readBody(req)
720
+ let body = {}
721
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
722
+ const account = String(body.account || '').trim()
723
+
724
+ if (settingsApi?.replace) {
725
+ const next = Config({ ...live(), activeAccount: account })
726
+ await settingsApi.replace(next)
727
+ await syncProviderState(next)
728
+ writeJson(res, 200, { ok: true, activeAccount: next.activeAccount })
729
+ } else {
730
+ writeJson(res, 200, { ok: true, activeAccount: account })
731
+ }
732
+ } catch (err) {
733
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
734
+ }
735
+ },
736
+ }), 'dsh-clinebot: /accounts/active')
737
+
738
+ // 11. POST /dsh-clinebot/auth/begin — start loopback auth listener
739
+ let authSession = null
740
+ ctx.effect(() => ctx.webServer.register({
741
+ kind: 'exact',
742
+ path: '/dsh-clinebot/auth/begin',
743
+ handler: async (req, res) => {
744
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
745
+ if (!isTrustedSettingsRequest(req)) {
746
+ return writeJson(res, 403, { ok: false, error: 'Forbidden' })
747
+ }
748
+ try {
749
+ const authUrl = 'https://app.cline.bot'
750
+ authSession = {
751
+ state: 'waiting',
752
+ startedAt: Date.now(),
753
+ authUrl,
754
+ }
755
+ writeJson(res, 200, {
756
+ ok: true,
757
+ status: authSession.state,
758
+ authUrl,
759
+ })
760
+ } catch (err) {
761
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
762
+ }
763
+ },
764
+ }), 'dsh-clinebot: /auth/begin')
765
+
766
+ // 12. GET /dsh-clinebot/auth/status — query current fast auth state
767
+ ctx.effect(() => ctx.webServer.register({
768
+ kind: 'exact',
769
+ path: '/dsh-clinebot/auth/status',
770
+ handler: async (req, res) => {
771
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
772
+ writeJson(res, 200, {
773
+ ok: true,
774
+ status: authSession?.state || 'idle',
775
+ authUrl: authSession?.authUrl || 'https://app.cline.bot',
776
+ })
777
+ },
778
+ }), 'dsh-clinebot: /auth/status')
598
779
  }
599
780
 
600
781
  // Register /cline chat slash-command if commands service is present
@@ -604,17 +785,70 @@ export function apply(ctx, config) {
604
785
 
605
786
  const unregister = commands.register({
606
787
  name: 'cline',
607
- description: 'Check ClinePass subscription quota, rate limits, session stats and latency',
608
- execute: async () => {
788
+ description: 'Check ClinePass subscription quota, models, accounts and session stats (/cline [quota|models|accounts|switch <name>])',
789
+ execute: async (rawArgs) => {
609
790
  const pub = publicConfig(live())
610
- const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
611
- if (!key.value) {
791
+ const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
792
+ const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
793
+
794
+ // 1. Subcommand /cline models
795
+ if (subcmd === 'models') {
796
+ const allModels = getAllModels(pub.dynamicModels)
797
+ const disabledSet = new Set(pub.disabledModels || [])
798
+ const lines = [
799
+ '### 🎯 ClinePass Models Catalog',
800
+ `* **Всего моделей**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} активно)`,
801
+ '',
802
+ ]
803
+ for (const m of allModels) {
804
+ const active = !disabledSet.has(m.id) ? '✅' : '❌'
805
+ const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
806
+ const efforts = Array.isArray(m.reasoningEfforts) ? `🧠 [${m.reasoningEfforts.join(', ')}]` : ''
807
+ lines.push(`* ${active} **${m.name}** (\`${m.id}\`) — ${isVis} · ${formatModelContext(m.contextLength)} ${efforts}`)
808
+ }
809
+ return lines.join('\n')
810
+ }
811
+
812
+ // 2. Subcommand /cline accounts
813
+ if (subcmd === 'accounts') {
814
+ const pool = await resolveAccountPool(ctx, live())
815
+ const lines = [
816
+ '### 🔑 ClinePass Accounts Pool',
817
+ `* **Активный аккаунт**: \`${pub.activeAccount || pub.apiKeyEnv}\``,
818
+ '',
819
+ ]
820
+ for (const acc of pool) {
821
+ const pinBadge = acc.isPinned ? '📌 [Pinned]' : ''
822
+ const statusBadge = acc.present ? '✅ Configured' : '⚠️ Missing'
823
+ lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}`)
824
+ }
825
+ lines.push('', 'Переключить активный аккаунт: `/cline switch <имя_переменной>`')
826
+ return lines.join('\n')
827
+ }
828
+
829
+ // 3. Subcommand /cline switch <account>
830
+ if (subcmd === 'switch') {
831
+ if (!param) {
832
+ return '⚠️ Укажите аккаунт: `/cline switch <CLINEBOT_API_KEY_2>`'
833
+ }
834
+ if (settingsApi?.replace) {
835
+ const next = Config({ ...live(), activeAccount: param })
836
+ await settingsApi.replace(next)
837
+ await syncProviderState(next)
838
+ return `✅ Активный аккаунт переключен на \`${param}\``
839
+ }
840
+ return `⚠️ Не удалось применить настройку (сервис настроек недоступен).`
841
+ }
842
+
843
+ // 4. Default /cline quota
844
+ const activeKey = await resolveActiveAccountKey(ctx, live())
845
+ if (!activeKey.value) {
612
846
  return '⚠️ **ClineBot**: API-ключ не настроен. Откройте **Настройки → ClineBot** и сохраните ключ.'
613
847
  }
614
848
 
615
849
  const [health, usage] = await Promise.all([
616
850
  probeHealth(pub.baseUrl, { timeoutMs: 5000 }),
617
- fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: 8000 }),
851
+ fetchUsageLimits(pub.baseUrl, activeKey.value, { timeoutMs: 8000 }),
618
852
  ])
619
853
 
620
854
  const fiveHour = usage?.windows?.fiveHour
@@ -625,7 +859,7 @@ export function apply(ctx, config) {
625
859
  const lines = [
626
860
  `### 🤖 ClinePass Status (${usage?.plan || 'ClinePass'})`,
627
861
  `* **Пинг хоста**: ${health.ok ? `✅ ${health.latencyMs} мс` : '❌ Недоступен'}`,
628
- `* **Активный ключ**: ${key.envName} (${key.source})`,
862
+ `* **Активный ключ**: \`${activeKey.envName}\` (${activeKey.source})`,
629
863
  `* **Модель по умолчанию**: \`${pub.defaultModel}\``,
630
864
  '',
631
865
  `**⏱ 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.3",
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",