tt-help-cli-ycl 1.4.171 → 1.4.175

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.
@@ -14,13 +14,18 @@
14
14
  * 3 粘性 槽位原位保留——订阅还活着就不挪窝(端口 = 槽位序);
15
15
  * 不在池子的存量 URL 状态未知,同样视为可用(迁移无感)
16
16
  * 4 节点内摊开 同一机器槽位尽量用不同订阅;count > 可用去重数才回绕
17
- * 5 跨机器错开 补槽时优先选全集群占用数最少的可用订阅
18
- * (占用数 = 该订阅在所有机器 alloc 里出现的次数)
17
+ * 5 跨机器错开 补槽时优先选全集群「加权占用」最小的可用订阅
18
+ * (占用数 = 该订阅在所有机器 alloc 里出现的次数;
19
+ * 加权占用 = 占用数 / weight,weight 缺省 1 → 退化为大家 1:1:1:1 摊开,与无权重时代数完全一致)
19
20
  * 6 重复合法 回绕重复按池子顺序取
20
21
  * 7 空保护 可用集为空 → alloc 原地不动 + 标黄,绝不下发空列表
21
22
  * 8 决定性 纯函数 + 池子顺序决胜;心跳重跑输入不变则结果恒等,天然粘性
22
23
  *
23
- * 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数升序 → 池子顺序。
24
+ * 权重语义: weight 只影响跨机器份额,不破坏粘性——存量槽照旧占位计数,
25
+ * 份额超了后续新槽自然不再选它,平滑收敛到 weight 比例;
26
+ * 节点内摊开(规则4)不受权重影响,机器内部仍优先用不同订阅。
27
+ *
28
+ * 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数/weight 升序 → 池子顺序。
24
29
  * 全局占用数每次现算(遍历所有节点 alloc,几十个节点毫秒级);分配时机 = 心跳惰性计算,
25
30
  * 结果与存量不同才写回配置(下一行 sendJSON 读到的即是新 alloc,同一个心跳就下发)。
26
31
  *
@@ -86,11 +91,16 @@ function scoreLess(a, b) {
86
91
  * @returns {{ alloc: Array<{name,url}>, warn: string|null }}
87
92
  * warn: "pool-empty" 可用集为空且存在补不上的槽(规则 #7)
88
93
  */
89
- export function allocateSubscriptions({ available, count, prevAlloc, usage, isAvailUrl }) {
94
+ export function allocateSubscriptions({ available, count, prevAlloc, usage, weights, isAvailUrl }) {
90
95
  const prev = Array.isArray(prevAlloc) ? prevAlloc.map(normEntry) : [];
91
96
  const n = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, parseInt(count, 10) || 1));
92
97
  const avail = Array.isArray(available) ? available : [];
93
98
  const use = usage && typeof usage === "object" ? usage : {};
99
+ const wt = weights && typeof weights === "object" ? weights : {};
100
+ const weightOf = (url) => {
101
+ const w = parseInt(wt[url], 10);
102
+ return Number.isFinite(w) && w > 0 ? w : 1; // 非法/缺省一律按 1,行为与无权重版一致
103
+ };
94
104
 
95
105
  const nodeUsed = new Map(); // 本节点新 alloc 内的占用(含粘性保留槽,跨节点占用走 usage)
96
106
  const globalUsed = (url) => (use[url] || 0) + (nodeUsed.get(url) || 0);
@@ -120,7 +130,7 @@ export function allocateSubscriptions({ available, count, prevAlloc, usage, isAv
120
130
  for (let k = 0; k < avail.length; k++) {
121
131
  const s = avail[k];
122
132
  const inNode = (nodeUsed.get(s.url) || 0) > 0;
123
- const score = [inNode ? 1 : 0, globalUsed(s.url), k];
133
+ const score = [inNode ? 1 : 0, globalUsed(s.url) / weightOf(s.url), k];
124
134
  if (!bestScore || scoreLess(score, bestScore)) {
125
135
  best = s;
126
136
  bestScore = score;
@@ -199,6 +209,8 @@ export function reconcileAutoAlloc(userId) {
199
209
  const views = getSubscriptionView();
200
210
  const viewByUrl = new Map(views.map((v) => [v.url, v]));
201
211
  const available = views.filter(isSubAvailable);
212
+ const weights = {};
213
+ for (const v of views) weights[v.url] = v.weight;
202
214
 
203
215
  // 全局占用数现算:其它节点 alloc 里该 URL 出现的次数(本节点走分配内计数)
204
216
  const usage = {};
@@ -220,6 +232,7 @@ export function reconcileAutoAlloc(userId) {
220
232
  count,
221
233
  prevAlloc,
222
234
  usage,
235
+ weights,
223
236
  isAvailUrl: (u) => isAllocUrlAvailable(u, viewByUrl),
224
237
  });
225
238
 
@@ -45,6 +45,7 @@ const HISTORY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 快照历史保留 30
45
45
  const RESETS_MAX = 20; // 重置事件最多保留条数
46
46
  const NODE_REPORT_STALE_MS = 26 * 60 * 60 * 1000; // 节点代报数据超过 26h 视为陈旧
47
47
  const SUBS_MAX = 64; // 订阅池容量上限
48
+ const WEIGHT_MAX = 10; // 自动分配权重上限(1=普通份额,越大占比越高;下限恒为 1,无 0 值语义)
48
49
  const UA = "clash.meta/1.19.31"; // 机场按 UA 发货:浏览器 UA 拿到 HTML 页,Clash UA 才带头
49
50
  const SAVE_DEBOUNCE_MS = 3 * 1000;
50
51
 
@@ -423,7 +424,14 @@ function cleanText(v, max) {
423
424
  return typeof v === "string" ? v.trim().slice(0, max || 120) : "";
424
425
  }
425
426
 
426
- export function addSubscription({ name, url, note } = {}) {
427
+ /** 权重归一化:非法/缺省 → 1;钳位 1..WEIGHT_MAX(只入整数) */
428
+ function normalizeWeight(v) {
429
+ const n = parseInt(v, 10);
430
+ if (!Number.isFinite(n)) return 1;
431
+ return Math.max(1, Math.min(WEIGHT_MAX, n));
432
+ }
433
+
434
+ export function addSubscription({ name, url, note, weight } = {}) {
427
435
  load();
428
436
  url = cleanText(url, 2048);
429
437
  if (!url || !/^https?:\/\//.test(url))
@@ -438,6 +446,7 @@ export function addSubscription({ name, url, note } = {}) {
438
446
  name: cleanText(name, 64) || `订阅-${_subs.length + 1}`,
439
447
  url,
440
448
  note: cleanText(note, 200),
449
+ weight: normalizeWeight(weight),
441
450
  enabled: true,
442
451
  addedAt: now,
443
452
  updatedAt: now,
@@ -452,7 +461,7 @@ export function addSubscription({ name, url, note } = {}) {
452
461
  return { ok: true, entry };
453
462
  }
454
463
 
455
- export function updateSubscription({ id, name, url, note, enabled } = {}) {
464
+ export function updateSubscription({ id, name, url, note, enabled, weight } = {}) {
456
465
  load();
457
466
  const entry = _subs.find((s) => s.id === id);
458
467
  if (!entry) return { ok: false, error: "订阅不存在" };
@@ -470,6 +479,12 @@ export function updateSubscription({ id, name, url, note, enabled } = {}) {
470
479
  if (name !== undefined) entry.name = cleanText(name, 64) || entry.name;
471
480
  if (note !== undefined) entry.note = cleanText(note, 200);
472
481
  if (enabled !== undefined) entry.enabled = !!enabled;
482
+ if (weight !== undefined) {
483
+ const w = normalizeWeight(weight);
484
+ if (w !== (entry.weight || 1))
485
+ console.error(`[订阅中心] ⚖ 「${entry.name}」分配权重 ${entry.weight || 1} → ${w}`);
486
+ entry.weight = w;
487
+ }
473
488
  entry.updatedAt = Date.now();
474
489
  saveNow();
475
490
  console.error(
@@ -532,6 +547,7 @@ export function getSubscriptionView() {
532
547
  id: e.id,
533
548
  name: e.name,
534
549
  note: e.note || "",
550
+ weight: e.weight || 1,
535
551
  enabled: e.enabled !== false,
536
552
  addedAt: e.addedAt || 0,
537
553
  // URL 仅控制页面可见,含 token 不进任何服务端日志
@@ -30,6 +30,7 @@ import {
30
30
  probeSubscription,
31
31
  } from "./subscription-center.js";
32
32
  import { reconcileAutoAlloc, ALLOC_MAX_INSTANCES } from "./subscription-allocator.js";
33
+ import { heartbeatLoginPool, getLoginPoolView, addAccount, updateAccount, deleteAccount, assignAccount, releaseAccount } from "./login-pool.js";
33
34
 
34
35
  const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
35
36
  const WATCHDOG_CONFIG_PATH = join(homeDir, ".tt-help-watchdog.json");
@@ -58,6 +59,8 @@ const clashCmdQueue = new Map();
58
59
  // 登录检测手动重检队列:clientId → true(后台页面点「重检」下发,随心跳取走即清空,一次性)
59
60
  const loginRecheckQueue = new Map();
60
61
 
62
+ // 登录池命令队列在 login-pool.js 内部管理(userId → [applyLogin/clearLogin],取走即清空)
63
+
61
64
  // 国家过滤配置幂等下发记录:userId → JSON.stringify(countries)
62
65
  // 仅当配置变化时才入队,避免每次心跳都触发 daemon 刷新候选集
63
66
  const lastSentCountries = new Map();
@@ -558,6 +561,14 @@ async function handleHeartbeat(params, body, res) {
558
561
  const loginRecheck = loginRecheckQueue.get(clientId) || false;
559
562
  if (loginRecheck) loginRecheckQueue.delete(clientId);
560
563
 
564
+ // 登录池:续租 + 健康采集 + 取走 applyLogin/clearLogin 命令(一次性)
565
+ let loginPoolCmds;
566
+ try {
567
+ loginPoolCmds = heartbeatLoginPool(userId, body);
568
+ } catch (e) {
569
+ console.error(`[watchdog] 登录池心跳处理异常: ${e.message}`);
570
+ }
571
+
561
572
  const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
562
573
  console.error(
563
574
  `[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令${clashCommands.length ? ", " + clashCommands.length + "条Clash命令" : ""}`,
@@ -575,6 +586,7 @@ async function handleHeartbeat(params, body, res) {
575
586
  autoUpgrade: getAutoUpgradeStored(userId),
576
587
  loginPorts: getLoginPortsStored(userId),
577
588
  loginRecheck: loginRecheck || undefined,
589
+ loginPool: loginPoolCmds,
578
590
  serverTime: Date.now(),
579
591
  });
580
592
  }
@@ -595,6 +607,8 @@ async function handleGetClients(params, body, res) {
595
607
  configPath: WATCHDOG_CONFIG_PATH,
596
608
  // 订阅中心视图(探测/代报快照 + 状态推导 + 引用关系;独立文件,读多写少)
597
609
  subscriptions: getSubscriptionViewSafe(),
610
+ // 登录池视图(脱敏,永不含 cookies 明文;独立文件)
611
+ loginPool: getLoginPoolViewSafe(),
598
612
  });
599
613
  }
600
614
 
@@ -607,6 +621,15 @@ function getSubscriptionViewSafe() {
607
621
  }
608
622
  }
609
623
 
624
+ function getLoginPoolViewSafe() {
625
+ try {
626
+ return getLoginPoolView();
627
+ } catch (e) {
628
+ console.error(`[watchdog] 登录池视图构建失败: ${e.message}`);
629
+ return { accounts: [], total: 0 };
630
+ }
631
+ }
632
+
610
633
  // ── 订阅中心 API(Phase 1: 池子 CRUD + 主动探测 + 只读引用展示,不改任何节点行为) ──
611
634
 
612
635
  async function handleSubscriptionsList(params, body, res) {
@@ -1118,6 +1141,72 @@ async function handleRecheckLogin(params, body, res) {
1118
1141
  sendJSON(res, 200, { ok: true, message: "已下发重检,客户端下次心跳时执行" });
1119
1142
  }
1120
1143
 
1144
+ // ── 登录池 API(脱敏视图;cookies 明文只在心跳响应通道下行,永不经 GET API) ──
1145
+
1146
+ function loginPoolError(res, e) {
1147
+ console.error(`[watchdog] 登录池操作失败: ${e.message}`);
1148
+ sendJSON(res, 400, { error: e.message });
1149
+ }
1150
+
1151
+ async function handleLoginPoolView(params, body, res) {
1152
+ try {
1153
+ sendJSON(res, 200, { ok: true, ...getLoginPoolView() });
1154
+ } catch (e) {
1155
+ loginPoolError(res, e);
1156
+ }
1157
+ }
1158
+
1159
+ async function handleLoginPoolAdd(params, body, res) {
1160
+ try {
1161
+ const view = addAccount(body || {});
1162
+ console.error(`[watchdog] 登录池入池: ${view.username}(${view.cookieCount} 条 cookies)`);
1163
+ sendJSON(res, 200, { ok: true, account: view });
1164
+ } catch (e) {
1165
+ loginPoolError(res, e);
1166
+ }
1167
+ }
1168
+
1169
+ async function handleLoginPoolUpdate(params, body, res) {
1170
+ try {
1171
+ const view = updateAccount(body || {});
1172
+ sendJSON(res, 200, { ok: true, account: view });
1173
+ } catch (e) {
1174
+ loginPoolError(res, e);
1175
+ }
1176
+ }
1177
+
1178
+ async function handleLoginPoolDelete(params, body, res) {
1179
+ try {
1180
+ const r = deleteAccount((body || {}).id);
1181
+ console.error(`[watchdog] 登录池删除: ${r.username}`);
1182
+ sendJSON(res, 200, { ok: true, ...r });
1183
+ } catch (e) {
1184
+ loginPoolError(res, e);
1185
+ }
1186
+ }
1187
+
1188
+ async function handleLoginPoolAssign(params, body, res) {
1189
+ try {
1190
+ const view = assignAccount(body || {});
1191
+ console.error(
1192
+ `[watchdog] 登录池指派: ${view.username} → ${body.userId}:${body.port}(下次心跳下发 applyLogin)`,
1193
+ );
1194
+ sendJSON(res, 200, { ok: true, account: view });
1195
+ } catch (e) {
1196
+ loginPoolError(res, e);
1197
+ }
1198
+ }
1199
+
1200
+ async function handleLoginPoolRelease(params, body, res) {
1201
+ try {
1202
+ const view = releaseAccount(body || {});
1203
+ console.error(`[watchdog] 登录池释放: ${view.username}`);
1204
+ sendJSON(res, 200, { ok: true, account: view });
1205
+ } catch (e) {
1206
+ loginPoolError(res, e);
1207
+ }
1208
+ }
1209
+
1121
1210
  // ── 公开 ──
1122
1211
 
1123
1212
  export function registerWatchdogRoutes(httpServer) {
@@ -1226,6 +1315,30 @@ export async function tryHandleWatchdogRoute(
1226
1315
  await handleRecheckLogin(params, body, res);
1227
1316
  return true;
1228
1317
  }
1318
+ if (method === "GET" && routePath === "/api/watchdog/login-pool") {
1319
+ await handleLoginPoolView(params, body, res);
1320
+ return true;
1321
+ }
1322
+ if (method === "POST" && routePath === "/api/watchdog/login-pool/add") {
1323
+ await handleLoginPoolAdd(params, body, res);
1324
+ return true;
1325
+ }
1326
+ if (method === "POST" && routePath === "/api/watchdog/login-pool/update") {
1327
+ await handleLoginPoolUpdate(params, body, res);
1328
+ return true;
1329
+ }
1330
+ if (method === "POST" && routePath === "/api/watchdog/login-pool/delete") {
1331
+ await handleLoginPoolDelete(params, body, res);
1332
+ return true;
1333
+ }
1334
+ if (method === "POST" && routePath === "/api/watchdog/login-pool/assign") {
1335
+ await handleLoginPoolAssign(params, body, res);
1336
+ return true;
1337
+ }
1338
+ if (method === "POST" && routePath === "/api/watchdog/login-pool/release") {
1339
+ await handleLoginPoolRelease(params, body, res);
1340
+ return true;
1341
+ }
1229
1342
  if (method === "POST" && routePath === "/api/watchdog/request-logs") {
1230
1343
  await handleRequestLogs(params, body, res);
1231
1344
  return true;
@@ -33,6 +33,7 @@ import {
33
33
  DETECT_CAPTCHA_COUNT_THRESHOLD,
34
34
  } from "./client-error-monitor.js";
35
35
  import { takeoverEnvironment } from "./env-init.js";
36
+ import { applyLoginToPort, clearLoginOnPort } from "./login-apply.js";
36
37
 
37
38
  // ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
38
39
  // 包装 console.error:本进程所有 [watchdog]/[llm-analyzer]/[upgrade] 等日志
@@ -259,14 +260,12 @@ export class WatchdogAgent {
259
260
  * @param {string} opts.clientId - 客户端标识
260
261
  * @param {number} opts.intervalMs - 心跳间隔(毫秒)
261
262
  * @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
262
- * @param {number} [opts.loginCheckIntervalMs] - 登录检测周期(毫秒),默认 2 小时
263
263
  */
264
264
  constructor({
265
265
  serverUrl,
266
266
  clientId,
267
267
  intervalMs = 15000,
268
268
  spawnDaemon = true,
269
- loginCheckIntervalMs = 2 * 60 * 60 * 1000,
270
269
  }) {
271
270
  this.serverUrl = serverUrl.replace(/\/+$/, "");
272
271
  this.clientId = clientId;
@@ -275,16 +274,13 @@ export class WatchdogAgent {
275
274
  writeStartedFile();
276
275
  this.intervalMs = intervalMs;
277
276
  this.spawnDaemon = !!spawnDaemon;
278
- this.loginCheckIntervalMs = Math.max(
279
- 60 * 1000,
280
- loginCheckIntervalMs || 2 * 60 * 60 * 1000,
281
- );
282
- this.loginPorts = []; // 服务端下发的检测端口列表
283
- this._loginPortsKey = ""; // 端口列表指纹(变化时立即检测)
284
- this.loginReport = null; // 最近一次登录检测结果(随心跳上报)
285
- this._loginChecking = false; // 防重入
286
- this._lastLoginCheckAt = 0; // 上次检测时间戳
287
- this.loginCheckTimer = null;
277
+ this.loginPorts = []; // 服务端下发的登录检测端口列表(账号启动导入的来源)
278
+ this._poolQueue = []; // 登录池命令队列(applyLogin/clearLogin,串行执行)
279
+ this._poolBusy = false; // 池命令 worker 防重入
280
+ this._poolReports = []; // 池命令执行结果(随下次心跳上报后清空)
281
+ this._proxySubsSig = null; // 已转发的订阅配置指纹(变化才转发)
282
+ this._proxySubsSentAt = 0; // 最近一次转发时间戳(daemon applyResult.at 与之比较,只认新失败)
283
+ this._loginImportDone = false; // 登录态导入:启动首跳执行一次的标记
288
284
  this.running = false;
289
285
  this.heartbeatTimer = null;
290
286
  this.thresholdTimer = null;
@@ -344,6 +340,17 @@ export class WatchdogAgent {
344
340
  `[watchdog] 升级: ${isGlobalInstall() ? "已启用" : "跳过(源码模式)"}`,
345
341
  );
346
342
 
343
+ // Linux 环境自举(幂等、失败不阻断;Windows/macOS 路径零改动)——
344
+ // headless 自动补 DISPLAY(Xvfb)、Edge 探测、能力随心跳上报,详见 linux-env-init.js
345
+ if (process.platform === "linux") {
346
+ try {
347
+ const { ensureLinuxEnv } = await import("../lib/linux-env-init.js");
348
+ this.envCapability = await ensureLinuxEnv();
349
+ } catch (e) {
350
+ console.error(`[watchdog] ⚠ Linux 环境自举失败(不阻断): ${e.message}`);
351
+ }
352
+ }
353
+
347
354
  // 把 clientId 写入 ~/.tt-help.json:业务进程上报异常时携带,看门狗按
348
355
  // clientId(每台唯一)关联本机条目;克隆机群 hostname 重复不可用
349
356
  if (!setWatchdogClientId(this.clientId)) {
@@ -416,14 +423,6 @@ export class WatchdogAgent {
416
423
  this._checkProxySwitch();
417
424
  }, SWITCH_SCAN_INTERVAL_MS);
418
425
 
419
- // 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
420
- this.loginCheckTimer = setInterval(() => {
421
- this._maybeRunLoginCheck();
422
- }, 60_000);
423
- setTimeout(() => {
424
- this._maybeRunLoginCheck();
425
- }, 60_000);
426
-
427
426
  // LLM 日志分析(每 10 分钟调服务端接口)
428
427
  this._startLlmAnalyzer();
429
428
 
@@ -459,10 +458,6 @@ export class WatchdogAgent {
459
458
  clearInterval(this._switchCheckTimer);
460
459
  this._switchCheckTimer = null;
461
460
  }
462
- if (this.loginCheckTimer) {
463
- clearInterval(this.loginCheckTimer);
464
- this.loginCheckTimer = null;
465
- }
466
461
  if (this.llmAnalyzerTimer) {
467
462
  clearInterval(this.llmAnalyzerTimer);
468
463
  this.llmAnalyzerTimer = null;
@@ -799,7 +794,8 @@ export class WatchdogAgent {
799
794
  llmAnalysis: this._llmResults,
800
795
  clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
801
796
  clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
802
- loginReport: this.loginReport, // 最近一次登录检测结果
797
+ loginPool: this._poolReports.length > 0 ? { applied: this._poolReports.splice(0) } : undefined,
798
+ env: this.envCapability || null, // Linux 环境能力(display/Edge 就绪度;旧服务端忽略)
803
799
  };
804
800
  }
805
801
 
@@ -831,6 +827,44 @@ export class WatchdogAgent {
831
827
  const data = await resp.json();
832
828
  this.consecutiveFailures = 0;
833
829
 
830
+ // 服务端下发的登录检测端口列表(先于业务拉起处理——启动导入依赖它)
831
+ if (Array.isArray(data.loginPorts)) {
832
+ const key = data.loginPorts.join(",");
833
+ if (key !== this._loginPortsKey) {
834
+ this._loginPortsKey = key;
835
+ this.loginPorts = data.loginPorts;
836
+ console.error(
837
+ `[watchdog] 登录检测端口(启动导入来源): ${key || "未配置"}`,
838
+ );
839
+ }
840
+ }
841
+
842
+ // 登录态导入(启动首次 loginPorts 到达时执行一次,先于业务拉起;独立步骤,失败不阻塞)
843
+ // 服务端可下发 loginImport:false 按节点关闭(如无需同步的机器)
844
+ if (
845
+ Array.isArray(data.loginPorts) &&
846
+ data.loginPorts.length > 0 &&
847
+ data.loginImport !== false &&
848
+ !this._loginImportDone
849
+ ) {
850
+ this._loginImportDone = true;
851
+ try {
852
+ const { syncLoginStates } = await import("./login-import.js");
853
+ const cfg = getClientConfig();
854
+ const sum = await syncLoginStates({
855
+ serverUrl: this.serverUrl,
856
+ userId: cfg.userId,
857
+ ports: data.loginPorts,
858
+ proxyServer: cfg.proxy || null,
859
+ });
860
+ console.error(
861
+ `[watchdog] 登录态导入完成: 导入 ${sum.imported.length} · 跳过 ${sum.skipped.length} · 失败 ${sum.failed.length}`,
862
+ );
863
+ } catch (e) {
864
+ console.error(`[watchdog] 登录态导入失败(不阻塞启动): ${e.message}`);
865
+ }
866
+ }
867
+
834
868
  // 同步进程命令
835
869
  if (Array.isArray(data.commands)) {
836
870
  const prev = this.processManager.runningCount;
@@ -870,6 +904,24 @@ export class WatchdogAgent {
870
904
  applyClientConfig(data.clientConfig);
871
905
  }
872
906
  // 服务端下发的代理订阅配置(变化才转发给 daemon 热应用;daemon 侧幂等)
907
+ // 重试约定:daemon 应用失败不持久化、结果写 clash-health.json(meta.proxy.applyResult),
908
+ // 靠 agent 检测到失败后重置 sig 重发全量。若不重置,sig 已消耗、重试链断裂
909
+ //(两侧约定脱节 → 永远停在「未配置订阅,不拉起」,F-11 踩坑)。
910
+ if (this._proxySubsSig) {
911
+ const ar = getClashHealth()?.meta?.proxy?.applyResult;
912
+ // ar.at > 转发时间戳:只认本轮转发之后新产生的失败结果,避免旧结果误触发重复重发
913
+ if (
914
+ ar &&
915
+ ar.ok === false &&
916
+ (!this._proxySubsSentAt || ar.at > this._proxySubsSentAt)
917
+ ) {
918
+ console.error(
919
+ `[watchdog] daemon 订阅应用失败: ${ar.error || "未知原因"} → 重置订阅签名,本轮心跳重发`,
920
+ );
921
+ this._proxySubsSig = null;
922
+ this._proxySubsSentAt = 0;
923
+ }
924
+ }
873
925
  if (
874
926
  data.proxySettings &&
875
927
  Array.isArray(data.proxySettings.subscriptions)
@@ -877,6 +929,7 @@ export class WatchdogAgent {
877
929
  const sig = JSON.stringify(data.proxySettings.subscriptions);
878
930
  if (sig !== this._proxySubsSig) {
879
931
  this._proxySubsSig = sig;
932
+ this._proxySubsSentAt = Date.now();
880
933
  try {
881
934
  const ipc = await import("../clash/ipc.js");
882
935
  ipc.writeCommand({
@@ -903,22 +956,18 @@ export class WatchdogAgent {
903
956
  if (data.autoUpgrade !== undefined) {
904
957
  this._applyAutoUpgradeConfig(data.autoUpgrade);
905
958
  }
906
- // 服务端下发的登录检测端口列表
907
- if (Array.isArray(data.loginPorts)) {
908
- const key = data.loginPorts.join(",");
909
- if (key !== this._loginPortsKey) {
910
- this._loginPortsKey = key;
911
- this.loginPorts = data.loginPorts;
912
- this._lastLoginCheckAt = 0; // 配置变化 → 立即触发一次检测
913
- console.error(
914
- `[watchdog] 登录检测端口: ${key || "未配置"}(周期 ${Math.round(this.loginCheckIntervalMs / 60000)} 分钟)`,
915
- );
959
+
960
+ // 登录池命令(applyLogin/clearLogin):入队串行执行,结果随下次心跳上报
961
+ if (data.loginPool && Array.isArray(data.loginPool.commands) && data.loginPool.commands.length > 0) {
962
+ for (const c of data.loginPool.commands) {
963
+ if (c && (c.action === "applyLogin" || c.action === "clearLogin") && c.port) {
964
+ this._poolQueue.push(c);
965
+ }
916
966
  }
917
- }
918
- // 服务端下发「立即重检」:清零上次检测时间,60s 调度器下一跳即执行
919
- if (data.loginRecheck) {
920
- this._lastLoginCheckAt = 0;
921
- console.error("[watchdog] 服务端下发登录重检,下一跳立即执行");
967
+ console.error(
968
+ `[watchdog] 收到登录池命令 ${data.loginPool.commands.length} 条(队列 ${this._poolQueue.length}),后台串行执行`,
969
+ );
970
+ this._drainPoolQueue();
922
971
  }
923
972
 
924
973
  // 心跳日志:仅状态变化或每 10 次打印一次
@@ -973,161 +1022,7 @@ export class WatchdogAgent {
973
1022
  }
974
1023
  }
975
1024
 
976
- /**
977
- * 登录检测调度入口:到点且服务端配置了端口时执行一次检测
978
- */
979
- _maybeRunLoginCheck() {
980
- if (!this.running || this._loginChecking) return;
981
- if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
982
- if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
983
- this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
984
- this._runLoginCheck().catch((e) => {
985
- console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
986
- });
987
- }
988
-
989
- /**
990
- * 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
991
- */
992
- async _runLoginCheck() {
993
- this._loginChecking = true;
994
- const ports = this.loginPorts;
995
- console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
996
- try {
997
- // 全局安装用 tt-help 命令;源码模式用 node cli.js
998
- const cliPath = join(__dirname, "..", "..", "cli.js");
999
- const cmd = isGlobalInstall() ? "tt-help" : process.execPath;
1000
- const args = isGlobalInstall()
1001
- ? ["login-check", ports.join(","), "--json"]
1002
- : [cliPath, "login-check", ports.join(","), "--json"];
1003
- // 带上 ~/.tt-help.json 里配置的代理(服务端可通过 clientConfig 下发)
1004
- const cfg = getClientConfig();
1005
- if (cfg.proxy) args.push("--proxy", cfg.proxy);
1006
-
1007
- const { stdout, stderr, code } = await new Promise((resolve) => {
1008
- // Windows 下 tt-help 是 .cmd 包装,必须 shell:true 才能被 cmd.exe 解析
1009
- // (与 process-manager.startCommand 的 Windows 处理保持一致)
1010
- const child = IS_WIN
1011
- ? spawn([cmd, ...args].join(" "), {
1012
- stdio: ["ignore", "pipe", "pipe"],
1013
- shell: true,
1014
- windowsHide: true,
1015
- })
1016
- : spawn(cmd, args, {
1017
- stdio: ["ignore", "pipe", "pipe"],
1018
- });
1019
- let out = "",
1020
- err = "";
1021
- child.stdout.on("data", (d) => (out += d));
1022
- child.stderr.on("data", (d) => (err += d));
1023
- child.on("error", (e) =>
1024
- resolve({ stdout: out, stderr: err + e.message, code: -1 }),
1025
- );
1026
- child.on("close", (c) =>
1027
- resolve({ stdout: out, stderr: err, code: c }),
1028
- );
1029
- // 超时保护 10 分钟(单端口含启动探测最长约 2 分钟)
1030
- setTimeout(
1031
- () => {
1032
- try {
1033
- child.kill("SIGKILL");
1034
- } catch {
1035
- /* ignore */
1036
- }
1037
- },
1038
- 10 * 60 * 1000,
1039
- );
1040
- });
1041
1025
 
1042
- if (code !== 0) {
1043
- // 部分端口失败时 login-check 也会输出完整 JSON(退出码 2)。
1044
- // 优先采用 stdout 里的 JSON 结果,仅解析失败才记为 error。
1045
- let partial = null;
1046
- try {
1047
- const parsed = JSON.parse(String(stdout).trim());
1048
- if (Array.isArray(parsed)) partial = parsed;
1049
- } catch {
1050
- // stdout 不是 JSON → 走错误分支
1051
- }
1052
- if (partial) {
1053
- this.loginReport = { ports: partial, checkedAt: Date.now() };
1054
- console.error(
1055
- `[watchdog] 登录检测完成(部分失败): ${
1056
- String(stderr)
1057
- .split("\n")
1058
- .filter((l) => l.includes("探测失败"))
1059
- .slice(-3)
1060
- .join(" | ")
1061
- .slice(-300) || "见端口状态"
1062
- }`,
1063
- );
1064
- return;
1065
- }
1066
- this.loginReport = {
1067
- error: `exit ${code}: ${String(stderr).slice(-500)}`,
1068
- checkedAt: Date.now(),
1069
- };
1070
- console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
1071
- return;
1072
- }
1073
- const parsed = JSON.parse(String(stdout).trim());
1074
- if (!Array.isArray(parsed)) throw new Error("结果不是数组");
1075
- this.loginReport = { ports: parsed, checkedAt: Date.now() };
1076
- const summary = parsed
1077
- .map(
1078
- (r) =>
1079
- `${r.port}:${r.status}${r.status === "logged_in" && r.account ? "@" + r.account : ""}`,
1080
- )
1081
- .join(" ");
1082
- console.error(`[watchdog] 登录检测完成: ${summary}`);
1083
- } catch (e) {
1084
- this.loginReport = { error: e.message, checkedAt: Date.now() };
1085
- console.error(`[watchdog] 登录检测异常: ${e.message}`);
1086
- } finally {
1087
- this._loginChecking = false;
1088
- }
1089
- }
1090
-
1091
- /**
1092
- * 处理服务端下发的日志拉取请求(后台页面点击"日志"按钮触发)
1093
- * 收集本机日志(watchdog 自身环形缓冲 / 进程日志只读快照)→ POST 回传服务端落盘
1094
- * @param {Array<{cmdId:string, tail?:number}>} requests
1095
- */
1096
- async _handleLogRequests(requests) {
1097
- for (const req of requests) {
1098
- const cmdId = req && req.cmdId ? String(req.cmdId) : "";
1099
- if (!cmdId) continue;
1100
- const tail = Math.max(1, Math.min(Number(req.tail) || 500, 500));
1101
- let logs = [];
1102
- if (cmdId === "__watchdog__") {
1103
- logs = getWatchdogSelfLogs(tail);
1104
- } else {
1105
- logs = this.processManager.getProcessLogSnapshot(cmdId, tail);
1106
- }
1107
- try {
1108
- const resp = await fetch(`${this.serverUrl}/api/watchdog/report-logs`, {
1109
- method: "POST",
1110
- headers: { "Content-Type": "application/json" },
1111
- body: JSON.stringify({
1112
- clientId: this.clientId,
1113
- cmdId,
1114
- logs,
1115
- capturedAt: Date.now(),
1116
- }),
1117
- signal: AbortSignal.timeout(20000),
1118
- });
1119
- if (!resp.ok) {
1120
- console.error(
1121
- `[watchdog] 日志回传失败: ${cmdId} HTTP ${resp.status}`,
1122
- );
1123
- continue;
1124
- }
1125
- console.error(`[watchdog] 日志回传完成: ${cmdId} (${logs.length}行)`);
1126
- } catch (e) {
1127
- console.error(`[watchdog] 日志回传失败: ${cmdId}: ${e.message}`);
1128
- }
1129
- }
1130
- }
1131
1026
 
1132
1027
  /**
1133
1028
  * 启动 LLM 日志分析定时器(每 10 分钟调服务端接口)