@goodandready/dsh-clinebot 0.5.8 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ 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.9] - 2026-10-01
9
+
10
+ ### Fixed
11
+ - **Updater Timeout Exit & SIGKILL Escalation (Issue #172)**: Ensured `installExact` child process cleanup waits for the operating system process to fully terminate via `exit`/`close` events before releasing profile locks or rejecting update promises. On timeout, sends `SIGTERM`, waits for a grace period, and escalates to `SIGKILL` for stubborn subprocesses.
12
+ - **Proxy Client Disconnection SSE Abort Fixture (Issue #173)**: Validated client disconnection handling against a real Node.js loopback HTTP fixture with client TCP stream destruction, asserting upstream `reader.cancel()` and fetch `signal.aborted` without relying exclusively on synthetic mocks.
13
+ - **Dependency Manifest & Lockfile Synchronization (Issue #174)**: Regenerated `package-lock.json` via clean npm resolution for all 21 dependencies to ensure deterministic, clean `npm ci` builds across environments, while maintaining proper exclusion of the lockfile from package archives.
14
+ - **UI Locale Key Completeness & Reasoning/Quota Strings (Issue #175)**: Resolved missing locale keys across `en` and `zh` dictionaries for structured quota countdowns/warnings, reasoning tags and effort selectors, capability badges, smoke test diagnostic outputs, and ErrorBoundary alerts. Added regression test asserting 100% dictionary completeness and symmetry for client `t()` calls.
15
+ - **Update Check State Machine & Error Handling (Issue #176)**: Implemented explicit update state machine (`idle`, `checking`, `current`, `available`, `error`) in `UpdateBanner` and `settings-page.js`. Eliminated false "✓ Up to date" status indicators on failed network checks or when `latestCheckFailed: true`, and added retry action.
16
+ - **Documentation & Design Contract Modernization (Issue #177)**: Synchronized `README.md`, `README.ru.md`, `README.zh.md`, and `docs/design/DESIGN.md` with active runtime contracts: documented dynamic subscription catalog, custom models, active configuration schema (`disabledModels`, `proxyMode`, `accounts`), corrected route prefixes (`/dsh-clinebot/*`), and documented all 4 registered UI seats and release testing workflows.
17
+
8
18
  ## [0.5.8] - 2026-09-30
9
19
 
10
20
  ### Fixed
package/README.md CHANGED
@@ -74,7 +74,7 @@ graph LR
74
74
  end
75
75
 
76
76
  subgraph PluginHost [dsh-clinebot Host Runtime]
77
- HttpEndpoints["API: /api/plugins/dsh-clinebot/*"]
77
+ HttpEndpoints["API: /dsh-clinebot/*"]
78
78
  ClientCore["lib/cline-client.js"]
79
79
  ModelCatalog["lib/models.js (Curated + Dynamic Plan)"]
80
80
  SlashCmd["Command: /cline"]
@@ -108,7 +108,7 @@ graph LR
108
108
  ## ✨ Features & Module Breakdown
109
109
 
110
110
  * **`lib/models.js`**:
111
- Manages the curated catalog (11 built-in models) with full reasoning effort mappings (`off: null`, `low`, `medium`, `high`, `max`), upstream 200k context limit declarations, and dynamic subscription plan model parsing (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
111
+ Manages the dynamic subscription catalog and fallback models with full reasoning effort mappings (`off: null`, `low`, `medium`, `high`, `max`), upstream 200k context limit declarations, user-defined custom models, and dynamic subscription plan model parsing (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
112
112
  * **`lib/cline-client.js`**:
113
113
  * `fetchUsageLimits`: queries `GET /users/me/plan/usage-limits`, `GET /users/me/plan`, and `GET /users/me` with in-memory caching.
114
114
  * `sessionStats` / `recordSessionRequest`: in-memory telemetry recording requests count, tokens, latency, and timestamps.
@@ -163,29 +163,42 @@ dsh-clinebot:
163
163
  enabled: true
164
164
  baseUrl: https://api.cline.bot/api/v1
165
165
  apiKeyEnv: CLINEBOT_API_KEY
166
- defaultModel: cline-pass/deepseek-v4-flash
167
- timeoutMs: 15000
168
- smokeTimeoutMs: 25000
169
- enabledModels:
170
- - cline-pass/deepseek-v4-flash
171
- - cline-pass/deepseek-v4-pro
172
- - cline-pass/kimi-k3
173
- - cline-pass/qwen3.7-max
174
- dynamicModels: []
166
+ defaultModel: your-default-model
167
+ proxyMode: true
168
+ disabledModels: []
169
+ customModels: []
170
+ modelReasoningDefaults:
171
+ your-reasoning-model: high
172
+ modelContextOverrides: []
173
+ statsPath: ~/.dsh/clinebot-stats.json
174
+ timeoutMs: 30000
175
+ smokeTimeoutMs: 60000
176
+ streamIdleTimeoutMs: 60000
177
+ accounts:
178
+ - label: Team Key
179
+ apiKeyEnv: CLINEBOT_API_KEY_2
175
180
  ```
176
181
 
177
182
  ### Configuration Parameters
178
183
 
179
184
  | Parameter | Type | Default | Description |
180
185
  |:---|:---|:---|:---|
181
- | `enabled` | `boolean` | `true` | Enable or disable the ClineBot provider bridge |
186
+ | `enabled` | `boolean` | `true` | Enable or disable the ClineBot provider bridge in DSH |
182
187
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI-compatible base URL |
183
188
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Environment variable / credentials key name |
184
- | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Default selected model ID |
189
+ | `defaultModel` | `string` | `"claude-3-7-sonnet"` | Default selected model ID for chat and smoke tests |
190
+ | `disabledModels` | `array` | `[]` | List of model IDs hidden from the DSH chat picker (new subscription models are auto-enabled) |
191
+ | `customModels` | `array` | `[]` | User-defined gateway models (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
192
+ | `modelReasoningDefaults` | `object` | `{}` | Configured default reasoning effort per model (`low`, `medium`, `high`, `max`) |
185
193
  | `modelContextOverrides` | `array` | `[]` | User-defined model context length and max token overrides |
186
- | `timeoutMs` | `number` | `15000` | HTTP request timeout in milliseconds |
187
- | `smokeTimeoutMs` | `number` | `25000` | Smoke test latency ping timeout |
188
- | `enabledModels` | `array` | `[...]` | List of models exposed in the DSH chat picker |
194
+ | `proxyMode` | `boolean` | `true` | Enable local transparent loopback proxy for sticky sessions and automatic 429 failover |
195
+ | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Persistent on-disk path for token usage analytics |
196
+ | `accounts` | `array` | `[]` | Additional accounts for multi-account failover and quota rotation (`[{ label, apiKeyEnv }]`) |
197
+ | `activeAccount` | `string` | `""` | Manually pinned active account env name (empty for least-used auto routing) |
198
+ | `timeoutMs` | `number` | `30000` | HTTP probe timeout in milliseconds |
199
+ | `smokeTimeoutMs` | `number` | `60000` | Timeout for smoke test chat completions in milliseconds |
200
+ | `streamIdleTimeoutMs` | `number` | `60000` | Idle timeout between SSE stream chunks in milliseconds |
201
+ | `enabledModels` | `array` | *(deprecated)* | Read-only in public config; rejected on `PUT /config` in favor of `disabledModels` |
189
202
  | `dynamicModels` | `array` | `[]` | Dynamic models automatically synced from the official plan |
190
203
 
191
204
  ---
package/README.ru.md CHANGED
@@ -74,7 +74,7 @@ graph LR
74
74
  end
75
75
 
76
76
  subgraph PluginHost [Хост-часть dsh-clinebot]
77
- HttpEndpoints["API: /api/plugins/dsh-clinebot/*"]
77
+ HttpEndpoints["API: /dsh-clinebot/*"]
78
78
  ClientCore["lib/cline-client.js"]
79
79
  ModelCatalog["lib/models.js (Встроенные + Динамические из плана)"]
80
80
  SlashCmd["Слэш-команда: /cline"]
@@ -107,7 +107,7 @@ graph LR
107
107
 
108
108
  ## ✨ Структура модулей и возможности
109
109
 
110
- * **`lib/models.js`**: каталог встроенных моделей ClinePass (11 моделей с полной поддержкой уровней размышлений `Off` / `Low` / `Medium` / `High` / `Max` и фиксацией лимита шлюза в 200k токенов) и парсер моделей подписки (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
110
+ * **`lib/models.js`**: динамический каталог моделей подписки и встроенных шаблонов с поддержкой уровней рассуждения (`Off` / `Low` / `Medium` / `High` / `Max`), контекстных лимитов, пользовательских моделей (`customModels`) и парсинга тарифа (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
111
111
  * **`lib/cline-client.js`**:
112
112
  * `fetchUsageLimits`: параллельный опрос `GET /users/me/plan/usage-limits`, `GET /users/me/plan` и `GET /users/me` с кэшированием в памяти.
113
113
  * `sessionStats` / `recordSessionRequest`: счетчики сессии (запросы, токены, задержка, время).
@@ -158,29 +158,42 @@ dsh-clinebot:
158
158
  enabled: true
159
159
  baseUrl: https://api.cline.bot/api/v1
160
160
  apiKeyEnv: CLINEBOT_API_KEY
161
- defaultModel: cline-pass/deepseek-v4-flash
162
- timeoutMs: 15000
163
- smokeTimeoutMs: 25000
164
- enabledModels:
165
- - cline-pass/deepseek-v4-flash
166
- - cline-pass/deepseek-v4-pro
167
- - cline-pass/kimi-k3
168
- - cline-pass/qwen3.7-max
169
- dynamicModels: []
161
+ defaultModel: your-default-model
162
+ proxyMode: true
163
+ disabledModels: []
164
+ customModels: []
165
+ modelReasoningDefaults:
166
+ your-reasoning-model: high
167
+ modelContextOverrides: []
168
+ statsPath: ~/.dsh/clinebot-stats.json
169
+ timeoutMs: 30000
170
+ smokeTimeoutMs: 60000
171
+ streamIdleTimeoutMs: 60000
172
+ accounts:
173
+ - label: Team Key
174
+ apiKeyEnv: CLINEBOT_API_KEY_2
170
175
  ```
171
176
 
172
177
  ### Параметры конфигурации
173
178
 
174
179
  | Параметр | Тип | По умолчанию | Описание |
175
180
  |:---|:---|:---|:---|
176
- | `enabled` | `boolean` | `true` | Включение моста провайдера ClineBot |
181
+ | `enabled` | `boolean` | `true` | Включение моста провайдера ClineBot в DSH |
177
182
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | Базовый URL OpenAI-совместимого API ClinePass |
178
183
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Имя переменной / ключа в хранилище credentials |
179
- | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Модель, выбираемая по умолчанию |
184
+ | `defaultModel` | `string` | `"claude-3-7-sonnet"` | Модель, выбираемая по умолчанию для чата и smoke-тестов |
185
+ | `disabledModels` | `array` | `[]` | Список отключённых моделей (новые модели подписки включаются автоматически) |
186
+ | `customModels` | `array` | `[]` | Пользовательские модели шлюза (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
187
+ | `modelReasoningDefaults` | `object` | `{}` | Значения thinking effort по умолчанию для моделей (`low`, `medium`, `high`, `max`) |
180
188
  | `modelContextOverrides` | `array` | `[]` | Пользовательские переопределения размера контекста и лимита токенов |
181
- | `timeoutMs` | `number` | `15000` | Таймаут HTTP-запросов (мс) |
182
- | `smokeTimeoutMs` | `number` | `25000` | Таймаут тестового пинга (мс) |
183
- | `enabledModels` | `array` | `[...]` | Список моделей, активных в селекторе чата |
189
+ | `proxyMode` | `boolean` | `true` | Включение локального прозрачного loopback-прокси с сессионным роутингом и failover |
190
+ | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Путь к файлу персистентной аналитики использования токенов |
191
+ | `accounts` | `array` | `[]` | Дополнительные аккаунты для ротации квоты и failover (`[{ label, apiKeyEnv }]`) |
192
+ | `activeAccount` | `string` | `""` | Принудительно закреплённый аккаунт (пусто для автоматического выбора least-used) |
193
+ | `timeoutMs` | `number` | `30000` | Таймаут HTTP-запросов (мс) |
194
+ | `smokeTimeoutMs` | `number` | `60000` | Таймаут тестового пинга инференса (мс) |
195
+ | `streamIdleTimeoutMs` | `number` | `60000` | Таймаут ожидания между чанками SSE-потока (мс) |
196
+ | `enabledModels` | `array` | *(устарело)* | Только для чтения в public config; отклоняется при `PUT /config` в пользу `disabledModels` |
184
197
  | `dynamicModels` | `array` | `[]` | Динамические модели, автоматически синхронизированные из тарифа |
185
198
 
186
199
  ---
package/README.zh.md CHANGED
@@ -74,7 +74,7 @@ graph LR
74
74
  end
75
75
 
76
76
  subgraph PluginHost [dsh-clinebot 宿主运行环境]
77
- HttpEndpoints["API 路由: /api/plugins/dsh-clinebot/*"]
77
+ HttpEndpoints["API 路由: /dsh-clinebot/*"]
78
78
  ClientCore["lib/cline-client.js"]
79
79
  ModelCatalog["lib/models.js (内置精选 + 动态订阅解析)"]
80
80
  SlashCmd["斜杠指令: /cline"]
@@ -107,7 +107,7 @@ graph LR
107
107
 
108
108
  ## ✨ 核心模块与功能
109
109
 
110
- * **`lib/models.js`**:管理 11 款官方精选内置模型(提供完整的 `Off` / `Low` / `Medium` / `High` / `Max` 思考强度映射、上游 200k 上下文容量标注)以及套餐模型动态解析器(`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`)。
110
+ * **`lib/models.js`**:管理动态订阅模型目录与内置精选模板(提供完整的 `Off` / `Low` / `Medium` / `High` / `Max` 思考强度映射、上游 200k 上下文容量标注、自定义模型支持)以及套餐模型动态解析器(`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`)。
111
111
  * **`lib/cline-client.js`**:
112
112
  * `fetchUsageLimits`:高效并发轮询 `GET /users/me/plan/usage-limits`、`GET /users/me/plan` 与 `GET /users/me` 并进行内存缓存。
113
113
  * `sessionStats` / `recordSessionRequest`:内存级会话度量记录器(请求次数、Token 估算、延迟、时间戳)。
@@ -160,29 +160,42 @@ dsh-clinebot:
160
160
  enabled: true
161
161
  baseUrl: https://api.cline.bot/api/v1
162
162
  apiKeyEnv: CLINEBOT_API_KEY
163
- defaultModel: cline-pass/deepseek-v4-flash
164
- timeoutMs: 15000
165
- smokeTimeoutMs: 25000
166
- enabledModels:
167
- - cline-pass/deepseek-v4-flash
168
- - cline-pass/deepseek-v4-pro
169
- - cline-pass/kimi-k3
170
- - cline-pass/qwen3.7-max
171
- dynamicModels: []
163
+ defaultModel: your-default-model
164
+ proxyMode: true
165
+ disabledModels: []
166
+ customModels: []
167
+ modelReasoningDefaults:
168
+ your-reasoning-model: high
169
+ modelContextOverrides: []
170
+ statsPath: ~/.dsh/clinebot-stats.json
171
+ timeoutMs: 30000
172
+ smokeTimeoutMs: 60000
173
+ streamIdleTimeoutMs: 60000
174
+ accounts:
175
+ - label: Team Key
176
+ apiKeyEnv: CLINEBOT_API_KEY_2
172
177
  ```
173
178
 
174
179
  ### 配置参数说明
175
180
 
176
181
  | 参数项 | 类型 | 默认值 | 说明 |
177
182
  |:---|:---|:---|:---|
178
- | `enabled` | `boolean` | `true` | 是否启用 ClineBot 桥接插件 |
183
+ | `enabled` | `boolean` | `true` | 是否在 DSH 中启用 ClineBot 桥接插件 |
179
184
  | `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI 兼容接口地址 |
180
185
  | `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | 凭据管理系统中的密钥名称 |
181
- | `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | 默认选中的模型 ID |
186
+ | `defaultModel` | `string` | `"claude-3-7-sonnet"` | 默认选中的模型 ID(用于对话及探活) |
187
+ | `disabledModels` | `array` | `[]` | 在选择器中隐藏的模型 ID 列表(新订阅模型默认自动启用) |
188
+ | `customModels` | `array` | `[]` | 用户自定义网关模型 (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
189
+ | `modelReasoningDefaults` | `object` | `{}` | 各模型默认思考强度配置 (`low`, `medium`, `high`, `max`) |
182
190
  | `modelContextOverrides` | `array` | `[]` | 用户自定义模型上下文窗口与最大 Token 数量覆盖 |
183
- | `timeoutMs` | `number` | `15000` | HTTP 请求超时时间(毫秒) |
184
- | `smokeTimeoutMs` | `number` | `25000` | 探活测试超时时间(毫秒) |
185
- | `enabledModels` | `array` | `[...]` | 允许在聊天下拉框中显示的可用模型列表 |
191
+ | `proxyMode` | `boolean` | `true` | 启用透明本地环回代理以支持会话粘性路由与 429 自动故障转移 |
192
+ | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | 本地持久化 Token 用量统计文件路径 |
193
+ | `accounts` | `array` | `[]` | 额外轮转账号池 (`[{ label, apiKeyEnv }]`) |
194
+ | `activeAccount` | `string` | `""` | 手动指定的主账号(为空时自动按最低配额消耗优先路由) |
195
+ | `timeoutMs` | `number` | `30000` | HTTP 请求探测超时时间(毫秒) |
196
+ | `smokeTimeoutMs` | `number` | `60000` | 探活测试超时时间(毫秒) |
197
+ | `streamIdleTimeoutMs` | `number` | `60000` | SSE 流式输出块间空闲超时时间(毫秒) |
198
+ | `enabledModels` | `array` | *(已废弃)* | 仅在 public config 中只读提供,`PUT /config` 时禁止写入并请使用 `disabledModels` |
186
199
  | `dynamicModels` | `array` | `[]` | 从官方套餐中自动同步的动态模型列表 |
187
200
 
188
201
  ---
package/lib/client.js CHANGED
@@ -24,6 +24,19 @@ function readConfigForms(ctx) {
24
24
  try { return ctx.get('configForms') || undefined } catch { return undefined }
25
25
  }
26
26
 
27
+ function useConfigFormsSnapshot(ctx) {
28
+ const scope = React.useMemo(() => {
29
+ try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
30
+ }, [ctx])
31
+ const subscribe = React.useMemo(() => (cb) => {
32
+ try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
33
+ }, [scope])
34
+ const getSnapshot = React.useCallback(() => {
35
+ try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
36
+ }, [scope])
37
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
38
+ }
39
+
27
40
  // --- src/client/locales.js ---
28
41
  const en = {
29
42
  title: 'ClineBot',
@@ -79,6 +92,27 @@ const en = {
79
92
  'quota.window_weekly': '📅 Weekly Window',
80
93
  'quota.used': '{pct}% used',
81
94
  'quota.reset_at': 'Reset: {time}',
95
+ 'quota.window_monthly': '📅 Monthly Window',
96
+ 'quota.countdown': 'Resets in {time}',
97
+ 'quota.warning_low': 'Quota is below 10%. Requests may be throttled soon.',
98
+ 'quota.exhausted_5h': '5-hour rolling limit is almost exhausted ({percentUsed}%). New requests may be rejected until quota reset.',
99
+ 'quota.warning_5h': 'Low quota warning: {percentUsed}% of 5-hour limit consumed (<10% remaining).',
100
+ 'quota.warning_weekly': 'Low weekly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
101
+ 'quota.warning_monthly': 'Low monthly quota warning: {percentUsed}% consumed ({remainingPercent}% remaining).',
102
+ 'models.reasoning_tag': '🧠 Reasoning',
103
+ 'models.effort_low': 'Low',
104
+ 'models.effort_medium': 'Medium',
105
+ 'models.effort_high': 'High',
106
+ 'models.effort_max': 'Max',
107
+ 'models.caps_vision': 'Vision',
108
+ 'models.caps_general': 'General',
109
+ 'models.caps_coding': 'Coding',
110
+ 'diag.smoke_result': '✅ Latency: {latency} ms | Model: {model}\nResponse: {preview}',
111
+ 'diag.empty': '(empty)',
112
+ 'ui.error_title': '⚠️ ClineBot UI Error: ',
113
+ 'ui.retry': 'Retry',
114
+ 'update.check_failed': 'Failed to check for updates',
115
+ 'update.retry': 'Retry check',
82
116
  'models.title': '🎯 Model Picker in DSH ({enabled} of {total} active)',
83
117
  'models.desc': 'Catalog synchronizes directly from ClinePass subscription. New plan models are enabled automatically. Uncheck models to hide them from DSH chat picker. Thinking effort is selectable in the composer model menu.',
84
118
  'models.reasoning_tooltip': 'Supports thinking effort selection in composer model menu (Off / Low / Medium / High / Max)',
@@ -206,6 +240,27 @@ const en = {
206
240
  'quota.window_weekly': '📅 每周滚动窗口',
207
241
  'quota.used': '已使用 {pct}%',
208
242
  'quota.reset_at': '重置时间:{time}',
243
+ 'quota.window_monthly': '📅 每月滚动窗口',
244
+ 'quota.countdown': '{time}后重置',
245
+ 'quota.warning_low': '额度低于 10%,请求可能很快会受到限制。',
246
+ 'quota.exhausted_5h': '5 小时滚动限额已接近耗尽(已用 {percentUsed}%)。在额度重置前新请求可能会被拒绝。',
247
+ 'quota.warning_5h': '低额度警告:5 小时限额已消耗 {percentUsed}%(剩余不足 10%)。',
248
+ 'quota.warning_weekly': '周限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
249
+ 'quota.warning_monthly': '月限额偏低警告:已消耗 {percentUsed}%(剩余 {remainingPercent}%)。',
250
+ 'models.reasoning_tag': '🧠 深度思考',
251
+ 'models.effort_low': '低',
252
+ 'models.effort_medium': '中',
253
+ 'models.effort_high': '高',
254
+ 'models.effort_max': '最高',
255
+ 'models.caps_vision': '视觉',
256
+ 'models.caps_general': '通用',
257
+ 'models.caps_coding': '编程',
258
+ 'diag.smoke_result': '✅ 延迟:{latency} ms | 模型:{model}\n响应:{preview}',
259
+ 'diag.empty': '(空)',
260
+ 'ui.error_title': '⚠️ ClineBot 界面错误:',
261
+ 'ui.retry': '重试',
262
+ 'update.check_failed': '检查更新失败',
263
+ 'update.retry': '重试检查',
209
264
  'models.title': '🎯 DSH 模型选择列表(已启用 {enabled}/{total})',
210
265
  'models.desc': '模型目录与 ClinePass 官方订阅实时同步。新订阅模型默认自动启用。取消勾选可在 DSH 对话模型列表中隐藏不需要的模型。支持在对话输入框上方切换推理思考强度(Effort)。',
211
266
  'models.reasoning_tooltip': '支持在对话框上方模型菜单中选择思考强度(Off / Low / Medium / High / Max)',
@@ -399,10 +454,13 @@ function createErrorBoundary() {
399
454
  }
400
455
  render() {
401
456
  if (this.state.hasError) {
457
+ const t = this.props && typeof this.props.t === 'function' ? this.props.t : null
458
+ const errorTitle = t ? t('ui.error_title') : '⚠️ ClineBot UI Error: '
459
+ const retryLabel = t ? t('ui.retry') : 'Retry'
402
460
  return React.createElement(
403
461
  'div',
404
462
  { className: 'cb-alert-err' },
405
- React.createElement('strong', null, '⚠️ ClineBot UI Error: '),
463
+ React.createElement('strong', null, errorTitle),
406
464
  String(this.state.error?.message || this.state.error || 'Unknown rendering error'),
407
465
  React.createElement(
408
466
  'button',
@@ -412,7 +470,7 @@ function createErrorBoundary() {
412
470
  style: { marginLeft: '12px', padding: '2px 8px', fontSize: '11px' },
413
471
  onClick: () => this.setState({ hasError: false, error: null }),
414
472
  },
415
- 'Retry'
473
+ retryLabel
416
474
  )
417
475
  )
418
476
  }
@@ -537,7 +595,22 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
537
595
  }
538
596
 
539
597
  // --- src/client/components/update-banner.js ---
540
- function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
598
+ function UpdateBanner({ updateState, handleTriggerUpdate, handleCheckUpdate, t }) {
599
+ const status = updateState.status || (
600
+ updateState.checking
601
+ ? 'checking'
602
+ : (updateState.latestCheckFailed
603
+ ? 'error'
604
+ : (updateState.updateAvailable
605
+ ? 'available'
606
+ : (updateState.lastCheckedAt ? 'current' : 'idle')))
607
+ )
608
+
609
+ const isChecking = status === 'checking' || updateState.checking
610
+ const isAvailable = status === 'available' || updateState.updateAvailable
611
+ const isCurrent = status === 'current'
612
+ const isError = status === 'error' || updateState.latestCheckFailed
613
+
541
614
  return React.createElement(
542
615
  React.Fragment,
543
616
  null,
@@ -561,19 +634,25 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
561
634
  `v${updateState.currentVersion}`
562
635
  )
563
636
  : null,
564
- updateState.checking
637
+ isChecking
565
638
  ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px' } },
566
639
  t('update.checking')
567
640
  )
568
- : updateState.updateAvailable
641
+ : isAvailable
569
642
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
570
643
  t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
571
644
  )
572
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
573
- '✓ ' + t('update.up_to_date')
574
- )
645
+ : isCurrent
646
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' },
647
+ '✓ ' + t('update.up_to_date')
648
+ )
649
+ : isError
650
+ ? React.createElement('span', { className: 'cb-badge cb-badge-bad' },
651
+ t('update.check_failed')
652
+ )
653
+ : null
575
654
  ),
576
- updateState.updateAvailable
655
+ isAvailable
577
656
  ? React.createElement(
578
657
  'button',
579
658
  {
@@ -585,7 +664,19 @@ function UpdateBanner({ updateState, handleTriggerUpdate, t }) {
585
664
  },
586
665
  updateState.updating ? t('update.updating') : t('update.btn')
587
666
  )
588
- : null
667
+ : isError && handleCheckUpdate
668
+ ? React.createElement(
669
+ 'button',
670
+ {
671
+ type: 'button',
672
+ className: 'cb-btn',
673
+ disabled: isChecking,
674
+ onClick: handleCheckUpdate,
675
+ style: { padding: '5px 12px', fontSize: '12px' }
676
+ },
677
+ t('update.retry')
678
+ )
679
+ : null
589
680
  ),
590
681
  updateState.notice ? React.createElement('div', { className: 'cb-alert-ok' }, '✓ ' + updateState.notice) : null,
591
682
  updateState.error ? React.createElement('div', { className: 'cb-alert-err' }, updateState.error) : null
@@ -936,7 +1027,7 @@ function QuotaSection({ keyPresent, status, usage, busy, handleRefreshQuota, t }
936
1027
  React.createElement(
937
1028
  'div',
938
1029
  { style: { flex: 1 } },
939
- React.createElement('div', null, status.quotaWarning.message),
1030
+ React.createElement('div', null, (status.quotaWarning.code && t(status.quotaWarning.code, status.quotaWarning.params)) || status.quotaWarning.message),
940
1031
  status.quotaWarning.resetsAt
941
1032
  ? React.createElement(
942
1033
  'div',
@@ -1552,19 +1643,20 @@ function ModelsSection({
1552
1643
  m.category === 'reasoning'
1553
1644
  )
1554
1645
  const category = m.category && m.category !== 'reasoning' ? m.category : (hasReasoning ? null : 'general')
1646
+ const categoryLabel = category === 'general' ? t('models.caps_general') : (category === 'coding' ? t('models.caps_coding') : category)
1555
1647
  const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
1556
1648
  return React.createElement(
1557
1649
  React.Fragment,
1558
1650
  null,
1559
- category ? React.createElement('span', { className: 'cb-badge' }, category) : null,
1651
+ category ? React.createElement('span', { className: 'cb-badge' }, categoryLabel) : null,
1560
1652
  hasVision
1561
- ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, 'Vision')
1653
+ ? React.createElement('span', { className: 'cb-badge', style: { marginLeft: category ? '4px' : '0' } }, t('models.caps_vision'))
1562
1654
  : null,
1563
1655
  hasReasoning
1564
1656
  ? React.createElement(
1565
1657
  React.Fragment,
1566
1658
  null,
1567
- React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, '🧠 Reasoning'),
1659
+ React.createElement('span', { className: 'cb-badge cb-badge-ok', style: { marginLeft: (category || hasVision) ? '4px' : '0' }, title: t('models.reasoning_tooltip') }, t('models.reasoning_tag')),
1568
1660
  React.createElement(
1569
1661
  'select',
1570
1662
  {
@@ -1579,10 +1671,10 @@ function ModelsSection({
1579
1671
  },
1580
1672
  },
1581
1673
  React.createElement('option', { value: '' }, t('models.effort_auto')),
1582
- React.createElement('option', { value: 'low' }, 'Low'),
1583
- React.createElement('option', { value: 'medium' }, 'Medium'),
1584
- React.createElement('option', { value: 'high' }, 'High'),
1585
- React.createElement('option', { value: 'max' }, 'Max')
1674
+ React.createElement('option', { value: 'low' }, t('models.effort_low')),
1675
+ React.createElement('option', { value: 'medium' }, t('models.effort_medium')),
1676
+ React.createElement('option', { value: 'high' }, t('models.effort_high')),
1677
+ React.createElement('option', { value: 'max' }, t('models.effort_max'))
1586
1678
  )
1587
1679
  )
1588
1680
  : null
@@ -1785,27 +1877,17 @@ function DiagSection({ keyPresent, isRegistered, busy, smokeResult, handleSmoke,
1785
1877
  ? React.createElement(
1786
1878
  'div',
1787
1879
  { className: 'cb-preview' },
1788
- `✅ Latency: ${smokeResult.latencyMs} ms | Model: ${smokeResult.model}
1789
- Response: ${smokeResult.preview || '(empty)'}`
1880
+ t('diag.smoke_result', {
1881
+ latency: smokeResult.latencyMs,
1882
+ model: smokeResult.model,
1883
+ preview: smokeResult.preview || t('diag.empty'),
1884
+ })
1790
1885
  )
1791
1886
  : null
1792
1887
  )
1793
1888
  }
1794
1889
 
1795
1890
  // --- src/client/settings-page.js ---
1796
- function useConfigFormsSnapshot(ctx) {
1797
- const scope = React.useMemo(() => {
1798
- try { return readConfigForms(ctx)?.get?.(NS) || undefined } catch { return undefined }
1799
- }, [ctx])
1800
- const subscribe = React.useMemo(() => (cb) => {
1801
- try { return scope?.subscribe ? (scope.subscribe(cb) || (() => {})) : () => {} } catch { return () => {} }
1802
- }, [scope])
1803
- const getSnapshot = React.useCallback(() => {
1804
- try { return scope?.getSnapshot?.() || SNAPSHOT_READY } catch { return SNAPSHOT_READY }
1805
- }, [scope])
1806
- return React.useSyncExternalStore(subscribe, getSnapshot, () => SNAPSHOT_READY)?.status || 'loading'
1807
- }
1808
-
1809
1891
  function SettingsPage(props) {
1810
1892
  const ctx = props?.ctx
1811
1893
  const t = props?.t || makeT(en, en)
@@ -1819,8 +1901,9 @@ function SettingsPage(props) {
1819
1901
  const [smokeResult, setSmokeResult] = React.useState(null)
1820
1902
 
1821
1903
  const [updateState, setUpdateState] = React.useState({
1822
- checking: false, updating: false, currentVersion: '', latestVersion: '',
1823
- updateAvailable: false, canAutoUpdate: true, error: '', notice: '',
1904
+ status: 'idle', checking: false, updating: false, currentVersion: '', latestVersion: '',
1905
+ updateAvailable: false, canAutoUpdate: true, latestCheckFailed: false, lastCheckedAt: null,
1906
+ error: '', notice: '',
1824
1907
  })
1825
1908
 
1826
1909
  const [apiKeyInput, setApiKeyInput] = React.useState('')
@@ -1847,23 +1930,45 @@ function SettingsPage(props) {
1847
1930
  }, [load])
1848
1931
 
1849
1932
  const checkUpdate = React.useCallback(async () => {
1850
- setUpdateState((s) => ({ ...s, checking: true, error: '' }))
1933
+ setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
1851
1934
  try {
1852
1935
  const res = await fetch(`${ROUTE_PREFIX}/update`)
1853
1936
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
1854
1937
  const data = await res.json().catch(() => ({}))
1938
+ if (data.latestCheckFailed) {
1939
+ setUpdateState((s) => ({
1940
+ ...s,
1941
+ checking: false,
1942
+ status: 'error',
1943
+ latestCheckFailed: true,
1944
+ currentVersion: data.currentVersion || s.currentVersion,
1945
+ error: t('update.check_failed'),
1946
+ }))
1947
+ return
1948
+ }
1949
+ const isAvailable = Boolean(data.updateAvailable)
1855
1950
  setUpdateState((s) => ({
1856
1951
  ...s,
1857
1952
  checking: false,
1953
+ status: isAvailable ? 'available' : 'current',
1858
1954
  currentVersion: data.currentVersion || s.currentVersion,
1859
1955
  latestVersion: data.latestVersion || '',
1860
- updateAvailable: !!data.updateAvailable,
1956
+ updateAvailable: isAvailable,
1957
+ latestCheckFailed: false,
1958
+ lastCheckedAt: Date.now(),
1861
1959
  canAutoUpdate: data.canAutoUpdate !== false,
1960
+ error: '',
1961
+ }))
1962
+ } catch (err) {
1963
+ setUpdateState((s) => ({
1964
+ ...s,
1965
+ checking: false,
1966
+ status: 'error',
1967
+ latestCheckFailed: true,
1968
+ error: String(err?.message || err || t('update.check_failed')),
1862
1969
  }))
1863
- } catch (_) {
1864
- setUpdateState((s) => ({ ...s, checking: false }))
1865
1970
  }
1866
- }, [])
1971
+ }, [t])
1867
1972
 
1868
1973
  React.useEffect(() => { checkUpdate() }, [checkUpdate])
1869
1974
 
@@ -1881,13 +1986,20 @@ function SettingsPage(props) {
1881
1986
  setUpdateState((s) => ({
1882
1987
  ...s,
1883
1988
  updating: false,
1989
+ status: 'current',
1884
1990
  updateAvailable: false,
1885
1991
  currentVersion: newVer,
1992
+ lastCheckedAt: Date.now(),
1886
1993
  notice: t('update.done', { version: newVer }),
1887
1994
  }))
1888
1995
  setTimeout(() => checkUpdate(), 2000)
1889
1996
  } catch (err) {
1890
- setUpdateState((s) => ({ ...s, updating: false, error: t('update.failed', { error: String(err.message || err) }) }))
1997
+ setUpdateState((s) => ({
1998
+ ...s,
1999
+ updating: false,
2000
+ status: s.updateAvailable ? 'available' : 'error',
2001
+ error: t('update.failed', { error: String(err?.message || err) }),
2002
+ }))
1891
2003
  }
1892
2004
  }
1893
2005
 
@@ -1909,8 +2021,7 @@ function SettingsPage(props) {
1909
2021
  if (!keyVal) { setErr(t('key.empty_err')); return }
1910
2022
  await performAction('save-key', async () => {
1911
2023
  const res = await fetch(`${ROUTE_PREFIX}/save-key`, {
1912
- method: 'POST',
1913
- headers: { 'Content-Type': 'application/json' },
2024
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1914
2025
  body: JSON.stringify({ apiKey: keyVal, apiKeyEnv: draft?.apiKeyEnv }),
1915
2026
  })
1916
2027
  const data = await res.json().catch(() => ({}))
@@ -1934,8 +2045,7 @@ function SettingsPage(props) {
1934
2045
  async function handleUpdateModelContext(payload) {
1935
2046
  await performAction('update-context', async () => {
1936
2047
  const res = await fetch(`${ROUTE_PREFIX}/models/context`, {
1937
- method: 'POST',
1938
- headers: { 'Content-Type': 'application/json' },
2048
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1939
2049
  body: JSON.stringify(payload),
1940
2050
  })
1941
2051
  const data = await res.json().catch(() => ({}))
@@ -2004,8 +2114,7 @@ function SettingsPage(props) {
2004
2114
  setVerifyStatus({ state: 'verifying', email: '', plan: '', error: '' })
2005
2115
  try {
2006
2116
  const res = await fetch(`${ROUTE_PREFIX}/key/verify`, {
2007
- method: 'POST',
2008
- headers: { 'Content-Type': 'application/json' },
2117
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2009
2118
  body: JSON.stringify({ key: raw }),
2010
2119
  })
2011
2120
  const data = await res.json().catch(() => ({}))
@@ -2030,8 +2139,7 @@ function SettingsPage(props) {
2030
2139
  async function handlePinAccount(accountEnv) {
2031
2140
  await performAction('pin-account', async () => {
2032
2141
  const res = await fetch(`${ROUTE_PREFIX}/accounts/active`, {
2033
- method: 'POST',
2034
- headers: { 'Content-Type': 'application/json' },
2142
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2035
2143
  body: JSON.stringify({ account: accountEnv }),
2036
2144
  })
2037
2145
  const data = await res.json().catch(() => ({}))
@@ -2043,8 +2151,7 @@ function SettingsPage(props) {
2043
2151
  async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
2044
2152
  await performAction('add-account', async () => {
2045
2153
  const res = await fetch(`${ROUTE_PREFIX}/accounts`, {
2046
- method: 'POST',
2047
- headers: { 'Content-Type': 'application/json' },
2154
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2048
2155
  body: JSON.stringify({ label, apiKeyEnv, apiKey }),
2049
2156
  })
2050
2157
  const data = await res.json().catch(() => ({}))
@@ -2057,8 +2164,7 @@ function SettingsPage(props) {
2057
2164
  async function handleDeleteAccount(accountEnv, deleteSecret = true) {
2058
2165
  await performAction('delete-account', async () => {
2059
2166
  const res = await fetch(`${ROUTE_PREFIX}/accounts/delete`, {
2060
- method: 'POST',
2061
- headers: { 'Content-Type': 'application/json' },
2167
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2062
2168
  body: JSON.stringify({ apiKeyEnv: accountEnv, deleteSecret }),
2063
2169
  })
2064
2170
  const data = await res.json().catch(() => ({}))
@@ -2071,8 +2177,7 @@ function SettingsPage(props) {
2071
2177
  setDraft((d) => ({ ...(d || {}), ...(patch || {}) }))
2072
2178
  await performAction('patch-config', async () => {
2073
2179
  const res = await fetch(`${ROUTE_PREFIX}/config`, {
2074
- method: 'PUT',
2075
- headers: { 'Content-Type': 'application/json' },
2180
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
2076
2181
  body: JSON.stringify({ config: patch }),
2077
2182
  })
2078
2183
  const data = await res.json().catch(() => ({}))
@@ -2136,15 +2241,10 @@ function SettingsPage(props) {
2136
2241
  function handleSetModelsFilter(type) {
2137
2242
  const all = status?.availableModels || []
2138
2243
  let allowed = new Set()
2139
- if (type === 'all') {
2140
- allowed = new Set(all.map((m) => m.id))
2141
- } else if (type === 'vision') {
2142
- allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
2143
- } else if (type === 'coding') {
2144
- allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
2145
- } else if (type === 'recommended') {
2146
- allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
2147
- }
2244
+ if (type === 'all') allowed = new Set(all.map((m) => m.id))
2245
+ else if (type === 'vision') allowed = new Set(all.filter((m) => m.input?.includes('image') || m.input?.includes('vision')).map((m) => m.id))
2246
+ else if (type === 'coding') allowed = new Set(all.filter((m) => m.category === 'coding').map((m) => m.id))
2247
+ else if (type === 'recommended') allowed = new Set(all.filter((m) => m.recommended).map((m) => m.id))
2148
2248
 
2149
2249
  const curr = draft?.disabledModels || []
2150
2250
  const nextDisabled = all.map((m) => m.id).filter((id) => !allowed.has(id))
@@ -2205,7 +2305,7 @@ function SettingsPage(props) {
2205
2305
  ),
2206
2306
 
2207
2307
  // In-app Update Bar
2208
- React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, t }),
2308
+ React.createElement(UpdateBanner, { updateState, handleTriggerUpdate, handleCheckUpdate: checkUpdate, t }),
2209
2309
 
2210
2310
  // Settings host status warning
2211
2311
  snapshotStatus === 'unavailable'
@@ -2338,7 +2438,7 @@ function PluginCard(props) {
2338
2438
  { style: { marginTop: '16px' } },
2339
2439
  React.createElement(
2340
2440
  ErrorBoundary,
2341
- null,
2441
+ { t },
2342
2442
  React.createElement(SettingsPage, { ...props, ctx: props && props.ctx })
2343
2443
  )
2344
2444
  )
@@ -2423,7 +2523,7 @@ function apply(ctx) {
2423
2523
  locale: NS,
2424
2524
  inject: () => ({ ctx }),
2425
2525
  },
2426
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2526
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2427
2527
  )
2428
2528
  )
2429
2529
 
@@ -2440,7 +2540,7 @@ function apply(ctx) {
2440
2540
  locale: NS,
2441
2541
  inject: () => ({ ctx }),
2442
2542
  },
2443
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2543
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2444
2544
  )
2445
2545
  )
2446
2546
  }
@@ -2453,7 +2553,7 @@ function apply(ctx) {
2453
2553
  locale: NS,
2454
2554
  inject: () => ({ ctx }),
2455
2555
  },
2456
- (props) => React.createElement(ErrorBoundary, null, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2556
+ (props) => React.createElement(ErrorBoundary, { t: props && props.t }, React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx }))
2457
2557
  )
2458
2558
  )
2459
2559
  }
@@ -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
  }
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.8",
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",