@goodandready/dsh-clinebot 0.5.7 → 0.5.9

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/index.js CHANGED
@@ -230,6 +230,7 @@ export function apply(ctx, config) {
230
230
  const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
231
231
  const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
232
232
 
233
+ const isProxy = publicConfig(live()).proxyMode !== false
233
234
  if (isError) {
234
235
  recordSessionRequest({
235
236
  latencyMs,
@@ -239,15 +240,17 @@ export function apply(ctx, config) {
239
240
  completionTokens,
240
241
  model: options?.model,
241
242
  })
242
- recordUsage({
243
- model: options?.model,
244
- promptTokens,
245
- completionTokens,
246
- totalTokens: promptTokens + completionTokens,
247
- isError: true,
248
- is429,
249
- statsPath: publicConfig(live()).statsPath,
250
- })
243
+ if (!isProxy) {
244
+ recordUsage({
245
+ model: options?.model,
246
+ promptTokens,
247
+ completionTokens,
248
+ totalTokens: promptTokens + completionTokens,
249
+ isError: true,
250
+ is429,
251
+ statsPath: publicConfig(live()).statsPath,
252
+ })
253
+ }
251
254
  if (is429 || isExceeded) {
252
255
  handleStream429()
253
256
  }
@@ -268,13 +271,15 @@ export function apply(ctx, config) {
268
271
  completionTokens,
269
272
  model: options?.model,
270
273
  })
271
- recordUsage({
272
- model: options?.model,
273
- promptTokens,
274
- completionTokens,
275
- totalTokens: promptTokens + completionTokens,
276
- statsPath: publicConfig(live()).statsPath,
277
- })
274
+ if (!isProxy) {
275
+ recordUsage({
276
+ model: options?.model,
277
+ promptTokens,
278
+ completionTokens,
279
+ totalTokens: promptTokens + completionTokens,
280
+ statsPath: publicConfig(live()).statsPath,
281
+ })
282
+ }
278
283
  }
279
284
  }
280
285
  yield chunk
@@ -287,13 +292,16 @@ export function apply(ctx, config) {
287
292
  completionTokens,
288
293
  model: options?.model,
289
294
  })
290
- recordUsage({
291
- model: options?.model,
292
- promptTokens,
293
- completionTokens,
294
- totalTokens: promptTokens + completionTokens,
295
- statsPath: publicConfig(live()).statsPath,
296
- })
295
+ const isProxy = publicConfig(live()).proxyMode !== false
296
+ if (!isProxy) {
297
+ recordUsage({
298
+ model: options?.model,
299
+ promptTokens,
300
+ completionTokens,
301
+ totalTokens: promptTokens + completionTokens,
302
+ statsPath: publicConfig(live()).statsPath,
303
+ })
304
+ }
297
305
  }
298
306
  } catch (err) {
299
307
  if (isCline) {
@@ -308,15 +316,18 @@ export function apply(ctx, config) {
308
316
  completionTokens,
309
317
  model: options?.model,
310
318
  })
311
- recordUsage({
312
- model: options?.model,
313
- promptTokens,
314
- completionTokens,
315
- totalTokens: promptTokens + completionTokens,
316
- isError: true,
317
- is429: status === 429,
318
- statsPath: publicConfig(live()).statsPath,
319
- })
319
+ const isProxy = publicConfig(live()).proxyMode !== false
320
+ if (!isProxy) {
321
+ recordUsage({
322
+ model: options?.model,
323
+ promptTokens,
324
+ completionTokens,
325
+ totalTokens: promptTokens + completionTokens,
326
+ isError: true,
327
+ is429: status === 429,
328
+ statsPath: publicConfig(live()).statsPath,
329
+ })
330
+ }
320
331
  if (status === 429 || isQuotaExceededError(msg)) {
321
332
  handleStream429()
322
333
  }
package/lib/models.js CHANGED
@@ -422,10 +422,10 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
422
422
  description: cm.description || `Custom model ${cm.name || cm.id}`,
423
423
  contextLength: cm.contextLength || 128000,
424
424
  maxTokens: cm.maxTokens || 8192,
425
- input: ['text'],
425
+ input: Array.isArray(cm.input) && cm.input.length ? cm.input : ['text'],
426
426
  category: cm.category || 'general',
427
427
  isCustom: true,
428
- reasoningEfforts: cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined,
428
+ reasoningEfforts: cm.reasoningEfforts !== undefined ? cm.reasoningEfforts : (cm.isReasoning ? ['low', 'medium', 'high', 'max'] : undefined),
429
429
  unverified: false,
430
430
  }
431
431
  baseModels.push(applyOverride(fullCustom))
@@ -437,19 +437,19 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
437
437
  return baseModels
438
438
  }
439
439
 
440
- function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
- const all = getAllModels(dynamicModels, modelContextOverrides)
440
+ export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
+ const all = getAllModels(dynamicModels, modelContextOverrides, customModels)
442
442
  const found = all.find((m) => m.id === id)
443
443
  if (found) return found
444
444
  return CLINE_MODELS.find((m) => m.id === id) || null
445
445
  }
446
446
 
447
447
  export function isSupportedModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
448
- return findModel(id, dynamicModels, modelContextOverrides) !== null
448
+ return findModel(id, dynamicModels, modelContextOverrides, customModels) !== null
449
449
  }
450
450
 
451
- function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
- return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
451
+ export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
+ return getAllModels(dynamicModels, modelContextOverrides, customModels).map((m) => m.id)
453
453
  }
454
454
 
455
455
  /**
@@ -501,8 +501,8 @@ export function formatModelDescription(model) {
501
501
  /**
502
502
  * Check if a model supports vision input.
503
503
  */
504
- export function isVisionModel(id, dynamicModels = [], modelContextOverrides = []) {
505
- const m = findModel(id, dynamicModels, modelContextOverrides)
504
+ export function isVisionModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
505
+ const m = findModel(id, dynamicModels, modelContextOverrides, customModels)
506
506
  if (!m) return false
507
507
  return Boolean(m.input?.includes('image') || m.input?.includes('vision'))
508
508
  }
@@ -548,3 +548,29 @@ export async function loadModelsDiskCache(cachePath) {
548
548
  return null
549
549
  }
550
550
  }
551
+
552
+ /**
553
+ * Detects whether the authoritative dynamic models catalog differs from the current catalog
554
+ * in model count, IDs, or metadata (e.g. contextLength, maxTokens, category, etc.).
555
+ */
556
+ export function isCatalogDifferent(currentList = [], authoritativeList = []) {
557
+ if (!Array.isArray(currentList) || !Array.isArray(authoritativeList)) return true
558
+ if (currentList.length !== authoritativeList.length) return true
559
+ const currentMap = new Map(currentList.map((m) => [m.id, m]))
560
+ for (const auth of authoritativeList) {
561
+ const cur = currentMap.get(auth.id)
562
+ if (!cur) return true
563
+ if (
564
+ cur.name !== auth.name ||
565
+ cur.contextLength !== auth.contextLength ||
566
+ cur.maxTokens !== auth.maxTokens ||
567
+ cur.category !== auth.category ||
568
+ cur.description !== auth.description ||
569
+ JSON.stringify(cur.input || []) !== JSON.stringify(auth.input || []) ||
570
+ JSON.stringify(cur.reasoningEfforts || []) !== JSON.stringify(auth.reasoningEfforts || [])
571
+ ) {
572
+ return true
573
+ }
574
+ }
575
+ return false
576
+ }
@@ -1,3 +1,4 @@
1
+ import { getStatsSummary } from './stats-storage.js'
1
2
  import os from 'node:os'
2
3
  import path from 'node:path'
3
4
  import {
@@ -8,6 +9,7 @@ import {
8
9
  loadModelsDiskCache,
9
10
  OBSOLETE_MODEL_ID_MAP,
10
11
  migrateModelEntry,
12
+ isCatalogDifferent,
11
13
  } from './models.js'
12
14
  import {
13
15
  probeHealth,
@@ -121,12 +123,16 @@ export async function buildStatus(ctx, cfg) {
121
123
  if (fiveHour.percentUsed >= 95) {
122
124
  quotaWarning = {
123
125
  level: 'exhausted',
126
+ code: 'quota.exhausted_5h',
127
+ params: { percentUsed: fiveHour.percentUsed },
124
128
  message: `5-hour rolling limit is almost exhausted (${fiveHour.percentUsed}%). New requests may be rejected until quota reset.`,
125
129
  resetsAt: fiveHour.resetsAt,
126
130
  }
127
131
  } else if (fiveHour.percentUsed >= 90) {
128
132
  quotaWarning = {
129
133
  level: 'warning',
134
+ code: 'quota.warning_5h',
135
+ params: { percentUsed: fiveHour.percentUsed },
130
136
  message: `Low quota warning: ${fiveHour.percentUsed}% of 5-hour limit consumed (<10% remaining).`,
131
137
  resetsAt: fiveHour.resetsAt,
132
138
  }
@@ -134,17 +140,23 @@ export async function buildStatus(ctx, cfg) {
134
140
  }
135
141
 
136
142
  if (!quotaWarning && weekly && typeof weekly.percentUsed === 'number' && weekly.percentUsed >= 90) {
143
+ const remainingPercent = weekly.remainingPercent ?? (100 - weekly.percentUsed)
137
144
  quotaWarning = {
138
145
  level: weekly.percentUsed >= 99 ? 'exhausted' : 'warning',
139
- 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).`,
140
149
  resetsAt: weekly.resetsAt,
141
150
  }
142
151
  }
143
152
 
144
153
  if (!quotaWarning && monthly && typeof monthly.percentUsed === 'number' && monthly.percentUsed >= 90) {
154
+ const remainingPercent = monthly.remainingPercent ?? (100 - monthly.percentUsed)
145
155
  quotaWarning = {
146
156
  level: monthly.percentUsed >= 99 ? 'exhausted' : 'warning',
147
- 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).`,
148
160
  resetsAt: monthly.resetsAt,
149
161
  }
150
162
  }
@@ -176,6 +188,7 @@ export async function buildStatus(ctx, cfg) {
176
188
  usage: publicUsage(usage),
177
189
  quotaWarning,
178
190
  sessionStats: { ...sessionStats },
191
+ statsSummary: getStatsSummary(),
179
192
  lastRotation: getLastRotation(),
180
193
  isRegistered,
181
194
  availableModels: allModels,
@@ -213,9 +226,10 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
213
226
  const providerObj = buildPiAiProvider({
214
227
  baseUrl: effectiveBaseUrl,
215
228
  apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
216
- apiKey: localToken,
217
229
  models: modelsToRegister.length ? modelsToRegister : allModels,
218
- customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
230
+ dynamicModels: pub.dynamicModels,
231
+ modelContextOverrides: pub.modelContextOverrides,
232
+ customModels: pub.customModels,
219
233
  displayName: PROVIDER_DISPLAY_NAME,
220
234
  })
221
235
 
@@ -301,12 +315,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
301
315
  bypassCache: true,
302
316
  })
303
317
 
304
- if (usageData?.ok && Array.isArray(usageData.dynamicModels) && usageData.dynamicModels.length > 0) {
318
+ if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
305
319
  const existingDynamic = pub.dynamicModels || []
306
- const existingIds = new Set(existingDynamic.map((m) => m.id))
307
- const hasNew = usageData.dynamicModels.some((m) => !existingIds.has(m.id))
320
+ const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
308
321
 
309
- if (hasNew && settingsApi?.replace) {
322
+ if (isDifferent && settingsApi?.replace) {
310
323
  const now = Date.now()
311
324
  const next = plainConfig(Config({
312
325
  ...plainConfig(live()),
@@ -33,7 +33,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
33
33
  }
34
34
 
35
35
  const dynamicModels = Array.isArray(usageData.dynamicModels) ? usageData.dynamicModels : []
36
- const allModels = getAllModels(dynamicModels, pub.modelContextOverrides)
36
+ const allModels = getAllModels(dynamicModels, pub.modelContextOverrides, pub.customModels)
37
37
  const settingsApi = getSettingsApi()
38
38
  if (!settingsApi || typeof settingsApi.replace !== 'function') {
39
39
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
@@ -87,7 +87,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
87
87
  if (Array.isArray(body.disabledModels)) {
88
88
  patch.disabledModels = body.disabledModels
89
89
  } else if (Array.isArray(body.enabledModels)) {
90
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
90
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
91
91
  const enabledSet = new Set(body.enabledModels)
92
92
  patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
93
93
  }
@@ -120,7 +120,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
120
120
  }
121
121
 
122
122
  const pub = publicConfig(live())
123
- const allModels = getAllModels(pub.dynamicModels, [])
123
+ const allModels = getAllModels(pub.dynamicModels, [], pub.customModels)
124
124
  let currentOverrides = Array.isArray(pub.modelContextOverrides) ? [...pub.modelContextOverrides] : []
125
125
 
126
126
  if (body.mode === 'all-original') {
@@ -171,7 +171,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
171
171
  await settingsApi.replace(next)
172
172
  await syncProviderState(next)
173
173
 
174
- const updatedModels = getAllModels(pub.dynamicModels, currentOverrides)
174
+ const updatedModels = getAllModels(pub.dynamicModels, currentOverrides, pub.customModels)
175
175
  return writeJson(res, 200, {
176
176
  ok: true,
177
177
  modelContextOverrides: next.modelContextOverrides,
@@ -154,6 +154,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
154
154
  let attempt = 0
155
155
  let lastError = null
156
156
  let hadTimeout = false
157
+ let lastTargetAccount = null
158
+ let lastRetryAfter = null
157
159
 
158
160
  while (attempt < maxRetries) {
159
161
  attempt += 1
@@ -165,6 +167,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
165
167
 
166
168
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
167
169
  const targetAccount = routeResult.account
170
+ lastTargetAccount = targetAccount
168
171
 
169
172
  if (!targetAccount || !targetAccount.value) {
170
173
  cleanupClientListeners()
@@ -230,13 +233,23 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
230
233
  const accountId = targetAccount.apiKeyEnv || targetAccount.id || targetAccount.label
231
234
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
232
235
  if (targetAccount.id && targetAccount.id !== accountId) markAccountCooldown(targetAccount.id, 60000, null, 'upstream_429')
233
- recordUsage({
234
- model: body.model,
235
- accountId,
236
- is429: true,
237
- isError: true,
238
- statsPath: pub.statsPath
239
- })
236
+ lastError = new Error(`Upstream rejected with HTTP ${upstreamRes.status}`)
237
+ lastError.status = upstreamRes.status
238
+ const retryAfter = upstreamRes.headers?.get?.('retry-after')
239
+ if (retryAfter) {
240
+ lastRetryAfter = retryAfter
241
+ }
242
+ if (upstreamRes.body) {
243
+ try {
244
+ if (typeof upstreamRes.body.cancel === 'function') {
245
+ upstreamRes.body.cancel().catch(() => {})
246
+ } else if (typeof upstreamRes.body.destroy === 'function') {
247
+ upstreamRes.body.destroy()
248
+ }
249
+ } catch {
250
+ /* ignore cancel error */
251
+ }
252
+ }
240
253
  ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" hit 429, attempting failover (attempt ${attempt}/${maxRetries})`)
241
254
  continue
242
255
  }
@@ -419,21 +432,47 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
419
432
 
420
433
  cleanupClientListeners()
421
434
  // Exhausted all retries
435
+ const is429Exhausted = !hadTimeout && (lastError?.status === 429 || lastError?.status === 402)
436
+ recordUsage({
437
+ model: body.model,
438
+ accountId: lastTargetAccount?.id || lastTargetAccount?.label || 'default',
439
+ isError: true,
440
+ is429: is429Exhausted,
441
+ statsPath: pub.statsPath
442
+ })
422
443
  if (hadTimeout) {
423
444
  return writeJson(res, 504, {
424
445
  error: {
425
446
  message: 'Gateway Timeout: upstream provider timed out across all available accounts',
426
447
  type: 'gateway_timeout',
427
- code: 'upstream_timeout'
448
+ code: 'upstream_timeout',
449
+ details: lastError?.message || null
450
+ }
451
+ })
452
+ }
453
+
454
+ if (is429Exhausted) {
455
+ if (lastRetryAfter) {
456
+ res.setHeader('Retry-After', lastRetryAfter)
457
+ }
458
+ return writeJson(res, 429, {
459
+ error: {
460
+ message: 'All configured accounts in pool exceeded rate limits or quota',
461
+ type: 'rate_limit_exceeded',
462
+ code: 'rate_limit_exceeded',
463
+ details: lastError?.message || null
428
464
  }
429
465
  })
430
466
  }
431
467
 
432
- writeJson(res, 429, {
468
+ const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
469
+ ? lastError.status
470
+ : 502
471
+ return writeJson(res, status, {
433
472
  error: {
434
- message: 'All configured accounts in pool exceeded rate limits or quota',
435
- type: 'rate_limit_exceeded',
436
- code: 'rate_limit_exceeded',
473
+ message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
474
+ type: 'bad_gateway',
475
+ code: 'upstream_unavailable',
437
476
  details: lastError?.message || null
438
477
  }
439
478
  })
@@ -16,6 +16,7 @@ import {
16
16
  clearUsageCache,
17
17
  clearProbeCache,
18
18
  usageCache,
19
+ buildUsageCacheKey,
19
20
  sessionStats,
20
21
  } from './cline-client.js'
21
22
 
@@ -33,7 +34,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
33
34
 
34
35
  // 1. Subcommand /cline models
35
36
  if (subcmd === 'models') {
36
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides)
37
+ const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
37
38
  const disabledSet = new Set(pub.disabledModels || [])
38
39
  const activeCount = allModels.filter((m) => !disabledSet.has(m.id)).length
39
40
  const syncStatus = pub.planSynced
@@ -76,7 +77,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
76
77
  for (const acc of pool) {
77
78
  const pinStr = acc.isPinned ? ' [Pinned]' : ''
78
79
  const statusStr = acc.present ? 'Configured' : 'Missing Key'
79
- const cacheKey = `cline:usage:${(acc.value || '').slice(-8)}`
80
+ const cacheKey = buildUsageCacheKey(pub.baseUrl, acc.value)
80
81
  const cached = usageCache.get(cacheKey)?.data
81
82
  const usageInfo = cached?.windows?.fiveHour ? ` · 5h ${cached.windows.fiveHour.percentUsed}% used` : ''
82
83
  lines.push(` ${acc.label} (${acc.apiKeyEnv})`)
@@ -156,7 +157,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
156
157
 
157
158
  // 7. Subcommand /cline test [model] / smoke
158
159
  if (subcmd === 'test' || subcmd === 'smoke') {
159
- if (param && !isSupportedModel(param, pub.dynamicModels)) {
160
+ if (param && !isSupportedModel(param, pub.dynamicModels, pub.modelContextOverrides, pub.customModels)) {
160
161
  return `ClineBot: Model "${param}" is not recognized. Use /cline models to see available models.`
161
162
  }
162
163
  const activeKey = await resolveActiveAccountKey(ctx, live())
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/'}`,
@@ -235,40 +257,71 @@ async function installExact(target, packageSpec, options) {
235
257
  let detail = ''
236
258
  child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
237
259
  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) {
260
+
261
+ let exited = false
262
+ let isTimedOut = false
263
+ let sigtermTimer = null
264
+ let sigkillTimer = null
265
+
266
+ function cleanupTimers() {
267
+ clearTimeout(mainTimer)
268
+ if (sigtermTimer) clearTimeout(sigtermTimer)
269
+ if (sigkillTimer) clearTimeout(sigkillTimer)
270
+ }
271
+
272
+ function onProcessExit(code, signal) {
273
+ if (exited) return
274
+ exited = true
275
+ cleanupTimers()
276
+
277
+ if (isTimedOut) {
278
+ cleanStaleLock(target.profileDir)
279
+ reject(new Error('Update timed out.'))
280
+ } else if (code === 0) {
281
+ resolvePromise()
282
+ } else {
283
+ reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
284
+ }
285
+ }
286
+
287
+ child.once('error', (error) => {
288
+ cleanupTimers()
289
+ reject(error)
290
+ })
291
+
292
+ child.once('exit', onProcessExit)
293
+ child.once('close', onProcessExit)
294
+
295
+ const mainTimer = setTimeout(() => {
296
+ isTimedOut = true
297
+ // Step 1: Send SIGTERM
298
+ try {
299
+ child.kill('SIGTERM')
300
+ } catch (err) {
301
+ /* ignore if process already terminated */
302
+ }
303
+
304
+ // Step 2: Wait for grace period, if process has not exited escalate to SIGKILL (#172)
305
+ sigtermTimer = setTimeout(() => {
306
+ const hasExited = exited || (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
307
+ if (!hasExited) {
242
308
  try {
243
309
  child.kill('SIGKILL')
244
310
  } catch (err) {
245
311
  /* ignore if process already terminated */
246
312
  }
247
- }
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
- }
313
+
314
+ // Step 3: Hard deadline for exit notification
315
+ sigkillTimer = setTimeout(() => {
316
+ if (!exited) {
317
+ exited = true
318
+ cleanStaleLock(target.profileDir)
319
+ reject(new Error('Update timed out.'))
260
320
  }
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
- })
321
+ }, sigkillGraceMs)
322
+ }
323
+ }, sigtermGraceMs)
324
+ }, timeoutMs)
272
325
  })
273
326
  }
274
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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",