@goodandready/dsh-clinebot 0.5.9 → 0.5.11
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 +27 -0
- package/README.md +21 -9
- package/README.ru.md +25 -11
- package/README.zh.md +27 -13
- package/lib/account-pool.js +5 -3
- package/lib/client.js +6 -1
- package/lib/cline-client.js +6 -7
- package/lib/config.js +1 -1
- package/lib/index.js +78 -13
- package/lib/models.js +26 -5
- package/lib/provider-sync.js +33 -22
- package/lib/routes/accounts.js +75 -11
- package/lib/routes/models.js +6 -1
- package/lib/routes/proxy.js +60 -4
- package/lib/session-router.js +5 -5
- package/lib/stats-storage.js +23 -0
- package/lib/updater.js +54 -18
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.11] - 2026-10-01
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Dynamic Stats Storage Switching & Counter Preservation (Issue #158)**: Flushes and synchronizes in-memory metrics before switching file storage targets when `statsPath` is updated dynamically, avoiding overwriting accumulated statistics when paths change.
|
|
12
|
+
- **Session Affinity Native Transport Bridge via Prompt Cache Key (Issue #163)**: Bridge native DSH `llm-pi-ai` requests to ClineBot sticky sessions by enabling `cacheRetention: 'long'` in the provider contract, and extracting session identifiers from `prompt_cache_key` on the proxy route, maintaining consistent account affinity across turns.
|
|
13
|
+
- **Catalog Lifecycle & Cache Identity Isolation (Issue #168)**: Isolated disk model cache records by specific account and endpoint scopes, clearing dynamic model cache and sync timestamps on active account switch, and reporting `planSynced: false` when offline cached plans cannot be actively verified against the provider.
|
|
14
|
+
- **Updater Process Group Escalation on Parent Exit (Issue #172)**: Ensured process group supervision continues after parent SIGTERM exit in `updater.js`, checking `isProcessGroupAlive(child.pid)` and escalating to SIGKILL across the detached process group (`-child.pid`) to eliminate lingering background processes.
|
|
15
|
+
- **Transactional Account Delete Surgical Rollback & Retry (Issues #184, #187)**: Replaced full-config rollback with surgical restoration of removed accounts into fresh live configuration on secret deletion failure, preserving concurrent settings updates. Exposed partial deletion state (`partial: true`) and allowed safe retry of credential cleanup without HTTP 404 errors.
|
|
16
|
+
- **DESIGN Contract Alignment with Native Runtime Contracts (Issue #177)**: Synchronized `docs/design/DESIGN.md` with active implementation: documented native DSH transport bridge, dynamic catalog lifecycle, updater process group cleanup, and dynamic stats storage switching.
|
|
17
|
+
- **Regression & Hardening Test Coverage (Issue #173)**: Added regression tests for dynamic stats switching, shifting quota session stickiness, process group lifecycle escalation on parent SIGTERM, and transactional partial account deletion and retry.
|
|
18
|
+
|
|
19
|
+
## [0.5.10] - 2026-10-01
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Native Cosmokit Volatile Box & Proxy Target Preservation (Issues #157, #173)**: Preserved native Cosmokit `{ get }` volatile references without scalar overwrites during config mutations, and ensured extensible proxy targets for frozen configurations to prevent `TypeError: 'ownKeys' on proxy` invariants.
|
|
23
|
+
- **In-Memory Stats Retention Across Config Updates (Issue #158)**: Retained unwritten in-memory metrics across unrelated configuration saves (`PUT /config`) without reloading from disk or dropping unpersisted counters. Flushes pending metrics before switching storage paths when `statsPath` changes.
|
|
24
|
+
- **Account Pool Least-Used Routing on Empty Active Account (Issue #162)**: Allowed automatic least-used quota routing when `activeAccount` is set to an empty string or `"auto"`, preventing unintended pinning of the primary default account.
|
|
25
|
+
- **Native DSH Provider Compatibility Flag Normalization (Issue #163)**: Removed obsolete session affinity compatibility flags (`sendSessionAffinityHeaders`, `sessionAffinityFormat`) rejected by native DSH `llm-pi-ai`, restoring clean catalog registration and model resolution.
|
|
26
|
+
- **Disk Models Cache Isolation by Account & Endpoint (Issue #168)**: Keyed on-disk models cache (`saveModelsDiskCache`/`loadModelsDiskCache`) to specific account and endpoint identities, preventing cross-account model pollution and false `planSynced: true` indications in offline mode.
|
|
27
|
+
- **Process Group Termination & Zombie Prevention in Updater (Issue #172)**: Spanned updater processes with `detached: true` on POSIX, transmitting `SIGTERM` and `SIGKILL` across the entire process group (`-child.pid`) on timeout to guarantee complete termination of spawned child/grandchild processes and prompt lock release.
|
|
28
|
+
- **Session Router Bounded Memory Capacity on Manual Pin (Issue #183)**: Enforced strict capacity bounds (`MAX_SESSIONS = 1000`) on manual pin branches in `resolveSessionAccount`, ensuring LRU eviction and preventing unbounded memory growth.
|
|
29
|
+
- **Transactional Account Deletion & Secret Preservation (Issue #184)**: Persisted configuration updates prior to unsetting credentials in `handleDeleteAccount`, preventing irrevocable API key loss if settings persistence fails, and rolling back settings on credential deletion failures.
|
|
30
|
+
- **Non-Stream Proxy Response Body Deadline & Fail-Closed Error Handling (Issue #185)**: Wrapped non-streaming proxy response body reading in a `Promise.race` deadline bounded by `streamIdleTimeoutMs`, aborting upstream requests and returning HTTP 504 Gateway Timeout or 502 Bad Gateway with error metrics on stalls or socket drops instead of false HTTP 200 `{}`.
|
|
31
|
+
- **Localized Quota Countdown & Duplicate Chinese Reset Label Elimination (Issue #175)**: Added localized `quota.resetting` keys in `zh` ("正在重置") and `en` ("Resetting…") and handled resetting countdown states cleanly in `makeT` and `ProgressBar`, eliminating awkward "resetting后重置" strings.
|
|
32
|
+
- **Documentation & DESIGN Contract Alignment (Issue #177)**: Removed obsolete `settings.yaml` references across all documentation, updated runtime defaults (`cline-pass/deepseek-v4-flash`, timeouts 15s/25s/30s), documented 256 KiB control vs 64 MiB proxy body limits, and documented auto-routing policies.
|
|
33
|
+
- **Native DSH Adapter Integration Test Coverage (Issue #173)**: Added regression and integration tests verifying native DSH `llm-pi-ai` provider registration without `catalogError` and validating reactive volatile schema behavior.
|
|
34
|
+
|
|
8
35
|
## [0.5.9] - 2026-10-01
|
|
9
36
|
|
|
10
37
|
### Fixed
|
package/README.md
CHANGED
|
@@ -154,9 +154,9 @@ From any DSH chat session, type `/cline` to inspect quota, warning alerts, and s
|
|
|
154
154
|
|
|
155
155
|
---
|
|
156
156
|
|
|
157
|
-
## ⚙️ Configuration Reference (
|
|
157
|
+
## ⚙️ Configuration Reference (DSH Settings & Web UI)
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
In modern DeepSeek Harness (0.1.7+), settings are managed natively via the DSH Web UI Settings card or `PUT /dsh-clinebot/config`, with API keys persisted securely in DSH Credentials storage (`~/.dsh/.credentials.yaml`).
|
|
160
160
|
|
|
161
161
|
```yaml
|
|
162
162
|
dsh-clinebot:
|
|
@@ -186,7 +186,7 @@ dsh-clinebot:
|
|
|
186
186
|
| `enabled` | `boolean` | `true` | Enable or disable the ClineBot provider bridge in DSH |
|
|
187
187
|
| `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI-compatible base URL |
|
|
188
188
|
| `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Environment variable / credentials key name |
|
|
189
|
-
| `defaultModel` | `string` | `"
|
|
189
|
+
| `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Default selected model ID for chat and smoke tests |
|
|
190
190
|
| `disabledModels` | `array` | `[]` | List of model IDs hidden from the DSH chat picker (new subscription models are auto-enabled) |
|
|
191
191
|
| `customModels` | `array` | `[]` | User-defined gateway models (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
|
|
192
192
|
| `modelReasoningDefaults` | `object` | `{}` | Configured default reasoning effort per model (`low`, `medium`, `high`, `max`) |
|
|
@@ -195,9 +195,9 @@ dsh-clinebot:
|
|
|
195
195
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Persistent on-disk path for token usage analytics |
|
|
196
196
|
| `accounts` | `array` | `[]` | Additional accounts for multi-account failover and quota rotation (`[{ label, apiKeyEnv }]`) |
|
|
197
197
|
| `activeAccount` | `string` | `""` | Manually pinned active account env name (empty for least-used auto routing) |
|
|
198
|
-
| `timeoutMs` | `number` | `
|
|
199
|
-
| `smokeTimeoutMs` | `number` | `
|
|
200
|
-
| `streamIdleTimeoutMs` | `number` | `
|
|
198
|
+
| `timeoutMs` | `number` | `15000` | HTTP probe timeout in milliseconds |
|
|
199
|
+
| `smokeTimeoutMs` | `number` | `25000` | Timeout for smoke test chat completions in milliseconds |
|
|
200
|
+
| `streamIdleTimeoutMs` | `number` | `30000` | Idle timeout between SSE stream chunks in milliseconds |
|
|
201
201
|
| `enabledModels` | `array` | *(deprecated)* | Read-only in public config; rejected on `PUT /config` in favor of `disabledModels` |
|
|
202
202
|
| `dynamicModels` | `array` | `[]` | Dynamic models automatically synced from the official plan |
|
|
203
203
|
|
|
@@ -205,17 +205,29 @@ dsh-clinebot:
|
|
|
205
205
|
|
|
206
206
|
## 🌐 HTTP API Endpoints
|
|
207
207
|
|
|
208
|
-
All endpoints are registered under `/dsh-clinebot/*` and protected against untrusted cross-site origins (same-origin and loopback allowed):
|
|
208
|
+
All endpoints are registered under `/dsh-clinebot/*` and protected against untrusted cross-site origins (same-origin and loopback allowed). Control endpoints enforce a 256 KiB request payload limit; the loopback proxy endpoint supports up to 64 MiB payloads (`MAX_PROXY_BODY_BYTES`):
|
|
209
209
|
|
|
210
|
+
### Management & Control Endpoints (Max 256 KiB)
|
|
210
211
|
* `GET /dsh-clinebot/status` — Live status report including provider health, active credential, quota, and session metrics.
|
|
211
212
|
* `GET /dsh-clinebot/config` — Diagnostic endpoint returning public configuration without secret keys.
|
|
212
213
|
* `PUT /dsh-clinebot/config` — Update configuration fields. Accepts only known schema properties (unknown fields or deprecated `enabledModels` return `400 Bad Request`).
|
|
213
214
|
* `POST /dsh-clinebot/key/verify` — Validates a candidate API key against `api.cline.bot` and returns account email and plan name.
|
|
214
215
|
* `POST /dsh-clinebot/save-key` — Saves a key into DSH credentials service under a valid `CLINEBOT_API_KEY*` name.
|
|
215
216
|
* `POST /dsh-clinebot/models/sync` — Synchronizes models with your active subscription plan.
|
|
216
|
-
* `POST /dsh-clinebot/models/toggle` — Toggles models via `disabledModels
|
|
217
|
-
* `POST /dsh-clinebot/
|
|
217
|
+
* `POST /dsh-clinebot/models/toggle` — Toggles models via `disabledModels` (atomic Volatile preservation).
|
|
218
|
+
* `POST /dsh-clinebot/models/custom` / `DELETE /dsh-clinebot/models/custom` — Manage custom user-defined models.
|
|
219
|
+
* `POST /dsh-clinebot/models/context` / `DELETE /dsh-clinebot/models/context` — Manage model context length overrides.
|
|
220
|
+
* `POST /dsh-clinebot/accounts` — Add account to the multi-account pool.
|
|
221
|
+
* `POST /dsh-clinebot/accounts/delete` — Delete account from the pool with atomic credential preservation.
|
|
222
|
+
* `POST /dsh-clinebot/accounts/active` — Switch or pin active account (or empty for auto least-used routing).
|
|
223
|
+
* `POST /dsh-clinebot/stats/reset` — Reset session request and token statistics.
|
|
218
224
|
* `POST /dsh-clinebot/smoke` — Runs a live latency test ping.
|
|
225
|
+
* `GET /dsh-clinebot/update/status` — Query available GitHub releases and plugin updates.
|
|
226
|
+
* `POST /dsh-clinebot/update/apply` — Apply version upgrade with process-group cleanup and lockfile sync.
|
|
227
|
+
|
|
228
|
+
### OpenAI-Compatible Loopback Proxy (Max 64 MiB)
|
|
229
|
+
* `POST /dsh-clinebot/v1/chat/completions` — Transparent local proxy supporting streaming SSE and non-streaming responses, Bearer token authentication, sticky session affinity with least-used quota allocation, body read watchdog (`streamIdleTimeoutMs`), and automatic 429 failover.
|
|
230
|
+
* `GET /dsh-clinebot/v1/models` — OpenAI-compatible catalog listing all active plan and custom models.
|
|
219
231
|
|
|
220
232
|
---
|
|
221
233
|
|
package/README.ru.md
CHANGED
|
@@ -151,7 +151,9 @@ dsh plugin --profile web add @goodandready/dsh-clinebot
|
|
|
151
151
|
|
|
152
152
|
---
|
|
153
153
|
|
|
154
|
-
## ⚙️
|
|
154
|
+
## ⚙️ Конфигурация (Настройки DSH и Web UI)
|
|
155
|
+
|
|
156
|
+
В современном DeepSeek Harness (0.1.7+) настройки управляются через карточку в Web UI или `PUT /dsh-clinebot/config`, а секретные ключи безопасно хранятся в DSH Credentials (`~/.dsh/.credentials.yaml`).
|
|
155
157
|
|
|
156
158
|
```yaml
|
|
157
159
|
dsh-clinebot:
|
|
@@ -181,7 +183,7 @@ dsh-clinebot:
|
|
|
181
183
|
| `enabled` | `boolean` | `true` | Включение моста провайдера ClineBot в DSH |
|
|
182
184
|
| `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | Базовый URL OpenAI-совместимого API ClinePass |
|
|
183
185
|
| `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | Имя переменной / ключа в хранилище credentials |
|
|
184
|
-
| `defaultModel` | `string` | `"
|
|
186
|
+
| `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | Модель, выбираемая по умолчанию для чата и smoke-тестов |
|
|
185
187
|
| `disabledModels` | `array` | `[]` | Список отключённых моделей (новые модели подписки включаются автоматически) |
|
|
186
188
|
| `customModels` | `array` | `[]` | Пользовательские модели шлюза (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
|
|
187
189
|
| `modelReasoningDefaults` | `object` | `{}` | Значения thinking effort по умолчанию для моделей (`low`, `medium`, `high`, `max`) |
|
|
@@ -190,9 +192,9 @@ dsh-clinebot:
|
|
|
190
192
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Путь к файлу персистентной аналитики использования токенов |
|
|
191
193
|
| `accounts` | `array` | `[]` | Дополнительные аккаунты для ротации квоты и failover (`[{ label, apiKeyEnv }]`) |
|
|
192
194
|
| `activeAccount` | `string` | `""` | Принудительно закреплённый аккаунт (пусто для автоматического выбора least-used) |
|
|
193
|
-
| `timeoutMs` | `number` | `
|
|
194
|
-
| `smokeTimeoutMs` | `number` | `
|
|
195
|
-
| `streamIdleTimeoutMs` | `number` | `
|
|
195
|
+
| `timeoutMs` | `number` | `15000` | Таймаут HTTP-запросов (мс) |
|
|
196
|
+
| `smokeTimeoutMs` | `number` | `25000` | Таймаут тестового пинга инференса (мс) |
|
|
197
|
+
| `streamIdleTimeoutMs` | `number` | `30000` | Таймаут ожидания между чанками SSE-потока (мс) |
|
|
196
198
|
| `enabledModels` | `array` | *(устарело)* | Только для чтения в public config; отклоняется при `PUT /config` в пользу `disabledModels` |
|
|
197
199
|
| `dynamicModels` | `array` | `[]` | Динамические модели, автоматически синхронизированные из тарифа |
|
|
198
200
|
|
|
@@ -200,17 +202,29 @@ dsh-clinebot:
|
|
|
200
202
|
|
|
201
203
|
## 🌐 Маршруты HTTP API
|
|
202
204
|
|
|
203
|
-
Все маршруты регистрируются с префиксом `/dsh-clinebot/*` и защищены от
|
|
205
|
+
Все маршруты регистрируются с префиксом `/dsh-clinebot/*` и защищены от межсайтовых запросов (разрешены same-origin и loopback). Управляющие эндпоинты ограничены лимитом 256 КиБ; прокси-эндпоинт поддерживает до 64 МиБ полезной нагрузки (`MAX_PROXY_BODY_BYTES`):
|
|
204
206
|
|
|
207
|
+
### Управляющие эндпоинты (макс. 256 КиБ)
|
|
205
208
|
* `GET /dsh-clinebot/status` — Текущее состояние провайдера, активный аккаунт, квоты и статистика сессий.
|
|
206
209
|
* `GET /dsh-clinebot/config` — Диагностический маршрут, возвращающий открытую конфигурацию (без секретов).
|
|
207
|
-
* `PUT /dsh-clinebot/config` — Обновление параметров
|
|
208
|
-
* `POST /dsh-clinebot/key/verify` — Проверка API-ключа на сервере `api.cline.bot` с возвратом email и
|
|
209
|
-
* `POST /dsh-clinebot/save-key` — Сохранение ключа в сервис credentials DSH под именем
|
|
210
|
+
* `PUT /dsh-clinebot/config` — Обновление параметров конфигурации (устаревший `enabledModels` блокируется).
|
|
211
|
+
* `POST /dsh-clinebot/key/verify` — Проверка API-ключа на сервере `api.cline.bot` с возвратом email и тарифа.
|
|
212
|
+
* `POST /dsh-clinebot/save-key` — Сохранение ключа в сервис credentials DSH под именем `CLINEBOT_API_KEY*`.
|
|
210
213
|
* `POST /dsh-clinebot/models/sync` — Синхронизация списка моделей с официальным тарифом ClinePass.
|
|
211
|
-
* `POST /dsh-clinebot/models/toggle` — Включение и отключение моделей через `disabledModels
|
|
212
|
-
* `POST /dsh-clinebot/
|
|
214
|
+
* `POST /dsh-clinebot/models/toggle` — Включение и отключение моделей через `disabledModels` с сохранением Volatile-ссылок.
|
|
215
|
+
* `POST /dsh-clinebot/models/custom` / `DELETE /dsh-clinebot/models/custom` — Управление пользовательскими моделями.
|
|
216
|
+
* `POST /dsh-clinebot/models/context` / `DELETE /dsh-clinebot/models/context` — Переопределение контекстного окна моделей.
|
|
217
|
+
* `POST /dsh-clinebot/accounts` — Добавление аккаунта в пул ротации.
|
|
218
|
+
* `POST /dsh-clinebot/accounts/delete` — Удаление аккаунта с атомарной защитой секретного ключа.
|
|
219
|
+
* `POST /dsh-clinebot/accounts/active` — Назначение активного аккаунта из пула (или пусто для auto least-used).
|
|
220
|
+
* `POST /dsh-clinebot/stats/reset` — Сброс счётчиков запросов и токенов.
|
|
213
221
|
* `POST /dsh-clinebot/smoke` — Выполнение тестового пинга задержки.
|
|
222
|
+
* `GET /dsh-clinebot/update/status` — Проверка наличия обновлений плагина.
|
|
223
|
+
* `POST /dsh-clinebot/update/apply` — Обновление плагина с корректным завершением групп процессов и блокировками профиля.
|
|
224
|
+
|
|
225
|
+
### OpenAI-совместимый loopback-прокси (макс. 64 МиБ)
|
|
226
|
+
* `POST /dsh-clinebot/v1/chat/completions` — Прозрачный локальный прокси с поддержкой SSE и нестриминговых ответов, Bearer-аутентификацией, сессионной липкостью и least-used распределением квот, контролем дедлайнов (`streamIdleTimeoutMs`) и автоматическим 429 failover.
|
|
227
|
+
* `GET /dsh-clinebot/v1/models` — Каталог моделей в стандарте OpenAI API.
|
|
214
228
|
|
|
215
229
|
---
|
|
216
230
|
|
package/README.zh.md
CHANGED
|
@@ -153,7 +153,9 @@ dsh plugin --profile web add @goodandready/dsh-clinebot
|
|
|
153
153
|
|
|
154
154
|
---
|
|
155
155
|
|
|
156
|
-
## ⚙️ 配置项参考 (
|
|
156
|
+
## ⚙️ 配置项参考 (DSH 设置与 Web UI)
|
|
157
|
+
|
|
158
|
+
在现代 DeepSeek Harness (0.1.7+) 中,各项配置通过 Web UI 或 `PUT /dsh-clinebot/config` 原生管理,密钥安全存储于 DSH Credentials 凭据中心 (`~/.dsh/.credentials.yaml`)。
|
|
157
159
|
|
|
158
160
|
```yaml
|
|
159
161
|
dsh-clinebot:
|
|
@@ -183,7 +185,7 @@ dsh-clinebot:
|
|
|
183
185
|
| `enabled` | `boolean` | `true` | 是否在 DSH 中启用 ClineBot 桥接插件 |
|
|
184
186
|
| `baseUrl` | `string` | `"https://api.cline.bot/api/v1"` | ClinePass OpenAI 兼容接口地址 |
|
|
185
187
|
| `apiKeyEnv` | `string` | `"CLINEBOT_API_KEY"` | 凭据管理系统中的密钥名称 |
|
|
186
|
-
| `defaultModel` | `string` | `"
|
|
188
|
+
| `defaultModel` | `string` | `"cline-pass/deepseek-v4-flash"` | 默认选中的模型 ID(用于对话及探活) |
|
|
187
189
|
| `disabledModels` | `array` | `[]` | 在选择器中隐藏的模型 ID 列表(新订阅模型默认自动启用) |
|
|
188
190
|
| `customModels` | `array` | `[]` | 用户自定义网关模型 (`[{ id, name, contextLength, maxTokens, category, isReasoning }]`) |
|
|
189
191
|
| `modelReasoningDefaults` | `object` | `{}` | 各模型默认思考强度配置 (`low`, `medium`, `high`, `max`) |
|
|
@@ -192,9 +194,9 @@ dsh-clinebot:
|
|
|
192
194
|
| `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | 本地持久化 Token 用量统计文件路径 |
|
|
193
195
|
| `accounts` | `array` | `[]` | 额外轮转账号池 (`[{ label, apiKeyEnv }]`) |
|
|
194
196
|
| `activeAccount` | `string` | `""` | 手动指定的主账号(为空时自动按最低配额消耗优先路由) |
|
|
195
|
-
| `timeoutMs` | `number` | `
|
|
196
|
-
| `smokeTimeoutMs` | `number` | `
|
|
197
|
-
| `streamIdleTimeoutMs` | `number` | `
|
|
197
|
+
| `timeoutMs` | `number` | `15000` | HTTP 请求探测超时时间(毫秒) |
|
|
198
|
+
| `smokeTimeoutMs` | `number` | `25000` | 探活测试超时时间(毫秒) |
|
|
199
|
+
| `streamIdleTimeoutMs` | `number` | `30000` | SSE 流式输出块间空闲超时时间(毫秒) |
|
|
198
200
|
| `enabledModels` | `array` | *(已废弃)* | 仅在 public config 中只读提供,`PUT /config` 时禁止写入并请使用 `disabledModels` |
|
|
199
201
|
| `dynamicModels` | `array` | `[]` | 从官方套餐中自动同步的动态模型列表 |
|
|
200
202
|
|
|
@@ -202,17 +204,29 @@ dsh-clinebot:
|
|
|
202
204
|
|
|
203
205
|
## 🌐 HTTP API 接口说明
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
所有接口挂载于 `/dsh-clinebot/*` 路径,并受到跨站请求防护(仅放行同源或环回请求)。管理接口限制请求体上限 256 KiB;环回推理代理接口支持最高 64 MiB 有效载荷 (`MAX_PROXY_BODY_BYTES`):
|
|
206
208
|
|
|
209
|
+
### 管理与控制接口(上限 256 KiB)
|
|
207
210
|
* `GET /dsh-clinebot/status` — 服务运行状态,包括健康探活、当前凭据名称、配额限制及会话统计。
|
|
208
|
-
* `GET /dsh-clinebot/config` —
|
|
209
|
-
* `PUT /dsh-clinebot/config` —
|
|
210
|
-
* `POST /dsh-clinebot/key/verify` —
|
|
211
|
-
* `POST /dsh-clinebot/save-key` — 将 API
|
|
212
|
-
* `POST /dsh-clinebot/models/sync` —
|
|
213
|
-
* `POST /dsh-clinebot/models/toggle` —
|
|
214
|
-
* `POST /dsh-clinebot/
|
|
211
|
+
* `GET /dsh-clinebot/config` — 诊断接口,安全获取脱敏后的公共配置(不含敏感密钥)。
|
|
212
|
+
* `PUT /dsh-clinebot/config` — 更新配置项(严格校验模式,已废弃的 `enabledModels` 被拦截)。
|
|
213
|
+
* `POST /dsh-clinebot/key/verify` — 实时验证 API 密钥有效性,返回绑定邮箱及套餐名称。
|
|
214
|
+
* `POST /dsh-clinebot/save-key` — 将 API 密钥安全写入 DSH 凭据服务。
|
|
215
|
+
* `POST /dsh-clinebot/models/sync` — 手动同步官方套餐动态模型列表。
|
|
216
|
+
* `POST /dsh-clinebot/models/toggle` — 批量切换模型可用性(更新 `disabledModels` 并保留 Volatile 引用)。
|
|
217
|
+
* `POST /dsh-clinebot/models/custom` / `DELETE /dsh-clinebot/models/custom` — 自定义模型增删。
|
|
218
|
+
* `POST /dsh-clinebot/models/context` / `DELETE /dsh-clinebot/models/context` — 上下文窗口与最大 Token 覆盖。
|
|
219
|
+
* `POST /dsh-clinebot/accounts` — 添加多账号备用凭据。
|
|
220
|
+
* `POST /dsh-clinebot/accounts/delete` — 删除账号(具备原子化凭据保护)。
|
|
221
|
+
* `POST /dsh-clinebot/accounts/active` — 指定活动账号(为空时自动启用最低用量优先路由)。
|
|
222
|
+
* `POST /dsh-clinebot/stats/reset` — 重置会话与 Token 消耗统计。
|
|
215
223
|
* `POST /dsh-clinebot/smoke` — 发起快速探测并返回实时延迟。
|
|
224
|
+
* `GET /dsh-clinebot/update/status` — 查询插件版本更新状态。
|
|
225
|
+
* `POST /dsh-clinebot/update/apply` — 应用版本更新(支持进程树完全清理与锁文件保护)。
|
|
226
|
+
|
|
227
|
+
### OpenAI 兼容环回代理接口(上限 64 MiB)
|
|
228
|
+
* `POST /dsh-clinebot/v1/chat/completions` — 本地透明代理,支持 SSE 流式与完整响应、会话粘性路由、最低用量负载均衡、响应体超时防护 (`streamIdleTimeoutMs`) 以及 429 自动故障转移。
|
|
229
|
+
* `GET /dsh-clinebot/v1/models` — 符合 OpenAI API 规范的模型列表。
|
|
216
230
|
|
|
217
231
|
---
|
|
218
232
|
|
package/lib/account-pool.js
CHANGED
|
@@ -84,9 +84,11 @@ export async function resolveAccountPool(ctx, cfg) {
|
|
|
84
84
|
const envName = textSetting(slot.apiKeyEnv) || (i === 0 ? apiKeyEnv : `CLINEBOT_API_KEY_${i + 1}`)
|
|
85
85
|
const keyInfo = await resolveKeyValue(ctx, envName)
|
|
86
86
|
const slotId = slot.id || (i === 0 ? 'default' : `account-${i + 1}`)
|
|
87
|
-
const isPinned =
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
const isPinned = Boolean(
|
|
88
|
+
activeAccount &&
|
|
89
|
+
activeAccount !== 'auto' &&
|
|
90
|
+
(activeAccount === envName || activeAccount === slotId)
|
|
91
|
+
)
|
|
90
92
|
const quota = keyInfo.value ? getAccountQuotaSnapshot(keyInfo.value, baseUrl) : null
|
|
91
93
|
resolved.push({
|
|
92
94
|
id: slotId,
|
package/lib/client.js
CHANGED
|
@@ -94,6 +94,7 @@ const en = {
|
|
|
94
94
|
'quota.reset_at': 'Reset: {time}',
|
|
95
95
|
'quota.window_monthly': '📅 Monthly Window',
|
|
96
96
|
'quota.countdown': 'Resets in {time}',
|
|
97
|
+
'quota.resetting': 'Resetting…',
|
|
97
98
|
'quota.warning_low': 'Quota is below 10%. Requests may be throttled soon.',
|
|
98
99
|
'quota.exhausted_5h': '5-hour rolling limit is almost exhausted ({percentUsed}%). New requests may be rejected until quota reset.',
|
|
99
100
|
'quota.warning_5h': 'Low quota warning: {percentUsed}% of 5-hour limit consumed (<10% remaining).',
|
|
@@ -242,6 +243,7 @@ const en = {
|
|
|
242
243
|
'quota.reset_at': '重置时间:{time}',
|
|
243
244
|
'quota.window_monthly': '📅 每月滚动窗口',
|
|
244
245
|
'quota.countdown': '{time}后重置',
|
|
246
|
+
'quota.resetting': '正在重置',
|
|
245
247
|
'quota.warning_low': '额度低于 10%,请求可能很快会受到限制。',
|
|
246
248
|
'quota.exhausted_5h': '5 小时滚动限额已接近耗尽(已用 {percentUsed}%)。在额度重置前新请求可能会被拒绝。',
|
|
247
249
|
'quota.warning_5h': '低额度警告:5 小时限额已消耗 {percentUsed}%(剩余不足 10%)。',
|
|
@@ -336,6 +338,9 @@ const en = {
|
|
|
336
338
|
|
|
337
339
|
function makeT(dict, fallback) {
|
|
338
340
|
return function t(key, vars) {
|
|
341
|
+
if (key === 'quota.countdown' && vars?.time === 'resetting') {
|
|
342
|
+
return (dict && dict['quota.resetting']) || (fallback && fallback['quota.resetting']) || '正在重置'
|
|
343
|
+
}
|
|
339
344
|
let val = (dict && dict[key]) || (fallback && fallback[key]) || key
|
|
340
345
|
if (vars && typeof val === 'string') {
|
|
341
346
|
for (const k of Object.keys(vars)) {
|
|
@@ -570,7 +575,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
|
|
|
570
575
|
},
|
|
571
576
|
},
|
|
572
577
|
t('quota.used', { pct }),
|
|
573
|
-
resetCountdown ? ` · ${t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
|
|
578
|
+
resetCountdown ? ` · ${resetCountdown === 'resetting' ? (t('quota.resetting') || '正在重置') : t('quota.countdown', { time: resetCountdown })}` : (resetClock ? ` · ${t('quota.reset_at', { time: resetClock })}` : '')
|
|
574
579
|
)
|
|
575
580
|
),
|
|
576
581
|
React.createElement(
|
package/lib/cline-client.js
CHANGED
|
@@ -528,10 +528,7 @@ export function buildPiAiProvider({
|
|
|
528
528
|
if (validEfforts.includes(key)) efforts[key] = key === 'off' ? (v === undefined ? null : v) : (typeof v === 'string' && v.trim() ? v.trim() : key)
|
|
529
529
|
}
|
|
530
530
|
}
|
|
531
|
-
const modelCompat = {
|
|
532
|
-
sendSessionAffinityHeaders: true,
|
|
533
|
-
sessionAffinityFormat: 'openrouter',
|
|
534
|
-
}
|
|
531
|
+
const modelCompat = {}
|
|
535
532
|
if (efforts && Object.keys(efforts).length) {
|
|
536
533
|
if (!('off' in efforts)) efforts.off = null
|
|
537
534
|
res.reasoningEfforts = efforts
|
|
@@ -539,7 +536,9 @@ export function buildPiAiProvider({
|
|
|
539
536
|
} else {
|
|
540
537
|
res.reasoningEfforts = false
|
|
541
538
|
}
|
|
542
|
-
|
|
539
|
+
if (Object.keys(modelCompat).length > 0) {
|
|
540
|
+
res.compat = modelCompat
|
|
541
|
+
}
|
|
543
542
|
return res
|
|
544
543
|
})
|
|
545
544
|
|
|
@@ -548,10 +547,10 @@ export function buildPiAiProvider({
|
|
|
548
547
|
api: 'openai-completions',
|
|
549
548
|
baseURL: normalizeBaseUrl(baseUrl),
|
|
550
549
|
apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
550
|
+
cacheRetention: 'long',
|
|
551
551
|
compat: {
|
|
552
552
|
supportsReasoningEffort: true,
|
|
553
|
-
|
|
554
|
-
sessionAffinityFormat: 'openrouter',
|
|
553
|
+
supportsLongCacheRetention: true,
|
|
555
554
|
},
|
|
556
555
|
models: modelList,
|
|
557
556
|
}
|
package/lib/config.js
CHANGED
|
@@ -129,7 +129,7 @@ export function publicConfig(cfg) {
|
|
|
129
129
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
130
130
|
const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
|
|
131
131
|
const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
|
|
132
|
-
const isSynced = dynamic.length > 0
|
|
132
|
+
const isSynced = dynamic.length > 0 && (Number(cfg?.planSyncedAt) > 0)
|
|
133
133
|
const allModels = getAllModels(dynamic, modelContextOverrides, custom)
|
|
134
134
|
const allDefaultIds = allModels.map((m) => m.id)
|
|
135
135
|
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
|
|
|
22
22
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
23
23
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
24
24
|
import { registerSlashCommand } from './slash-command.js'
|
|
25
|
-
import { recordUsage, loadStats, flushStats } from './stats-storage.js'
|
|
25
|
+
import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
|
|
26
26
|
|
|
27
27
|
export const name = '@goodandready/dsh-clinebot'
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
@@ -35,7 +35,41 @@ export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSess
|
|
|
35
35
|
|
|
36
36
|
export function apply(ctx, config) {
|
|
37
37
|
let currentConfig = config
|
|
38
|
-
|
|
38
|
+
const overrides = {}
|
|
39
|
+
const getConfig = () => {
|
|
40
|
+
if (!currentConfig) return currentConfig
|
|
41
|
+
if (Object.isFrozen(currentConfig)) {
|
|
42
|
+
return new Proxy({}, {
|
|
43
|
+
get(_target, prop) {
|
|
44
|
+
const orig = Reflect.get(currentConfig, prop)
|
|
45
|
+
if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(currentConfig, prop)?.get) {
|
|
46
|
+
return orig
|
|
47
|
+
}
|
|
48
|
+
if (prop in overrides) {
|
|
49
|
+
return overrides[prop]
|
|
50
|
+
}
|
|
51
|
+
return orig
|
|
52
|
+
},
|
|
53
|
+
has(_target, prop) {
|
|
54
|
+
return prop in overrides || Reflect.has(currentConfig, prop)
|
|
55
|
+
},
|
|
56
|
+
ownKeys() {
|
|
57
|
+
return Array.from(new Set([...Reflect.ownKeys(currentConfig), ...Object.keys(overrides)]))
|
|
58
|
+
},
|
|
59
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
60
|
+
if (prop in overrides) {
|
|
61
|
+
return { configurable: true, enumerable: true, value: overrides[prop], writable: true }
|
|
62
|
+
}
|
|
63
|
+
const desc = Object.getOwnPropertyDescriptor(currentConfig, prop)
|
|
64
|
+
if (desc) {
|
|
65
|
+
return { configurable: true, enumerable: desc.enumerable ?? true, value: Reflect.get(currentConfig, prop) }
|
|
66
|
+
}
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return currentConfig
|
|
72
|
+
}
|
|
39
73
|
const live = () => (getConfig() ? Config(structuredClone(plainConfig(getConfig()))) : config)
|
|
40
74
|
let settingsApi
|
|
41
75
|
|
|
@@ -59,6 +93,23 @@ export function apply(ctx, config) {
|
|
|
59
93
|
autoDiscoverPlanModels(ctx, { live, getSettingsApi: () => settingsApi, syncProviderState })
|
|
60
94
|
}
|
|
61
95
|
|
|
96
|
+
let activeStatsPath = resolvePath(publicConfig(live()).statsPath)
|
|
97
|
+
loadStats(activeStatsPath)
|
|
98
|
+
|
|
99
|
+
const syncStatsStorage = (cfg) => {
|
|
100
|
+
const nextPath = resolvePath(publicConfig(cfg).statsPath)
|
|
101
|
+
if (nextPath !== activeStatsPath) {
|
|
102
|
+
switchStatsStorage(nextPath)
|
|
103
|
+
activeStatsPath = nextPath
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof ctx?.on === 'function') {
|
|
108
|
+
ctx.on('loader/volatile-update', () => {
|
|
109
|
+
syncStatsStorage(live())
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
const getRevision = (svc) => {
|
|
63
114
|
try {
|
|
64
115
|
return svc?.describe?.().find((row) => row.ns === NS)?.revision
|
|
@@ -69,22 +120,37 @@ export function apply(ctx, config) {
|
|
|
69
120
|
|
|
70
121
|
const applyConfigMutations = (target, source) => {
|
|
71
122
|
const targetConfig = typeof target === 'function' ? target() : target
|
|
72
|
-
if (targetConfig && typeof targetConfig === 'object'
|
|
123
|
+
if (targetConfig && typeof targetConfig === 'object') {
|
|
124
|
+
if (Object.isFrozen(targetConfig)) {
|
|
125
|
+
for (const [key, value] of Object.entries(source)) {
|
|
126
|
+
const orig = targetConfig[key]
|
|
127
|
+
if (isVolatileRef(orig) || Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
overrides[key] = value
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
73
134
|
for (const [key, value] of Object.entries(source)) {
|
|
74
135
|
try {
|
|
75
|
-
if (isVolatileRef(targetConfig[key])
|
|
76
|
-
|
|
77
|
-
|
|
136
|
+
if (isVolatileRef(targetConfig[key])) {
|
|
137
|
+
// Cosmokit native Volatile references are reactive and authoritative;
|
|
138
|
+
// preserve the reference without overwriting with plain value.
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const desc = Object.getOwnPropertyDescriptor(targetConfig, key)
|
|
142
|
+
if (desc?.get && !desc.set) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (desc?.set) {
|
|
78
146
|
targetConfig[key] = value
|
|
79
|
-
} else
|
|
147
|
+
} else {
|
|
80
148
|
targetConfig[key] = value
|
|
81
149
|
}
|
|
82
150
|
} catch {
|
|
83
151
|
/* ignore read-only */
|
|
84
152
|
}
|
|
85
153
|
}
|
|
86
|
-
} else {
|
|
87
|
-
currentConfig = source
|
|
88
154
|
}
|
|
89
155
|
}
|
|
90
156
|
|
|
@@ -104,7 +170,7 @@ export function apply(ctx, config) {
|
|
|
104
170
|
await svc.update(NS, payload, rev)
|
|
105
171
|
}
|
|
106
172
|
applyConfigMutations(currentConfig, parsed)
|
|
107
|
-
|
|
173
|
+
syncStatsStorage(live())
|
|
108
174
|
await syncProviderState(live())
|
|
109
175
|
return parsed
|
|
110
176
|
},
|
|
@@ -122,7 +188,7 @@ export function apply(ctx, config) {
|
|
|
122
188
|
await svc.replace(NS, payload, rev)
|
|
123
189
|
}
|
|
124
190
|
applyConfigMutations(currentConfig, merged)
|
|
125
|
-
|
|
191
|
+
syncStatsStorage(live())
|
|
126
192
|
await syncProviderState(live())
|
|
127
193
|
return merged
|
|
128
194
|
},
|
|
@@ -133,11 +199,10 @@ export function apply(ctx, config) {
|
|
|
133
199
|
},
|
|
134
200
|
}
|
|
135
201
|
|
|
136
|
-
loadStats(publicConfig(live()).statsPath)
|
|
137
202
|
if (typeof ctx.effect === 'function') {
|
|
138
203
|
ctx.effect(() => {
|
|
139
204
|
return () => {
|
|
140
|
-
flushStats(
|
|
205
|
+
flushStats(activeStatsPath)
|
|
141
206
|
}
|
|
142
207
|
}, 'dsh-clinebot: stats-storage')
|
|
143
208
|
}
|
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
|
@@ -293,34 +293,30 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
293
293
|
await syncProviderState(next)
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
if ((!pub.dynamicModels || !pub.dynamicModels.length) && cacheFile) {
|
|
297
|
-
const fromDisk = await loadModelsDiskCache(cacheFile)
|
|
298
|
-
if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
|
|
299
|
-
const migratedDisk = fromDisk.map(migrateModelEntry)
|
|
300
|
-
const next = plainConfig(Config({
|
|
301
|
-
...plainConfig(live()),
|
|
302
|
-
dynamicModels: migratedDisk,
|
|
303
|
-
planSyncedAt: fromDisk.planSyncedAt || Date.now(),
|
|
304
|
-
}))
|
|
305
|
-
await settingsApi.replace(next)
|
|
306
|
-
await syncProviderState(next)
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
296
|
const activeAcc = await resolveActiveAccountKey(ctx, cfg)
|
|
311
297
|
if (!activeAcc.value) return
|
|
312
298
|
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
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
|
+
}
|
|
317
313
|
|
|
318
314
|
if (usageData?.ok && Array.isArray(usageData.dynamicModels)) {
|
|
319
315
|
const existingDynamic = pub.dynamicModels || []
|
|
320
316
|
const isDifferent = isCatalogDifferent(existingDynamic, usageData.dynamicModels)
|
|
317
|
+
const now = Date.now()
|
|
321
318
|
|
|
322
319
|
if (isDifferent && settingsApi?.replace) {
|
|
323
|
-
const now = Date.now()
|
|
324
320
|
const next = plainConfig(Config({
|
|
325
321
|
...plainConfig(live()),
|
|
326
322
|
dynamicModels: usageData.dynamicModels,
|
|
@@ -328,9 +324,24 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
|
|
|
328
324
|
}))
|
|
329
325
|
await settingsApi.replace(next)
|
|
330
326
|
await syncProviderState(next)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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)
|
|
334
345
|
}
|
|
335
346
|
}
|
|
336
347
|
} catch {
|
package/lib/routes/accounts.js
CHANGED
|
@@ -155,20 +155,30 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
155
155
|
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
156
156
|
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
157
157
|
if (!accountToDelete) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
158
|
+
if (body.deleteSecret && ctx?.get?.('credentials')) {
|
|
159
|
+
let retryDelRes = null
|
|
160
|
+
try {
|
|
161
|
+
retryDelRes = await deleteCredentialKey(ctx, targetEnv)
|
|
162
|
+
} catch {
|
|
163
|
+
retryDelRes = null
|
|
164
|
+
}
|
|
165
|
+
if (retryDelRes && retryDelRes.ok) {
|
|
166
|
+
clearUsageCache()
|
|
167
|
+
clearProbeCache()
|
|
168
|
+
return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
|
|
169
|
+
}
|
|
167
170
|
return writeJson(res, 500, {
|
|
168
171
|
ok: false,
|
|
169
|
-
|
|
172
|
+
partial: true,
|
|
173
|
+
accountRemoved: true,
|
|
174
|
+
secretDeleted: false,
|
|
175
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
|
|
170
176
|
})
|
|
171
177
|
}
|
|
178
|
+
return writeJson(res, 404, {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
181
|
+
})
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
|
|
@@ -177,10 +187,58 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
177
187
|
activeAccount = ''
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
// Persist settings changes first before unsetting credentials (#184)
|
|
180
191
|
const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
|
|
181
192
|
await settingsApi.replace(next)
|
|
182
193
|
await syncProviderState(next)
|
|
183
194
|
|
|
195
|
+
if (body.deleteSecret) {
|
|
196
|
+
let delRes = null
|
|
197
|
+
try {
|
|
198
|
+
delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
199
|
+
} catch {
|
|
200
|
+
delRes = null
|
|
201
|
+
}
|
|
202
|
+
if (!delRes || !delRes.ok) {
|
|
203
|
+
let rollbackSuccess = false
|
|
204
|
+
try {
|
|
205
|
+
const fresh = plainConfig(live())
|
|
206
|
+
const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
|
|
207
|
+
if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
|
|
208
|
+
freshAccounts.push(accountToDelete)
|
|
209
|
+
}
|
|
210
|
+
const restoredConfig = plainConfig(Config({
|
|
211
|
+
...fresh,
|
|
212
|
+
accounts: freshAccounts,
|
|
213
|
+
activeAccount: curr.activeAccount === targetEnv ? targetEnv : fresh.activeAccount,
|
|
214
|
+
}))
|
|
215
|
+
await settingsApi.replace(restoredConfig)
|
|
216
|
+
await syncProviderState(restoredConfig)
|
|
217
|
+
rollbackSuccess = true
|
|
218
|
+
} catch {
|
|
219
|
+
rollbackSuccess = false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (rollbackSuccess) {
|
|
223
|
+
return writeJson(res, 500, {
|
|
224
|
+
ok: false,
|
|
225
|
+
partial: false,
|
|
226
|
+
accountRemoved: false,
|
|
227
|
+
secretDeleted: false,
|
|
228
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return writeJson(res, 500, {
|
|
233
|
+
ok: false,
|
|
234
|
+
partial: true,
|
|
235
|
+
accountRemoved: true,
|
|
236
|
+
secretDeleted: false,
|
|
237
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
184
242
|
clearUsageCache()
|
|
185
243
|
clearProbeCache()
|
|
186
244
|
|
|
@@ -222,7 +280,13 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
222
280
|
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
223
281
|
}
|
|
224
282
|
|
|
225
|
-
const
|
|
283
|
+
const currentLive = plainConfig(live())
|
|
284
|
+
const accountChanged = currentLive.activeAccount !== account
|
|
285
|
+
const next = plainConfig(Config({
|
|
286
|
+
...currentLive,
|
|
287
|
+
activeAccount: account,
|
|
288
|
+
...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
|
|
289
|
+
}))
|
|
226
290
|
await settingsApi.replace(next)
|
|
227
291
|
await syncProviderState(next)
|
|
228
292
|
writeJson(res, 200, { ok: true, activeAccount: account })
|
package/lib/routes/models.js
CHANGED
|
@@ -49,7 +49,12 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
49
49
|
await syncProviderState(next)
|
|
50
50
|
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
51
51
|
if (cacheFile) {
|
|
52
|
-
await
|
|
52
|
+
const activeAcc = await resolveActiveAccountKey(ctx, live())
|
|
53
|
+
const accountRef = activeAcc?.envName || activeAcc?.apiKeyEnv || pub.activeAccount || pub.apiKeyEnv || 'CLINEBOT_API_KEY'
|
|
54
|
+
await saveModelsDiskCache(cacheFile, dynamicModels, now, {
|
|
55
|
+
endpoint: pub.baseUrl,
|
|
56
|
+
accountRef,
|
|
57
|
+
})
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
return writeJson(res, 200, {
|
package/lib/routes/proxy.js
CHANGED
|
@@ -112,11 +112,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
112
112
|
req.headers['x-client-request-id'] ||
|
|
113
113
|
body?.session_id ||
|
|
114
114
|
body?.conversation_id ||
|
|
115
|
+
body?.prompt_cache_key ||
|
|
115
116
|
body?.chat_id ||
|
|
116
117
|
body?.user ||
|
|
117
118
|
''
|
|
118
119
|
).trim()
|
|
119
120
|
|
|
121
|
+
if (sessionId) {
|
|
122
|
+
if (!body.session_id) body.session_id = sessionId
|
|
123
|
+
if (!body.conversation_id) body.conversation_id = sessionId
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
// Ensure model is set, fallback to defaultModel (#140)
|
|
121
127
|
body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
|
|
122
128
|
|
|
@@ -192,13 +198,15 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
192
198
|
const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
|
|
193
199
|
|
|
194
200
|
const connectAbortCtrl = new AbortController()
|
|
201
|
+
const bodyAbortCtrl = new AbortController()
|
|
195
202
|
let connectTimedOut = false
|
|
203
|
+
let bodyTimedOut = false
|
|
196
204
|
const connectTimer = setTimeout(() => {
|
|
197
205
|
connectTimedOut = true
|
|
198
206
|
connectAbortCtrl.abort(new Error(`Upstream connect timeout after ${connectTimeoutMs}ms`))
|
|
199
207
|
}, connectTimeoutMs)
|
|
200
208
|
|
|
201
|
-
const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal])
|
|
209
|
+
const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, connectAbortCtrl.signal, bodyAbortCtrl.signal])
|
|
202
210
|
|
|
203
211
|
let upstreamRes
|
|
204
212
|
try {
|
|
@@ -404,8 +412,57 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
404
412
|
return
|
|
405
413
|
}
|
|
406
414
|
|
|
407
|
-
// Non-streaming JSON response
|
|
408
|
-
const
|
|
415
|
+
// Non-streaming JSON response with deadline and fail-closed error handling (#185)
|
|
416
|
+
const idleTimeoutMs = streamIdleTimeoutMs
|
|
417
|
+
let bodyTimer = null
|
|
418
|
+
const bodyTimeoutPromise = new Promise((_, reject) => {
|
|
419
|
+
bodyTimer = setTimeout(() => {
|
|
420
|
+
bodyTimedOut = true
|
|
421
|
+
bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
|
|
422
|
+
reject(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
|
|
423
|
+
}, idleTimeoutMs)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
let resText = ''
|
|
427
|
+
let readError = null
|
|
428
|
+
try {
|
|
429
|
+
resText = await Promise.race([
|
|
430
|
+
upstreamRes.text(),
|
|
431
|
+
bodyTimeoutPromise,
|
|
432
|
+
])
|
|
433
|
+
} catch (err) {
|
|
434
|
+
readError = err
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(bodyTimer)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
cleanupClientListeners()
|
|
440
|
+
|
|
441
|
+
if (clientAbortCtrl.signal.aborted || clientClosed) {
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (bodyTimedOut || readError) {
|
|
446
|
+
const isTimeout = bodyTimedOut || bodyAbortCtrl.signal.aborted
|
|
447
|
+
recordUsage({
|
|
448
|
+
model: body.model,
|
|
449
|
+
accountId: targetAccount.id || targetAccount.label,
|
|
450
|
+
isError: true,
|
|
451
|
+
statsPath: pub.statsPath
|
|
452
|
+
})
|
|
453
|
+
const statusCode = isTimeout ? 504 : 502
|
|
454
|
+
const errMessage = isTimeout
|
|
455
|
+
? `Gateway Timeout: upstream body read timed out after ${idleTimeoutMs}ms`
|
|
456
|
+
: `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
|
|
457
|
+
return writeJson(res, statusCode, {
|
|
458
|
+
error: {
|
|
459
|
+
message: errMessage,
|
|
460
|
+
type: isTimeout ? 'gateway_timeout' : 'bad_gateway',
|
|
461
|
+
code: isTimeout ? 'body_read_timeout' : 'body_read_error'
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
409
466
|
let resJson = null
|
|
410
467
|
try {
|
|
411
468
|
resJson = JSON.parse(resText)
|
|
@@ -413,7 +470,6 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
413
470
|
resJson = null
|
|
414
471
|
}
|
|
415
472
|
|
|
416
|
-
cleanupClientListeners()
|
|
417
473
|
recordUsage({
|
|
418
474
|
model: body.model,
|
|
419
475
|
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/stats-storage.js
CHANGED
|
@@ -119,6 +119,26 @@ export function loadStats(customPath) {
|
|
|
119
119
|
return inMemoryStats
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Switch active stats storage to a new path.
|
|
124
|
+
* Flushes pending writes of current storage and loads stats from the new path.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} newPath
|
|
127
|
+
* @returns {string} active stats path
|
|
128
|
+
*/
|
|
129
|
+
export function switchStatsStorage(newPath) {
|
|
130
|
+
const resolved = resolvePath(newPath)
|
|
131
|
+
if (resolved && resolved !== activeStatsPath) {
|
|
132
|
+
if (activeStatsPath) {
|
|
133
|
+
flushStats(activeStatsPath)
|
|
134
|
+
}
|
|
135
|
+
activeStatsPath = resolved
|
|
136
|
+
loadStats(resolved)
|
|
137
|
+
}
|
|
138
|
+
return activeStatsPath
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
122
142
|
/**
|
|
123
143
|
* Flush any debounced pending writes immediately to disk.
|
|
124
144
|
*
|
|
@@ -201,6 +221,9 @@ export function recordUsage({
|
|
|
201
221
|
is429 = false,
|
|
202
222
|
statsPath
|
|
203
223
|
} = {}) {
|
|
224
|
+
if (statsPath) {
|
|
225
|
+
switchStatsStorage(statsPath)
|
|
226
|
+
}
|
|
204
227
|
const now = new Date()
|
|
205
228
|
const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
|
|
206
229
|
const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
|
package/lib/updater.js
CHANGED
|
@@ -253,6 +253,7 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
253
253
|
windowsHide: true,
|
|
254
254
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
255
255
|
env: { ...process.env, NO_COLOR: '1' },
|
|
256
|
+
detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
|
|
256
257
|
})
|
|
257
258
|
let detail = ''
|
|
258
259
|
child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
|
|
@@ -263,6 +264,16 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
263
264
|
let sigtermTimer = null
|
|
264
265
|
let sigkillTimer = null
|
|
265
266
|
|
|
267
|
+
function isProcessGroupAlive(pid) {
|
|
268
|
+
if (typeof pid !== 'number' || pid <= 0 || process.platform === 'win32') return false
|
|
269
|
+
try {
|
|
270
|
+
process.kill(-pid, 0)
|
|
271
|
+
return true
|
|
272
|
+
} catch {
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
266
277
|
function cleanupTimers() {
|
|
267
278
|
clearTimeout(mainTimer)
|
|
268
279
|
if (sigtermTimer) clearTimeout(sigtermTimer)
|
|
@@ -271,16 +282,26 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
271
282
|
|
|
272
283
|
function onProcessExit(code, signal) {
|
|
273
284
|
if (exited) return
|
|
274
|
-
exited = true
|
|
275
|
-
cleanupTimers()
|
|
276
285
|
|
|
277
286
|
if (isTimedOut) {
|
|
287
|
+
if (isProcessGroupAlive(child.pid)) {
|
|
288
|
+
// Parent exited, but descendants in process group are still alive;
|
|
289
|
+
// keep escalation timer active (#172)
|
|
290
|
+
clearTimeout(mainTimer)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
exited = true
|
|
294
|
+
cleanupTimers()
|
|
278
295
|
cleanStaleLock(target.profileDir)
|
|
279
296
|
reject(new Error('Update timed out.'))
|
|
280
|
-
} else if (code === 0) {
|
|
281
|
-
resolvePromise()
|
|
282
297
|
} else {
|
|
283
|
-
|
|
298
|
+
exited = true
|
|
299
|
+
cleanupTimers()
|
|
300
|
+
if (code === 0) {
|
|
301
|
+
resolvePromise()
|
|
302
|
+
} else {
|
|
303
|
+
reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
304
|
+
}
|
|
284
305
|
}
|
|
285
306
|
}
|
|
286
307
|
|
|
@@ -292,33 +313,48 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
292
313
|
child.once('exit', onProcessExit)
|
|
293
314
|
child.once('close', onProcessExit)
|
|
294
315
|
|
|
316
|
+
function killProcessTree(sig) {
|
|
317
|
+
if (!child) return
|
|
318
|
+
if (typeof child.pid === 'number' && child.pid > 0 && process.platform !== 'win32') {
|
|
319
|
+
try {
|
|
320
|
+
process.kill(-child.pid, sig)
|
|
321
|
+
} catch {
|
|
322
|
+
/* process group might not exist or already terminated */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
child.kill(sig)
|
|
327
|
+
} catch {
|
|
328
|
+
/* ignore if already dead */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
295
332
|
const mainTimer = setTimeout(() => {
|
|
296
333
|
isTimedOut = true
|
|
297
334
|
// Step 1: Send SIGTERM
|
|
298
|
-
|
|
299
|
-
child.kill('SIGTERM')
|
|
300
|
-
} catch (err) {
|
|
301
|
-
/* ignore if process already terminated */
|
|
302
|
-
}
|
|
335
|
+
killProcessTree('SIGTERM')
|
|
303
336
|
|
|
304
337
|
// Step 2: Wait for grace period, if process has not exited escalate to SIGKILL (#172)
|
|
305
338
|
sigtermTimer = setTimeout(() => {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
} catch (err) {
|
|
311
|
-
/* ignore if process already terminated */
|
|
312
|
-
}
|
|
339
|
+
const groupAlive = isProcessGroupAlive(child.pid)
|
|
340
|
+
const parentExited = (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
|
|
341
|
+
if (!parentExited || groupAlive) {
|
|
342
|
+
killProcessTree('SIGKILL')
|
|
313
343
|
|
|
314
344
|
// Step 3: Hard deadline for exit notification
|
|
315
345
|
sigkillTimer = setTimeout(() => {
|
|
316
346
|
if (!exited) {
|
|
317
347
|
exited = true
|
|
348
|
+
cleanupTimers()
|
|
318
349
|
cleanStaleLock(target.profileDir)
|
|
319
350
|
reject(new Error('Update timed out.'))
|
|
320
351
|
}
|
|
321
|
-
}, sigkillGraceMs)
|
|
352
|
+
}, Math.min(sigkillGraceMs, 50))
|
|
353
|
+
} else if (!exited) {
|
|
354
|
+
exited = true
|
|
355
|
+
cleanupTimers()
|
|
356
|
+
cleanStaleLock(target.profileDir)
|
|
357
|
+
reject(new Error('Update timed out.'))
|
|
322
358
|
}
|
|
323
359
|
}, sigtermGraceMs)
|
|
324
360
|
}, timeoutMs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
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",
|