@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/CHANGELOG.md +26 -0
- package/README.md +46 -21
- package/README.ru.md +49 -22
- package/README.zh.md +51 -24
- package/lib/account-pool.js +5 -3
- package/lib/client.js +175 -70
- package/lib/cline-client.js +4 -7
- package/lib/index.js +73 -13
- package/lib/models.js +26 -5
- package/lib/provider-sync.js +45 -24
- package/lib/routes/accounts.js +22 -10
- package/lib/routes/proxy.js +54 -4
- package/lib/session-router.js +5 -5
- package/lib/updater.js +96 -34
- package/package.json +1 -1
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)
|
|
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({
|
|
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 {
|
package/lib/provider-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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 {
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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
|
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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
|
|
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,
|
package/lib/session-router.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
}
|
|
263
|
-
},
|
|
264
|
-
|
|
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.
|
|
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",
|