coding-tool-x 3.9.5-261009 → 3.9.5-261013

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
@@ -23,6 +23,8 @@
23
23
  - **依赖安全升级** - adm-zip 升至 0.6.0(GHSA-xcpc-8h2w-3j85)、js-yaml 升至 4.3.1(GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m / GHSA-5p4m-2wfm-xmqj)、pm2 升至 7.0.3(GHSA-x5gf-qvw8-r2rm),overrides 同步更新 basic-ftp / qs / systeminformation / js-yaml;`npm audit` 报告归零
24
24
 
25
25
  ### Fixed
26
+ - **代理连接与内存回收** - 修复各 CLI 请求残留、客户端断开后的上游取消和渠道释放;后台退出时排空代理请求,延长上游空闲超时;OMP 重试取消失败响应,过滤连接保活头,并增加内存压力回收。
27
+ - **CLI 状态和错误子命令** - 修复 `ctx status` 的代理 Driver 状态解析;错误子命令返回非零退出码,不再进入控制面。
26
28
  - **Claude / Gemini 原生 wire 兼容** - OpenCode Claude / Gemini 代理、模型探测和测速统一使用 Anthropic Messages 与 Google Generative AI(含 Gemini CLI)原生请求格式、认证头与目标 URL;OMP managed provider 写入 `anthropic-messages` / `google-generative-ai` 标识,避免代理、adapter 与探测各自维护不一致的请求格式
27
29
  - **OMP Codex Responses 路径兼容** - managed gateway 将 OMP 原生 `openai-codex-responses` 的 `/codex/responses` 请求映射到 OpenAI-compatible 渠道的 `/v1/responses`,避免 Codex-only 服务返回 HTTP 404
28
30
  - **OMP 渠道失败不再冻结** - OMP 动态切换开启后,失败渠道不再进入冻结状态。OMP 网关按请求即时回退其他渠道,冻结会导致短暂故障的渠道在恢复前被排除在后续分配之外
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-tool-x",
3
- "version": "3.9.5-261009",
3
+ "version": "3.9.5-261013",
4
4
  "description": "Vibe Coding 增强工作助手 - 智能会话管理、动态渠道切换、全局搜索、实时监控",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,7 +12,8 @@ const {
12
12
  formatPortToolIssue
13
13
  } = require('../utils/port-helper');
14
14
  const { hasHostFlag } = require('../utils/cli-flags');
15
- const { getPlatformRegistry, getPlatformRuntime } = require('../platforms/runtime');
15
+ const { getPlatformRegistry } = require('../platforms/runtime');
16
+ const { createProxyControl } = require('./proxy-control');
16
17
  const PM2_APP_NAME = 'cc-tool';
17
18
  const STARTUP_LOG_FILE = 'cc-tool-out.log';
18
19
  const PM2_RUNTIME_ENV_KEYS = [
@@ -279,34 +280,48 @@ function getPlatformPort(platform, config = {}) {
279
280
  return configured || platform.defaultPort || null;
280
281
  }
281
282
 
282
- async function getProxyStatusEntries({ registry = getPlatformRegistry(), runtime = getPlatformRuntime(), config = loadConfig() } = {}) {
283
- const entries = [];
284
- for (const platform of listProxyPlatforms(registry)) {
283
+ function getUIConnectionOptions(existing, config = loadConfig()) {
284
+ const env = existing?.pm2_env || {};
285
+ const port = Number(env.CC_TOOL_PORT || env.env?.CC_TOOL_PORT || config.ports?.webUI || 19999);
286
+ const args = Array.isArray(env.args) ? env.args : String(env.args || '').split(/\s+/);
287
+ return { port, useHttps: args.includes('--https') };
288
+ }
289
+
290
+ async function getProxyStatusEntries({ registry = getPlatformRegistry(), config = loadConfig(), httpRequest, connection } = {}) {
291
+ // Servers live in the UI daemon. Loading a driver here creates a separate
292
+ // singleton in the CLI process whose status will always be stopped.
293
+ const control = createProxyControl({ registry, loadConfig: () => config, httpRequest, connection });
294
+ return Promise.all(listProxyPlatforms(registry).map(async (platform) => {
285
295
  const context = {
286
296
  platform: platform.key,
287
297
  capability: 'proxy',
288
298
  operation: 'status'
289
299
  };
290
300
  try {
291
- const driver = runtime && typeof runtime.getDriver === 'function'
292
- ? runtime.getDriver(platform.key, 'proxy')
293
- : null;
294
- if (!driver || (typeof driver.status === 'string' && driver.status !== 'ok')) {
295
- entries.push({ platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) });
296
- continue;
301
+ if (platform.capabilities?.proxy === 'unsupported' || platform.capabilities?.proxy === false) {
302
+ return { platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) };
297
303
  }
298
- if (typeof driver.status !== 'function') {
299
- entries.push({ platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) });
300
- continue;
304
+ const { response } = await control.status(platform.key);
305
+ const result = response.data;
306
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status >= 300) {
307
+ const failure = result?.error;
308
+ if (failure && typeof failure === 'object') {
309
+ return { platform, state: { ...failure, status: failure.status && failure.status !== 'ok' ? failure.status : 'failed', ...context }, port: getPlatformPort(platform, config) };
310
+ }
311
+ throw new Error(typeof failure === 'string' ? failure : `HTTP ${response.status}`);
301
312
  }
302
- const state = await Promise.resolve(driver.status());
313
+ // The API normally unwraps the Driver result. Accept wrapped payloads
314
+ // too, but never interpret an invalid response as a stopped proxy.
315
+ const state = result && result.status === 'ok'
316
+ ? (result.data && typeof result.data === 'object' ? result.data : {})
317
+ : result;
303
318
  if (state && typeof state.status === 'string' && state.status !== 'ok') {
304
- entries.push({ platform, state: { ...state, ...context }, port: getPlatformPort(platform, config) });
305
- } else {
306
- entries.push({ platform, state: state || {}, port: getPlatformPort(platform, config) });
319
+ return { platform, state: { ...state, ...context }, port: getPlatformPort(platform, config) };
307
320
  }
321
+ if (!state || typeof state.running !== 'boolean') throw new Error('Invalid proxy status response');
322
+ return { platform, state, port: getPlatformPort(platform, config) };
308
323
  } catch (error) {
309
- entries.push({
324
+ return {
310
325
  platform,
311
326
  state: {
312
327
  status: 'failed',
@@ -314,10 +329,9 @@ async function getProxyStatusEntries({ registry = getPlatformRegistry(), runtime
314
329
  error: error && error.message ? error.message : String(error)
315
330
  },
316
331
  port: getPlatformPort(platform, config)
317
- });
332
+ };
318
333
  }
319
- }
320
- return entries;
334
+ }));
321
335
  }
322
336
 
323
337
  function getManagedPorts(config = loadConfig(), registry = getPlatformRegistry()) {
@@ -508,8 +522,10 @@ function buildStartOptions(port, enableHost, enableHttps) {
508
522
  interpreter: 'node',
509
523
  windowsHide: true,
510
524
  autorestart: true,
511
- kill_timeout: 5000,
525
+ // Let active OMP streams drain before PM2 forcibly terminates the process.
526
+ kill_timeout: 125000,
512
527
  max_memory_restart: '500M',
528
+ node_args: '--expose-gc',
513
529
  env,
514
530
  output: path.join(PATHS.logs, STARTUP_LOG_FILE),
515
531
  error: path.join(PATHS.logs, STARTUP_LOG_FILE),
@@ -731,6 +747,12 @@ async function handleStatus() {
731
747
 
732
748
  const existing = await getCCToolProcess();
733
749
  const config = loadConfig();
750
+ const connection = getUIConnectionOptions(existing, config);
751
+ const protocol = connection.useHttps ? 'https' : 'http';
752
+ if (connection.useHttps && PATHS.config) {
753
+ const certPath = path.join(PATHS.config, 'https', 'localhost-cert.pem');
754
+ if (fs.existsSync(certPath)) connection.ca = fs.readFileSync(certPath);
755
+ }
734
756
 
735
757
  console.log(chalk.bold.cyan('\n╔======================================╗'));
736
758
  console.log(chalk.bold.cyan('║ Coding-Tool 服务状态 ║'));
@@ -740,7 +762,7 @@ async function handleStatus() {
740
762
  console.log(chalk.bold('[UI] Web UI 服务:'));
741
763
  if (existing && existing.pm2_env.status === 'online') {
742
764
  console.log(chalk.green(' [OK] 状态: 运行中'));
743
- console.log(chalk.gray(` [NET] 地址: http://localhost:${config.ports?.webUI || 19999}`));
765
+ console.log(chalk.gray(` [NET] 地址: ${protocol}://localhost:${connection.port}`));
744
766
  console.log(chalk.gray(` [KEY] 进程 ID: ${existing.pid}`));
745
767
  console.log(chalk.gray(` [TIMER] 运行时长: ${formatUptime(existing.pm2_env.pm_uptime)}`));
746
768
  console.log(chalk.gray(` [SAVE] 内存使用: ${formatMemory(existing.monit?.memory)}`));
@@ -749,8 +771,11 @@ async function handleStatus() {
749
771
  console.log(chalk.gray(' [ERROR] 状态: 未运行'));
750
772
  }
751
773
 
752
- // 代理服务状态由 Registry 中声明的 proxy driver 提供
753
- const proxyStatuses = await getProxyStatusEntries({ config });
774
+ // Query the drivers in the running backend through manifest-declared APIs.
775
+ const proxyStatuses = await getProxyStatusEntries({
776
+ config: { ...config, ports: { ...config.ports, webUI: connection.port } },
777
+ connection
778
+ });
754
779
 
755
780
  console.log(chalk.bold('\n[PROXY] 代理服务:'));
756
781
  for (const entry of proxyStatuses) {
@@ -761,9 +786,9 @@ async function handleStatus() {
761
786
  console.log(chalk.gray(` ${label}${chalk.gray('[N/A] 不支持')}`));
762
787
  continue;
763
788
  }
764
- if (state.status === 'failed') {
789
+ if (state.status && state.status !== 'ok') {
765
790
  console.log(chalk.red(` ${label}${chalk.red('[ERROR] 查询失败')}`) +
766
- chalk.gray(` (${state.error || 'unknown error'})`));
791
+ chalk.gray(` (${state.error || state.status || 'unknown error'})`));
767
792
  continue;
768
793
  }
769
794
  console.log(chalk.gray(` ${label}`) + (state.running
@@ -827,6 +852,7 @@ module.exports = {
827
852
  shouldTreatPortOwnershipAsReady,
828
853
  getManagedPorts,
829
854
  getProxyStatusEntries,
855
+ getUIConnectionOptions,
830
856
  listProxyPlatforms,
831
857
  shouldStopPM2Process
832
858
  }
@@ -1,5 +1,6 @@
1
1
  const chalk = require('chalk');
2
2
  const http = require('http');
3
+ const https = require('https');
3
4
  const { loadConfig } = require('../config/loader');
4
5
  const { normalizePlatformKey } = require('../shared/platforms');
5
6
 
@@ -38,7 +39,7 @@ function getPlatformInfo(channel, registry = getDefaultRegistry()) {
38
39
  };
39
40
  }
40
41
 
41
- function createHttpRequest(loadConfigImpl = loadConfig) {
42
+ function createHttpRequest(loadConfigImpl = loadConfig, { useHttps = false, ca } = {}) {
42
43
  return function request(method, requestPath, data = null) {
43
44
  const config = loadConfigImpl();
44
45
  const port = config.ports?.webUI || 19999;
@@ -56,10 +57,19 @@ function createHttpRequest(loadConfigImpl = loadConfig) {
56
57
  },
57
58
  timeout: 5000
58
59
  };
60
+ if (useHttps) {
61
+ // This client only connects to localhost. Trust the daemon's generated
62
+ // certificate when available (Windows may use a PFX instead of PEM).
63
+ options.ca = ca;
64
+ options.rejectUnauthorized = Boolean(ca);
65
+ }
59
66
 
60
- const req = http.request(options, (res) => {
67
+ const req = (useHttps ? https : http).request(options, (res) => {
61
68
  let responseData = '';
62
69
 
70
+ res.on('error', reject);
71
+ res.on('aborted', () => reject(new Error('Response connection closed unexpectedly')));
72
+
63
73
  res.on('data', (chunk) => {
64
74
  responseData += chunk;
65
75
  });
@@ -86,8 +96,8 @@ function createHttpRequest(loadConfigImpl = loadConfig) {
86
96
  };
87
97
  }
88
98
 
89
- function createProxyControl({ registry = getDefaultRegistry(), httpRequest: request, loadConfig: loadConfigImpl, sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
90
- const requestImpl = request || createHttpRequest(loadConfigImpl || loadConfig);
99
+ function createProxyControl({ registry = getDefaultRegistry(), httpRequest: request, loadConfig: loadConfigImpl, connection, sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
100
+ const requestImpl = request || createHttpRequest(loadConfigImpl || loadConfig, connection);
91
101
 
92
102
  function resolve(channel) {
93
103
  const info = getPlatformInfo(channel, registry);
package/src/index.js CHANGED
@@ -164,6 +164,11 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
164
164
  const channel = platform.key;
165
165
  const options = dependencies.registry ? { registry: dependencies.registry } : {};
166
166
 
167
+ if (args.length > 1) {
168
+ reportInvalidCommand('参数', args[1], `ctx ${platform.command || platform.key} ${action}`);
169
+ return;
170
+ }
171
+
167
172
  switch (action) {
168
173
  case 'start':
169
174
  await proxyStart(channel, options);
@@ -178,11 +183,23 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
178
183
  await proxyStatus(channel, options);
179
184
  return;
180
185
  default:
181
- console.log(chalk.red(`\n[ERROR] 未知操作: ${action}\n`));
182
- console.log(chalk.gray('支持的操作: start, stop, restart, status\n'));
186
+ reportInvalidCommand('操作', action, '支持的操作: start, stop, restart, status');
183
187
  }
184
188
  }
185
189
 
190
+ function reportInvalidCommand(kind, value, usage = '') {
191
+ console.error(chalk.red(`\n[ERROR] 未知${kind}: ${value}\n`));
192
+ if (usage) console.error(chalk.gray(`${usage}\n`));
193
+ process.exitCode = 1;
194
+ }
195
+
196
+ function validateCommandOptions(command, args, allowedOptions) {
197
+ const invalidOption = args.find(argument => !allowedOptions.has(argument));
198
+ if (!invalidOption) return true;
199
+ reportInvalidCommand('参数', invalidOption, `用法: ${command}`);
200
+ return false;
201
+ }
202
+
186
203
  let processShutdownPromise = null;
187
204
 
188
205
  async function stopOwnedOmpGatewayBeforeExit() {
@@ -193,7 +210,7 @@ async function stopOwnedOmpGatewayBeforeExit() {
193
210
  } = require('./platforms/drivers/omp/proxy-implementation');
194
211
  if (getOmpProxyStatus().running) {
195
212
  const preserveManagedMode = process.argv.includes('--daemon');
196
- await stopOmpProxyServer({ forceAfterMs: 1500, preserveManagedMode });
213
+ await stopOmpProxyServer({ forceAfterMs: 120000, preserveManagedMode });
197
214
  }
198
215
  } catch (error) {
199
216
  console.error(chalk.yellow(`[WARN] OMP 动态网关退出清理失败: ${error.message}`));
@@ -203,12 +220,21 @@ async function stopOwnedOmpGatewayBeforeExit() {
203
220
  function shutdownProcess(code = 0, error = null) {
204
221
  if (processShutdownPromise) return processShutdownPromise;
205
222
  processShutdownPromise = (async () => {
223
+ try {
224
+ require('./server/services/memory-reclaimer').stopMemoryReclaimer();
225
+ } catch (memoryError) {
226
+ console.error(chalk.yellow(`[WARN] 内存回收器退出清理失败: ${memoryError.message}`));
227
+ }
206
228
  try {
207
229
  require('./server/services/native-log-observer').shutdownNativeCliLogObserver();
208
230
  } catch (observerError) {
209
231
  console.error(chalk.yellow(`[WARN] CLI 原生会话日志退出清理失败: ${observerError.message}`));
210
232
  }
211
- await stopOwnedOmpGatewayBeforeExit();
233
+ await Promise.all([
234
+ stopOwnedOmpGatewayBeforeExit(),
235
+ require('./server/services/server-shutdown').drainProxyServers({ forceAfterMs: 120000 })
236
+ .catch(drainError => console.error(`[WARN] Proxy drain failed: ${drainError.message}`))
237
+ ]);
212
238
  eventBus.emitSync('cli:shutdown', {});
213
239
  PluginManager.shutdownPlugins();
214
240
  try {
@@ -282,6 +308,11 @@ async function main({ menuDependencies } = {}) {
282
308
  if (args[0] === 'daemon') {
283
309
  const subCommand = args[1] || 'status';
284
310
 
311
+ if (['start', 'stop', 'restart', 'status'].includes(subCommand) && args.length > 2) {
312
+ reportInvalidCommand('参数', args[2], `ctx daemon ${subCommand}`);
313
+ return;
314
+ }
315
+
285
316
  if (subCommand === 'start') {
286
317
  await handleStart();
287
318
  return finishCli(0);
@@ -314,25 +345,37 @@ async function main({ menuDependencies } = {}) {
314
345
  return;
315
346
  }
316
347
 
317
- console.log(chalk.red(`\n[ERROR] 未知 daemon 子命令: ${subCommand}\n`));
318
- console.log(chalk.gray('支持的命令: start, stop, restart, status, logs\n'));
348
+ reportInvalidCommand('daemon 子命令', subCommand, '支持的命令: start, stop, restart, status, logs');
319
349
  return;
320
350
  }
321
351
 
322
352
  // reset 命令 - 恢复默认配置
323
353
  if (args[0] === 'reset') {
354
+ if (args.length > 1) {
355
+ reportInvalidCommand('参数', args[1], '用法: ctx reset');
356
+ return;
357
+ }
324
358
  await resetConfig();
325
359
  return;
326
360
  }
327
361
 
328
362
  // security 命令 - 安全设置
329
363
  if (args[0] === 'security') {
330
- const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
331
364
  const subCommand = args[1] || 'help';
332
365
 
366
+ if (!['reset', 'disable', 'off', 'help'].includes(subCommand)) {
367
+ reportInvalidCommand('security 子命令', subCommand, '支持的命令: reset, disable, off, help');
368
+ return;
369
+ }
370
+ const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
371
+
333
372
  if (subCommand === 'reset' || subCommand === 'disable' || subCommand === 'off') {
373
+ if (args.length > 2) {
374
+ reportInvalidCommand('参数', args[2], `ctx security ${subCommand}`);
375
+ return;
376
+ }
334
377
  await handleSecurityReset();
335
- } else {
378
+ } else if (subCommand === 'help') {
336
379
  showSecurityHelp();
337
380
  }
338
381
  return;
@@ -340,31 +383,52 @@ async function main({ menuDependencies } = {}) {
340
383
 
341
384
  // start 命令 - 启动服务(后台)
342
385
  if (args[0] === 'start') {
386
+ if (!validateCommandOptions('ctx start [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
343
387
  await handleStart();
344
388
  return finishCli(0);
345
389
  }
346
390
 
347
391
  // stop 命令 - 停止服务
348
392
  if (args[0] === 'stop') {
393
+ if (args.length > 1) {
394
+ reportInvalidCommand('参数', args[1], '用法: ctx stop');
395
+ return;
396
+ }
349
397
  await handleStop();
350
398
  return finishCli(0);
351
399
  }
352
400
 
353
401
  // restart 命令 - 重启服务
354
402
  if (args[0] === 'restart') {
403
+ if (!validateCommandOptions('ctx restart [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
355
404
  await handleRestart();
356
405
  return finishCli(0);
357
406
  }
358
407
 
359
408
  // status 命令 - 查看服务状态
360
409
  if (args[0] === 'status') {
410
+ if (args.length > 1) {
411
+ reportInvalidCommand('参数', args[1], '用法: ctx status');
412
+ return;
413
+ }
361
414
  await handleStatus();
362
415
  return finishCli(0);
363
416
  }
364
417
 
365
418
  // ui 命令 - Web UI 管理
366
419
  if (args[0] === 'ui') {
367
- const subCommand = args[1];
420
+ const commandArgs = args.slice(1);
421
+ const subCommand = commandArgs[0] && !commandArgs[0].startsWith('-') ? commandArgs[0] : null;
422
+ const optionArgs = subCommand ? commandArgs.slice(1) : commandArgs;
423
+ const allowedOptions = new Set(['--https', '--host', '--hosts', '--daemon']);
424
+
425
+ if (subCommand && !['start', 'stop', 'restart'].includes(subCommand)) {
426
+ reportInvalidCommand('ui 子命令', subCommand, '支持的命令: start, stop, restart');
427
+ return;
428
+ }
429
+ if (!validateCommandOptions(`ctx ui${subCommand ? ` ${subCommand}` : ''}`, optionArgs,
430
+ subCommand === 'stop' ? new Set() : allowedOptions)) return;
431
+
368
432
  if (subCommand === 'start') {
369
433
  await handleStart(); // UI start 实际上就是启动整个服务
370
434
  return finishCli(0);
@@ -440,12 +504,17 @@ async function main({ menuDependencies } = {}) {
440
504
 
441
505
  // doctor 命令 - 系统诊断
442
506
  if (args[0] === 'doctor') {
507
+ if (args.length > 1) {
508
+ reportInvalidCommand('参数', args[1], '用法: ctx doctor');
509
+ return;
510
+ }
443
511
  await handleDoctor();
444
512
  return;
445
513
  }
446
514
 
447
515
  // update 命令 - 检查并更新到最新版本
448
516
  if (args[0] === 'update') {
517
+ if (!validateCommandOptions('ctx update [--check]', args.slice(1), new Set(['--check']))) return;
449
518
  const checkOnly = args.includes('--check');
450
519
  await handleUpdate({ checkOnly });
451
520
  return;
@@ -453,6 +522,10 @@ async function main({ menuDependencies } = {}) {
453
522
 
454
523
  // port 命令 - 配置端口
455
524
  if (args[0] === 'port') {
525
+ if (args.length > 1) {
526
+ reportInvalidCommand('参数', args[1], '用法: ctx port');
527
+ return;
528
+ }
456
529
  const { handlePortConfig } = require('./commands/port-config');
457
530
  await handlePortConfig();
458
531
  return;
@@ -460,9 +533,18 @@ async function main({ menuDependencies } = {}) {
460
533
 
461
534
  // 代理命令
462
535
  if (args[0] === 'proxy') {
463
- const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
464
536
  const subCommand = args[1] || 'start';
465
537
 
538
+ if (!['start', 'stop', 'status'].includes(subCommand)) {
539
+ reportInvalidCommand('proxy 子命令', subCommand, '支持的命令: start, stop, status');
540
+ return;
541
+ }
542
+ if (args.length > 2) {
543
+ reportInvalidCommand('参数', args[2], `ctx proxy ${subCommand}`);
544
+ return;
545
+ }
546
+ const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
547
+
466
548
  switch (subCommand) {
467
549
  case 'start':
468
550
  await handleProxyStart();
@@ -476,10 +558,6 @@ async function main({ menuDependencies } = {}) {
476
558
  handleProxyStatus();
477
559
  return;
478
560
 
479
- default:
480
- // 默认执行 start
481
- await handleProxyStart();
482
- return;
483
561
  }
484
562
  }
485
563
 
@@ -508,6 +586,12 @@ async function main({ menuDependencies } = {}) {
508
586
  return;
509
587
  }
510
588
 
589
+ if (args.length > 0) {
590
+ reportInvalidCommand('命令', args[0], '运行 ctx --help 查看可用命令');
591
+ PluginManager.shutdownPlugins();
592
+ return;
593
+ }
594
+
511
595
  while (true) {
512
596
  // 显示主菜单
513
597
  const { showMainMenu } = require('./ui/menu');
@@ -7,7 +7,7 @@ const { createDecodedStream } = require('../../../server/services/response-decod
7
7
  const { parseNonStreamingUsage } = require('../../../shared/response-usage-parser');
8
8
  const { convertClaudeToOpenCodePayload } = require('../opencode/gateway-converter');
9
9
 
10
- const REQUEST_TIMEOUT_MS = 120000;
10
+ const { PROXY_IDLE_TIMEOUT_MS: REQUEST_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
11
11
 
12
12
  function getRequestPathname(urlPath = '') {
13
13
  try {
@@ -19,14 +19,15 @@ const {
19
19
  const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('../../../server/services/request-logger');
20
20
  const { redirectModel, normalizeGatewaySourceType } = require('../../../shared/proxy-utils');
21
21
  const { handleClaudeOpenAiGatewayRequest } = require('./openai-gateway');
22
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
22
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
23
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
23
24
 
24
25
  let proxyServer = null;
25
26
  let proxyApp = null;
26
27
  let currentPort = null;
27
28
 
28
29
  // 用于存储每个请求的元数据(用于 WebSocket 日志)
29
- const requestMetadata = new Map();
30
+ let requestMetadata = new WeakMap();
30
31
 
31
32
  // 用于缓存已打印过的模型重定向规则,避免重复打印
32
33
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -35,7 +36,7 @@ const printedRedirectCache = new Map();
35
36
  const jsonBodyParser = express.json({
36
37
  limit: '100mb',
37
38
  verify: (req, res, buf) => {
38
- req.rawBody = Buffer.from(buf);
39
+ req.rawBody = buf;
39
40
  }
40
41
  });
41
42
 
@@ -174,6 +175,7 @@ async function startProxyServer(options = {}) {
174
175
  const proxy = httpProxy.createProxyServer({});
175
176
 
176
177
  proxy.on('proxyReq', (proxyReq, req, res) => {
178
+ bindProxyRequest(proxyReq, res);
177
179
  const selectedChannel = req.selectedChannel;
178
180
  if (selectedChannel) {
179
181
  const requestId = `${Date.now()}-${Math.random()}`;
@@ -238,9 +240,10 @@ async function startProxyServer(options = {}) {
238
240
  };
239
241
 
240
242
  req.__releaseChannel = release;
241
-
243
+ res.on('finish', release);
242
244
  res.on('close', release);
243
245
  res.on('error', release);
246
+ if (res.destroyed || res.writableEnded) { release(); return; }
244
247
 
245
248
  const effectiveKey = getEffectiveApiKey(channel);
246
249
  if (!effectiveKey) {
@@ -306,8 +309,8 @@ async function startProxyServer(options = {}) {
306
309
  const proxyOptions = {
307
310
  target: channel.baseUrl,
308
311
  changeOrigin: true,
309
- proxyTimeout: 120000, // 代理连接超时 2 分钟
310
- timeout: 120000 // 请求超时 2 分钟
312
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
313
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
311
314
  };
312
315
 
313
316
  if (channel.proxyUrl) {
@@ -329,6 +332,7 @@ async function startProxyServer(options = {}) {
329
332
  }
330
333
  });
331
334
  } catch (error) {
335
+ req.__releaseChannel?.();
332
336
  console.error('Channel allocation error:', error);
333
337
  if (!res.headersSent) {
334
338
  res.status(503).json({
@@ -341,6 +345,7 @@ async function startProxyServer(options = {}) {
341
345
 
342
346
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
343
347
  proxy.on('proxyRes', (proxyRes, req, res) => {
348
+ prepareProxyResponse(proxyRes, res);
344
349
  const metadata = requestMetadata.get(req);
345
350
  if (!metadata) return;
346
351
 
@@ -425,9 +430,9 @@ async function stopProxyServer(options = {}) {
425
430
  return { success: true, message: 'Proxy server not running' };
426
431
  }
427
432
 
428
- requestMetadata.clear();
433
+ requestMetadata = new WeakMap();
429
434
 
430
- const shutdownTimer = expediteServerShutdown(proxyServer);
435
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
431
436
 
432
437
  return new Promise((resolve) => {
433
438
  proxyServer.close(() => {
@@ -15,14 +15,15 @@ const {
15
15
  isChatCompletionsPath,
16
16
  ensureOpenAiStreamUsage
17
17
  } = require('../../../shared/proxy-utils');
18
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
18
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
19
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
19
20
 
20
21
  let proxyServer = null;
21
22
  let proxyApp = null;
22
23
  let currentPort = null;
23
24
 
24
25
  // 用于存储每个请求的元数据
25
- const requestMetadata = new Map();
26
+ let requestMetadata = new WeakMap();
26
27
 
27
28
  // 用于缓存已打印过的模型重定向规则,避免重复打印
28
29
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -37,7 +38,7 @@ const resolveCodexTarget = resolveTargetUrl;
37
38
  const jsonBodyParser = express.json({
38
39
  limit: '100mb',
39
40
  verify: (req, res, buf) => {
40
- req.rawBody = Buffer.from(buf);
41
+ req.rawBody = buf;
41
42
  }
42
43
  });
43
44
 
@@ -77,7 +78,8 @@ async function startCodexProxyServer(options = {}) {
77
78
 
78
79
  const proxy = httpProxy.createProxyServer({});
79
80
 
80
- proxy.on('proxyReq', (proxyReq, req) => {
81
+ proxy.on('proxyReq', (proxyReq, req, res) => {
82
+ bindProxyRequest(proxyReq, res);
81
83
  const activeChannel = req.selectedChannel;
82
84
  if (!activeChannel) return;
83
85
 
@@ -127,8 +129,11 @@ async function startCodexProxyServer(options = {}) {
127
129
  };
128
130
  })();
129
131
 
132
+ req.__releaseChannel = release;
133
+ res.on('finish', release);
130
134
  res.on('close', release);
131
135
  res.on('error', release);
136
+ if (res.destroyed || res.writableEnded) { release(); return; }
132
137
 
133
138
  broadcastSchedulerState('codex', getSchedulerState('codex'));
134
139
 
@@ -200,8 +205,8 @@ async function startCodexProxyServer(options = {}) {
200
205
  proxy.web(req, res, {
201
206
  target,
202
207
  changeOrigin: true,
203
- proxyTimeout: 120000, // 代理连接超时 2 分钟
204
- timeout: 120000 // 请求超时 2 分钟
208
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
209
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
205
210
  }, (err) => {
206
211
  release();
207
212
  if (err) {
@@ -218,6 +223,7 @@ async function startCodexProxyServer(options = {}) {
218
223
  }
219
224
  });
220
225
  } catch (error) {
226
+ req.__releaseChannel?.();
221
227
  console.error('Codex channel allocation error:', error);
222
228
  if (!res.headersSent) {
223
229
  res.status(503).json({
@@ -232,6 +238,7 @@ async function startCodexProxyServer(options = {}) {
232
238
 
233
239
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
234
240
  proxy.on('proxyRes', (proxyRes, req, res) => {
241
+ prepareProxyResponse(proxyRes, res);
235
242
  const metadata = requestMetadata.get(req);
236
243
  if (!metadata) return;
237
244
 
@@ -277,8 +284,7 @@ async function startCodexProxyServer(options = {}) {
277
284
  console.error('Codex proxy error:', err);
278
285
  if (req && req.selectedChannel) {
279
286
  recordFailure(req.selectedChannel.id, 'codex', err);
280
- releaseChannel(req.selectedChannel.id, 'codex');
281
- broadcastSchedulerState('codex', getSchedulerState('codex'));
287
+ req.__releaseChannel?.();
282
288
  }
283
289
  if (res && !res.headersSent) {
284
290
  res.status(502).json({
@@ -331,9 +337,9 @@ async function stopCodexProxyServer(options = {}) {
331
337
  return { success: true, message: 'Codex proxy server not running' };
332
338
  }
333
339
 
334
- requestMetadata.clear();
340
+ requestMetadata = new WeakMap();
335
341
 
336
- const shutdownTimer = expediteServerShutdown(proxyServer);
342
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
337
343
 
338
344
  return new Promise((resolve) => {
339
345
  proxyServer.close(() => {