@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 +19 -1
- package/lib/account-pool.js +12 -4
- package/lib/client.js +7 -13
- package/lib/config.js +51 -10
- package/lib/index.js +59 -9
- package/package.json +1 -1
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
|
|
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
|
|
package/lib/account-pool.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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,
|
|
546
|
-
React.createElement('td', null, React.createElement('code', null,
|
|
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(
|
|
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 (
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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",
|