@goodandready/dsh-clinebot 0.5.8 → 0.5.10

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/lib/models.js CHANGED
@@ -510,14 +510,20 @@ export function isVisionModel(id, dynamicModels = [], modelContextOverrides = []
510
510
  /**
511
511
  * Disk caching for discovered models.
512
512
  */
513
- export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt = Date.now()) {
514
- if (!cachePath || !Array.isArray(models) || !models.length) return false
513
+ export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt = Date.now(), options = {}) {
514
+ if (!cachePath || !Array.isArray(models)) return false
515
515
  try {
516
516
  const fs = await import('node:fs/promises')
517
517
  const path = await import('node:path')
518
518
  const dir = path.dirname(cachePath)
519
519
  await fs.mkdir(dir, { recursive: true })
520
- const payload = JSON.stringify({ savedAt: Date.now(), planSyncedAt, models }, null, 2)
520
+ const payload = JSON.stringify({
521
+ savedAt: Date.now(),
522
+ planSyncedAt,
523
+ endpoint: options.endpoint || options.baseUrl || '',
524
+ accountRef: options.accountRef || options.accountId || options.apiKeyEnv || '',
525
+ models,
526
+ }, null, 2)
521
527
  await fs.writeFile(cachePath, payload, 'utf8')
522
528
  return true
523
529
  } catch {
@@ -525,7 +531,7 @@ export async function saveModelsDiskCache(cachePath, models = [], planSyncedAt =
525
531
  }
526
532
  }
527
533
 
528
- export async function loadModelsDiskCache(cachePath) {
534
+ export async function loadModelsDiskCache(cachePath, options = {}) {
529
535
  if (!cachePath) return null
530
536
  try {
531
537
  const fs = await import('node:fs/promises')
@@ -533,6 +539,19 @@ export async function loadModelsDiskCache(cachePath) {
533
539
  const parsed = JSON.parse(raw)
534
540
  const rawList = Array.isArray(parsed?.models) ? parsed.models : (Array.isArray(parsed) ? parsed : null)
535
541
  if (!rawList) return null
542
+
543
+ const expectedEndpoint = options.endpoint || options.baseUrl
544
+ const expectedAccount = options.accountRef || options.accountId || options.apiKeyEnv
545
+ if (expectedEndpoint && parsed?.endpoint && parsed.endpoint !== expectedEndpoint) {
546
+ return null
547
+ }
548
+ if (expectedAccount && parsed?.accountRef && parsed.accountRef !== expectedAccount) {
549
+ return null
550
+ }
551
+ if ((expectedEndpoint || expectedAccount) && (!parsed?.endpoint || !parsed?.accountRef)) {
552
+ return null
553
+ }
554
+
536
555
  let hasObsolete = false
537
556
  const list = rawList.map((m) => {
538
557
  const migrated = migrateModelEntry(m)
@@ -540,8 +559,10 @@ export async function loadModelsDiskCache(cachePath) {
540
559
  return migrated
541
560
  })
542
561
  list.planSyncedAt = typeof parsed?.planSyncedAt === 'number' ? parsed.planSyncedAt : (typeof parsed?.savedAt === 'number' ? parsed.savedAt : 0)
562
+ list.endpoint = parsed?.endpoint
563
+ list.accountRef = parsed?.accountRef
543
564
  if (hasObsolete) {
544
- saveModelsDiskCache(cachePath, list, list.planSyncedAt).catch(() => {})
565
+ saveModelsDiskCache(cachePath, list, list.planSyncedAt, options).catch(() => {})
545
566
  }
546
567
  return list
547
568
  } catch {
@@ -123,12 +123,16 @@ export async function buildStatus(ctx, cfg) {
123
123
  if (fiveHour.percentUsed >= 95) {
124
124
  quotaWarning = {
125
125
  level: 'exhausted',
126
+ code: 'quota.exhausted_5h',
127
+ params: { percentUsed: fiveHour.percentUsed },
126
128
  message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
127
129
  resetsAt: fiveHour.resetsAt,
128
130
  }
129
131
  } else if (fiveHour.percentUsed >= 90) {
130
132
  quotaWarning = {
131
133
  level: 'warning',
134
+ code: 'quota.warning_5h',
135
+ params: { percentUsed: fiveHour.percentUsed },
132
136
  message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
133
137
  resetsAt: fiveHour.resetsAt,
134
138
  }
@@ -136,17 +140,23 @@ export async function buildStatus(ctx, cfg) {
136
140
  }
137
141
 
138
142
  if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
143
+ const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
139
144
  quotaWarning = {
140
145
  level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
141
- message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${weekly.remainingPercent ?? (100 - weekly.percentUsed)}% remaining).`,
146
+ code: 'quota.warning_weekly',
147
+ params: { percentUsed: weekly.percentUsed, remainingPercent },
148
+ message: `Low weekly quota warning: ${weekly.percentUsed}% consumed (${remainingPercent}% remaining).`,
142
149
  resetsAt: weekly.resetsAt,
143
150
  }
144
151
  }
145
152
 
146
153
  if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
154
+ const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
147
155
  quotaWarning = {
148
156
  level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
149
- message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${monthly.remainingPercent ?? (100 - monthly.percentUsed)}% remaining).`,
157
+ code: 'quota.warning_monthly',
158
+ params: { percentUsed: monthly.percentUsed, remainingPercent },
159
+ message: `Low monthly quota warning: ${monthly.percentUsed}% consumed (${remainingPercent}% remaining).`,
150
160
  resetsAt: monthly.resetsAt,
151
161
  }
152
162
  }
@@ -283,34 +293,30 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
283
293
  await syncProviderState(next)
284
294
  }
285
295
 
286
- if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
287
- const fromDisk = await loadModelsDiskCache(cacheFile)
288
- if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
289
- const migratedDisk = fromDisk.map(migrateModelEntry)
290
- const next = plainConfig(Config({
291
- ...plainConfig(live()),
292
- dynamicModels: migratedDisk,
293
- planSyncedAt: fromDisk.planSyncedAt || Date.now(),
294
- }))
295
- await settingsApi.replace(next)
296
- await syncProviderState(next)
297
- }
298
- }
299
-
300
296
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
301
297
  if (!activeAcc.value) return
302
298
 
303
- const usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
304
- timeoutMs: Math.min(pub.timeoutMs, 5000),
305
- bypassCache: true,
306
- })
299
+ const cacheIdentity = {
300
+ endpoint: pub.baseUrl,
301
+ accountRef: activeAcc.envName || activeAcc.id || pub.apiKeyEnv,
302
+ }
303
+
304
+ let usageData = null
305
+ try {
306
+ usageData = await fetchUsageLimits(pub.baseUrl, activeAcc.value, {
307
+ timeoutMs: Math.min(pub.timeoutMs, 5000),
308
+ bypassCache: true,
309
+ })
310
+ } catch {
311
+ usageData = null
312
+ }
307
313
 
308
314
  if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
309
315
  const existingDynamic = pub.dynamicModels || []
310
316
  const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
317
+ const now = Date.now()
311
318
 
312
319
  if (isDifferent && settingsApi?.replace) {
313
- const now = Date.now()
314
320
  const next = plainConfig(Config({
315
321
  ...plainConfig(live()),
316
322
  dynamicModels: usageData.dynamicModels,
@@ -318,9 +324,24 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
318
324
  }))
319
325
  await settingsApi.replace(next)
320
326
  await syncProviderState(next)
321
- if (cacheFile) {
322
- await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now)
323
- }
327
+ }
328
+ if (cacheFile) {
329
+ await saveModelsDiskCache(cacheFile, usageData.dynamicModels, now, cacheIdentity)
330
+ }
331
+ return
332
+ }
333
+
334
+ if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
335
+ const fromDisk = await loadModelsDiskCache(cacheFile, cacheIdentity)
336
+ if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
337
+ const migratedDisk = fromDisk.map(migrateModelEntry)
338
+ const next = plainConfig(Config({
339
+ ...plainConfig(live()),
340
+ dynamicModels: migratedDisk,
341
+ planSyncedAt: 0,
342
+ }))
343
+ await settingsApi.replace(next)
344
+ await syncProviderState(next)
324
345
  }
325
346
  }
326
347
  } catch {
@@ -161,26 +161,38 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
161
161
  })
162
162
  }
163
163
 
164
- if (body.deleteSecret) {
165
- const delRes = await deleteCredentialKey(ctx, targetEnv)
166
- if (!delRes || !delRes.ok) {
167
- return writeJson(res, 500, {
168
- ok: false,
169
- error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
170
- })
171
- }
172
- }
173
-
174
164
  const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
175
165
  let activeAccount = curr.activeAccount
176
166
  if (activeAccount === targetEnv) {
177
167
  activeAccount = ''
178
168
  }
179
169
 
170
+ // Persist settings changes first before unsetting credentials (#184)
180
171
  const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
181
172
  await settingsApi.replace(next)
182
173
  await syncProviderState(next)
183
174
 
175
+ if (body.deleteSecret) {
176
+ let delRes = null
177
+ try {
178
+ delRes = await deleteCredentialKey(ctx, targetEnv)
179
+ } catch {
180
+ delRes = null
181
+ }
182
+ if (!delRes || !delRes.ok) {
183
+ try {
184
+ await settingsApi.replace(curr)
185
+ await syncProviderState(curr)
186
+ } catch {
187
+ /* ignore rollback error */
188
+ }
189
+ return writeJson(res, 500, {
190
+ ok: false,
191
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
192
+ })
193
+ }
194
+ }
195
+
184
196
  clearUsageCache()
185
197
  clearProbeCache()
186
198
 
@@ -192,13 +192,15 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
192
192
  const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
193
193
 
194
194
  const connectAbortCtrl = new AbortController()
195
+ const bodyAbortCtrl = new AbortController()
195
196
  let connectTimedOut = false
197
+ let bodyTimedOut = false
196
198
  const connectTimer = setTimeout(() => {
197
199
  connectTimedOut = true
198
200
  connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
199
201
  }, connectTimeoutMs)
200
202
 
201
- const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal])
203
+ const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal, bodyAbortCtrl.signal])
202
204
 
203
205
  let upstreamRes
204
206
  try {
@@ -404,8 +406,57 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
404
406
  return
405
407
  }
406
408
 
407
- // Non-streaming JSON response
408
- const resText = await upstreamRes.text().catch(() => '{}')
409
+ // Non-streaming JSON response with deadline and fail-closed error handling (#185)
410
+ const idleTimeoutMs = streamIdleTimeoutMs
411
+ let bodyTimer = null
412
+ const bodyTimeoutPromise = new Promise((_, reject) => {
413
+ bodyTimer = setTimeout(() => {
414
+ bodyTimedOut = true
415
+ bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
416
+ reject(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
417
+ }, idleTimeoutMs)
418
+ })
419
+
420
+ let resText = ''
421
+ let readError = null
422
+ try {
423
+ resText = await Promise.race([
424
+ upstreamRes.text(),
425
+ bodyTimeoutPromise,
426
+ ])
427
+ } catch (err) {
428
+ readError = err
429
+ } finally {
430
+ clearTimeout(bodyTimer)
431
+ }
432
+
433
+ cleanupClientListeners()
434
+
435
+ if (clientAbortCtrl.signal.aborted || clientClosed) {
436
+ return
437
+ }
438
+
439
+ if (bodyTimedOut || readError) {
440
+ const isTimeout = bodyTimedOut || bodyAbortCtrl.signal.aborted
441
+ recordUsage({
442
+ model: body.model,
443
+ accountId: targetAccount.id || targetAccount.label,
444
+ isError: true,
445
+ statsPath: pub.statsPath
446
+ })
447
+ const statusCode = isTimeout ? 504 : 502
448
+ const errMessage = isTimeout
449
+ ? `Gateway Timeout: upstream body read timed out after ${idleTimeoutMs}ms`
450
+ : `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
451
+ return writeJson(res, statusCode, {
452
+ error: {
453
+ message: errMessage,
454
+ type: isTimeout ? 'gateway_timeout' : 'bad_gateway',
455
+ code: isTimeout ? 'body_read_timeout' : 'body_read_error'
456
+ }
457
+ })
458
+ }
459
+
409
460
  let resJson = null
410
461
  try {
411
462
  resJson = JSON.parse(resText)
@@ -413,7 +464,6 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
413
464
  resJson = null
414
465
  }
415
466
 
416
- cleanupClientListeners()
417
467
  recordUsage({
418
468
  model: body.model,
419
469
  accountId: targetAccount.id || targetAccount.label,
@@ -205,6 +205,11 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
205
205
  }
206
206
  }
207
207
 
208
+ // Prune if needed before allocating a new session entry (#183)
209
+ if (!existing && sessions.size >= MAX_SESSIONS) {
210
+ pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
211
+ }
212
+
208
213
  // If manual pin is configured and available:
209
214
  if (pinnedAvailable) {
210
215
  const reassigned = Boolean(existing && !matchesAccount(pinnedAvailable, existing.accountId))
@@ -230,11 +235,6 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
230
235
  // Otherwise, failover is needed
231
236
  }
232
237
 
233
- // Prune if needed
234
- if (!existing && sessions.size >= MAX_SESSIONS) {
235
- pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
236
- }
237
-
238
238
  // Pick least-used available account
239
239
  const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts[0]
240
240
  if (chosen) {
package/lib/updater.js CHANGED
@@ -219,10 +219,32 @@ export async function checkUpdateStatus(options, target = detectRuntime()) {
219
219
  }
220
220
  }
221
221
 
222
- async function installExact(target, packageSpec, options) {
222
+ export function cleanStaleLock(profileDir) {
223
+ if (!profileDir) return false
224
+ const lockFile = resolve(profileDir, 'package.json.lock')
225
+ if (existsSync(lockFile)) {
226
+ try {
227
+ const pid = parseInt(readFileSync(lockFile, 'utf8').trim(), 10)
228
+ if (!isPidAlive(pid)) {
229
+ unlinkSync(lockFile)
230
+ return true
231
+ }
232
+ } catch (err) {
233
+ /* best-effort stale lock unlink */
234
+ }
235
+ }
236
+ return false
237
+ }
238
+
239
+ export async function installExact(target, packageSpec, options = {}) {
223
240
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
241
+ const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : UPDATE_TIMEOUT_MS
242
+ const sigtermGraceMs = typeof options.sigtermGraceMs === 'number' ? options.sigtermGraceMs : 500
243
+ const sigkillGraceMs = typeof options.sigkillGraceMs === 'number' ? options.sigkillGraceMs : 500
244
+ const spawnFn = typeof options.spawn === 'function' ? options.spawn : spawn
245
+
224
246
  await new Promise((resolvePromise, reject) => {
225
- const child = spawn(process.execPath, [
247
+ const child = spawnFn(process.execPath, [
226
248
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
227
249
  packageSpec,
228
250
  `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
@@ -231,44 +253,84 @@ async function installExact(target, packageSpec, options) {
231
253
  windowsHide: true,
232
254
  stdio: ['ignore', 'pipe', 'pipe'],
233
255
  env: { ...process.env, NO_COLOR: '1' },
256
+ detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
234
257
  })
235
258
  let detail = ''
236
259
  child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
237
260
  child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
238
- const timer = setTimeout(() => {
239
- child.kill('SIGTERM')
240
- setTimeout(() => {
241
- if (!child.killed) {
242
- try {
243
- child.kill('SIGKILL')
244
- } catch (err) {
245
- /* ignore if process already terminated */
246
- }
261
+
262
+ let exited = false
263
+ let isTimedOut = false
264
+ let sigtermTimer = null
265
+ let sigkillTimer = null
266
+
267
+ function cleanupTimers() {
268
+ clearTimeout(mainTimer)
269
+ if (sigtermTimer) clearTimeout(sigtermTimer)
270
+ if (sigkillTimer) clearTimeout(sigkillTimer)
271
+ }
272
+
273
+ function onProcessExit(code, signal) {
274
+ if (exited) return
275
+ exited = true
276
+ cleanupTimers()
277
+
278
+ if (isTimedOut) {
279
+ cleanStaleLock(target.profileDir)
280
+ reject(new Error('Update timed out.'))
281
+ } else if (code === 0) {
282
+ resolvePromise()
283
+ } else {
284
+ reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
285
+ }
286
+ }
287
+
288
+ child.once('error', (error) => {
289
+ cleanupTimers()
290
+ reject(error)
291
+ })
292
+
293
+ child.once('exit', onProcessExit)
294
+ child.once('close', onProcessExit)
295
+
296
+ function killProcessTree(sig) {
297
+ if (!child) return
298
+ if (typeof child.pid === 'number' && child.pid > 0 && process.platform !== 'win32') {
299
+ try {
300
+ process.kill(-child.pid, sig)
301
+ } catch {
302
+ /* process group might not exist or already terminated */
247
303
  }
248
- setTimeout(() => {
249
- if (target.profileDir) {
250
- const lockFile = resolve(target.profileDir, 'package.json.lock')
251
- if (existsSync(lockFile)) {
252
- try {
253
- const pid = parseInt(readFileSync(lockFile, 'utf8').trim(), 10)
254
- if (!isPidAlive(pid)) {
255
- unlinkSync(lockFile)
256
- }
257
- } catch (err) {
258
- /* best-effort stale lock unlink */
259
- }
304
+ }
305
+ try {
306
+ child.kill(sig)
307
+ } catch {
308
+ /* ignore if already dead */
309
+ }
310
+ }
311
+
312
+ const mainTimer = setTimeout(() => {
313
+ isTimedOut = true
314
+ // Step 1: Send SIGTERM
315
+ killProcessTree('SIGTERM')
316
+
317
+ // Step 2: Wait for grace period, if process has not exited escalate to SIGKILL (#172)
318
+ sigtermTimer = setTimeout(() => {
319
+ const hasExited = exited || (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
320
+ if (!hasExited) {
321
+ killProcessTree('SIGKILL')
322
+
323
+ // Step 3: Hard deadline for exit notification
324
+ sigkillTimer = setTimeout(() => {
325
+ if (!exited) {
326
+ exited = true
327
+ cleanStaleLock(target.profileDir)
328
+ reject(new Error('Update timed out.'))
260
329
  }
261
- }
262
- }, 100)
263
- }, 500)
264
- reject(new Error('Update timed out.'))
265
- }, UPDATE_TIMEOUT_MS)
266
- child.once('error', (error) => { clearTimeout(timer); reject(error) })
267
- child.once('exit', (code) => {
268
- clearTimeout(timer)
269
- if (code === 0) resolvePromise()
270
- else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
271
- })
330
+ }, sigkillGraceMs)
331
+ }
332
+ }, sigtermGraceMs)
333
+ }, timeoutMs)
272
334
  })
273
335
  }
274
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
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",