@goodandready/dsh-clinebot 0.5.10 → 0.5.12

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,25 @@ 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.12] - 2026-10-02
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
16
+ ## [0.5.11] - 2026-10-01
17
+
18
+ ### Fixed
19
+ - **Dynamic Stats Storage Switching & Counter Preservation (Issue #158)**: Flushes and synchronizes in-memory metrics before switching file storage targets when `statsPath` is updated dynamically, avoiding overwriting accumulated statistics when paths change.
20
+ - **Session Affinity Native Transport Bridge via Prompt Cache Key (Issue #163)**: Bridge native DSH `llm-pi-ai` requests to ClineBot sticky sessions by enabling `cacheRetention: 'long'` in the provider contract, and extracting session identifiers from `prompt_cache_key` on the proxy route, maintaining consistent account affinity across turns.
21
+ - **Catalog Lifecycle & Cache Identity Isolation (Issue #168)**: Isolated disk model cache records by specific account and endpoint scopes, clearing dynamic model cache and sync timestamps on active account switch, and reporting `planSynced: false` when offline cached plans cannot be actively verified against the provider.
22
+ - **Updater Process Group Escalation on Parent Exit (Issue #172)**: Ensured process group supervision continues after parent SIGTERM exit in `updater.js`, checking `isProcessGroupAlive(child.pid)` and escalating to SIGKILL across the detached process group (`-child.pid`) to eliminate lingering background processes.
23
+ - **Transactional Account Delete Surgical Rollback & Retry (Issues #184, #187)**: Replaced full-config rollback with surgical restoration of removed accounts into fresh live configuration on secret deletion failure, preserving concurrent settings updates. Exposed partial deletion state (`partial: true`) and allowed safe retry of credential cleanup without HTTP 404 errors.
24
+ - **DESIGN Contract Alignment with Native Runtime Contracts (Issue #177)**: Synchronized `docs/design/DESIGN.md` with active implementation: documented native DSH transport bridge, dynamic catalog lifecycle, updater process group cleanup, and dynamic stats storage switching.
25
+ - **Regression & Hardening Test Coverage (Issue #173)**: Added regression tests for dynamic stats switching, shifting quota session stickiness, process group lifecycle escalation on parent SIGTERM, and transactional partial account deletion and retry.
26
+
8
27
  ## [0.5.10] - 2026-10-01
9
28
 
10
29
  ### 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))
@@ -547,8 +547,10 @@ export function buildPiAiProvider({
547
547
  api: 'openai-completions',
548
548
  baseURL: normalizeBaseUrl(baseUrl),
549
549
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
550
+ cacheRetention: 'long',
550
551
  compat: {
551
552
  supportsReasoningEffort: true,
553
+ supportsLongCacheRetention: true,
552
554
  },
553
555
  models: modelList,
554
556
  }
package/lib/config.js CHANGED
@@ -129,7 +129,7 @@ export function publicConfig(cfg) {
129
129
  const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
130
130
  const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
131
131
  const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
132
- const isSynced = dynamic.length > 0
132
+ const isSynced = dynamic.length > 0 && (Number(cfg?.planSyncedAt) > 0)
133
133
  const allModels = getAllModels(dynamic, modelContextOverrides, custom)
134
134
  const allDefaultIds = allModels.map((m) => m.id)
135
135
 
package/lib/index.js CHANGED
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
22
22
  import { registerAuthRoutes } from './routes/auth.js'
23
23
  import { registerProxyRoutes } from './routes/proxy.js'
24
24
  import { registerSlashCommand } from './slash-command.js'
25
- import { recordUsage, loadStats, flushStats, resolvePath } from './stats-storage.js'
25
+ import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
26
26
 
27
27
  export const name = '@goodandready/dsh-clinebot'
28
28
  export const inject = ['webServer', 'credentials']
@@ -99,12 +99,17 @@ export function apply(ctx, config) {
99
99
  const syncStatsStorage = (cfg) => {
100
100
  const nextPath = resolvePath(publicConfig(cfg).statsPath)
101
101
  if (nextPath !== activeStatsPath) {
102
- flushStats(activeStatsPath)
102
+ switchStatsStorage(nextPath)
103
103
  activeStatsPath = nextPath
104
- loadStats(nextPath)
105
104
  }
106
105
  }
107
106
 
107
+ if (typeof ctx?.on === 'function') {
108
+ ctx.on('loader/volatile-update', () => {
109
+ syncStatsStorage(live())
110
+ })
111
+ }
112
+
108
113
  const getRevision = (svc) => {
109
114
  try {
110
115
  return svc?.describe?.().find((row) => row.ns === NS)?.revision
@@ -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,6 +157,27 @@ 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) {
160
+ if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
161
+ let retryDelRes = null
162
+ try {
163
+ retryDelRes = await deleteCredentialKey(ctx, targetEnv)
164
+ } catch {
165
+ retryDelRes = null
166
+ }
167
+ if (retryDelRes && retryDelRes.ok) {
168
+ pendingSecretCleanupAccounts.delete(targetEnv)
169
+ clearUsageCache()
170
+ clearProbeCache()
171
+ return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
172
+ }
173
+ return writeJson(res, 500, {
174
+ ok: false,
175
+ partial: true,
176
+ accountRemoved: true,
177
+ secretDeleted: false,
178
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
179
+ })
180
+ }
158
181
  return writeJson(res, 404, {
159
182
  ok: false,
160
183
  error: `Account "${targetEnv}" not found in accounts pool`,
@@ -172,31 +195,62 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
172
195
  await settingsApi.replace(next)
173
196
  await syncProviderState(next)
174
197
 
198
+ let delRes = null
175
199
  if (body.deleteSecret) {
176
- let delRes = null
177
200
  try {
178
201
  delRes = await deleteCredentialKey(ctx, targetEnv)
179
202
  } catch {
180
203
  delRes = null
181
204
  }
182
205
  if (!delRes || !delRes.ok) {
206
+ let rollbackSuccess = false
183
207
  try {
184
- await settingsApi.replace(curr)
185
- await syncProviderState(curr)
208
+ const fresh = plainConfig(live())
209
+ const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
210
+ if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
211
+ freshAccounts.push(accountToDelete)
212
+ }
213
+ const shouldRestoreActive = (curr.activeAccount === targetEnv && (!fresh.activeAccount || fresh.activeAccount === targetEnv))
214
+ ? targetEnv
215
+ : fresh.activeAccount
216
+ const restoredConfig = plainConfig(Config({
217
+ ...fresh,
218
+ accounts: freshAccounts,
219
+ activeAccount: shouldRestoreActive,
220
+ }))
221
+ await settingsApi.replace(restoredConfig)
222
+ await syncProviderState(restoredConfig)
223
+ rollbackSuccess = true
186
224
  } catch {
187
- /* ignore rollback error */
225
+ rollbackSuccess = false
188
226
  }
227
+
228
+ if (rollbackSuccess) {
229
+ return writeJson(res, 500, {
230
+ ok: false,
231
+ partial: false,
232
+ accountRemoved: false,
233
+ secretDeleted: false,
234
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
235
+ })
236
+ }
237
+
238
+ pendingSecretCleanupAccounts.add(targetEnv)
189
239
  return writeJson(res, 500, {
190
240
  ok: false,
241
+ partial: true,
242
+ accountRemoved: true,
243
+ secretDeleted: false,
191
244
  error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
192
245
  })
193
246
  }
194
247
  }
195
248
 
249
+ pendingSecretCleanupAccounts.delete(targetEnv)
196
250
  clearUsageCache()
197
251
  clearProbeCache()
198
252
 
199
- writeJson(res, 200, { ok: true, removed: targetEnv })
253
+ writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
200
254
  } catch (err) {
201
255
  writeJson(res, 500, { ok: false, error: String(err?.message || err) })
202
256
  }
@@ -234,7 +288,13 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
234
288
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
235
289
  }
236
290
 
237
- const next = plainConfig(Config({ ...plainConfig(live()), activeAccount: account }))
291
+ const currentLive = plainConfig(live())
292
+ const accountChanged = currentLive.activeAccount !== account
293
+ const next = plainConfig(Config({
294
+ ...currentLive,
295
+ activeAccount: account,
296
+ ...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
297
+ }))
238
298
  await settingsApi.replace(next)
239
299
  await syncProviderState(next)
240
300
  writeJson(res, 200, { ok: true, activeAccount: account })
@@ -49,7 +49,12 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
49
49
  await syncProviderState(next)
50
50
  const cacheFile = resolvePathWithHome(pub.modelsCachePath)
51
51
  if (cacheFile) {
52
- await saveModelsDiskCache(cacheFile, dynamicModels, now)
52
+ const activeAcc = await resolveActiveAccountKey(ctx, live())
53
+ const accountRef = activeAcc?.envName || activeAcc?.apiKeyEnv || pub.activeAccount || pub.apiKeyEnv || 'CLINEBOT_API_KEY'
54
+ await saveModelsDiskCache(cacheFile, dynamicModels, now, {
55
+ endpoint: pub.baseUrl,
56
+ accountRef,
57
+ })
53
58
  }
54
59
 
55
60
  return writeJson(res, 200, {
@@ -112,11 +112,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
112
112
  req.headers['x-client-request-id'] ||
113
113
  body?.session_id ||
114
114
  body?.conversation_id ||
115
+ body?.prompt_cache_key ||
115
116
  body?.chat_id ||
116
117
  body?.user ||
117
118
  ''
118
119
  ).trim()
119
120
 
121
+ if (sessionId) {
122
+ if (!body.session_id) body.session_id = sessionId
123
+ if (!body.conversation_id) body.conversation_id = sessionId
124
+ }
125
+
120
126
  // Ensure model is set, fallback to defaultModel (#140)
121
127
  body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
122
128
 
@@ -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}`
@@ -119,6 +119,26 @@ export function loadStats(customPath) {
119
119
  return inMemoryStats
120
120
  }
121
121
 
122
+ /**
123
+ * Switch active stats storage to a new path.
124
+ * Flushes pending writes of current storage and loads stats from the new path.
125
+ *
126
+ * @param {string} newPath
127
+ * @returns {string} active stats path
128
+ */
129
+ export function switchStatsStorage(newPath) {
130
+ const resolved = resolvePath(newPath)
131
+ if (resolved && resolved !== activeStatsPath) {
132
+ if (activeStatsPath) {
133
+ flushStats(activeStatsPath)
134
+ }
135
+ activeStatsPath = resolved
136
+ loadStats(resolved)
137
+ }
138
+ return activeStatsPath
139
+ }
140
+
141
+
122
142
  /**
123
143
  * Flush any debounced pending writes immediately to disk.
124
144
  *
@@ -201,6 +221,9 @@ export function recordUsage({
201
221
  is429 = false,
202
222
  statsPath
203
223
  } = {}) {
224
+ if (statsPath) {
225
+ switchStatsStorage(statsPath)
226
+ }
204
227
  const now = new Date()
205
228
  const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
206
229
  const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
package/lib/updater.js CHANGED
@@ -264,6 +264,16 @@ export async function installExact(target, packageSpec, options = {}) {
264
264
  let sigtermTimer = null
265
265
  let sigkillTimer = null
266
266
 
267
+ function isProcessGroupAlive(pid) {
268
+ if (typeof pid !== 'number' || pid <= 0 || process.platform === 'win32') return false
269
+ try {
270
+ process.kill(-pid, 0)
271
+ return true
272
+ } catch {
273
+ return false
274
+ }
275
+ }
276
+
267
277
  function cleanupTimers() {
268
278
  clearTimeout(mainTimer)
269
279
  if (sigtermTimer) clearTimeout(sigtermTimer)
@@ -272,16 +282,26 @@ export async function installExact(target, packageSpec, options = {}) {
272
282
 
273
283
  function onProcessExit(code, signal) {
274
284
  if (exited) return
275
- exited = true
276
- cleanupTimers()
277
285
 
278
286
  if (isTimedOut) {
287
+ if (isProcessGroupAlive(child.pid)) {
288
+ // Parent exited, but descendants in process group are still alive;
289
+ // keep escalation timer active (#172)
290
+ clearTimeout(mainTimer)
291
+ return
292
+ }
293
+ exited = true
294
+ cleanupTimers()
279
295
  cleanStaleLock(target.profileDir)
280
296
  reject(new Error('Update timed out.'))
281
- } else if (code === 0) {
282
- resolvePromise()
283
297
  } else {
284
- reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
298
+ exited = true
299
+ cleanupTimers()
300
+ if (code === 0) {
301
+ resolvePromise()
302
+ } else {
303
+ reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
304
+ }
285
305
  }
286
306
  }
287
307
 
@@ -316,18 +336,25 @@ export async function installExact(target, packageSpec, options = {}) {
316
336
 
317
337
  // Step 2: Wait for grace period, if process has not exited escalate to SIGKILL (#172)
318
338
  sigtermTimer = setTimeout(() => {
319
- const hasExited = exited || (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
320
- if (!hasExited) {
339
+ const groupAlive = isProcessGroupAlive(child.pid)
340
+ const parentExited = (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
341
+ if (!parentExited || groupAlive) {
321
342
  killProcessTree('SIGKILL')
322
343
 
323
344
  // Step 3: Hard deadline for exit notification
324
345
  sigkillTimer = setTimeout(() => {
325
346
  if (!exited) {
326
347
  exited = true
348
+ cleanupTimers()
327
349
  cleanStaleLock(target.profileDir)
328
350
  reject(new Error('Update timed out.'))
329
351
  }
330
- }, sigkillGraceMs)
352
+ }, Math.min(sigkillGraceMs, 50))
353
+ } else if (!exited) {
354
+ exited = true
355
+ cleanupTimers()
356
+ cleanStaleLock(target.profileDir)
357
+ reject(new Error('Update timed out.'))
331
358
  }
332
359
  }, sigtermGraceMs)
333
360
  }, timeoutMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
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",