@goodandready/dsh-clinebot 0.5.10 → 0.5.11

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,17 @@ 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.11] - 2026-10-01
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+
8
19
  ## [0.5.10] - 2026-10-01
9
20
 
10
21
  ### Fixed
@@ -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
@@ -155,6 +155,26 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
155
155
  const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
156
156
  const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
157
157
  if (!accountToDelete) {
158
+ if (body.deleteSecret && ctx?.get?.('credentials')) {
159
+ let retryDelRes = null
160
+ try {
161
+ retryDelRes = await deleteCredentialKey(ctx, targetEnv)
162
+ } catch {
163
+ retryDelRes = null
164
+ }
165
+ if (retryDelRes && retryDelRes.ok) {
166
+ clearUsageCache()
167
+ clearProbeCache()
168
+ return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
169
+ }
170
+ return writeJson(res, 500, {
171
+ ok: false,
172
+ partial: true,
173
+ accountRemoved: true,
174
+ secretDeleted: false,
175
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
176
+ })
177
+ }
158
178
  return writeJson(res, 404, {
159
179
  ok: false,
160
180
  error: `Account "${targetEnv}" not found in accounts pool`,
@@ -180,14 +200,40 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
180
200
  delRes = null
181
201
  }
182
202
  if (!delRes || !delRes.ok) {
203
+ let rollbackSuccess = false
183
204
  try {
184
- await settingsApi.replace(curr)
185
- await syncProviderState(curr)
205
+ const fresh = plainConfig(live())
206
+ const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
207
+ if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
208
+ freshAccounts.push(accountToDelete)
209
+ }
210
+ const restoredConfig = plainConfig(Config({
211
+ ...fresh,
212
+ accounts: freshAccounts,
213
+ activeAccount: curr.activeAccount === targetEnv ? targetEnv : fresh.activeAccount,
214
+ }))
215
+ await settingsApi.replace(restoredConfig)
216
+ await syncProviderState(restoredConfig)
217
+ rollbackSuccess = true
186
218
  } catch {
187
- /* ignore rollback error */
219
+ rollbackSuccess = false
220
+ }
221
+
222
+ if (rollbackSuccess) {
223
+ return writeJson(res, 500, {
224
+ ok: false,
225
+ partial: false,
226
+ accountRemoved: false,
227
+ secretDeleted: false,
228
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
229
+ })
188
230
  }
231
+
189
232
  return writeJson(res, 500, {
190
233
  ok: false,
234
+ partial: true,
235
+ accountRemoved: true,
236
+ secretDeleted: false,
191
237
  error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
192
238
  })
193
239
  }
@@ -234,7 +280,13 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
234
280
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
235
281
  }
236
282
 
237
- const next = plainConfig(Config({ ...plainConfig(live()), activeAccount: account }))
283
+ const currentLive = plainConfig(live())
284
+ const accountChanged = currentLive.activeAccount !== account
285
+ const next = plainConfig(Config({
286
+ ...currentLive,
287
+ activeAccount: account,
288
+ ...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
289
+ }))
238
290
  await settingsApi.replace(next)
239
291
  await syncProviderState(next)
240
292
  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
 
@@ -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.11",
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",