@goodandready/dsh-clinebot 0.5.11 → 0.5.13

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,20 @@ 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.13] - 2026-10-02
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **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: ""`).
13
+
14
+ ## [0.5.12] - 2026-10-02
15
+
16
+ ### Fixed
17
+ - **Credential Ownership Enforcement on Deletion Retry (Issue #155)**: Reject deletion of unowned or non-existent credentials with HTTP 404 and zero calls to the credentials store, preventing unconfirmed secrets deletion. Retries of previously failed secret cleanups are explicitly tracked in-memory to safely allow recovery without exposing arbitrary secret deletion.
18
+ - **Catalog Lifecycle Across All Account Switch Paths (Issue #168)**: Reset dynamic model catalog (`dynamicModels: []`) and plan synchronization state (`planSyncedAt: 0`) uniformly across slash-command switches (`/cline switch`), automatic account pool rotations, and manual configuration updates, preventing stale or cross-account models from being considered verified.
19
+ - **Rollback Concurrency & Active Account Selection Preservation (Issue #187)**: Ensure transactional account deletion rollback does not overwrite concurrent changes to `activeAccount`. Only restore the deleted account as active if no concurrent change has occurred, preserving external selection alongside other settings.
20
+ - **Test Hardening & Multi-Environment Portability (Issue #173)**: Hardened regression tests to detect surviving descendants across parent exits under genuine failure conditions, updated account deletion tests to assert honest partial state (`partial: true`), and replaced hardcoded path references with dynamic DSH adapter discovery and graceful skips across diverse environments.
21
+
8
22
  ## [0.5.11] - 2026-10-01
9
23
 
10
24
  ### Fixed
@@ -168,10 +168,19 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
168
168
  // Fallback if all candidates are either exhausted or uncached: pick next in round-robin
169
169
  const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
170
170
 
171
+ const prevActive = String(cfg.activeAccount || cfg.apiKeyEnv || "").trim()
172
+ const nextActive = String(nextAcc.apiKeyEnv || "").trim()
173
+ const isDifferent = nextActive !== prevActive
174
+
171
175
  let updated = false
172
176
  if (settingsApi?.replace) {
173
177
  try {
174
- const next = { ...toPlainSnapshot(cfg), activeAccount: nextAcc.apiKeyEnv }
178
+ const next = {
179
+ ...toPlainSnapshot(cfg),
180
+ activeAccount: nextAcc.apiKeyEnv,
181
+ dynamicModels: isDifferent ? [] : (cfg.dynamicModels || []),
182
+ planSyncedAt: isDifferent ? 0 : Number(cfg.planSyncedAt || 0),
183
+ }
175
184
  await settingsApi.replace(next)
176
185
  updated = true
177
186
  } catch (err) {
@@ -181,9 +190,14 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
181
190
  const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
182
191
  if (settings?.mutate) {
183
192
  try {
184
- await settings.mutate('dsh-clinebot', [
193
+ const ops = [
185
194
  { op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
186
- ])
195
+ ]
196
+ if (isDifferent) {
197
+ ops.push({ op: 'set', path: ['dynamicModels'], value: [] })
198
+ ops.push({ op: 'set', path: ['planSyncedAt'], value: 0 })
199
+ }
200
+ await settings.mutate('dsh-clinebot', ops)
187
201
  updated = true
188
202
  } catch (err) {
189
203
  ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
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,27 @@ export function apply(ctx, config) {
104
106
  }
105
107
  }
106
108
 
109
+ let lastActiveAccount = publicConfig(live()).activeAccount
107
110
  if (typeof ctx?.on === 'function') {
108
111
  ctx.on('loader/volatile-update', () => {
109
- syncStatsStorage(live())
112
+ const liveCfg = live()
113
+ const currentActive = publicConfig(liveCfg).activeAccount
114
+ if (currentActive !== lastActiveAccount) {
115
+ lastActiveAccount = currentActive
116
+ try {
117
+ if (currentConfig && typeof currentConfig === 'object') {
118
+ currentConfig.dynamicModels = []
119
+ currentConfig.planSyncedAt = 0
120
+ }
121
+ } catch {
122
+ /* ignore read-only or frozen */
123
+ }
124
+ overrides.dynamicModels = []
125
+ overrides.planSyncedAt = 0
126
+ clearUsageCache()
127
+ clearProbeCache()
128
+ }
129
+ syncStatsStorage(liveCfg)
110
130
  })
111
131
  }
112
132
 
@@ -11,6 +11,8 @@ import {
11
11
  DEFAULT_API_KEY_ENV,
12
12
  } from '../cline-client.js'
13
13
 
14
+ const pendingSecretCleanupAccounts = new Set()
15
+
14
16
  export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
15
17
  // POST /dsh-clinebot/accounts — add or update account in pool
16
18
  ctx.effect(() => ctx.webServer.register({
@@ -155,7 +157,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
155
157
  const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
156
158
  const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
157
159
  if (!accountToDelete) {
158
- if (body.deleteSecret && ctx?.get?.('credentials')) {
160
+ if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
159
161
  let retryDelRes = null
160
162
  try {
161
163
  retryDelRes = await deleteCredentialKey(ctx, targetEnv)
@@ -163,6 +165,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
163
165
  retryDelRes = null
164
166
  }
165
167
  if (retryDelRes && retryDelRes.ok) {
168
+ pendingSecretCleanupAccounts.delete(targetEnv)
166
169
  clearUsageCache()
167
170
  clearProbeCache()
168
171
  return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
@@ -192,8 +195,8 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
192
195
  await settingsApi.replace(next)
193
196
  await syncProviderState(next)
194
197
 
198
+ let delRes = null
195
199
  if (body.deleteSecret) {
196
- let delRes = null
197
200
  try {
198
201
  delRes = await deleteCredentialKey(ctx, targetEnv)
199
202
  } catch {
@@ -210,7 +213,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
210
213
  const restoredConfig = plainConfig(Config({
211
214
  ...fresh,
212
215
  accounts: freshAccounts,
213
- activeAccount: curr.activeAccount === targetEnv ? targetEnv : fresh.activeAccount,
216
+ activeAccount: fresh.activeAccount,
214
217
  }))
215
218
  await settingsApi.replace(restoredConfig)
216
219
  await syncProviderState(restoredConfig)
@@ -229,6 +232,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
229
232
  })
230
233
  }
231
234
 
235
+ pendingSecretCleanupAccounts.add(targetEnv)
232
236
  return writeJson(res, 500, {
233
237
  ok: false,
234
238
  partial: true,
@@ -239,10 +243,11 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
239
243
  }
240
244
  }
241
245
 
246
+ pendingSecretCleanupAccounts.delete(targetEnv)
242
247
  clearUsageCache()
243
248
  clearProbeCache()
244
249
 
245
- writeJson(res, 200, { ok: true, removed: targetEnv })
250
+ writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
246
251
  } catch (err) {
247
252
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
248
253
  }
@@ -93,6 +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()) {
97
+ if (!("dynamicModels" in rawPayload)) merged.dynamicModels = []
98
+ if (!("planSyncedAt" in rawPayload)) merged.planSyncedAt = 0
99
+ }
96
100
  const parsed = plainConfig(Config(merged))
97
101
  await settingsApi.replace(parsed)
98
102
  await syncProviderState(parsed)
@@ -100,7 +100,14 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
100
100
  clearProbeCache()
101
101
  const settingsApi = getSettingsApi()
102
102
  if (settingsApi?.replace) {
103
- const next = Config({ ...plainConfig(live()), activeAccount: param })
103
+ const curr = plainConfig(live())
104
+ const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
105
+ const next = Config({
106
+ ...curr,
107
+ activeAccount: param,
108
+ dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
109
+ planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
110
+ })
104
111
  await settingsApi.replace(next)
105
112
  await syncProviderState(next)
106
113
  return `Active account switched to ${param}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
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",