@goodandready/dsh-clinebot 0.3.23 → 0.3.25

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,10 +5,28 @@ 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.25] - 2026-09-24
9
+
10
+ ### Fixed
11
+
12
+ - **SettingsPage "Settings Unavailable" Banner Removal (#96 / GitHub #7)**: Removed the blocking `snapshotStatus === 'unavailable'` render gate in `SettingsPage`. When DSH serves no native settings form or when the page is accessed over non-loopback connections (`persistence === 'memory'`), the page no longer displays the dead-end warning banner and instead smoothly renders its fully functional standalone REST UI from `/dsh-clinebot/status` and `/dsh-clinebot/config`.
13
+ - **Modern DSH `SettingsForms` Adapter**: In `lib/index.js`, implemented a robust adapter for modern DSH `SettingsForms` (`svc.replace` / `svc.update` / `svc.describe`). DSH 0.1.7 removed `sctx.settings.register`, which previously caused `PUT /dsh-clinebot/config` to fail with HTTP 503 `settings not ready` and prevented key changes, account switching, and model toggles from persisting to DSH.
14
+ - **Volatile Field Projection (`volatileConfig`)**: Marked only user-editable settings as `.volatile()`, leaving internal derived fields (`dynamicModels` and deprecated `enabledModels`) non-volatile. Added `volatileConfig` helper to strip non-volatile fields before passing payloads to `SettingsForms.replace`, preventing DSH from rejecting configuration writes with `Config field is not volatile`.
15
+
16
+ ## [0.3.24] - 2026-09-24
17
+
18
+ ### Fixed
19
+
20
+ - The settings card opens. Host settings are published as a namespace, copied to plain values before use, and account credential names are read as text, so the form renders instead of a React error.
21
+
8
22
  ## [0.3.23] - 2026-09-24
9
23
 
10
24
  ### Fixed
11
25
  - **React Error #185 Infinite Loop Fix (#74 / GitHub #6)**: Restored stable reference constant `SNAPSHOT_READY` in `SettingsPage.getSnapshot` for `useSyncExternalStore`. Returning an inline object literal caused `Object.is` mismatch on every render, triggering an infinite update depth loop (`Maximum update depth exceeded`). The snapshot fallback now returns frozen `SNAPSHOT_READY`, guaranteeing reference stability while keeping the page unblocked in standalone mode.
26
+ - The settings schema marks the user-editable fields volatile, so DSH includes the `dsh-clinebot` namespace and the configuration page is no longer stuck on "host namespace is not ready".
27
+ - Nested fields inside an already volatile array stay plain. The plugin copies host volatile references to plain values before `structuredClone`, so startup does not reject the config or fail to clone a getter.
28
+ - The configuration page renders its own status payload when the host form snapshot is still loading. A missing host namespace still shows the unavailable banner until that payload arrives.
29
+ - Account credential names are read as text. A host volatile reference was sent through as an empty object, and React error #31 replaced the settings card.
12
30
  - The settings page binds `configForms.get('dsh-clinebot')` on DSH 0.1.7. The removed `settingsScope` and `lanSettings` services are no longer consulted, so the plugin configuration page can render after install.
13
31
  - `GET /dsh-clinebot/status`, `/config`, `/usage`, and `/auth/status` use the same trusted-request check as the write routes. Usage responses keep the quota fields the card shows and omit the raw provider payload.
14
32
  - `/cline models` prints the model total in English.
@@ -194,7 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
194
212
  ## [0.3.4] - 2026-09-08
195
213
 
196
214
  ### Fixed
197
- - **Cache Path Resolution**: Corrected POSIX home directory expansion (`~/`) in `resolvePathWithHome` so `~/.dsh/clinebot-models-cache.json` resolves cleanly to `/home/vadim/.dsh/...` instead of root-level paths.
215
+ - **Cache Path Resolution**: Corrected POSIX home directory expansion (`~/`) in `resolvePathWithHome` so `~/.dsh/clinebot-models-cache.json` resolves under the user home directory instead of a root-level path.
198
216
 
199
217
  ## [0.3.3] - 2026-09-08
200
218
 
@@ -3,8 +3,16 @@ import { resolveKeyValue, usageCache, clearUsageCache, clearProbeCache, DEFAULT_
3
3
  /**
4
4
  * Resolve all accounts in pool with their status and keys.
5
5
  */
6
+ function textSetting(value) {
7
+ let current = value
8
+ while (current && typeof current === 'object' && !Array.isArray(current) && typeof current.get === 'function') {
9
+ current = current.get()
10
+ }
11
+ return typeof current === 'string' ? current.trim() : ''
12
+ }
13
+
6
14
  export async function resolveAccountPool(ctx, cfg) {
7
- const apiKeyEnv = cfg?.apiKeyEnv || DEFAULT_API_KEY_ENV
15
+ const apiKeyEnv = textSetting(cfg?.apiKeyEnv) || DEFAULT_API_KEY_ENV
8
16
  const defaultSlot = {
9
17
  id: 'default',
10
18
  label: 'Default',
@@ -12,16 +20,16 @@ export async function resolveAccountPool(ctx, cfg) {
12
20
  }
13
21
  const accounts = Array.isArray(cfg?.accounts) ? cfg.accounts : []
14
22
  const allSlots = [defaultSlot, ...accounts]
15
- const activeAccount = String(cfg?.activeAccount || '')
23
+ const activeAccount = textSetting(cfg?.activeAccount)
16
24
  const resolved = []
17
25
 
18
26
  for (let i = 0; i < allSlots.length; i++) {
19
27
  const slot = allSlots[i]
20
- const envName = slot.apiKeyEnv || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
28
+ const envName = textSetting(slot.apiKeyEnv) || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
21
29
  const keyInfo = await resolveKeyValue(ctx, envName)
22
30
  resolved.push({
23
31
  id: slot.id || (i === 0 ? 'default' : `account-${i + 1}`),
24
- label: slot.label || (i === 0 ? 'Default' : `Account ${i + 1}`),
32
+ label: textSetting(slot.label) || (i === 0 ? 'Default' : `Account ${i + 1}`),
25
33
  apiKeyEnv: envName,
26
34
  present: Boolean(keyInfo.value),
27
35
  source: keyInfo.source,
package/lib/client.js CHANGED
@@ -538,12 +538,14 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
538
538
  'tbody',
539
539
  null,
540
540
  status.accounts.map((acc) => {
541
- const isActive = status.activeAccount === acc.apiKeyEnv || (!status.activeAccount && acc.id === 'default')
541
+ const envName = typeof acc.apiKeyEnv === 'string' ? acc.apiKeyEnv : ''
542
+ const label = typeof acc.label === 'string' ? acc.label : ''
543
+ const isActive = status.activeAccount === envName || (!status.activeAccount && acc.id === 'default')
542
544
  return React.createElement(
543
545
  'tr',
544
546
  { key: acc.id },
545
- React.createElement('td', null, React.createElement('strong', null, acc.label)),
546
- React.createElement('td', null, React.createElement('code', null, acc.apiKeyEnv)),
547
+ React.createElement('td', null, React.createElement('strong', null, label)),
548
+ React.createElement('td', null, React.createElement('code', null, envName)),
547
549
  React.createElement(
548
550
  'td',
549
551
  null,
@@ -565,7 +567,7 @@ function AccountsSection({ status, busy, handlePinAccount, t }) {
565
567
  className: 'cb-btn',
566
568
  style: { padding: '4px 8px', fontSize: '11px' },
567
569
  disabled: !!busy,
568
- onClick: () => handlePinAccount(acc.apiKeyEnv),
570
+ onClick: () => handlePinAccount(envName),
569
571
  },
570
572
  t('accounts.pin_btn')
571
573
  )
@@ -1196,15 +1198,7 @@ function SettingsPage(props) {
1196
1198
  debounceSaveDisabledModels(nextDisabled)
1197
1199
  }
1198
1200
 
1199
- if (snapshotStatus === 'unavailable') {
1200
- return React.createElement(
1201
- 'div',
1202
- { className: 'cb-page' },
1203
- React.createElement('div', { className: 'cb-banner-warning' }, t('settings.unavailable'))
1204
- )
1205
- }
1206
-
1207
- if (!status || !draft || snapshotStatus === 'loading') {
1201
+ if (!status || !draft) {
1208
1202
  if (err) {
1209
1203
  return React.createElement(
1210
1204
  'div',
package/lib/config.js CHANGED
@@ -13,20 +13,30 @@ import {
13
13
  getActiveModelIds,
14
14
  } from './models.js'
15
15
 
16
+
17
+ // DSH settings.describe only publishes fields marked volatile. Without them the
18
+ // namespace is omitted and the configuration page stays unavailable.
19
+ if (typeof z.prototype?.volatile !== 'function') {
20
+ z.prototype.volatile = function volatile() {
21
+ if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
22
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
23
+ }
24
+ }
25
+
16
26
  export const NS = 'dsh-clinebot'
17
27
  export const LLM_PI_AI_NS = 'llm-pi-ai'
18
28
 
19
29
  export const Config = z.object({
20
30
  enabled: z.boolean().default(true)
21
- .description('When true, ClineBot is registered as a model provider in DSH.'),
31
+ .description('When true, ClineBot is registered as a model provider in DSH.').volatile(),
22
32
  baseUrl: z.string().default(DEFAULT_BASE_URL)
23
- .description('Base API URL (default: https://api.cline.bot/api/v1).'),
33
+ .description('Base API URL (default: https://api.cline.bot/api/v1).').volatile(),
24
34
  apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV)
25
- .description('Credential / env name containing the ClinePass API key (never store key directly here).'),
35
+ .description('Credential / env name containing the ClinePass API key (never store key directly here).').volatile(),
26
36
  defaultModel: z.string().default(DEFAULT_MODEL_ID)
27
- .description('Default model ID for chat and smoke tests.'),
37
+ .description('Default model ID for chat and smoke tests.').volatile(),
28
38
  disabledModels: z.array(z.string()).default([])
29
- .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).'),
39
+ .description('List of model IDs explicitly disabled by the user (new models are enabled automatically).').volatile(),
30
40
  enabledModels: z.array(z.string()).default([])
31
41
  .description('Deprecated: preserved for backwards compatibility with earlier versions.'),
32
42
  dynamicModels: z.array(z.object({
@@ -42,21 +52,52 @@ export const Config = z.object({
42
52
  })).default([])
43
53
  .description('Models automatically discovered from the official ClinePass subscription plan.'),
44
54
  timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS)
45
- .description('HTTP probe timeout in milliseconds.'),
55
+ .description('HTTP probe timeout in milliseconds.').volatile(),
46
56
  smokeTimeoutMs: z.number().default(DEFAULT_SMOKE_TIMEOUT_MS)
47
- .description('Timeout for smoke chat completions in milliseconds.'),
57
+ .description('Timeout for smoke chat completions in milliseconds.').volatile(),
48
58
  modelsCachePath: z.string().default('~/.dsh/clinebot-models-cache.json')
49
- .description('Local on-disk cache path for models snapshot.'),
59
+ .description('Local on-disk cache path for models snapshot.').volatile(),
50
60
  accounts: z.array(z.object({
51
61
  label: z.string().default(''),
52
62
  apiKeyEnv: z.string(),
53
63
  })).default([])
54
- .description('Additional accounts for multi-account failover and rate limit rotation.'),
64
+ .description('Additional accounts for multi-account failover and rate limit rotation.').volatile(),
55
65
  activeAccount: z.string().default('')
56
- .description('Manually pinned active account envName or empty for auto/default.'),
66
+ .description('Manually pinned active account envName or empty for auto/default.').volatile(),
57
67
  })
58
68
 
69
+
70
+ function isVolatileRef(value) {
71
+ return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
72
+ }
73
+
74
+ // DSH stores each volatile field as { get }. structuredClone cannot copy that
75
+ // getter, so callers need a plain snapshot before validation.
76
+ export function plainConfig(cfg) {
77
+ if (isVolatileRef(cfg)) return plainConfig(cfg.get())
78
+ if (!cfg || typeof cfg !== 'object') return cfg
79
+ const out = {}
80
+ for (const key of Object.keys(cfg)) {
81
+ const value = cfg[key]
82
+ out[key] = isVolatileRef(value) ? value.get() : value
83
+ }
84
+ return out
85
+ }
86
+
87
+ export function volatileConfig(cfg) {
88
+ const plain = plainConfig(cfg)
89
+ if (!plain || typeof plain !== 'object') return plain
90
+ const out = {}
91
+ for (const [key, field] of Object.entries(Config.dict || {})) {
92
+ if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
93
+ out[key] = plain[key]
94
+ }
95
+ }
96
+ return out
97
+ }
98
+
59
99
  export function publicConfig(cfg) {
100
+ cfg = plainConfig(cfg)
60
101
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
61
102
  const allDefaultIds = getDefaultModelIds(dynamic)
62
103
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config, publicConfig, NS, LLM_PI_AI_NS } from './config.js'
1
+ import { Config, publicConfig, plainConfig, volatileConfig, NS, LLM_PI_AI_NS } from './config.js'
2
2
  import { registerPluginUpdater } from './updater.js'
3
3
  import {
4
4
  sessionStats,
@@ -29,8 +29,9 @@ export const inject = ['settings', 'webServer', 'credentials']
29
29
  export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
30
30
 
31
31
  export function apply(ctx, config) {
32
- let getConfig = () => config
33
- const live = () => (getConfig() ? Config(structuredClone(getConfig())) : config)
32
+ let currentConfig = config
33
+ let getConfig = () => currentConfig
34
+ const live = () => (getConfig() ? Config(structuredClone(plainConfig(getConfig()))) : config)
34
35
  let settingsApi
35
36
 
36
37
  const syncProviderState = async (cfg) => {
@@ -55,14 +56,61 @@ export function apply(ctx, config) {
55
56
  autoDiscoverPlanModels(ctx, { live, getSettingsApi: () => settingsApi, syncProviderState })
56
57
  }
57
58
 
59
+ const createSettingsAdapter = (svc) => {
60
+ if (!svc) return undefined
61
+ if (typeof svc.replace === 'function' || typeof svc.update === 'function' || typeof svc.write === 'function') {
62
+ const getRevision = () => {
63
+ try {
64
+ return svc.describe?.().find((row) => row.ns === NS)?.revision
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+ return {
70
+ get: () => live(),
71
+ replace: async (next) => {
72
+ const parsed = Config(structuredClone(plainConfig(next)))
73
+ currentConfig = parsed
74
+ const payload = volatileConfig(parsed)
75
+ if (typeof svc.replace === 'function') {
76
+ await svc.replace(NS, payload, getRevision())
77
+ } else if (typeof svc.update === 'function') {
78
+ await svc.update(NS, payload, getRevision())
79
+ }
80
+ return parsed
81
+ },
82
+ update: async (patch) => {
83
+ const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
84
+ currentConfig = merged
85
+ const payload = volatileConfig(merged)
86
+ if (typeof svc.update === 'function') {
87
+ await svc.update(NS, payload, getRevision())
88
+ } else if (typeof svc.replace === 'function') {
89
+ await svc.replace(NS, payload, getRevision())
90
+ }
91
+ return merged
92
+ },
93
+ watch: (cb) => {
94
+ if (typeof svc.watch === 'function') return svc.watch(cb)
95
+ return () => {}
96
+ },
97
+ }
98
+ }
99
+ return undefined
100
+ }
101
+
58
102
  if (typeof ctx.inject === 'function') {
59
103
  ctx.inject(['settings'], (sctx) => {
60
- const scope = sctx.settings.register(NS, Config, { base: config })
61
- settingsApi = scope
62
- getConfig = () => (scope?.get?.() ?? config) ?? config
63
- sctx.effect(() => scope.watch((next) => {
64
- syncProviderState(live())
65
- }), 'dsh-clinebot: settings')
104
+ if (typeof sctx.settings?.register === 'function') {
105
+ const scope = sctx.settings.register(NS, Config, { base: config })
106
+ settingsApi = scope
107
+ getConfig = () => (scope?.get?.() ?? config) ?? config
108
+ sctx.effect(() => scope.watch((next) => {
109
+ syncProviderState(live())
110
+ }), 'dsh-clinebot: settings')
111
+ } else {
112
+ settingsApi = createSettingsAdapter(sctx.settings)
113
+ }
66
114
  sctx.effect(() => () => {
67
115
  getConfig = () => config
68
116
  settingsApi = undefined
@@ -79,6 +127,8 @@ export function apply(ctx, config) {
79
127
  syncProviderState(live())
80
128
  }), 'dsh-clinebot: settings')
81
129
  }
130
+ } else {
131
+ settingsApi = createSettingsAdapter(settingsService)
82
132
  }
83
133
  }
84
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
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",