@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 +11 -0
- package/lib/cline-client.js +2 -0
- package/lib/config.js +1 -1
- package/lib/index.js +8 -3
- package/lib/routes/accounts.js +56 -4
- package/lib/routes/models.js +6 -1
- package/lib/routes/proxy.js +6 -0
- package/lib/stats-storage.js +23 -0
- package/lib/updater.js +35 -8
- package/package.json +1 -1
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
|
package/lib/cline-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
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 })
|
package/lib/routes/models.js
CHANGED
|
@@ -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
|
|
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, {
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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
|
|
package/lib/stats-storage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
320
|
-
|
|
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.
|
|
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",
|