tt-help-cli-ycl 1.4.115 → 1.4.119

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.
@@ -12,6 +12,7 @@ import {
12
12
  getClientConfig,
13
13
  getLocalIP,
14
14
  getClashHealth,
15
+ getClashHealthChannels,
15
16
  } from "./reporter.js";
16
17
  import { ProcessManager } from "./process-manager.js";
17
18
  import {
@@ -160,12 +161,29 @@ export class WatchdogAgent {
160
161
  * @param {string} opts.clientId - 客户端标识
161
162
  * @param {number} opts.intervalMs - 心跳间隔(毫秒)
162
163
  * @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
164
+ * @param {number} [opts.loginCheckIntervalMs] - 登录检测周期(毫秒),默认 2 小时
163
165
  */
164
- constructor({ serverUrl, clientId, intervalMs = 15000, spawnDaemon = true }) {
166
+ constructor({
167
+ serverUrl,
168
+ clientId,
169
+ intervalMs = 15000,
170
+ spawnDaemon = true,
171
+ loginCheckIntervalMs = 2 * 60 * 60 * 1000,
172
+ }) {
165
173
  this.serverUrl = serverUrl.replace(/\/+$/, "");
166
174
  this.clientId = clientId;
167
175
  this.intervalMs = intervalMs;
168
176
  this.spawnDaemon = !!spawnDaemon;
177
+ this.loginCheckIntervalMs = Math.max(
178
+ 60 * 1000,
179
+ loginCheckIntervalMs || 2 * 60 * 60 * 1000,
180
+ );
181
+ this.loginPorts = []; // 服务端下发的检测端口列表
182
+ this._loginPortsKey = ""; // 端口列表指纹(变化时立即检测)
183
+ this.loginReport = null; // 最近一次登录检测结果(随心跳上报)
184
+ this._loginChecking = false; // 防重入
185
+ this._lastLoginCheckAt = 0; // 上次检测时间戳
186
+ this.loginCheckTimer = null;
169
187
  this.running = false;
170
188
  this.heartbeatTimer = null;
171
189
  this.thresholdTimer = null;
@@ -249,6 +267,14 @@ export class WatchdogAgent {
249
267
  this._checkProcessErrors();
250
268
  }, ERROR_SCAN_INTERVAL_MS);
251
269
 
270
+ // 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
271
+ this.loginCheckTimer = setInterval(() => {
272
+ this._maybeRunLoginCheck();
273
+ }, 60_000);
274
+ setTimeout(() => {
275
+ this._maybeRunLoginCheck();
276
+ }, 60_000);
277
+
252
278
  // LLM 日志分析(每 10 分钟调服务端接口)
253
279
  this._startLlmAnalyzer();
254
280
 
@@ -277,6 +303,10 @@ export class WatchdogAgent {
277
303
  clearInterval(this._errorTriggerTimer);
278
304
  this._errorTriggerTimer = null;
279
305
  }
306
+ if (this.loginCheckTimer) {
307
+ clearInterval(this.loginCheckTimer);
308
+ this.loginCheckTimer = null;
309
+ }
280
310
  if (this.llmAnalyzerTimer) {
281
311
  clearInterval(this.llmAnalyzerTimer);
282
312
  this.llmAnalyzerTimer = null;
@@ -414,7 +444,9 @@ export class WatchdogAgent {
414
444
  config: getClientConfig(),
415
445
  processes: this.processManager.getProcessesStatus(),
416
446
  llmAnalysis: this._llmResults,
417
- clashHealth: getClashHealth(), // daemon 写入的代理健康状态
447
+ clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
448
+ clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
449
+ loginReport: this.loginReport, // 最近一次登录检测结果
418
450
  };
419
451
  }
420
452
 
@@ -458,13 +490,21 @@ export class WatchdogAgent {
458
490
  }
459
491
  }
460
492
 
461
- // 处理 Clash daemon 命令(一次写入数组,daemon 轮询整批处理)
493
+ // 处理 Clash daemon 命令(按 proxyPort 分组写入对应通道,无端口走默认通道)
462
494
  if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
463
495
  const ipc = await import("../clash/ipc.js");
464
- ipc.writeCommand(data.clashCommands);
465
- console.error(
466
- `[watchdog] 下发 Clash 命令: ${JSON.stringify(data.clashCommands)}`,
467
- );
496
+ const byPort = new Map();
497
+ for (const cmd of data.clashCommands) {
498
+ const port = cmd && cmd.proxyPort ? Number(cmd.proxyPort) : 0;
499
+ if (!byPort.has(port)) byPort.set(port, []);
500
+ byPort.get(port).push(cmd);
501
+ }
502
+ for (const [port, cmds] of byPort) {
503
+ ipc.writeCommand(cmds, port);
504
+ console.error(
505
+ `[watchdog] 下发 Clash 命令${port ? ` (通道 ${port})` : ""}: ${JSON.stringify(cmds)}`,
506
+ );
507
+ }
468
508
  }
469
509
 
470
510
  // 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
@@ -475,6 +515,18 @@ export class WatchdogAgent {
475
515
  if (data.errorTrigger) {
476
516
  this._applyErrorTriggerConfig(data.errorTrigger);
477
517
  }
518
+ // 服务端下发的登录检测端口列表
519
+ if (Array.isArray(data.loginPorts)) {
520
+ const key = data.loginPorts.join(",");
521
+ if (key !== this._loginPortsKey) {
522
+ this._loginPortsKey = key;
523
+ this.loginPorts = data.loginPorts;
524
+ this._lastLoginCheckAt = 0; // 配置变化 → 立即触发一次检测
525
+ console.error(
526
+ `[watchdog] 登录检测端口: ${key || "未配置"}(周期 ${Math.round(this.loginCheckIntervalMs / 60000)} 分钟)`,
527
+ );
528
+ }
529
+ }
478
530
 
479
531
  // 心跳日志:仅状态变化或每 10 次打印一次
480
532
  this._heartbeatSeq++;
@@ -528,6 +580,113 @@ export class WatchdogAgent {
528
580
  }
529
581
  }
530
582
 
583
+ /**
584
+ * 登录检测调度入口:到点且服务端配置了端口时执行一次检测
585
+ */
586
+ _maybeRunLoginCheck() {
587
+ if (!this.running || this._loginChecking) return;
588
+ if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
589
+ if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
590
+ this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
591
+ this._runLoginCheck().catch((e) => {
592
+ console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
593
+ });
594
+ }
595
+
596
+ /**
597
+ * 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
598
+ */
599
+ async _runLoginCheck() {
600
+ this._loginChecking = true;
601
+ const ports = this.loginPorts;
602
+ console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
603
+ try {
604
+ // 全局安装用 tt-help 命令;源码模式用 node cli.js
605
+ const cliPath = join(__dirname, "..", "..", "cli.js");
606
+ const cmd = isGlobalInstall() ? "tt-help" : process.execPath;
607
+ const args = isGlobalInstall()
608
+ ? ["login-check", ports.join(","), "--json"]
609
+ : [cliPath, "login-check", ports.join(","), "--json"];
610
+ // 带上 ~/.tt-help.json 里配置的代理(服务端可通过 clientConfig 下发)
611
+ const cfg = getClientConfig();
612
+ if (cfg.proxy) args.push("--proxy", cfg.proxy);
613
+
614
+ const { stdout, stderr, code } = await new Promise((resolve) => {
615
+ const child = spawn(cmd, args, {
616
+ stdio: ["ignore", "pipe", "pipe"],
617
+ });
618
+ let out = "",
619
+ err = "";
620
+ child.stdout.on("data", (d) => (out += d));
621
+ child.stderr.on("data", (d) => (err += d));
622
+ child.on("error", (e) =>
623
+ resolve({ stdout: out, stderr: err + e.message, code: -1 }),
624
+ );
625
+ child.on("close", (c) =>
626
+ resolve({ stdout: out, stderr: err, code: c }),
627
+ );
628
+ // 超时保护 10 分钟(单端口含启动探测最长约 2 分钟)
629
+ setTimeout(
630
+ () => {
631
+ try {
632
+ child.kill("SIGKILL");
633
+ } catch {
634
+ /* ignore */
635
+ }
636
+ },
637
+ 10 * 60 * 1000,
638
+ );
639
+ });
640
+
641
+ if (code !== 0) {
642
+ // 部分端口失败时 login-check 也会输出完整 JSON(退出码 2)。
643
+ // 优先采用 stdout 里的 JSON 结果,仅解析失败才记为 error。
644
+ let partial = null;
645
+ try {
646
+ const parsed = JSON.parse(String(stdout).trim());
647
+ if (Array.isArray(parsed)) partial = parsed;
648
+ } catch {
649
+ // stdout 不是 JSON → 走错误分支
650
+ }
651
+ if (partial) {
652
+ this.loginReport = { ports: partial, checkedAt: Date.now() };
653
+ console.error(
654
+ `[watchdog] 登录检测完成(部分失败): ${
655
+ String(stderr)
656
+ .split("\n")
657
+ .filter((l) => l.includes("探测失败"))
658
+ .slice(-3)
659
+ .join(" | ")
660
+ .slice(-300) || "见端口状态"
661
+ }`,
662
+ );
663
+ return;
664
+ }
665
+ this.loginReport = {
666
+ error: `exit ${code}: ${String(stderr).slice(-500)}`,
667
+ checkedAt: Date.now(),
668
+ };
669
+ console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
670
+ return;
671
+ }
672
+ const parsed = JSON.parse(String(stdout).trim());
673
+ if (!Array.isArray(parsed)) throw new Error("结果不是数组");
674
+ this.loginReport = { ports: parsed, checkedAt: Date.now() };
675
+ const summary = parsed
676
+ .map(
677
+ (r) =>
678
+ `${r.port}:${r.status}${r.status === "logged_in" && r.account ? "@" + r.account : ""}`,
679
+ )
680
+ .join(" ");
681
+ console.error(`[watchdog] 登录检测完成: ${summary}`);
682
+ } catch (e) {
683
+ this.loginReport = { error: e.message, checkedAt: Date.now() };
684
+ console.error(`[watchdog] 登录检测异常: ${e.message}`);
685
+ } finally {
686
+ this._loginChecking = false;
687
+ }
688
+ }
689
+
531
690
  /**
532
691
  * 启动 LLM 日志分析定时器(每 10 分钟调服务端接口)
533
692
  */
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import os from "os";
7
- import { existsSync, readFileSync } from "fs";
7
+ import { existsSync, readFileSync, readdirSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { homedir, tmpdir } from "os";
10
10
 
@@ -91,3 +91,34 @@ export function getClashHealth() {
91
91
  }
92
92
  return null;
93
93
  }
94
+
95
+ /**
96
+ * 读取多控制端健康状态(daemon 多通道模式写入的 clash-health-{port}.json)
97
+ * @returns {Array<{port:number, stale:boolean, ageSeconds:number, ...health}>}
98
+ */
99
+ export function getClashHealthChannels() {
100
+ const out = [];
101
+ try {
102
+ for (const name of readdirSync(tmpdir())) {
103
+ const m = /^clash-health-(\d+)\.json$/.exec(name);
104
+ if (!m) continue;
105
+ const port = parseInt(m[1], 10);
106
+ if (!port) continue;
107
+ try {
108
+ const data = JSON.parse(readFileSync(join(tmpdir(), name), "utf-8"));
109
+ const age = Date.now() - new Date(data.lastCheck || 0).getTime();
110
+ out.push({
111
+ ...data,
112
+ port,
113
+ stale: age > 120_000,
114
+ ageSeconds: Math.round(age / 1000),
115
+ });
116
+ } catch {
117
+ /* 单个文件解析失败跳过 */
118
+ }
119
+ }
120
+ } catch {
121
+ /* 目录不可读 */
122
+ }
123
+ return out;
124
+ }