@goodandready/dsh-clinebot 0.3.1 → 0.3.2

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,18 @@ 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.2] - 2026-09-08
9
+
10
+ ### Added
11
+ - **Automatic Plan Models Discovery**: Dynamic subscription plan models are parsed and registered automatically upon plugin startup and key configuration without requiring manual sync clicks.
12
+ - **`disabledModels` Selection Model**: Migrated model activation state to `disabledModels`. Any newly added models in the ClinePass subscription plan are enabled automatically by default, while user exclusions are reliably preserved.
13
+ - **Instant Non-Blocking Settings Status**: Limited background health check and quota ping timeout to 2500 ms in `buildStatus()`, rendering settings immediately and avoiding UI freezes on cold start or network hiccups.
14
+ - **Full DSH English & Russian Localization**: Complete translation dictionary coverage (`en` canonical and `ru`) across headers, badges, quotas, models table, metrics, and diagnostics.
15
+ - **Dynamic New Model Badges**: Newly discovered models received directly from the user's subscription plan now feature an informative `New` badge in the model picker.
16
+
17
+ ### Fixed
18
+ - **DSH Registration Automation**: Provider registration in `llm-pi-ai` is now fully declarative and synchronized automatically when API credentials or model choices change. Removed redundant manual registration requirement.
19
+
8
20
  ## [0.3.1] - 2026-09-07
9
21
 
10
22
  ### Fixed
@@ -9,7 +9,8 @@ The plugin consists of two runtime boundaries conforming to DSH authoring standa
9
9
  ### 2.1 Host Runtime (`lib/index.js`, `lib/cline-client.js`, `lib/models.js`, `lib/http.js`)
10
10
  * **Cordis Service Registration**: Injects `['settings', 'webServer', 'credentials']`.
11
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`.
12
- * **State Synchronization**: Mutates the core `llm-pi-ai` settings space (`op: 'set', path: ['providers', 'clinebot']`) when enabled or registered.
12
+ * **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
+ * **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.
13
14
 
14
15
  ### 2.2 Client Runtime (`lib/client.js`)
15
16
  * Self-registering module via `window.__ModuleLoader__.load({ id: '@goodandready/dsh-clinebot', factory })`.
package/lib/client.js CHANGED
@@ -14,14 +14,144 @@ window.__ModuleLoader__.load({
14
14
  title: 'ClineBot',
15
15
  subtitle: 'ClinePass subscription ($9.99/mo) with curated & custom models.',
16
16
  'settings.loading': 'Loading ClineBot settings…',
17
+ 'settings.retry': 'Retry',
17
18
  'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
19
+ 'header.title': 'ClineBot (ClinePass) Provider',
20
+ 'header.sub': 'Connect models under ClinePass subscription ($9.99/mo). OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
21
+ 'badge.online': 'Host online ({latency} ms)',
22
+ 'badge.offline': 'Host unreachable',
23
+ 'badge.key_ok': 'Key ✓ ({source})',
24
+ 'badge.key_missing': 'Key missing',
25
+ 'badge.registered': 'DSH Registered ({count} models)',
26
+ 'badge.not_registered': 'Not registered',
27
+ 'key.title': '🔑 Authorization & API Key',
28
+ 'key.desc': 'The key is saved directly to DSH Credentials storage (~/.dsh/.credentials.yaml) and never exposed in plaintext configuration files.',
29
+ 'key.placeholder_has': '••••••••••••••••••••••••',
30
+ 'key.placeholder_empty': 'Paste ClinePass key (cline_...)',
31
+ 'key.show': 'Show',
32
+ 'key.hide': 'Hide',
33
+ 'key.save': 'Save Key',
34
+ 'key.saving': 'Saving…',
35
+ 'key.env_label': 'Credential environment name: ',
36
+ 'key.get_key': 'Get key in app.cline.bot console ↗',
37
+ 'key.saved_msg': 'Key saved to DSH Credentials. Validation: {status}',
38
+ 'key.empty_err': 'Please enter an API key before saving',
39
+ 'quota.title': '📊 ClinePass Quota & Rate Limits',
40
+ 'quota.desc': 'Official rolling window request limits from ClinePass',
41
+ 'quota.account': 'Account: {email} · Plan: {plan}',
42
+ 'quota.refresh': 'Refresh Quotas',
43
+ 'quota.refreshing': 'Refreshing…',
44
+ 'quota.refreshed_msg': 'Subscription quotas refreshed',
45
+ 'quota.window_5h': '⏱ 5-Hour Rolling Limit',
46
+ 'quota.window_weekly': '📅 Weekly Window',
47
+ 'quota.used': '{pct}% used',
48
+ 'quota.remaining': 'Remaining: {pct}%',
49
+ 'quota.reset_at': 'Reset: {time}',
50
+ 'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
51
+ 'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker.',
52
+ 'models.sync': '🔄 Check Plan Models',
53
+ 'models.syncing': 'Checking…',
54
+ 'models.synced_msg': 'Synchronized subscription models: {total} ({discovered} discovered from ClinePass)',
55
+ 'models.all': 'All',
56
+ 'models.vision': 'Vision Only',
57
+ 'models.coding': 'Coding Only',
58
+ 'models.recommended': 'Recommended',
59
+ 'models.th_active': 'On',
60
+ 'models.th_name': 'Model Name',
61
+ 'models.th_id': 'Model ID',
62
+ 'models.th_ctx': 'Context',
63
+ 'models.th_caps': 'Capabilities',
64
+ 'models.star': 'Star',
65
+ 'models.auto_new': 'New',
66
+ 'stats.title': '📈 Current DSH Session Telemetry',
67
+ 'stats.desc': 'Local monitoring of requests, network latency, and estimated token usage via ClinePass.',
68
+ 'stats.requests': 'Successful / Total Requests',
69
+ 'stats.tokens': 'Estimated Session Tokens',
70
+ 'stats.latency': 'Last Request Latency',
71
+ 'stats.last_req': 'Last Request Time',
72
+ 'diag.title': '⚡ Diagnostics & Auto-Registration',
73
+ 'diag.desc': 'ClineBot registers into DSH Models automatically when key is configured. You can run connection smoke test or force re-sync.',
74
+ 'diag.smoke_btn': 'Run Smoke Test (Ping)',
75
+ 'diag.smoke_testing': 'Testing…',
76
+ 'diag.smoke_ok': 'Smoke test passed! Latency: {latency} ms',
77
+ 'diag.resync_btn': 'Force Re-sync to DSH',
78
+ 'diag.resyncing': 'Syncing…',
79
+ 'diag.resynced_msg': 'Provider re-synced to DSH Models ({count} models)',
80
+ 'diag.unregister_btn': 'Unregister from DSH',
81
+ 'diag.unregistering': 'Removing…',
82
+ 'diag.unregistered_msg': 'Provider removed from DSH Models',
18
83
  }
19
84
 
20
85
  const ru = {
21
86
  title: 'ClineBot',
22
87
  subtitle: 'Подписка ClinePass ($9.99/мес) с каталогом проверенных моделей.',
23
88
  'settings.loading': 'Загрузка настроек ClineBot…',
89
+ 'settings.retry': 'Повторить попытку',
24
90
  'settings.unavailable': 'Настройки ClineBot недоступны (пространство хоста ещё не готово).',
91
+ 'header.title': 'Провайдер ClineBot (ClinePass)',
92
+ 'header.sub': 'Подключение моделей по подписке ClinePass ($9.99/мес). OpenAI API совместимый, автоматическое отслеживание лимитов и моментальная авторегистрация в DSH.',
93
+ 'badge.online': 'Сервер доступен ({latency} мс)',
94
+ 'badge.offline': 'Недоступен',
95
+ 'badge.key_ok': 'Ключ ✓ ({source})',
96
+ 'badge.key_missing': 'Ключ отсутствует',
97
+ 'badge.registered': 'Зарегистрирован в DSH ({count} мод.)',
98
+ 'badge.not_registered': 'Не зарегистрирован',
99
+ 'key.title': '🔑 Авторизация и API-ключ',
100
+ 'key.desc': 'Ключ сохраняется в системное хранилище DSH Credentials (~/.dsh/.credentials.yaml) и никогда не попадает в открытые файлы настроек.',
101
+ 'key.placeholder_has': '••••••••••••••••••••••••',
102
+ 'key.placeholder_empty': 'Вставьте ключ ClinePass (cline_...)',
103
+ 'key.show': 'Показать',
104
+ 'key.hide': 'Скрыть',
105
+ 'key.save': 'Сохранить ключ',
106
+ 'key.saving': 'Сохранение…',
107
+ 'key.env_label': 'Переменная учётных данных: ',
108
+ 'key.get_key': 'Получить ключ в консоли app.cline.bot ↗',
109
+ 'key.saved_msg': 'Ключ сохранён в DSH Credentials. Валидация: {status}',
110
+ 'key.empty_err': 'Введите API-ключ перед сохранением',
111
+ 'quota.title': '📊 Остаток лимитов подписки (ClinePass Quota)',
112
+ 'quota.desc': 'Официальные лимиты скользящих окон запросов ClinePass',
113
+ 'quota.account': 'Аккаунт: {email} · Тариф: {plan}',
114
+ 'quota.refresh': 'Обновить квоты',
115
+ 'quota.refreshing': 'Обновление…',
116
+ 'quota.refreshed_msg': 'Лимиты подписки обновлены',
117
+ 'quota.window_5h': '⏱ 5-часовое скользящее окно (Rolling Limit)',
118
+ 'quota.window_weekly': '📅 Недельное окно (Weekly Window)',
119
+ 'quota.used': '{pct}% использовано',
120
+ 'quota.remaining': 'Осталось: {pct}%',
121
+ 'quota.reset_at': 'Сброс: {time}',
122
+ 'models.title': '🎯 Модели в пикере DSH ({enabled} из {total} включено)',
123
+ 'models.desc': 'Список синхронизируется напрямую из подписки ClinePass. Новые модели появляются автоматически. Снимите галочку, чтобы скрыть ненужную модель из чата.',
124
+ 'models.sync': '🔄 Проверить модели плана',
125
+ 'models.syncing': 'Проверка…',
126
+ 'models.synced_msg': 'Синхронизировано моделей подписки: {total} ({discovered} получено напрямую из плана)',
127
+ 'models.all': 'Все',
128
+ 'models.vision': 'Только Vision',
129
+ 'models.coding': 'Только Кодинг',
130
+ 'models.recommended': 'Рекомендованные',
131
+ 'models.th_active': 'Вкл',
132
+ 'models.th_name': 'Название модели',
133
+ 'models.th_id': 'Model ID',
134
+ 'models.th_ctx': 'Контекст',
135
+ 'models.th_caps': 'Возможности',
136
+ 'models.star': 'Star',
137
+ 'models.auto_new': 'Новая',
138
+ 'stats.title': '📈 Статистика текущей сессии DSH',
139
+ 'stats.desc': 'Локальный мониторинг запросов, задержек сети и ориентировочного расхода токенов через ClinePass.',
140
+ 'stats.requests': 'Успешных запросов / Всего',
141
+ 'stats.tokens': 'Оценка токенов сессии',
142
+ 'stats.latency': 'Последняя задержка (Latency)',
143
+ 'stats.last_req': 'Время последнего запроса',
144
+ 'diag.title': '⚡ Диагностика и авторегистрация в DSH',
145
+ 'diag.desc': 'ClineBot автоматически регистрируется в DSH Models при наличии ключа. Вы можете запустить проверку связи или принудительно пересинхронизировать модели.',
146
+ 'diag.smoke_btn': 'Запустить Smoke Test (Ping)',
147
+ 'diag.smoke_testing': 'Тестирование…',
148
+ 'diag.smoke_ok': 'Smoke тест пройден! Задержка: {latency} мс',
149
+ 'diag.resync_btn': 'Принудительно обновить в DSH',
150
+ 'diag.resyncing': 'Обновление…',
151
+ 'diag.resynced_msg': 'Провайдер обновлен в DSH Models ({count} моделей)',
152
+ 'diag.unregister_btn': 'Удалить из DSH Models',
153
+ 'diag.unregistering': 'Удаление…',
154
+ 'diag.unregistered_msg': 'Провайдер удален из DSH Models',
25
155
  }
26
156
 
27
157
  function makeT(dict, fallback) {
@@ -85,7 +215,7 @@ window.__ModuleLoader__.load({
85
215
  .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88;visibility:visible !important}
86
216
  .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}
87
217
  .cb-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12) !important;border-color:rgba(239,68,68,0.5)}
88
- .cb-btn:disabled{opacity:0.5;cursor:not-allowed}
218
+ .cb-btn-disabled{opacity:0.5;cursor:not-allowed}
89
219
 
90
220
  .cb-bar-container{display:flex;flex-direction:column;gap:6px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
91
221
  .cb-bar-head{display:flex;justify-content:space-between;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
@@ -111,7 +241,7 @@ window.__ModuleLoader__.load({
111
241
  document.head.appendChild(style)
112
242
  }
113
243
 
114
- function ProgressBar({ label, percentUsed, remainingPercent, resetsAt }) {
244
+ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
115
245
  const used = Math.max(0, Math.min(100, percentUsed || 0))
116
246
  let fillColor = 'var(--dsw-alias-state-success-primary)'
117
247
  if (used > 75) fillColor = 'var(--dsw-alias-state-warning-primary)'
@@ -126,7 +256,7 @@ window.__ModuleLoader__.load({
126
256
  'div',
127
257
  { className: 'cb-bar-head' },
128
258
  React.createElement('span', null, label),
129
- React.createElement('span', { style: { fontWeight: 600 } }, `${used}% использовано`)
259
+ React.createElement('span', { style: { fontWeight: 600 } }, t('quota.used', { pct: used }))
130
260
  ),
131
261
  React.createElement(
132
262
  'div',
@@ -139,8 +269,8 @@ window.__ModuleLoader__.load({
139
269
  React.createElement(
140
270
  'div',
141
271
  { className: 'cb-bar-meta' },
142
- React.createElement('span', null, `Осталось: ${remainingPercent ?? (100 - used)}%`),
143
- React.createElement('span', null, `Сброс: ${resetStr}`)
272
+ React.createElement('span', null, t('quota.remaining', { pct: remainingPercent ?? (100 - used) })),
273
+ React.createElement('span', null, t('quota.reset_at', { time: resetStr }))
144
274
  )
145
275
  )
146
276
  }
@@ -192,7 +322,7 @@ window.__ModuleLoader__.load({
192
322
  // Save Key handler
193
323
  async function handleSaveKey() {
194
324
  if (!apiKeyInput.trim()) {
195
- setErr('Введите API-ключ перед сохранением')
325
+ setErr(t('key.empty_err'))
196
326
  return
197
327
  }
198
328
  setBusy('save-key')
@@ -209,7 +339,7 @@ window.__ModuleLoader__.load({
209
339
  })
210
340
  const data = await res.json().catch(() => ({}))
211
341
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
212
- setMsg(`Ключ сохранён в DSH Credentials (~/.dsh/.credentials.yaml). Валидация: ${data.validated ? 'Успешно' : 'Ошибка'}`)
342
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Error' }))
213
343
  setApiKeyInput('')
214
344
  await load()
215
345
  } catch (e) {
@@ -229,7 +359,7 @@ window.__ModuleLoader__.load({
229
359
  const data = await res.json().catch(() => ({}))
230
360
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
231
361
  setStatus((prev) => ({ ...prev, usage: data }))
232
- setMsg('Лимиты подписки обновлены')
362
+ setMsg(t('quota.refreshed_msg'))
233
363
  } catch (e) {
234
364
  setErr(String(e.message || e))
235
365
  } finally {
@@ -246,7 +376,7 @@ window.__ModuleLoader__.load({
246
376
  const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
247
377
  const data = await res.json().catch(() => ({}))
248
378
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
249
- setMsg(`Синхронизировано моделей подписки: ${data.totalModelsCount} (${data.discoveredCount} получено напрямую из плана ClinePass)`)
379
+ setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
250
380
  await load()
251
381
  } catch (e) {
252
382
  setErr(String(e.message || e))
@@ -255,7 +385,7 @@ window.__ModuleLoader__.load({
255
385
  }
256
386
  }
257
387
 
258
- // Register/Sync Provider in DSH
388
+ // Register/Force Re-sync Provider in DSH
259
389
  async function handleRegister() {
260
390
  setBusy('register')
261
391
  setErr('')
@@ -268,7 +398,7 @@ window.__ModuleLoader__.load({
268
398
  })
269
399
  const data = await res.json().catch(() => ({}))
270
400
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
271
- setMsg(`Провайдер успешно зарегистрирован в DSH Models (${draft.enabledModels?.length || 0} моделей)`)
401
+ setMsg(t('diag.resynced_msg', { count: draft.enabledModels?.length || 0 }))
272
402
  await load()
273
403
  } catch (e) {
274
404
  setErr(String(e.message || e))
@@ -286,7 +416,7 @@ window.__ModuleLoader__.load({
286
416
  const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
287
417
  const data = await res.json().catch(() => ({}))
288
418
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
289
- setMsg('Провайдер удален из DSH Models')
419
+ setMsg(t('diag.unregistered_msg'))
290
420
  await load()
291
421
  } catch (e) {
292
422
  setErr(String(e.message || e))
@@ -310,7 +440,7 @@ window.__ModuleLoader__.load({
310
440
  const data = await res.json().catch(() => ({}))
311
441
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
312
442
  setSmokeResult(data)
313
- setMsg(`Smoke тест пройден! Задержка: ${data.latencyMs} мс`)
443
+ setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
314
444
  await load()
315
445
  } catch (e) {
316
446
  setErr(String(e.message || e))
@@ -319,26 +449,31 @@ window.__ModuleLoader__.load({
319
449
  }
320
450
  }
321
451
 
322
- // Toggle model inclusion
452
+ // Toggle model exclusion (disabledModels logic)
323
453
  async function handleToggleModel(id) {
324
454
  if (!draft) return
325
- const current = new Set(draft.enabledModels || [])
326
- if (current.has(id)) {
327
- current.delete(id)
455
+ const currentDisabled = new Set(draft.disabledModels || [])
456
+ if (currentDisabled.has(id)) {
457
+ currentDisabled.delete(id) // Re-enable
328
458
  } else {
329
- current.add(id)
459
+ currentDisabled.add(id) // Disable
330
460
  }
331
- const nextList = Array.from(current)
332
- setDraft({ ...draft, enabledModels: nextList })
461
+ const nextDisabled = Array.from(currentDisabled)
462
+
463
+ // Compute next enabled models
464
+ const allIds = (status?.availableModels || []).map((m) => m.id)
465
+ const nextEnabled = allIds.filter((mId) => !currentDisabled.has(mId))
466
+
467
+ setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
333
468
 
334
469
  if (scope && snapshotStatus === 'ready') {
335
- try { await scope.set('enabledModels', nextList) } catch (_) {}
470
+ try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
336
471
  }
337
472
  try {
338
473
  await fetch(`${ROUTE_PREFIX}/models/toggle`, {
339
474
  method: 'POST',
340
475
  headers: { 'Content-Type': 'application/json' },
341
- body: JSON.stringify({ enabledModels: nextList }),
476
+ body: JSON.stringify({ disabledModels: nextDisabled }),
342
477
  })
343
478
  } catch {}
344
479
  }
@@ -346,26 +481,30 @@ window.__ModuleLoader__.load({
346
481
  // Select all / filter models
347
482
  async function handleSetModelsFilter(type) {
348
483
  if (!status?.availableModels) return
349
- let nextList = []
484
+ const all = status.availableModels
485
+ let allowed = new Set()
350
486
  if (type === 'all') {
351
- nextList = status.availableModels.map((m) => m.id)
487
+ allowed = new Set(all.map((m) => m.id))
352
488
  } else if (type === 'vision') {
353
- nextList = status.availableModels.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id)
489
+ allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
354
490
  } else if (type === 'coding') {
355
- nextList = status.availableModels.filter((m) => m.category === 'coding').map((m) => m.id)
491
+ allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
356
492
  } else if (type === 'recommended') {
357
- nextList = status.availableModels.filter((m) => m.recommended).map((m) => m.id)
493
+ allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
358
494
  }
359
495
 
360
- setDraft({ ...draft, enabledModels: nextList })
496
+ const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
497
+ const nextEnabled = Array.from(allowed)
498
+
499
+ setDraft({ ...draft, disabledModels: nextDisabled, enabledModels: nextEnabled })
361
500
  if (scope && snapshotStatus === 'ready') {
362
- try { await scope.set('enabledModels', nextList) } catch (_) {}
501
+ try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
363
502
  }
364
503
  try {
365
504
  await fetch(`${ROUTE_PREFIX}/models/toggle`, {
366
505
  method: 'POST',
367
506
  headers: { 'Content-Type': 'application/json' },
368
- body: JSON.stringify({ enabledModels: nextList }),
507
+ body: JSON.stringify({ disabledModels: nextDisabled }),
369
508
  })
370
509
  } catch {}
371
510
  }
@@ -383,30 +522,31 @@ window.__ModuleLoader__.load({
383
522
  return React.createElement(
384
523
  'div',
385
524
  { className: 'cb-page' },
386
- React.createElement('div', { className: 'cb-alert cb-alert-err' }, `Ошибка загрузки настроек ClineBot: ${err}`),
525
+ React.createElement('div', { className: 'cb-alert cb-alert-err' }, `${t('settings.loading')}: ${err}`),
387
526
  React.createElement(
388
527
  'button',
389
528
  {
390
529
  type: 'button',
391
- className: 'cb-btn cb-btn-secondary',
530
+ className: 'cb-btn',
392
531
  style: { marginTop: '12px', alignSelf: 'flex-start' },
393
532
  onClick: () => {
394
533
  setErr('')
395
534
  load().catch((e) => setErr(String(e.message || e)))
396
535
  },
397
536
  },
398
- 'Повторить попытку'
537
+ t('settings.retry')
399
538
  )
400
539
  )
401
540
  }
402
- return React.createElement('div', { className: 'cb-page' }, 'Загрузка настроек ClineBot…')
541
+ return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
403
542
  }
404
543
 
405
544
  const healthOk = !!status.health?.ok
406
545
  const keyPresent = !!status.key?.present
407
546
  const isRegistered = !!status.isRegistered
408
547
  const modelsList = status.availableModels || []
409
- const enabledSet = new Set(draft.enabledModels || [])
548
+ const disabledSet = new Set(draft.disabledModels || [])
549
+ const enabledCount = modelsList.filter((m) => !disabledSet.has(m.id)).length
410
550
  const usage = status.usage
411
551
 
412
552
  return React.createElement(
@@ -420,27 +560,27 @@ window.__ModuleLoader__.load({
420
560
  React.createElement(
421
561
  'div',
422
562
  { className: 'cb-page-title' },
423
- '🤖 ClineBot (ClinePass) Provider',
563
+ `🤖 ${t('header.title')}`,
424
564
  React.createElement(
425
565
  'span',
426
566
  { className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
427
- healthOk ? `Сервер доступен (${status.health?.latencyMs} мс)` : 'Недоступен'
567
+ healthOk ? t('badge.online', { latency: status.health?.latencyMs }) : t('badge.offline')
428
568
  ),
429
569
  React.createElement(
430
570
  'span',
431
571
  { className: `cb-badge ${keyPresent ? 'cb-badge-ok' : 'cb-badge-warn'}` },
432
- keyPresent ? `Ключ ✓ (${status.key?.source})` : 'Ключ отсутствует'
572
+ keyPresent ? t('badge.key_ok', { source: status.key?.source }) : t('badge.key_missing')
433
573
  ),
434
574
  React.createElement(
435
575
  'span',
436
576
  { className: `cb-badge ${isRegistered ? 'cb-badge-ok' : 'cb-badge-warn'}` },
437
- isRegistered ? 'Зарегистрирован в DSH' : 'Не зарегистрирован'
577
+ isRegistered ? t('badge.registered', { count: enabledCount }) : t('badge.not_registered')
438
578
  )
439
579
  ),
440
580
  React.createElement(
441
581
  'div',
442
582
  { className: 'cb-page-sub' },
443
- 'Подключение моделей по подписке ClinePass ($9.99/мес). Провайдер совместим с OpenAI API, автоматически отслеживает скользящие лимиты и регистрирует проверенный каталог моделей в DSH.'
583
+ t('header.sub')
444
584
  )
445
585
  ),
446
586
 
@@ -455,12 +595,12 @@ window.__ModuleLoader__.load({
455
595
  React.createElement(
456
596
  'div',
457
597
  { className: 'cb-section-title' },
458
- '🔑 Авторизация и API-ключ'
598
+ t('key.title')
459
599
  ),
460
600
  React.createElement(
461
601
  'div',
462
602
  { className: 'cb-section-desc' },
463
- 'Ключ сохраняется в системное хранилище DSH Credentials (~/.dsh/.credentials.yaml) и никогда не попадает в открытые файлы настроек.'
603
+ t('key.desc')
464
604
  ),
465
605
  React.createElement(
466
606
  'div',
@@ -468,7 +608,7 @@ window.__ModuleLoader__.load({
468
608
  React.createElement('input', {
469
609
  className: 'cb-input',
470
610
  type: showKey ? 'text' : 'password',
471
- placeholder: keyPresent ? '••••••••••••••••••••••••' : 'Вставьте ключ ClinePass (cline_...)',
611
+ placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
472
612
  value: apiKeyInput,
473
613
  onChange: (e) => setApiKeyInput(e.target.value),
474
614
  }),
@@ -479,7 +619,7 @@ window.__ModuleLoader__.load({
479
619
  className: 'cb-btn',
480
620
  onClick: () => setShowKey((v) => !v),
481
621
  },
482
- showKey ? 'Скрыть' : 'Показать'
622
+ showKey ? t('key.hide') : t('key.show')
483
623
  ),
484
624
  React.createElement(
485
625
  'button',
@@ -489,13 +629,13 @@ window.__ModuleLoader__.load({
489
629
  disabled: !!busy || !apiKeyInput.trim(),
490
630
  onClick: handleSaveKey,
491
631
  },
492
- busy === 'save-key' ? 'Сохранение…' : 'Сохранить ключ'
632
+ busy === 'save-key' ? t('key.saving') : t('key.save')
493
633
  )
494
634
  ),
495
635
  React.createElement(
496
636
  'div',
497
637
  { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
498
- React.createElement('span', null, `Переменная учётных данных: `),
638
+ React.createElement('span', null, t('key.env_label')),
499
639
  React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
500
640
  React.createElement('span', null, ` · `),
501
641
  React.createElement(
@@ -506,7 +646,7 @@ window.__ModuleLoader__.load({
506
646
  rel: 'noreferrer',
507
647
  style: { color: 'var(--dsw-alias-state-brand-primary)' },
508
648
  },
509
- 'Получить ключ в консоли app.cline.bot ↗'
649
+ t('key.get_key')
510
650
  )
511
651
  )
512
652
  ),
@@ -525,7 +665,7 @@ window.__ModuleLoader__.load({
525
665
  ? React.createElement(
526
666
  'div',
527
667
  { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
528
- `Ожидаемое время сброса лимита: ${new Date(status.quotaWarning.resetsAt).toLocaleTimeString()}`
668
+ t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
529
669
  )
530
670
  : null
531
671
  )
@@ -540,7 +680,7 @@ window.__ModuleLoader__.load({
540
680
  React.createElement(
541
681
  'div',
542
682
  { className: 'cb-section-title' },
543
- '📊 Остаток лимитов подписки (ClinePass Quota)',
683
+ t('quota.title'),
544
684
  React.createElement(
545
685
  'button',
546
686
  {
@@ -549,43 +689,45 @@ window.__ModuleLoader__.load({
549
689
  disabled: !!busy,
550
690
  onClick: handleRefreshQuota,
551
691
  },
552
- busy === 'refresh-quota' ? 'Обновление…' : 'Обновить квоты'
692
+ busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
553
693
  )
554
694
  ),
555
695
  React.createElement(
556
696
  'div',
557
697
  { className: 'cb-section-desc' },
558
698
  usage?.user?.email
559
- ? `Аккаунт: ${usage.user.email} · Тариф: ${usage.plan || 'ClinePass ($9.99/mo)'}`
560
- : 'Официальные лимиты скользящих окон запросов ClinePass'
699
+ ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
700
+ : t('quota.desc')
561
701
  ),
562
702
  React.createElement(
563
703
  'div',
564
704
  { className: 'cb-grid-2' },
565
705
  React.createElement(ProgressBar, {
566
- label: '⏱ 5-часовое скользящее окно (Rolling Limit)',
706
+ label: t('quota.window_5h'),
567
707
  percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
568
708
  remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
569
709
  resetsAt: usage?.windows?.fiveHour?.resetsAt,
710
+ t,
570
711
  }),
571
712
  React.createElement(ProgressBar, {
572
- label: '📅 Недельное окно (Weekly Window)',
713
+ label: t('quota.window_weekly'),
573
714
  percentUsed: usage?.windows?.weekly?.percentUsed || 0,
574
715
  remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
575
716
  resetsAt: usage?.windows?.weekly?.resetsAt,
717
+ t,
576
718
  })
577
719
  )
578
720
  )
579
721
  : null,
580
722
 
581
- // Card 3: Model Picker Management with Official Plan Sync
723
+ // Card 3: Model Picker Management with Official Plan Sync & Auto-Enable
582
724
  React.createElement(
583
725
  'div',
584
726
  { className: 'cb-section-card' },
585
727
  React.createElement(
586
728
  'div',
587
729
  { className: 'cb-section-title' },
588
- `🎯 Модели в пикере DSH (${enabledSet.size} из ${modelsList.length} включено)`,
730
+ t('models.title', { enabled: enabledCount, total: modelsList.length }),
589
731
  React.createElement(
590
732
  'div',
591
733
  { className: 'cb-row' },
@@ -597,34 +739,34 @@ window.__ModuleLoader__.load({
597
739
  disabled: !!busy || !keyPresent,
598
740
  onClick: handleSyncPlanModels,
599
741
  },
600
- busy === 'sync-models' ? 'Синхронизация…' : '🔄 Получить модели из плана'
742
+ busy === 'sync-models' ? t('models.syncing') : t('models.sync')
601
743
  ),
602
744
  React.createElement(
603
745
  'button',
604
746
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') },
605
- 'Все'
747
+ t('models.all')
606
748
  ),
607
749
  React.createElement(
608
750
  'button',
609
751
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') },
610
- 'Только Vision'
752
+ t('models.vision')
611
753
  ),
612
754
  React.createElement(
613
755
  'button',
614
756
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') },
615
- 'Только Кодинг'
757
+ t('models.coding')
616
758
  ),
617
759
  React.createElement(
618
760
  'button',
619
761
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('recommended') },
620
- 'Рекомендованные'
762
+ t('models.recommended')
621
763
  )
622
764
  )
623
765
  ),
624
766
  React.createElement(
625
767
  'div',
626
768
  { className: 'cb-section-desc' },
627
- 'Список формируется напрямую из официальной подписки ClinePass без ручного ввода. Отметьте модели, которые должны отображаться в выпадающем списке выбора моделей в чате DeepSeek Harness.'
769
+ t('models.desc')
628
770
  ),
629
771
  React.createElement(
630
772
  'table',
@@ -635,18 +777,19 @@ window.__ModuleLoader__.load({
635
777
  React.createElement(
636
778
  'tr',
637
779
  null,
638
- React.createElement('th', { style: { width: '40px' } }, 'Вкл'),
639
- React.createElement('th', null, 'Название модели'),
640
- React.createElement('th', null, 'Model ID'),
641
- React.createElement('th', null, 'Контекст'),
642
- React.createElement('th', null, 'Возможности')
780
+ React.createElement('th', { style: { width: '40px' } }, t('models.th_active')),
781
+ React.createElement('th', null, t('models.th_name')),
782
+ React.createElement('th', null, t('models.th_id')),
783
+ React.createElement('th', null, t('models.th_ctx')),
784
+ React.createElement('th', null, t('models.th_caps'))
643
785
  )
644
786
  ),
645
787
  React.createElement(
646
788
  'tbody',
647
789
  null,
648
- modelsList.map((m) =>
649
- React.createElement(
790
+ modelsList.map((m) => {
791
+ const isEnabled = !disabledSet.has(m.id)
792
+ return React.createElement(
650
793
  'tr',
651
794
  { key: m.id },
652
795
  React.createElement(
@@ -654,7 +797,7 @@ window.__ModuleLoader__.load({
654
797
  null,
655
798
  React.createElement('input', {
656
799
  type: 'checkbox',
657
- checked: enabledSet.has(m.id),
800
+ checked: isEnabled,
658
801
  onChange: () => handleToggleModel(m.id),
659
802
  })
660
803
  ),
@@ -663,7 +806,10 @@ window.__ModuleLoader__.load({
663
806
  null,
664
807
  React.createElement('strong', null, m.name),
665
808
  m.recommended
666
- ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, 'Star')
809
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: '6px' } }, t('models.star'))
810
+ : null,
811
+ m.isCustom
812
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: '6px', opacity: 0.8 } }, t('models.auto_new'))
667
813
  : null
668
814
  ),
669
815
  React.createElement('td', null, React.createElement('code', null, m.id)),
@@ -677,7 +823,7 @@ window.__ModuleLoader__.load({
677
823
  : null
678
824
  )
679
825
  )
680
- )
826
+ })
681
827
  )
682
828
  )
683
829
  ),
@@ -689,12 +835,12 @@ window.__ModuleLoader__.load({
689
835
  React.createElement(
690
836
  'div',
691
837
  { className: 'cb-section-title' },
692
- '📈 Статистика текущей сессии DSH'
838
+ t('stats.title')
693
839
  ),
694
840
  React.createElement(
695
841
  'div',
696
842
  { className: 'cb-section-desc' },
697
- 'Локальный мониторинг запросов, задержек сети и ориентировочного расхода токенов через ClinePass.'
843
+ t('stats.desc')
698
844
  ),
699
845
  React.createElement(
700
846
  'div',
@@ -703,25 +849,25 @@ window.__ModuleLoader__.load({
703
849
  'div',
704
850
  { className: 'cb-stat-box' },
705
851
  React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
706
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Успешных запросов / Всего')
852
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
707
853
  ),
708
854
  React.createElement(
709
855
  'div',
710
856
  { className: 'cb-stat-box' },
711
857
  React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
712
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Оценка токенов сессии')
858
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
713
859
  ),
714
860
  React.createElement(
715
861
  'div',
716
862
  { className: 'cb-stat-box' },
717
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} мс` : '—'),
718
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Последняя задержка (Latency)')
863
+ React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} ms` : '—'),
864
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.latency'))
719
865
  ),
720
866
  React.createElement(
721
867
  'div',
722
868
  { className: 'cb-stat-box' },
723
869
  React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
724
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Время последнего запроса')
870
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
725
871
  )
726
872
  )
727
873
  ),
@@ -733,12 +879,12 @@ window.__ModuleLoader__.load({
733
879
  React.createElement(
734
880
  'div',
735
881
  { className: 'cb-section-title' },
736
- '⚡ Диагностика и регистрация в DSH Models'
882
+ t('diag.title')
737
883
  ),
738
884
  React.createElement(
739
885
  'div',
740
886
  { className: 'cb-section-desc' },
741
- 'Проверьте связь с эндпоинтом и синхронизируйте активный список моделей с ядром DeepSeek Harness.'
887
+ t('diag.desc')
742
888
  ),
743
889
  React.createElement(
744
890
  'div',
@@ -751,7 +897,7 @@ window.__ModuleLoader__.load({
751
897
  disabled: !!busy || !keyPresent,
752
898
  onClick: handleSmoke,
753
899
  },
754
- busy === 'smoke' ? 'Тестирование…' : 'Запустить Smoke Test (Ping)'
900
+ busy === 'smoke' ? t('diag.smoke_testing') : t('diag.smoke_btn')
755
901
  ),
756
902
  isRegistered
757
903
  ? React.createElement(
@@ -762,7 +908,7 @@ window.__ModuleLoader__.load({
762
908
  disabled: !!busy,
763
909
  onClick: handleUnregister,
764
910
  },
765
- busy === 'unregister' ? 'Удаление…' : 'Удалить из DSH Models'
911
+ busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
766
912
  )
767
913
  : null,
768
914
  React.createElement(
@@ -774,14 +920,14 @@ window.__ModuleLoader__.load({
774
920
  disabled: !!busy,
775
921
  onClick: handleRegister,
776
922
  },
777
- busy === 'register' ? 'Синхронизация…' : 'Синхронизировать с DSH Models'
923
+ busy === 'register' ? t('diag.resyncing') : t('diag.resync_btn')
778
924
  )
779
925
  ),
780
926
  smokeResult
781
927
  ? React.createElement(
782
928
  'div',
783
929
  { className: 'cb-preview' },
784
- `✅ Задержка ответа: ${smokeResult.latencyMs} мс | Модель: ${smokeResult.model}\nОтвет: ${smokeResult.preview || '(пустой ответ)'}`
930
+ `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}\nResponse: ${smokeResult.preview || '(empty)'}`
785
931
  )
786
932
  : null
787
933
  )
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  PROVIDER_DISPLAY_NAME,
9
9
  getAllModels,
10
10
  getDefaultModelIds,
11
+ getActiveModelIds,
11
12
  parsePlanIncludedModels,
12
13
  } from './models.js'
13
14
  import {
@@ -44,8 +45,10 @@ export const Config = z.object({
44
45
  .description('Credential / env name containing the ClinePass API key (never store key directly here).'),
45
46
  defaultModel: z.string().default(DEFAULT_MODEL_ID)
46
47
  .description('Default model ID for chat and smoke tests.'),
47
- enabledModels: z.array(z.string()).default(getDefaultModelIds())
48
- .description('List of model IDs enabled for selection in DSH.'),
48
+ disabledModels: z.array(z.string()).default([])
49
+ .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
50
+ enabledModels: z.array(z.string()).default([])
51
+ .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
49
52
  dynamicModels: z.array(z.object({
50
53
  id: z.string(),
51
54
  name: z.string(),
@@ -65,16 +68,26 @@ export const Config = z.object({
65
68
 
66
69
  function publicConfig(cfg) {
67
70
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
68
- const allDefaultIds = getDefaultModelIds(dynamic)
71
+ const allModels = getAllModels(dynamic)
72
+ const allDefaultIds = allModels.map((m) => m.id)
73
+
74
+ // Migration / compatibility: if disabledModels was not yet set, but enabledModels was provided
75
+ let disabledList = Array.isArray(cfg?.disabledModels) ? cfg.disabledModels : []
76
+ if (!Array.isArray(cfg?.disabledModels) || (cfg.disabledModels.length === 0 && Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length > 0)) {
77
+ const enabledSet = new Set(cfg.enabledModels)
78
+ disabledList = allDefaultIds.filter((id) => !enabledSet.has(id))
79
+ }
80
+
81
+ const activeIds = getActiveModelIds(allDefaultIds, disabledList)
82
+
69
83
  return {
70
84
  enabled: !!cfg?.enabled,
71
85
  baseUrl: normalizeBaseUrl(cfg?.baseUrl),
72
86
  apiKeyEnv: cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV,
73
87
  defaultModel: cfg?.defaultModel || DEFAULT_MODEL_ID,
74
88
  dynamicModels: dynamic,
75
- enabledModels: Array.isArray(cfg?.enabledModels) && cfg.enabledModels.length
76
- ? cfg.enabledModels
77
- : allDefaultIds,
89
+ disabledModels: disabledList,
90
+ enabledModels: activeIds,
78
91
  timeoutMs: Number(cfg?.timeoutMs) || DEFAULT_TIMEOUT_MS,
79
92
  smokeTimeoutMs: Number(cfg?.smokeTimeoutMs) || DEFAULT_SMOKE_TIMEOUT_MS,
80
93
  }
@@ -115,13 +128,16 @@ async function checkRegisteredInPiAi(ctx) {
115
128
  async function buildStatus(ctx, cfg) {
116
129
  const pub = publicConfig(cfg)
117
130
  const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
118
- const health = await probeHealth(pub.baseUrl, { timeoutMs: pub.timeoutMs })
131
+ // Non-blocking quick health probe with low timeout so settings page loads instantly
132
+ const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
133
+ const health = await probeHealth(pub.baseUrl, { timeoutMs: probeTimeout })
119
134
  const isRegistered = await checkRegisteredInPiAi(ctx)
120
135
  const allModels = getAllModels(pub.dynamicModels)
121
136
 
122
137
  let usage = null
123
138
  if (key.value) {
124
- usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: pub.timeoutMs }).catch(() => null)
139
+ // Uses 60s cache; if cache miss, times out quickly
140
+ usage = await fetchUsageLimits(pub.baseUrl, key.value, { timeoutMs: probeTimeout }).catch(() => null)
125
141
  }
126
142
 
127
143
  // Evaluate warning state
@@ -162,7 +178,7 @@ async function buildStatus(ctx, cfg) {
162
178
  }
163
179
  }
164
180
 
165
- async function upsertPiAiProvider(ctx, cfg, dynamicModelIds) {
181
+ async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
166
182
  const settings = ctx?.get?.('settings')
167
183
  if (!settings?.mutate) {
168
184
  throw new Error('DSH settings service unavailable')
@@ -170,8 +186,8 @@ async function upsertPiAiProvider(ctx, cfg, dynamicModelIds) {
170
186
 
171
187
  const pub = publicConfig(cfg)
172
188
  const allModels = getAllModels(pub.dynamicModels)
173
- const selectedIds = new Set(dynamicModelIds || pub.enabledModels)
174
- const modelsToRegister = allModels.filter((m) => selectedIds.has(m.id))
189
+ const allowedSet = new Set(activeModelIds || pub.enabledModels)
190
+ const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
175
191
 
176
192
  const providerObj = buildPiAiProvider({
177
193
  baseUrl: pub.baseUrl,
@@ -218,6 +234,56 @@ export function apply(ctx, config) {
218
234
  let liveCfg = Config(structuredClone(config || {}))
219
235
  let settingsApi
220
236
 
237
+ // Declarative sync helper: auto-registers or unregisters provider based on config & key availability
238
+ const syncProviderState = async (cfg) => {
239
+ try {
240
+ const pub = publicConfig(cfg)
241
+ if (!pub.enabled) {
242
+ if (await checkRegisteredInPiAi(ctx)) {
243
+ await removePiAiProvider(ctx)
244
+ }
245
+ return
246
+ }
247
+ const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
248
+ if (key.value) {
249
+ await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
250
+ }
251
+ } catch {
252
+ /* ignore transient settings unavailable */
253
+ }
254
+ }
255
+
256
+ // Background check for subscription plan models (runs once on startup or key save)
257
+ const autoDiscoverPlanModels = async (cfg) => {
258
+ try {
259
+ const pub = publicConfig(cfg)
260
+ const key = await resolveKeyValue(ctx, pub.apiKeyEnv)
261
+ if (!key.value) return
262
+
263
+ const usageData = await fetchUsageLimits(pub.baseUrl, key.value, {
264
+ timeoutMs: Math.min(pub.timeoutMs, 5000),
265
+ bypassCache: true,
266
+ })
267
+
268
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
269
+ const existingDynamic = pub.dynamicModels || []
270
+ const existingIds = new Set(existingDynamic.map((m) => m.id))
271
+ const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
272
+
273
+ if (hasNew && settingsApi?.replace) {
274
+ const next = Config({
275
+ ...liveCfg,
276
+ dynamicModels: usageData.dynamicModels,
277
+ })
278
+ await settingsApi.replace(next)
279
+ await syncProviderState(next)
280
+ }
281
+ }
282
+ } catch {
283
+ /* best-effort discovery */
284
+ }
285
+ }
286
+
221
287
  const settingsService = ctx.get('settings')
222
288
  if (typeof settingsService?.register === 'function') {
223
289
  const scope = settingsService.register(NS, Config, { base: config })
@@ -225,11 +291,17 @@ export function apply(ctx, config) {
225
291
  liveCfg = Config(scope.get() ?? config)
226
292
  ctx.effect(() => scope.watch((next) => {
227
293
  liveCfg = Config(next ?? config)
294
+ syncProviderState(liveCfg)
228
295
  }), 'dsh-clinebot: settings')
229
296
  }
230
297
 
231
298
  const live = () => liveCfg
232
299
 
300
+ // On startup: ensure provider is synced to llm-pi-ai if key is present
301
+ syncProviderState(liveCfg)
302
+ // Background discover plan models on startup
303
+ setTimeout(() => autoDiscoverPlanModels(liveCfg), 500)
304
+
233
305
  // Web server HTTP route handlers
234
306
  if (ctx.webServer?.register) {
235
307
  // 1. GET /dsh-clinebot/status
@@ -274,6 +346,7 @@ export function apply(ctx, config) {
274
346
  try {
275
347
  const parsed = Config({ ...publicConfig(live()), ...payload })
276
348
  await settingsApi.replace(parsed)
349
+ await syncProviderState(parsed)
277
350
  writeJson(res, 200, { ok: true, config: publicConfig(live()) })
278
351
  } catch (e) {
279
352
  writeJson(res, 400, { ok: false, error: String(e?.message || e) })
@@ -304,6 +377,10 @@ export function apply(ctx, config) {
304
377
  const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
305
378
  await saveCredentialKey(ctx, targetEnvName, apiKey)
306
379
 
380
+ // Auto-sync provider to DSH Models and discover plan models
381
+ await syncProviderState(live())
382
+ autoDiscoverPlanModels(live())
383
+
307
384
  // Run validation probe with the newly saved key
308
385
  const validation = await smokeChat(pub.baseUrl, apiKey, {
309
386
  model: pub.defaultModel,
@@ -359,7 +436,8 @@ export function apply(ctx, config) {
359
436
  const bodyBuf = await readBody(req)
360
437
  let body = {}
361
438
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
362
- const result = await upsertPiAiProvider(ctx, live(), body.models)
439
+ const activeModels = body.models || publicConfig(live()).enabledModels
440
+ const result = await upsertPiAiProvider(ctx, live(), activeModels)
363
441
  writeJson(res, 200, { ok: true, provider: result })
364
442
  } catch (err) {
365
443
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
@@ -457,24 +535,14 @@ export function apply(ctx, config) {
457
535
 
458
536
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
459
537
  const allModels = getAllModels(dynamicModels)
460
- const allIds = allModels.map((m) => m.id)
461
-
462
- // Preserve currently enabled models, plus add any newly discovered ones
463
- const existingEnabled = new Set(live().enabledModels || getDefaultModelIds())
464
- for (const m of allModels) {
465
- existingEnabled.add(m.id)
466
- }
467
538
 
468
539
  if (settingsApi?.replace) {
469
540
  const next = Config({
470
541
  ...live(),
471
542
  dynamicModels,
472
- enabledModels: Array.from(existingEnabled),
473
543
  })
474
544
  await settingsApi.replace(next)
475
- if (await checkRegisteredInPiAi(ctx)) {
476
- await upsertPiAiProvider(ctx, next, Array.from(existingEnabled))
477
- }
545
+ await syncProviderState(next)
478
546
  }
479
547
 
480
548
  return writeJson(res, 200, {
@@ -490,7 +558,7 @@ export function apply(ctx, config) {
490
558
  },
491
559
  }), 'dsh-clinebot: /models/sync')
492
560
 
493
- // 9. POST /dsh-clinebot/models/toggle — toggle enabled status in picker
561
+ // 9. POST /dsh-clinebot/models/toggle — toggle disabled/enabled status in picker
494
562
  ctx.effect(() => ctx.webServer.register({
495
563
  kind: 'exact',
496
564
  path: '/dsh-clinebot/models/toggle',
@@ -504,16 +572,24 @@ export function apply(ctx, config) {
504
572
  let body = {}
505
573
  try { body = JSON.parse(bodyBuf.toString('utf8')) } catch {}
506
574
 
507
- if (Array.isArray(body.enabledModels) && settingsApi?.replace) {
508
- const patch = { enabledModels: body.enabledModels }
575
+ if (settingsApi?.replace) {
576
+ const patch = {}
577
+ if (Array.isArray(body.disabledModels)) {
578
+ patch.disabledModels = body.disabledModels
579
+ } else if (Array.isArray(body.enabledModels)) {
580
+ // Convert legacy enabledModels toggle to disabledModels
581
+ const allModels = getAllModels(live().dynamicModels)
582
+ const enabledSet = new Set(body.enabledModels)
583
+ patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
584
+ }
509
585
  if (body.defaultModel) patch.defaultModel = body.defaultModel
510
586
  const next = Config({ ...live(), ...patch })
511
587
  await settingsApi.replace(next)
512
- if (await checkRegisteredInPiAi(ctx)) {
513
- await upsertPiAiProvider(ctx, next, body.enabledModels)
514
- }
588
+ await syncProviderState(next)
589
+ writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
590
+ } else {
591
+ writeJson(res, 200, { ok: true })
515
592
  }
516
- writeJson(res, 200, { ok: true, enabledModels: body.enabledModels })
517
593
  } catch (err) {
518
594
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
519
595
  }
package/lib/models.js CHANGED
@@ -218,3 +218,14 @@ export function getDefaultModelIds(dynamicModels = []) {
218
218
  return getAllModels(dynamicModels).map((m) => m.id)
219
219
  }
220
220
 
221
+ /**
222
+ * Filter out models explicitly disabled by user.
223
+ * Any newly introduced model (not in disabledModelIds) is active by default.
224
+ */
225
+ export function getActiveModelIds(allModels = [], disabledModelIds = []) {
226
+ const disabledSet = new Set(Array.isArray(disabledModelIds) ? disabledModelIds : [])
227
+ return (Array.isArray(allModels) ? allModels : [])
228
+ .map((m) => (typeof m === 'string' ? m : m?.id))
229
+ .filter((id) => Boolean(id && !disabledSet.has(id)))
230
+ }
231
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",