@goodandready/dsh-clinebot 0.5.16 → 0.5.18

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,26 @@ 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.18] - 2026-10-06
9
+
10
+ ### Fixed
11
+ - **Canonical Core CSS Theme Tokens Replacement (Issue #204)**: Replaced 6 non-existent legacy theme tokens across `src/client/` (`state-warning-primary`, `state-brand-primary`, `bg-hover`, `border`, `bg-layer-4`, `status-danger`) with canonical DSH 0.2.0-rc.2 core alias tokens (`state-warn-primary`, `brand-primary`, `interactive-bg-hover`, `border-l2`, `state-error-primary`), restoring color accents to warning badges, progress bars, warning banners, input focus states, button hover states, and critical quota warnings. Added automated test verifying all `--dsw-alias-*` tokens against canonical core allowlist.
12
+ - **Client Fetch Abort Timeout Protection (Issue #205)**: Implemented modular client helper `fetchWithTimeout(url, opts, ms = 30000)` using `AbortController` in `src/client/api.js`. Wrapped all 18 client-side `fetch` calls in `src/client/settings-page.js` to prevent infinite loading/busy button lockups on unresponsive networks or stalled endpoints, unlocking UI states after 30 seconds with informative timeout messaging.
13
+ - **Explicit Warning and Local Fallback for Missing ESLint (Issue #206)**: Updated `scripts/build-client.js` to look for local `node_modules/.bin/eslint` before fallback and emit an explicit stdout warning when eslint is not installed rather than silently suppressing ENOENT. Added `AbortController` to global identifier declarations.
14
+ - **Build Drift Verification via --check Flag (Issue #207)**: Added `--check` mode to `scripts/build-client.js` to verify in memory whether `lib/client.js` is in sync with `src/client/` source modules without rewriting the bundle. Integrated `--check` validation into `test/decomposition.test.js` to prevent uncommitted client bundle drift.
15
+ - **Strict Canonical Row Slot Key Registration (Issue #208)**: Removed redundant loop in `src/client/entry.js` registering inert legacy key `dsh-clinebot#dsh-clinebot`. Retained strictly canonical row seat registration under `ROW_CONFIG_KEY` (`@goodandready/dsh-clinebot#dsh-clinebot`).
16
+ - **Locale Dictionary Registration Error Logging (Issue #209)**: In `src/client/entry.js`, added explicit `console.warn` diagnostics reporting namespace and locale details upon `ctx.locale.register` failure instead of silently swallowing dictionary errors.
17
+
18
+ ## [0.5.17] - 2026-10-05
19
+
20
+ ### Fixed
21
+ - **Cosmokit Volatile Getters Unwrapping in Live Config (Issues #194, #200)**: Resolved issue where `live()` returned raw Cosmokit `Volatile` `{ get }` object boxes for volatile schema fields, causing downstream boolean checks (such as `!pub.enabled` in `syncProviderState`) to always evaluate as truthy objects regardless of actual boolean value. Fully unwrapped `live()` return values via `plainConfig(Config(plainConfig(...)))` and guaranteed robust fallback defaults when `apply()` receives null/undefined config.
22
+ - **Proxy Body Read Timeout & Payload Size Bounds (Issues #198, #202)**: Switched non-streaming upstream body read timeouts from `streamIdleTimeoutMs` (30s) to `bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)`. Added strict guard against unbounded payload responses (`MAX_BODY_BYTES = 10 * 1024 * 1024`) returning 502 Bad Gateway if upstream response exceeds 10MB.
23
+ - **Dead Configuration Keys & Timeout Cleanup (Issue #197)**: Removed non-existent configuration keys (`streamTotalTimeoutMs`, `connectTimeoutMs`, `idleTimeoutMs`) from `lib/routes/proxy.js` and eliminated dead timer logic.
24
+ - **DSH Non-Existent Service API Calls Removal (Issue #199)**: Removed dead `settings?.get` and `settingsApi.watch` calls on DSH services that lack those methods.
25
+ - **Corrupted Stats Backup Pruning (Issue #201)**: Added automatic rotation and pruning for `.corrupt.*` backup files in `lib/stats-storage.js`, retaining at most the 3 latest diagnostic copies to prevent unbounded disk usage.
26
+ - **Version Literals & Slash Command Cleanup (Issue #202)**: Synchronized hardcoded fallback version literals to `'0.5.17'`, cleaned up unused `invocation` argument in `slash-command.js`, and updated fallback port detection (`ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080`).
27
+
8
28
  ## [0.5.16] - 2026-10-04
9
29
 
10
30
  ### Fixed
package/lib/client.js CHANGED
@@ -44,6 +44,7 @@ const en = {
44
44
  'settings.loading': 'Loading ClineBot settings…',
45
45
  'settings.retry': 'Retry',
46
46
  'settings.unavailable': 'ClineBot settings unavailable (host namespace is not ready).',
47
+ 'api.timeout': 'Request timed out ({seconds}s)',
47
48
  'header.title': 'ClineBot (ClinePass) Provider',
48
49
  'header.sub': 'Connect models under your active ClinePass subscription. OpenAI API compatible, automated rolling limit tracking, and instant auto-registration in DSH.',
49
50
  'badge.online': 'Host online ({latency} ms)',
@@ -197,6 +198,7 @@ const en = {
197
198
  'settings.loading': '正在加载 ClineBot 设置…',
198
199
  'settings.retry': '重试',
199
200
  'settings.unavailable': 'ClineBot 设置不可用(宿主命名空间尚未就绪)。',
201
+ 'api.timeout': '请求超时({seconds}秒)',
200
202
  'header.title': 'ClineBot (ClinePass) 模型服务商',
201
203
  'header.sub': '通过 ClinePass 订阅连接主流推理与编程模型。完全兼容 OpenAI 接口,自动追踪滚动限额并在 DSH 中即时生效。',
202
204
  'badge.online': '服务在线 ({latency} ms)',
@@ -406,15 +408,15 @@ function ensureCss() {
406
408
 
407
409
  .cb-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
408
410
  .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}
409
- .cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}
411
+ .cb-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}
410
412
  .cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}
411
413
 
412
414
  .cb-input{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}
413
- .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}
415
+ .cb-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
414
416
  .cb-input-group{display:flex;gap:8px;align-items:center}
415
417
 
416
418
  .cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
417
- .cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
419
+ .cb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
418
420
  .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
419
421
  .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88;visibility:visible !important}
420
422
  .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}
@@ -426,8 +428,8 @@ function ensureCss() {
426
428
  .cb-progress-label{font-weight:500;white-space:nowrap}
427
429
  .cb-progress-meta, .cb-bar-meta{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;margin-left:auto}
428
430
  .cb-progress-track, .cb-bar-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;border:1px solid var(--dsw-alias-border-l2)}
429
- .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-brand-primary, var(--dsw-alias-state-success-primary))}
430
- .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warning-primary)}
431
+ .cb-progress-bar-fill, .cb-bar-fill{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-brand-primary, var(--dsw-alias-state-success-primary))}
432
+ .cb-progress-bar-warn{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-warn-primary)}
431
433
  .cb-progress-bar-bad{height:100%;border-radius:999px;transition:width .3s;background:var(--dsw-alias-state-error-primary)}
432
434
 
433
435
  .cb-table{width:100%;border-collapse:collapse;margin-top:8px}
@@ -438,7 +440,7 @@ function ensureCss() {
438
440
  .cb-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px}
439
441
  .cb-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
440
442
  .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}
441
- .cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
443
+ .cb-banner-warning{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
442
444
  .cb-banner-exhausted{padding:12px 16px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border:1px solid var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:600}
443
445
  .cb-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}
444
446
  .cb-stat-val{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary)}
@@ -448,6 +450,29 @@ function ensureCss() {
448
450
  document.head.appendChild(style)
449
451
  }
450
452
 
453
+ // --- src/client/api.js ---
454
+ async function fetchWithTimeout(url, opts = {}, ms = 30000) {
455
+ const controller = new AbortController()
456
+ const timer = setTimeout(() => {
457
+ controller.abort()
458
+ }, ms)
459
+ const signal = opts.signal || controller.signal
460
+ try {
461
+ const res = await fetch(url, { ...opts, signal })
462
+ return res
463
+ } catch (err) {
464
+ if (err && (err.name === 'AbortError' || controller.signal.aborted)) {
465
+ const sec = Math.round(ms / 1000)
466
+ const tFn = typeof opts.t === 'function' ? opts.t : null
467
+ const msg = tFn ? tFn('api.timeout', { seconds: sec }) : ('Request timed out (' + sec + 's)')
468
+ throw new Error(msg)
469
+ }
470
+ throw err
471
+ } finally {
472
+ clearTimeout(timer)
473
+ }
474
+ }
475
+
451
476
  // --- src/client/error-boundary.js ---
452
477
  function createErrorBoundary() {
453
478
  try {
@@ -549,7 +574,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
549
574
  gap: '8px',
550
575
  padding: '12px 14px',
551
576
  borderRadius: '8px',
552
- border: isBad ? '1px solid var(--dsw-alias-status-danger)' : '1px solid var(--dsw-alias-border-l2)',
577
+ border: isBad ? '1px solid var(--dsw-alias-state-error-primary)' : '1px solid var(--dsw-alias-border-l2)',
553
578
  background: 'var(--dsw-alias-bg-layer-2)',
554
579
  },
555
580
  },
@@ -569,7 +594,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
569
594
  'span',
570
595
  { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
571
596
  label,
572
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-status-danger)' } }, '⚠️ <10%') : null
597
+ isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-state-error-primary)' } }, '⚠️ <10%') : null
573
598
  ),
574
599
  React.createElement(
575
600
  'span',
@@ -577,7 +602,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
577
602
  className: 'cb-progress-meta cb-bar-meta',
578
603
  style: {
579
604
  fontSize: '12px',
580
- color: isBad ? 'var(--dsw-alias-status-danger)' : 'var(--dsw-alias-label-secondary)',
605
+ color: isBad ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
581
606
  whiteSpace: 'nowrap',
582
607
  marginLeft: 'auto',
583
608
  },
@@ -764,7 +789,7 @@ function KeySection({ keyPresent, apiKeyInput, setApiKeyInput, showKey, setShowK
764
789
  href: 'https://app.cline.bot/settings/api-keys',
765
790
  target: '_blank',
766
791
  rel: 'noreferrer',
767
- style: { color: 'var(--dsw-alias-state-brand-primary)' },
792
+ style: { color: 'var(--dsw-alias-brand-primary)' },
768
793
  },
769
794
  t('key.get_key')
770
795
  )
@@ -850,8 +875,8 @@ function AccountsSection({ status, busy, handlePinAccount, handleAddAccount, han
850
875
  {
851
876
  onSubmit: onSubmitAdd,
852
877
  style: {
853
- background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
854
- border: '1px solid var(--dsw-alias-border, color-mix(in srgb, currentColor 12%, transparent))',
878
+ background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
879
+ border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent))',
855
880
  borderRadius: '8px',
856
881
  padding: '12px',
857
882
  marginBottom: '14px',
@@ -1522,7 +1547,7 @@ function ModelsSection({
1522
1547
  display: 'flex',
1523
1548
  flexDirection: 'column',
1524
1549
  gap: '6px',
1525
- background: 'var(--dsw-alias-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1550
+ background: 'var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 4%, transparent))',
1526
1551
  padding: '6px',
1527
1552
  borderRadius: '6px',
1528
1553
  border: '1px solid var(--dsw-alias-border-l2)',
@@ -1985,8 +2010,8 @@ function SettingsPage(props) {
1985
2010
  const load = React.useCallback(async () => {
1986
2011
  setErr('')
1987
2012
  const [res, statsRes] = await Promise.all([
1988
- fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
1989
- fetch(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
2013
+ fetchWithTimeout(`${ROUTE_PREFIX}/status`, { cache: 'no-store' }),
2014
+ fetchWithTimeout(`${ROUTE_PREFIX}/stats`, { cache: 'no-store' }).catch(() => null),
1990
2015
  ])
1991
2016
  const data = await res.json().catch(() => ({}))
1992
2017
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -2001,7 +2026,7 @@ function SettingsPage(props) {
2001
2026
  const checkUpdate = React.useCallback(async () => {
2002
2027
  setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
2003
2028
  try {
2004
- const res = await fetch(`${ROUTE_PREFIX}/update`)
2029
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`)
2005
2030
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
2006
2031
  const data = await res.json().catch(() => ({}))
2007
2032
  if (data.latestCheckFailed) {
@@ -2045,7 +2070,7 @@ function SettingsPage(props) {
2045
2070
  if (updateState.updating) return
2046
2071
  setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
2047
2072
  try {
2048
- const res = await fetch(`${ROUTE_PREFIX}/update`, {
2073
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/update`, {
2049
2074
  method: 'POST',
2050
2075
  headers: { 'x-dsh-plugin-update': '1' },
2051
2076
  })
@@ -2089,7 +2114,7 @@ function SettingsPage(props) {
2089
2114
  const keyVal = String(apiKeyInput || '').trim()
2090
2115
  if (!keyVal) { setErr(t('key.empty_err')); return }
2091
2116
  await performAction('save-key', async () => {
2092
- const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
2117
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/save-key`, {
2093
2118
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2094
2119
  body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
2095
2120
  })
@@ -2103,7 +2128,7 @@ function SettingsPage(props) {
2103
2128
 
2104
2129
  async function handleRefreshQuota() {
2105
2130
  await performAction('refresh-quota', async () => {
2106
- const res = await fetch(`${ROUTE_PREFIX}/usage`)
2131
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/usage`)
2107
2132
  const data = await res.json().catch(() => ({}))
2108
2133
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2109
2134
  setStatus((prev) => (prev ? { ...prev, usage: data } : prev))
@@ -2113,7 +2138,7 @@ function SettingsPage(props) {
2113
2138
 
2114
2139
  async function handleUpdateModelContext(payload) {
2115
2140
  await performAction('update-context', async () => {
2116
- const res = await fetch(`${ROUTE_PREFIX}/models/context`, {
2141
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/context`, {
2117
2142
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2118
2143
  body: JSON.stringify(payload),
2119
2144
  })
@@ -2126,7 +2151,7 @@ function SettingsPage(props) {
2126
2151
 
2127
2152
  async function handleSyncPlanModels() {
2128
2153
  await performAction('sync-models', async () => {
2129
- const res = await fetch(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
2154
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/sync`, { method: 'POST' })
2130
2155
  const data = await res.json().catch(() => ({}))
2131
2156
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2132
2157
  setMsg(t('models.synced_msg', { total: data.totalModelsCount, discovered: data.discoveredCount }))
@@ -2136,7 +2161,7 @@ function SettingsPage(props) {
2136
2161
 
2137
2162
  async function handleRegister() {
2138
2163
  await performAction('register', async () => {
2139
- const res = await fetch(`${ROUTE_PREFIX}/register`, { method: 'POST' })
2164
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/register`, { method: 'POST' })
2140
2165
  const data = await res.json().catch(() => ({}))
2141
2166
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2142
2167
  const count = status?.availableModels?.filter((m) => !(draft?.disabledModels || []).includes(m.id)).length || 0
@@ -2147,7 +2172,7 @@ function SettingsPage(props) {
2147
2172
 
2148
2173
  async function handleUnregister() {
2149
2174
  await performAction('unregister', async () => {
2150
- const res = await fetch(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
2175
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/unregister`, { method: 'POST' })
2151
2176
  const data = await res.json().catch(() => ({}))
2152
2177
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2153
2178
  setMsg(t('diag.unregistered_msg'))
@@ -2158,7 +2183,7 @@ function SettingsPage(props) {
2158
2183
  async function handleSmoke() {
2159
2184
  setSmokeResult(null)
2160
2185
  await performAction('smoke', async () => {
2161
- const res = await fetch(`${ROUTE_PREFIX}/smoke`, {
2186
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/smoke`, {
2162
2187
  method: 'POST',
2163
2188
  headers: { 'Content-Type': 'application/json' },
2164
2189
  body: JSON.stringify({ model: draft?.defaultModel }),
@@ -2182,7 +2207,7 @@ function SettingsPage(props) {
2182
2207
  verifyTimerRef.current = setTimeout(async () => {
2183
2208
  setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
2184
2209
  try {
2185
- const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
2210
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/key/verify`, {
2186
2211
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2187
2212
  body: JSON.stringify({ key: raw }),
2188
2213
  })
@@ -2207,7 +2232,7 @@ function SettingsPage(props) {
2207
2232
 
2208
2233
  async function handlePinAccount(accountEnv) {
2209
2234
  await performAction('pin-account', async () => {
2210
- const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
2235
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/active`, {
2211
2236
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2212
2237
  body: JSON.stringify({ account: accountEnv }),
2213
2238
  })
@@ -2219,7 +2244,7 @@ function SettingsPage(props) {
2219
2244
 
2220
2245
  async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
2221
2246
  await performAction('add-account', async () => {
2222
- const res = await fetch(`${ROUTE_PREFIX}/accounts`, {
2247
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts`, {
2223
2248
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2224
2249
  body: JSON.stringify({ label, apiKeyEnv, apiKey }),
2225
2250
  })
@@ -2232,7 +2257,7 @@ function SettingsPage(props) {
2232
2257
 
2233
2258
  async function handleDeleteAccount(accountEnv, deleteSecret = true) {
2234
2259
  await performAction('delete-account', async () => {
2235
- const res = await fetch(`${ROUTE_PREFIX}/accounts/delete`, {
2260
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts/delete`, {
2236
2261
  method: 'POST', headers: { 'Content-Type': 'application/json' },
2237
2262
  body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
2238
2263
  })
@@ -2245,7 +2270,7 @@ function SettingsPage(props) {
2245
2270
  async function handleConfigPatch(patch) {
2246
2271
  setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
2247
2272
  await performAction('patch-config', async () => {
2248
- const res = await fetch(`${ROUTE_PREFIX}/config`, {
2273
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/config`, {
2249
2274
  method: 'PUT', headers: { 'Content-Type': 'application/json' },
2250
2275
  body: JSON.stringify({ config: patch }),
2251
2276
  })
@@ -2257,7 +2282,7 @@ function SettingsPage(props) {
2257
2282
 
2258
2283
  async function handleResetStats() {
2259
2284
  await performAction('reset-stats', async () => {
2260
- const res = await fetch(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
2285
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/stats/reset`, { method: 'POST' })
2261
2286
  const data = await res.json().catch(() => ({}))
2262
2287
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
2263
2288
  await load()
@@ -2272,7 +2297,7 @@ function SettingsPage(props) {
2272
2297
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
2273
2298
  debounceTimerRef.current = setTimeout(async () => {
2274
2299
  try {
2275
- const res = await fetch(`${ROUTE_PREFIX}/models/toggle`, {
2300
+ const res = await fetchWithTimeout(`${ROUTE_PREFIX}/models/toggle`, {
2276
2301
  method: 'POST',
2277
2302
  headers: { 'Content-Type': 'application/json' },
2278
2303
  body: JSON.stringify({ disabledModels: nextDisabled }),
@@ -2527,7 +2552,8 @@ function apply(ctx) {
2527
2552
  const addLocale = (locale, dictionary) => {
2528
2553
  try {
2529
2554
  return ctx.locale.register(NS, locale, dictionary)
2530
- } catch (_) {
2555
+ } catch (err) {
2556
+ console.warn(`[dsh-clinebot] Failed to register locale dictionary for ns "${NS}", locale "${locale}":`, err)
2531
2557
  return () => {}
2532
2558
  }
2533
2559
  }
@@ -2597,19 +2623,18 @@ function apply(ctx) {
2597
2623
  // The core keys the seat as `<bundle name>#<row id>`; `bundle.name` may be the
2598
2624
  // package name or the short bundle name depending on the manager's view, so both
2599
2625
  // spellings are registered — the unused one is inert.
2600
- for (const key of [ROW_CONFIG_KEY, ROW_ID + '#' + ROW_ID]) {
2601
- registerSlotWhenReady('plugins.row.config', () =>
2602
- ctx.slots.register(
2603
- {
2604
- name: 'plugins.row.config',
2605
- key,
2606
- locale: NS,
2607
- inject: () => ({ ctx }),
2608
- },
2609
- (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2610
- )
2626
+ // Row seat: key = ROW_CONFIG_KEY ('@goodandready/dsh-clinebot#dsh-clinebot').
2627
+ registerSlotWhenReady('plugins.row.config', () =>
2628
+ ctx.slots.register(
2629
+ {
2630
+ name: 'plugins.row.config',
2631
+ key: ROW_CONFIG_KEY,
2632
+ locale: NS,
2633
+ inject: () => ({ ctx }),
2634
+ },
2635
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2611
2636
  )
2612
- }
2637
+ )
2613
2638
 
2614
2639
  registerSlotWhenReady('settings.plugin.item', () =>
2615
2640
  ctx.slots.register(
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ export function configReader(config) {
37
37
  export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
38
38
 
39
39
  export function apply(ctx, config) {
40
- let currentConfig = config
40
+ let currentConfig = config || {}
41
41
  const overrides = {}
42
42
  const getConfig = () => {
43
43
  if (!currentConfig) return currentConfig
@@ -73,7 +73,7 @@ export function apply(ctx, config) {
73
73
  }
74
74
  return currentConfig
75
75
  }
76
- const live = () => (getConfig() ? Config(structuredClone(plainConfig(getConfig()))) : config)
76
+ const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
77
77
  let settingsApi
78
78
 
79
79
  const syncProviderState = async (cfg) => {
@@ -219,7 +219,7 @@ export function apply(ctx, config) {
219
219
  applyConfigMutations(currentConfig, parsed)
220
220
  syncStatsStorage(live())
221
221
  await syncProviderState(live())
222
- return parsed
222
+ return plainConfig(parsed)
223
223
  },
224
224
  update: async (patch) => {
225
225
  const svc = ctx?.get?.('settings')
@@ -248,13 +248,9 @@ export function apply(ctx, config) {
248
248
  applyConfigMutations(currentConfig, merged)
249
249
  syncStatsStorage(live())
250
250
  await syncProviderState(live())
251
- return merged
252
- },
253
- watch: (cb) => {
254
- const svc = ctx?.get?.('settings')
255
- if (typeof svc?.watch === 'function') return svc.watch(cb)
256
- return () => {}
251
+ return plainConfig(merged)
257
252
  },
253
+
258
254
  }
259
255
 
260
256
  if (typeof ctx.effect === 'function') {
@@ -56,10 +56,7 @@ export async function checkRegisteredInPiAi(ctx) {
56
56
  const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
57
57
  if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
58
58
  }
59
- if (typeof settings?.get === 'function') {
60
- const piAi = settings.get(LLM_PI_AI_NS)
61
- if (piAi?.providers?.[PROVIDER_ID]) return true
62
- }
59
+
63
60
  const llm = ctx?.get?.('llm')
64
61
  if (typeof llm?.listProviders === 'function') {
65
62
  const list = await llm.listProviders()
@@ -215,7 +212,7 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
215
212
 
216
213
  if (isProxy) {
217
214
  if (!proxyPort) {
218
- proxyPort = ctx?.webServer?.port || process.env.PORT || 3080
215
+ proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
219
216
  const isFenced = await detectHostWebServerFence(proxyPort)
220
217
  if (isFenced) {
221
218
  ctx?.logger?.warn?.(
@@ -24,9 +24,9 @@ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
24
  import { getLocalProxyToken } from '../proxy-token.js'
25
25
 
26
26
  const require = createRequire(import.meta.url)
27
- let packageVersion = '0.5.3'
27
+ let packageVersion = '0.5.18'
28
28
  try {
29
- packageVersion = require('../../package.json').version || '0.5.6'
29
+ packageVersion = require('../../package.json').version || '0.5.18'
30
30
  } catch (err) {
31
31
  /* fallback to default version if manifest is unreadable */
32
32
  }
@@ -161,8 +161,8 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
161
161
  'User-Agent': USER_AGENT
162
162
  }
163
163
 
164
- const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
165
- const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
164
+ const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
165
+ const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
166
166
 
167
167
  const connectAbortCtrl = new AbortController()
168
168
  const bodyAbortCtrl = new AbortController()
@@ -283,16 +283,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
283
283
  }
284
284
  }
285
285
 
286
- let totalTimer = null
287
- if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
288
- const totalMs = Number(pub.streamTotalTimeoutMs)
289
- totalTimer = setTimeout(() => {
290
- streamError = new Error(`Stream total timeout after ${totalMs}ms`)
291
- try {
292
- reader.cancel('stream total timeout').catch(() => {})
293
- } catch { /* reader already cancelled or closed */ }
294
- }, totalMs)
295
- }
286
+
296
287
 
297
288
  try {
298
289
  resetIdleTimer()
@@ -341,7 +332,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
341
332
  ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
342
333
  } finally {
343
334
  clearIdleTimer()
344
- if (totalTimer) clearTimeout(totalTimer)
335
+
345
336
  clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
346
337
  cleanupClientListeners()
347
338
  try {
@@ -379,17 +370,26 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
379
370
  return
380
371
  }
381
372
 
382
- // Non-streaming JSON response with deadline and fail-closed error handling (#185)
383
- const idleTimeoutMs = streamIdleTimeoutMs
373
+ // Non-streaming JSON response with deadline and fail-closed error handling (#185, #198, #202)
374
+ const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
384
375
  let bodyTimer = null
385
376
  const bodyTimeoutPromise = new Promise((_, reject) => {
386
377
  bodyTimer = setTimeout(() => {
387
378
  bodyTimedOut = true
388
- bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
389
- reject(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
390
- }, idleTimeoutMs)
379
+ bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
380
+ reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
381
+ }, bodyTimeoutMs)
391
382
  })
392
383
 
384
+ const MAX_BODY_BYTES = 10 * 1024 * 1024
385
+ const contentLength = Number(upstreamRes.headers.get('content-length'))
386
+ if (contentLength > MAX_BODY_BYTES) {
387
+ clearTimeout(bodyTimer)
388
+ cleanupClientListeners()
389
+ writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
390
+ return
391
+ }
392
+
393
393
  let resText = ''
394
394
  let readError = null
395
395
  try {
@@ -397,6 +397,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
397
397
  upstreamRes.text(),
398
398
  bodyTimeoutPromise,
399
399
  ])
400
+ if (resText.length > MAX_BODY_BYTES) {
401
+ throw new Error('Upstream response exceeded maximum allowed size')
402
+ }
400
403
  } catch (err) {
401
404
  readError = err
402
405
  } finally {
@@ -419,7 +422,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
419
422
  })
420
423
  const statusCode = isTimeout ? 504 : 502
421
424
  const errMessage = isTimeout
422
- ? `Gateway Timeout: upstream body read timed out after ${idleTimeoutMs}ms`
425
+ ? `Gateway Timeout: upstream body read timed out after ${bodyTimeoutMs}ms`
423
426
  : `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
424
427
  return writeJson(res, statusCode, {
425
428
  error: {
@@ -26,7 +26,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
26
26
  const commands = cmdCtx.commands
27
27
  if (typeof commands?.register !== 'function') return
28
28
 
29
- const executeCommand = async (rawArgs, invocation = null) => {
29
+ const executeCommand = async (rawArgs) => {
30
30
  try {
31
31
  const pub = publicConfig(live())
32
32
  const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
@@ -102,12 +102,12 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
102
102
  if (settingsApi?.replace) {
103
103
  const curr = plainConfig(live())
104
104
  const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
105
- const next = Config({
105
+ const next = plainConfig(Config({
106
106
  ...curr,
107
107
  activeAccount: param,
108
108
  dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
109
109
  planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
110
- })
110
+ }))
111
111
  await settingsApi.replace(next)
112
112
  await syncProviderState(next)
113
113
  return `Active account switched to ${param}`
@@ -276,7 +276,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
276
276
  ? invocation
277
277
  : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
278
278
  try {
279
- const output = await executeCommand(rawArgs, invocation)
279
+ const output = await executeCommand(rawArgs)
280
280
  return {
281
281
  kind: 'success',
282
282
  text: output,
@@ -5,7 +5,7 @@
5
5
  * Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
@@ -104,11 +104,25 @@ export function loadStats(customPath) {
104
104
  }
105
105
  }
106
106
  } catch (err) {
107
- // If reading fails or file is corrupted, preserve corrupted file for diagnostics
107
+ // If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
108
108
  try {
109
109
  if (existsSync(filePath)) {
110
110
  const corruptBackup = `${filePath}.corrupt.${Date.now()}`
111
111
  copyFileSync(filePath, corruptBackup)
112
+ const dir = path.dirname(filePath)
113
+ const base = path.basename(filePath)
114
+ const prefix = `${base}.corrupt.`
115
+ const existing = readdirSync(dir)
116
+ .filter((name) => name.startsWith(prefix))
117
+ .sort()
118
+ while (existing.length > 3) {
119
+ const oldest = existing.shift()
120
+ try {
121
+ unlinkSync(path.join(dir, oldest))
122
+ } catch {
123
+ /* ignore deletion failure */
124
+ }
125
+ }
112
126
  }
113
127
  } catch {
114
128
  /* ignore backup failure */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
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",