@goodandready/dsh-clinebot 0.5.12 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.15] - 2026-10-02
9
+
10
+ ### Fixed
11
+ - **Atomic Catalog Snapshot Reset on Settings Persistence (Issue #192)**: In `settingsApi.update` and `settingsApi.replace`, defer invalidating the verified model catalog snapshot (`resetCatalogSnapshot()`) and updating `lastCatalogIdentity` until after persistence to the DSH settings service completes successfully. If settings persistence fails (e.g. revision conflict or rejection), local in-memory dynamic models and plan synchronization timestamp are strictly preserved, preventing catalog loss when endpoint and credentials remain unchanged.
12
+
13
+ ## [0.5.14] - 2026-10-02
14
+
15
+ ### Fixed
16
+ - **Catalog Identity Lifecycle on Endpoint & Primary Credential Switch (Issue #168)**: Unified catalog invalidation under an identity contract: the verified catalog and plan synchronization timestamp strictly belong to the identity tuple (`baseUrl::activeAccount::apiKeyEnv`). Changing primary credential reference (`apiKeyEnv`) or provider endpoint (`baseUrl`) — via native Cosmokit volatile updates or `PUT /dsh-clinebot/config` — immediately resets `dynamicModels = []`, `planSyncedAt = 0`, and purges both usage and probe caches (`clearUsageCache()`, `clearProbeCache()`), preventing foreign or unverified model snapshots from persisting across endpoint or credential changes.
17
+
18
+ ## [0.5.13] - 2026-10-02
19
+
20
+ ### Fixed
21
+ - **Native Volatile Account Switch Catalog & Plan Reset (Issue #168)**: In `loader/volatile-update`, actively detect when `activeAccount` changes via native Cosmokit volatile settings, resetting `dynamicModels = []`, `planSyncedAt = 0`, and clearing usage and probe caches (`clearUsageCache()`, `clearProbeCache()`) to guarantee stale catalogs from previous accounts are never marked verified.
22
+ - **Account Deletion Rollback Automatic Mode Preservation (Issue #187)**: Ensure surgical rollback of account deletion in `lib/routes/accounts.js` unconditionally preserves live `fresh.activeAccount`, preventing unintended restoration of deleted accounts or overriding concurrent selections of automatic mode (`activeAccount: ""`).
23
+
8
24
  ## [0.5.12] - 2026-10-02
9
25
 
10
26
  ### Fixed
package/lib/index.js CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  sessionStats,
5
5
  recordSessionRequest,
6
6
  resetSessionStats,
7
+ clearUsageCache,
8
+ clearProbeCache,
7
9
  } from './cline-client.js'
8
10
  import {
9
11
  rotateToNextAccount,
@@ -104,9 +106,37 @@ export function apply(ctx, config) {
104
106
  }
105
107
  }
106
108
 
109
+ const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
110
+ const getCatalogIdentity = (cfg) => {
111
+ const pub = publicConfig(cfg)
112
+ return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
113
+ }
114
+
115
+ let lastCatalogIdentity = getCatalogIdentity(live())
116
+ const resetCatalogSnapshot = () => {
117
+ try {
118
+ if (currentConfig && typeof currentConfig === 'object') {
119
+ currentConfig.dynamicModels = []
120
+ currentConfig.planSyncedAt = 0
121
+ }
122
+ } catch {
123
+ /* ignore read-only or frozen */
124
+ }
125
+ overrides.dynamicModels = []
126
+ overrides.planSyncedAt = 0
127
+ clearUsageCache()
128
+ clearProbeCache()
129
+ }
130
+
107
131
  if (typeof ctx?.on === 'function') {
108
132
  ctx.on('loader/volatile-update', () => {
109
- syncStatsStorage(live())
133
+ const liveCfg = live()
134
+ const currentIdentity = getCatalogIdentity(liveCfg)
135
+ if (currentIdentity !== lastCatalogIdentity) {
136
+ lastCatalogIdentity = currentIdentity
137
+ resetCatalogSnapshot()
138
+ }
139
+ syncStatsStorage(liveCfg)
110
140
  })
111
141
  }
112
142
 
@@ -161,7 +191,14 @@ export function apply(ctx, config) {
161
191
  if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
162
192
  throw new Error('DSH settings service is unavailable or non-writable')
163
193
  }
194
+ const prevIdentity = getCatalogIdentity(live())
164
195
  const parsed = Config(structuredClone(plainConfig(next)))
196
+ const nextIdentity = getCatalogIdentity(parsed)
197
+ const identityChanged = nextIdentity !== prevIdentity
198
+ if (identityChanged) {
199
+ if (!('dynamicModels' in next)) parsed.dynamicModels = []
200
+ if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
201
+ }
165
202
  const payload = volatileConfig(parsed)
166
203
  const rev = getRevision(svc)
167
204
  if (typeof svc.replace === 'function') {
@@ -169,6 +206,10 @@ export function apply(ctx, config) {
169
206
  } else {
170
207
  await svc.update(NS, payload, rev)
171
208
  }
209
+ if (identityChanged) {
210
+ lastCatalogIdentity = nextIdentity
211
+ resetCatalogSnapshot()
212
+ }
172
213
  applyConfigMutations(currentConfig, parsed)
173
214
  syncStatsStorage(live())
174
215
  await syncProviderState(live())
@@ -179,7 +220,14 @@ export function apply(ctx, config) {
179
220
  if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
180
221
  throw new Error('DSH settings service is unavailable or non-writable')
181
222
  }
223
+ const prevIdentity = getCatalogIdentity(live())
182
224
  const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
225
+ const nextIdentity = getCatalogIdentity(merged)
226
+ const identityChanged = nextIdentity !== prevIdentity
227
+ if (identityChanged) {
228
+ if (!('dynamicModels' in patch)) merged.dynamicModels = []
229
+ if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
230
+ }
183
231
  const payload = volatileConfig(merged)
184
232
  const rev = getRevision(svc)
185
233
  if (typeof svc.update === 'function') {
@@ -187,6 +235,10 @@ export function apply(ctx, config) {
187
235
  } else {
188
236
  await svc.replace(NS, payload, rev)
189
237
  }
238
+ if (identityChanged) {
239
+ lastCatalogIdentity = nextIdentity
240
+ resetCatalogSnapshot()
241
+ }
190
242
  applyConfigMutations(currentConfig, merged)
191
243
  syncStatsStorage(live())
192
244
  await syncProviderState(live())
@@ -210,13 +210,10 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
210
210
  if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
211
211
  freshAccounts.push(accountToDelete)
212
212
  }
213
- const shouldRestoreActive = (curr.activeAccount === targetEnv && (!fresh.activeAccount || fresh.activeAccount === targetEnv))
214
- ? targetEnv
215
- : fresh.activeAccount
216
213
  const restoredConfig = plainConfig(Config({
217
214
  ...fresh,
218
215
  accounts: freshAccounts,
219
- activeAccount: shouldRestoreActive,
216
+ activeAccount: fresh.activeAccount,
220
217
  }))
221
218
  await settingsApi.replace(restoredConfig)
222
219
  await syncProviderState(restoredConfig)
@@ -93,7 +93,10 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
93
93
  const base = plainConfig(live()) || {}
94
94
  delete base.enabledModels
95
95
  const merged = { ...base, ...rawPayload }
96
- if (rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()) {
96
+ const activeChanged = rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()
97
+ const apiKeyEnvChanged = rawPayload.apiKeyEnv !== undefined && String(rawPayload.apiKeyEnv).trim() !== String(base.apiKeyEnv || "").trim()
98
+ const baseUrlChanged = rawPayload.baseUrl !== undefined && String(rawPayload.baseUrl).trim().replace(/\/+$/, '') !== String(base.baseUrl || "").trim().replace(/\/+$/, '')
99
+ if (activeChanged || apiKeyEnvChanged || baseUrlChanged) {
97
100
  if (!("dynamicModels" in rawPayload)) merged.dynamicModels = []
98
101
  if (!("planSyncedAt" in rawPayload)) merged.planSyncedAt = 0
99
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.12",
3
+ "version": "0.5.15",
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",