@goodandready/dsh-clinebot 0.3.0 → 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,23 @@ 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
+
20
+ ## [0.3.1] - 2026-09-07
21
+
22
+ ### Fixed
23
+ - **Style Isolation Attribute**: Added `data-dsh-plugin="dsh-clinebot"` (`style.dataset.dshPlugin`) to dynamically injected CSS tag in `lib/client.js`, protecting styles from cleanup during neighbor plugin HMR and profile updates.
24
+
8
25
  ## [0.3.0] - 2026-09-05
9
26
 
10
27
  ### Added
@@ -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 })`.
@@ -18,6 +19,7 @@ The plugin consists of two runtime boundaries conforming to DSH authoring standa
18
19
  * Registers localized `en` and `ru` dictionaries via `ctx.locale.register()`.
19
20
  * Reactive binding via `ctx.settingsScope.bind({ namespace: NS })` with `useSyncExternalStore` guarding against `unavailable` / `loading` snapshot states.
20
21
  * Uses native design tokens (`--dsw-alias-...`) with full dark/light theme support.
22
+ * Injects isolated style tag tagged with `data-dsh-plugin="dsh-clinebot"`.
21
23
 
22
24
  ```mermaid
23
25
  graph LR
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) {
@@ -56,6 +186,7 @@ window.__ModuleLoader__.load({
56
186
  if (document.getElementById('dsh-clinebot-full-css')) return
57
187
  const style = document.createElement('style')
58
188
  style.id = 'dsh-clinebot-full-css'
189
+ style.dataset.dshPlugin = NS
59
190
  style.textContent = `
60
191
  .cb-page{display:flex;flex-direction:column;gap:20px;padding:8px 0 32px;max-width:960px}
61
192
  .cb-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}
@@ -84,7 +215,7 @@ window.__ModuleLoader__.load({
84
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}
85
216
  .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}
86
217
  .cb-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12) !important;border-color:rgba(239,68,68,0.5)}
87
- .cb-btn:disabled{opacity:0.5;cursor:not-allowed}
218
+ .cb-btn-disabled{opacity:0.5;cursor:not-allowed}
88
219
 
89
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)}
90
221
  .cb-bar-head{display:flex;justify-content:space-between;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
@@ -110,7 +241,7 @@ window.__ModuleLoader__.load({
110
241
  document.head.appendChild(style)
111
242
  }
112
243
 
113
- function ProgressBar({ label, percentUsed, remainingPercent, resetsAt }) {
244
+ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
114
245
  const used = Math.max(0, Math.min(100, percentUsed || 0))
115
246
  let fillColor = 'var(--dsw-alias-state-success-primary)'
116
247
  if (used > 75) fillColor = 'var(--dsw-alias-state-warning-primary)'
@@ -125,7 +256,7 @@ window.__ModuleLoader__.load({
125
256
  'div',
126
257
  { className: 'cb-bar-head' },
127
258
  React.createElement('span', null, label),
128
- React.createElement('span', { style: { fontWeight: 600 } }, `${used}% использовано`)
259
+ React.createElement('span', { style: { fontWeight: 600 } }, t('quota.used', { pct: used }))
129
260
  ),
130
261
  React.createElement(
131
262
  'div',
@@ -138,8 +269,8 @@ window.__ModuleLoader__.load({
138
269
  React.createElement(
139
270
  'div',
140
271
  { className: 'cb-bar-meta' },
141
- React.createElement('span', null, `Осталось: ${remainingPercent ?? (100 - used)}%`),
142
- 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 }))
143
274
  )
144
275
  )
145
276
  }
@@ -191,7 +322,7 @@ window.__ModuleLoader__.load({
191
322
  // Save Key handler
192
323
  async function handleSaveKey() {
193
324
  if (!apiKeyInput.trim()) {
194
- setErr('Введите API-ключ перед сохранением')
325
+ setErr(t('key.empty_err'))
195
326
  return
196
327
  }
197
328
  setBusy('save-key')
@@ -208,7 +339,7 @@ window.__ModuleLoader__.load({
208
339
  })
209
340
  const data = await res.json().catch(() => ({}))
210
341
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
211
- setMsg(`Ключ сохранён в DSH Credentials (~/.dsh/.credentials.yaml). Валидация: ${data.validated ? 'Успешно' : 'Ошибка'}`)
342
+ setMsg(t('key.saved_msg', { status: data.validated ? 'OK' : 'Error' }))
212
343
  setApiKeyInput('')
213
344
  await load()
214
345
  } catch (e) {
@@ -228,7 +359,7 @@ window.__ModuleLoader__.load({
228
359
  const data = await res.json().catch(() => ({}))
229
360
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
230
361
  setStatus((prev) => ({ ...prev, usage: data }))
231
- setMsg('Лимиты подписки обновлены')
362
+ setMsg(t('quota.refreshed_msg'))
232
363
  } catch (e) {
233
364
  setErr(String(e.message || e))
234
365
  } finally {
@@ -245,7 +376,7 @@ window.__ModuleLoader__.load({
245
376
  const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
246
377
  const data = await res.json().catch(() => ({}))
247
378
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
248
- setMsg(`Синхронизировано моделей подписки: ${data.totalModelsCount} (${data.discoveredCount} получено напрямую из плана ClinePass)`)
379
+ setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
249
380
  await load()
250
381
  } catch (e) {
251
382
  setErr(String(e.message || e))
@@ -254,7 +385,7 @@ window.__ModuleLoader__.load({
254
385
  }
255
386
  }
256
387
 
257
- // Register/Sync Provider in DSH
388
+ // Register/Force Re-sync Provider in DSH
258
389
  async function handleRegister() {
259
390
  setBusy('register')
260
391
  setErr('')
@@ -267,7 +398,7 @@ window.__ModuleLoader__.load({
267
398
  })
268
399
  const data = await res.json().catch(() => ({}))
269
400
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
270
- setMsg(`Провайдер успешно зарегистрирован в DSH Models (${draft.enabledModels?.length || 0} моделей)`)
401
+ setMsg(t('diag.resynced_msg', { count: draft.enabledModels?.length || 0 }))
271
402
  await load()
272
403
  } catch (e) {
273
404
  setErr(String(e.message || e))
@@ -285,7 +416,7 @@ window.__ModuleLoader__.load({
285
416
  const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
286
417
  const data = await res.json().catch(() => ({}))
287
418
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
288
- setMsg('Провайдер удален из DSH Models')
419
+ setMsg(t('diag.unregistered_msg'))
289
420
  await load()
290
421
  } catch (e) {
291
422
  setErr(String(e.message || e))
@@ -309,7 +440,7 @@ window.__ModuleLoader__.load({
309
440
  const data = await res.json().catch(() => ({}))
310
441
  if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
311
442
  setSmokeResult(data)
312
- setMsg(`Smoke тест пройден! Задержка: ${data.latencyMs} мс`)
443
+ setMsg(t('diag.smoke_ok', { latency: data.latencyMs }))
313
444
  await load()
314
445
  } catch (e) {
315
446
  setErr(String(e.message || e))
@@ -318,26 +449,31 @@ window.__ModuleLoader__.load({
318
449
  }
319
450
  }
320
451
 
321
- // Toggle model inclusion
452
+ // Toggle model exclusion (disabledModels logic)
322
453
  async function handleToggleModel(id) {
323
454
  if (!draft) return
324
- const current = new Set(draft.enabledModels || [])
325
- if (current.has(id)) {
326
- current.delete(id)
455
+ const currentDisabled = new Set(draft.disabledModels || [])
456
+ if (currentDisabled.has(id)) {
457
+ currentDisabled.delete(id) // Re-enable
327
458
  } else {
328
- current.add(id)
459
+ currentDisabled.add(id) // Disable
329
460
  }
330
- const nextList = Array.from(current)
331
- 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 })
332
468
 
333
469
  if (scope && snapshotStatus === 'ready') {
334
- try { await scope.set('enabledModels', nextList) } catch (_) {}
470
+ try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
335
471
  }
336
472
  try {
337
473
  await fetch(`${ROUTE_PREFIX}/models/toggle`, {
338
474
  method: 'POST',
339
475
  headers: { 'Content-Type': 'application/json' },
340
- body: JSON.stringify({ enabledModels: nextList }),
476
+ body: JSON.stringify({ disabledModels: nextDisabled }),
341
477
  })
342
478
  } catch {}
343
479
  }
@@ -345,26 +481,30 @@ window.__ModuleLoader__.load({
345
481
  // Select all / filter models
346
482
  async function handleSetModelsFilter(type) {
347
483
  if (!status?.availableModels) return
348
- let nextList = []
484
+ const all = status.availableModels
485
+ let allowed = new Set()
349
486
  if (type === 'all') {
350
- nextList = status.availableModels.map((m) => m.id)
487
+ allowed = new Set(all.map((m) => m.id))
351
488
  } else if (type === 'vision') {
352
- 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))
353
490
  } else if (type === 'coding') {
354
- 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))
355
492
  } else if (type === 'recommended') {
356
- nextList = status.availableModels.filter((m) => m.recommended).map((m) => m.id)
493
+ allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
357
494
  }
358
495
 
359
- 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 })
360
500
  if (scope && snapshotStatus === 'ready') {
361
- try { await scope.set('enabledModels', nextList) } catch (_) {}
501
+ try { await scope.set('disabledModels', nextDisabled) } catch (_) {}
362
502
  }
363
503
  try {
364
504
  await fetch(`${ROUTE_PREFIX}/models/toggle`, {
365
505
  method: 'POST',
366
506
  headers: { 'Content-Type': 'application/json' },
367
- body: JSON.stringify({ enabledModels: nextList }),
507
+ body: JSON.stringify({ disabledModels: nextDisabled }),
368
508
  })
369
509
  } catch {}
370
510
  }
@@ -382,30 +522,31 @@ window.__ModuleLoader__.load({
382
522
  return React.createElement(
383
523
  'div',
384
524
  { className: 'cb-page' },
385
- 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}`),
386
526
  React.createElement(
387
527
  'button',
388
528
  {
389
529
  type: 'button',
390
- className: 'cb-btn cb-btn-secondary',
530
+ className: 'cb-btn',
391
531
  style: { marginTop: '12px', alignSelf: 'flex-start' },
392
532
  onClick: () => {
393
533
  setErr('')
394
534
  load().catch((e) => setErr(String(e.message || e)))
395
535
  },
396
536
  },
397
- 'Повторить попытку'
537
+ t('settings.retry')
398
538
  )
399
539
  )
400
540
  }
401
- return React.createElement('div', { className: 'cb-page' }, 'Загрузка настроек ClineBot…')
541
+ return React.createElement('div', { className: 'cb-page' }, t('settings.loading'))
402
542
  }
403
543
 
404
544
  const healthOk = !!status.health?.ok
405
545
  const keyPresent = !!status.key?.present
406
546
  const isRegistered = !!status.isRegistered
407
547
  const modelsList = status.availableModels || []
408
- const enabledSet = new Set(draft.enabledModels || [])
548
+ const disabledSet = new Set(draft.disabledModels || [])
549
+ const enabledCount = modelsList.filter((m) => !disabledSet.has(m.id)).length
409
550
  const usage = status.usage
410
551
 
411
552
  return React.createElement(
@@ -419,27 +560,27 @@ window.__ModuleLoader__.load({
419
560
  React.createElement(
420
561
  'div',
421
562
  { className: 'cb-page-title' },
422
- '🤖 ClineBot (ClinePass) Provider',
563
+ `🤖 ${t('header.title')}`,
423
564
  React.createElement(
424
565
  'span',
425
566
  { className: `cb-badge ${healthOk ? 'cb-badge-ok' : 'cb-badge-bad'}` },
426
- healthOk ? `Сервер доступен (${status.health?.latencyMs} мс)` : 'Недоступен'
567
+ healthOk ? t('badge.online', { latency: status.health?.latencyMs }) : t('badge.offline')
427
568
  ),
428
569
  React.createElement(
429
570
  'span',
430
571
  { className: `cb-badge ${keyPresent ? 'cb-badge-ok' : 'cb-badge-warn'}` },
431
- keyPresent ? `Ключ ✓ (${status.key?.source})` : 'Ключ отсутствует'
572
+ keyPresent ? t('badge.key_ok', { source: status.key?.source }) : t('badge.key_missing')
432
573
  ),
433
574
  React.createElement(
434
575
  'span',
435
576
  { className: `cb-badge ${isRegistered ? 'cb-badge-ok' : 'cb-badge-warn'}` },
436
- isRegistered ? 'Зарегистрирован в DSH' : 'Не зарегистрирован'
577
+ isRegistered ? t('badge.registered', { count: enabledCount }) : t('badge.not_registered')
437
578
  )
438
579
  ),
439
580
  React.createElement(
440
581
  'div',
441
582
  { className: 'cb-page-sub' },
442
- 'Подключение моделей по подписке ClinePass ($9.99/мес). Провайдер совместим с OpenAI API, автоматически отслеживает скользящие лимиты и регистрирует проверенный каталог моделей в DSH.'
583
+ t('header.sub')
443
584
  )
444
585
  ),
445
586
 
@@ -454,12 +595,12 @@ window.__ModuleLoader__.load({
454
595
  React.createElement(
455
596
  'div',
456
597
  { className: 'cb-section-title' },
457
- '🔑 Авторизация и API-ключ'
598
+ t('key.title')
458
599
  ),
459
600
  React.createElement(
460
601
  'div',
461
602
  { className: 'cb-section-desc' },
462
- 'Ключ сохраняется в системное хранилище DSH Credentials (~/.dsh/.credentials.yaml) и никогда не попадает в открытые файлы настроек.'
603
+ t('key.desc')
463
604
  ),
464
605
  React.createElement(
465
606
  'div',
@@ -467,7 +608,7 @@ window.__ModuleLoader__.load({
467
608
  React.createElement('input', {
468
609
  className: 'cb-input',
469
610
  type: showKey ? 'text' : 'password',
470
- placeholder: keyPresent ? '••••••••••••••••••••••••' : 'Вставьте ключ ClinePass (cline_...)',
611
+ placeholder: keyPresent ? t('key.placeholder_has') : t('key.placeholder_empty'),
471
612
  value: apiKeyInput,
472
613
  onChange: (e) => setApiKeyInput(e.target.value),
473
614
  }),
@@ -478,7 +619,7 @@ window.__ModuleLoader__.load({
478
619
  className: 'cb-btn',
479
620
  onClick: () => setShowKey((v) => !v),
480
621
  },
481
- showKey ? 'Скрыть' : 'Показать'
622
+ showKey ? t('key.hide') : t('key.show')
482
623
  ),
483
624
  React.createElement(
484
625
  'button',
@@ -488,13 +629,13 @@ window.__ModuleLoader__.load({
488
629
  disabled: !!busy || !apiKeyInput.trim(),
489
630
  onClick: handleSaveKey,
490
631
  },
491
- busy === 'save-key' ? 'Сохранение…' : 'Сохранить ключ'
632
+ busy === 'save-key' ? t('key.saving') : t('key.save')
492
633
  )
493
634
  ),
494
635
  React.createElement(
495
636
  'div',
496
637
  { className: 'cb-row', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
497
- React.createElement('span', null, `Переменная учётных данных: `),
638
+ React.createElement('span', null, t('key.env_label')),
498
639
  React.createElement('code', null, draft.apiKeyEnv || 'CLINEBOT_API_KEY'),
499
640
  React.createElement('span', null, ` · `),
500
641
  React.createElement(
@@ -505,7 +646,7 @@ window.__ModuleLoader__.load({
505
646
  rel: 'noreferrer',
506
647
  style: { color: 'var(--dsw-alias-state-brand-primary)' },
507
648
  },
508
- 'Получить ключ в консоли app.cline.bot ↗'
649
+ t('key.get_key')
509
650
  )
510
651
  )
511
652
  ),
@@ -524,7 +665,7 @@ window.__ModuleLoader__.load({
524
665
  ? React.createElement(
525
666
  'div',
526
667
  { style: { fontSize: '11px', opacity: 0.9, marginTop: '2px' } },
527
- `Ожидаемое время сброса лимита: ${new Date(status.quotaWarning.resetsAt).toLocaleTimeString()}`
668
+ t('quota.reset_at', { time: new Date(status.quotaWarning.resetsAt).toLocaleTimeString() })
528
669
  )
529
670
  : null
530
671
  )
@@ -539,7 +680,7 @@ window.__ModuleLoader__.load({
539
680
  React.createElement(
540
681
  'div',
541
682
  { className: 'cb-section-title' },
542
- '📊 Остаток лимитов подписки (ClinePass Quota)',
683
+ t('quota.title'),
543
684
  React.createElement(
544
685
  'button',
545
686
  {
@@ -548,43 +689,45 @@ window.__ModuleLoader__.load({
548
689
  disabled: !!busy,
549
690
  onClick: handleRefreshQuota,
550
691
  },
551
- busy === 'refresh-quota' ? 'Обновление…' : 'Обновить квоты'
692
+ busy === 'refresh-quota' ? t('quota.refreshing') : t('quota.refresh')
552
693
  )
553
694
  ),
554
695
  React.createElement(
555
696
  'div',
556
697
  { className: 'cb-section-desc' },
557
698
  usage?.user?.email
558
- ? `Аккаунт: ${usage.user.email} · Тариф: ${usage.plan || 'ClinePass ($9.99/mo)'}`
559
- : 'Официальные лимиты скользящих окон запросов ClinePass'
699
+ ? t('quota.account', { email: usage.user.email, plan: usage.plan || 'ClinePass ($9.99/mo)' })
700
+ : t('quota.desc')
560
701
  ),
561
702
  React.createElement(
562
703
  'div',
563
704
  { className: 'cb-grid-2' },
564
705
  React.createElement(ProgressBar, {
565
- label: '⏱ 5-часовое скользящее окно (Rolling Limit)',
706
+ label: t('quota.window_5h'),
566
707
  percentUsed: usage?.windows?.fiveHour?.percentUsed || 0,
567
708
  remainingPercent: usage?.windows?.fiveHour?.remainingPercent || 100,
568
709
  resetsAt: usage?.windows?.fiveHour?.resetsAt,
710
+ t,
569
711
  }),
570
712
  React.createElement(ProgressBar, {
571
- label: '📅 Недельное окно (Weekly Window)',
713
+ label: t('quota.window_weekly'),
572
714
  percentUsed: usage?.windows?.weekly?.percentUsed || 0,
573
715
  remainingPercent: usage?.windows?.weekly?.remainingPercent || 100,
574
716
  resetsAt: usage?.windows?.weekly?.resetsAt,
717
+ t,
575
718
  })
576
719
  )
577
720
  )
578
721
  : null,
579
722
 
580
- // Card 3: Model Picker Management with Official Plan Sync
723
+ // Card 3: Model Picker Management with Official Plan Sync & Auto-Enable
581
724
  React.createElement(
582
725
  'div',
583
726
  { className: 'cb-section-card' },
584
727
  React.createElement(
585
728
  'div',
586
729
  { className: 'cb-section-title' },
587
- `🎯 Модели в пикере DSH (${enabledSet.size} из ${modelsList.length} включено)`,
730
+ t('models.title', { enabled: enabledCount, total: modelsList.length }),
588
731
  React.createElement(
589
732
  'div',
590
733
  { className: 'cb-row' },
@@ -596,34 +739,34 @@ window.__ModuleLoader__.load({
596
739
  disabled: !!busy || !keyPresent,
597
740
  onClick: handleSyncPlanModels,
598
741
  },
599
- busy === 'sync-models' ? 'Синхронизация…' : '🔄 Получить модели из плана'
742
+ busy === 'sync-models' ? t('models.syncing') : t('models.sync')
600
743
  ),
601
744
  React.createElement(
602
745
  'button',
603
746
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('all') },
604
- 'Все'
747
+ t('models.all')
605
748
  ),
606
749
  React.createElement(
607
750
  'button',
608
751
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('vision') },
609
- 'Только Vision'
752
+ t('models.vision')
610
753
  ),
611
754
  React.createElement(
612
755
  'button',
613
756
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('coding') },
614
- 'Только Кодинг'
757
+ t('models.coding')
615
758
  ),
616
759
  React.createElement(
617
760
  'button',
618
761
  { type: 'button', className: 'cb-btn', onClick: () => handleSetModelsFilter('recommended') },
619
- 'Рекомендованные'
762
+ t('models.recommended')
620
763
  )
621
764
  )
622
765
  ),
623
766
  React.createElement(
624
767
  'div',
625
768
  { className: 'cb-section-desc' },
626
- 'Список формируется напрямую из официальной подписки ClinePass без ручного ввода. Отметьте модели, которые должны отображаться в выпадающем списке выбора моделей в чате DeepSeek Harness.'
769
+ t('models.desc')
627
770
  ),
628
771
  React.createElement(
629
772
  'table',
@@ -634,18 +777,19 @@ window.__ModuleLoader__.load({
634
777
  React.createElement(
635
778
  'tr',
636
779
  null,
637
- React.createElement('th', { style: { width: '40px' } }, 'Вкл'),
638
- React.createElement('th', null, 'Название модели'),
639
- React.createElement('th', null, 'Model ID'),
640
- React.createElement('th', null, 'Контекст'),
641
- 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'))
642
785
  )
643
786
  ),
644
787
  React.createElement(
645
788
  'tbody',
646
789
  null,
647
- modelsList.map((m) =>
648
- React.createElement(
790
+ modelsList.map((m) => {
791
+ const isEnabled = !disabledSet.has(m.id)
792
+ return React.createElement(
649
793
  'tr',
650
794
  { key: m.id },
651
795
  React.createElement(
@@ -653,7 +797,7 @@ window.__ModuleLoader__.load({
653
797
  null,
654
798
  React.createElement('input', {
655
799
  type: 'checkbox',
656
- checked: enabledSet.has(m.id),
800
+ checked: isEnabled,
657
801
  onChange: () => handleToggleModel(m.id),
658
802
  })
659
803
  ),
@@ -662,7 +806,10 @@ window.__ModuleLoader__.load({
662
806
  null,
663
807
  React.createElement('strong', null, m.name),
664
808
  m.recommended
665
- ? 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'))
666
813
  : null
667
814
  ),
668
815
  React.createElement('td', null, React.createElement('code', null, m.id)),
@@ -676,7 +823,7 @@ window.__ModuleLoader__.load({
676
823
  : null
677
824
  )
678
825
  )
679
- )
826
+ })
680
827
  )
681
828
  )
682
829
  ),
@@ -688,12 +835,12 @@ window.__ModuleLoader__.load({
688
835
  React.createElement(
689
836
  'div',
690
837
  { className: 'cb-section-title' },
691
- '📈 Статистика текущей сессии DSH'
838
+ t('stats.title')
692
839
  ),
693
840
  React.createElement(
694
841
  'div',
695
842
  { className: 'cb-section-desc' },
696
- 'Локальный мониторинг запросов, задержек сети и ориентировочного расхода токенов через ClinePass.'
843
+ t('stats.desc')
697
844
  ),
698
845
  React.createElement(
699
846
  'div',
@@ -702,25 +849,25 @@ window.__ModuleLoader__.load({
702
849
  'div',
703
850
  { className: 'cb-stat-box' },
704
851
  React.createElement('div', { className: 'cb-stat-val' }, `${status.sessionStats?.successfulRequests || 0} / ${status.sessionStats?.totalRequests || 0}`),
705
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Успешных запросов / Всего')
852
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.requests'))
706
853
  ),
707
854
  React.createElement(
708
855
  'div',
709
856
  { className: 'cb-stat-box' },
710
857
  React.createElement('div', { className: 'cb-stat-val' }, `~${status.sessionStats?.totalTokensEst || 0}`),
711
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Оценка токенов сессии')
858
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.tokens'))
712
859
  ),
713
860
  React.createElement(
714
861
  'div',
715
862
  { className: 'cb-stat-box' },
716
- React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastLatencyMs ? `${status.sessionStats.lastLatencyMs} мс` : '—'),
717
- 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'))
718
865
  ),
719
866
  React.createElement(
720
867
  'div',
721
868
  { className: 'cb-stat-box' },
722
869
  React.createElement('div', { className: 'cb-stat-val' }, status.sessionStats?.lastRequestAt ? new Date(status.sessionStats.lastRequestAt).toLocaleTimeString() : '—'),
723
- React.createElement('div', { className: 'cb-stat-lbl' }, 'Время последнего запроса')
870
+ React.createElement('div', { className: 'cb-stat-lbl' }, t('stats.last_req'))
724
871
  )
725
872
  )
726
873
  ),
@@ -732,12 +879,12 @@ window.__ModuleLoader__.load({
732
879
  React.createElement(
733
880
  'div',
734
881
  { className: 'cb-section-title' },
735
- '⚡ Диагностика и регистрация в DSH Models'
882
+ t('diag.title')
736
883
  ),
737
884
  React.createElement(
738
885
  'div',
739
886
  { className: 'cb-section-desc' },
740
- 'Проверьте связь с эндпоинтом и синхронизируйте активный список моделей с ядром DeepSeek Harness.'
887
+ t('diag.desc')
741
888
  ),
742
889
  React.createElement(
743
890
  'div',
@@ -750,7 +897,7 @@ window.__ModuleLoader__.load({
750
897
  disabled: !!busy || !keyPresent,
751
898
  onClick: handleSmoke,
752
899
  },
753
- busy === 'smoke' ? 'Тестирование…' : 'Запустить Smoke Test (Ping)'
900
+ busy === 'smoke' ? t('diag.smoke_testing') : t('diag.smoke_btn')
754
901
  ),
755
902
  isRegistered
756
903
  ? React.createElement(
@@ -761,7 +908,7 @@ window.__ModuleLoader__.load({
761
908
  disabled: !!busy,
762
909
  onClick: handleUnregister,
763
910
  },
764
- busy === 'unregister' ? 'Удаление…' : 'Удалить из DSH Models'
911
+ busy === 'unregister' ? t('diag.unregistering') : t('diag.unregister_btn')
765
912
  )
766
913
  : null,
767
914
  React.createElement(
@@ -773,14 +920,14 @@ window.__ModuleLoader__.load({
773
920
  disabled: !!busy,
774
921
  onClick: handleRegister,
775
922
  },
776
- busy === 'register' ? 'Синхронизация…' : 'Синхронизировать с DSH Models'
923
+ busy === 'register' ? t('diag.resyncing') : t('diag.resync_btn')
777
924
  )
778
925
  ),
779
926
  smokeResult
780
927
  ? React.createElement(
781
928
  'div',
782
929
  { className: 'cb-preview' },
783
- `✅ Задержка ответа: ${smokeResult.latencyMs} мс | Модель: ${smokeResult.model}\nОтвет: ${smokeResult.preview || '(пустой ответ)'}`
930
+ `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}\nResponse: ${smokeResult.preview || '(empty)'}`
784
931
  )
785
932
  : null
786
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.0",
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",