mslxdff 0.1.199 → 0.1.203

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.
Files changed (327) hide show
  1. package/README.md +3 -3
  2. package/bin/mslxdff.js +1 -1
  3. package/package.json +38 -38
  4. package/plugins/README.md +14 -14
  5. package/plugins/prefer-model.mjs.example +14 -14
  6. package/src/anthropic/count-tokens.js +61 -61
  7. package/src/anthropic/request-body.js +126 -126
  8. package/src/anthropic/shared.js +18 -18
  9. package/src/anthropic/sse-aggregate.js +56 -56
  10. package/src/anthropic/translate.js +287 -287
  11. package/src/anthropic/web-search.js +361 -361
  12. package/src/api-kind.js +31 -31
  13. package/src/autostart.js +288 -288
  14. package/src/bench/probe.js +1 -1
  15. package/src/bench/runner.js +1 -1
  16. package/src/bench/via-probe.js +1 -1
  17. package/src/bench/via.js +144 -144
  18. package/src/bench/workbuddy-bench.js +1 -1
  19. package/src/chat/config.js +11 -11
  20. package/src/chat/cooling.js +81 -81
  21. package/src/chat/direct.js +58 -58
  22. package/src/chat/index.js +6 -6
  23. package/src/chat/spinner.js +38 -38
  24. package/src/chat/sse.js +69 -69
  25. package/src/chat/stats.js +247 -247
  26. package/src/chat/store.js +48 -48
  27. package/src/chat/tool-handlers.js +67 -67
  28. package/src/chat/tools.js +1 -1
  29. package/src/chat/upstream.js +59 -59
  30. package/src/chat-pipeline/auto-race.js +158 -158
  31. package/src/chat-pipeline/empty-turn.js +147 -147
  32. package/src/chat-pipeline/engine.js +17 -17
  33. package/src/chat-pipeline/serial-trial.js +283 -283
  34. package/src/chat-pipeline/terminal-verdict.js +147 -147
  35. package/src/chooser.js +54 -54
  36. package/src/claude-desktop/apply.js +101 -101
  37. package/src/claude-desktop/context1m.js +38 -38
  38. package/src/claude-desktop/doctor.js +146 -146
  39. package/src/claude-desktop/slots.js +152 -152
  40. package/src/cli/bootstrap.js +1 -1
  41. package/src/cli/commands/claude-desktop.js +284 -284
  42. package/src/cli/commands/daemon.js +231 -231
  43. package/src/cli/commands/model/list-live.js +22 -22
  44. package/src/cli/commands/model/list-providers.js +76 -76
  45. package/src/cli/commands/model/list-render.js +77 -77
  46. package/src/cli/commands/model/list-sort.js +52 -52
  47. package/src/cli/commands/model/list-tty.js +76 -76
  48. package/src/cli/commands/model/list.js +166 -166
  49. package/src/cli/commands/model/live-models.js +24 -24
  50. package/src/cli/commands/model/stats.js +43 -43
  51. package/src/cli/commands/model/status.js +47 -47
  52. package/src/cli/commands/model.js +51 -51
  53. package/src/cli/commands/provider/add.js +108 -108
  54. package/src/cli/commands/provider/allowlist.js +99 -99
  55. package/src/cli/commands/provider/cline-free.js +99 -99
  56. package/src/cli/commands/provider/cline-login.js +218 -218
  57. package/src/cli/commands/provider/cline-quota.js +65 -65
  58. package/src/cli/commands/provider/codearts-login.js +45 -45
  59. package/src/cli/commands/provider/config.js +119 -119
  60. package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
  61. package/src/cli/commands/provider/keys-guard.js +50 -50
  62. package/src/cli/commands/provider/keys.js +161 -161
  63. package/src/cli/commands/provider/qoder-checkin.js +81 -81
  64. package/src/cli/commands/provider/qoder-login.js +102 -102
  65. package/src/cli/commands/provider/raccoon-login.js +180 -180
  66. package/src/cli/commands/provider/raccoon-password.js +162 -162
  67. package/src/cli/commands/provider/raccoon-quota.js +109 -109
  68. package/src/cli/commands/provider/traework-checkin.js +144 -144
  69. package/src/cli/commands/provider/traework-login.js +164 -164
  70. package/src/cli/commands/provider/workbuddy-login.js +153 -153
  71. package/src/cli/commands/provider/zcode-login.js +77 -77
  72. package/src/cli/commands/provider/zcode-quota.js +55 -55
  73. package/src/cli/commands/provider.js +1 -1
  74. package/src/cli/commands/stats.js +149 -149
  75. package/src/cli/commands/system.js +261 -261
  76. package/src/cli/commands/timezone.js +47 -47
  77. package/src/cli/commands/use-group.js +60 -60
  78. package/src/cli/commands/workbuddy.js +189 -189
  79. package/src/cli/format.js +141 -141
  80. package/src/cli/group-helpers.js +70 -70
  81. package/src/cli/help.js +80 -80
  82. package/src/cli/index.js +70 -70
  83. package/src/cli/interactive.js +121 -121
  84. package/src/cli/join-core.js +99 -99
  85. package/src/cli/join-wizard.js +175 -175
  86. package/src/cli/policy.js +136 -136
  87. package/src/cli/provider-row.js +126 -126
  88. package/src/cli/util.js +24 -24
  89. package/src/compat.js +53 -53
  90. package/src/events.js +38 -38
  91. package/src/free-lane.js +131 -131
  92. package/src/free-watcher.js +63 -63
  93. package/src/groups.js +262 -262
  94. package/src/logs.js +195 -195
  95. package/src/metrics.js +62 -62
  96. package/src/model-capabilities/context-lookup.js +65 -65
  97. package/src/model-capabilities/merge.js +142 -142
  98. package/src/model-trace.js +187 -187
  99. package/src/models.js +1 -1
  100. package/src/opencode-identity.js +72 -72
  101. package/src/peers.js +254 -251
  102. package/src/plugins.js +107 -107
  103. package/src/providers/base.js +164 -164
  104. package/src/providers/classify.js +22 -22
  105. package/src/providers/cline/allowlist-sync.js +93 -93
  106. package/src/providers/cline/free-catalog.js +71 -71
  107. package/src/providers/cline/headers.js +37 -37
  108. package/src/providers/cline/index.js +130 -130
  109. package/src/providers/cline/models.js +150 -150
  110. package/src/providers/cline/usage.js +274 -274
  111. package/src/providers/codearts/auth-pool.js +148 -148
  112. package/src/providers/codearts/concurrency.js +34 -34
  113. package/src/providers/codearts/const.js +65 -65
  114. package/src/providers/codearts/dpop.js +95 -95
  115. package/src/providers/codearts/index.js +175 -175
  116. package/src/providers/codearts/login.js +214 -214
  117. package/src/providers/codearts/models.js +182 -182
  118. package/src/providers/codearts/sign.js +96 -96
  119. package/src/providers/codearts/sse.js +204 -204
  120. package/src/providers/codearts/stream.js +209 -209
  121. package/src/providers/codearts/sts.js +124 -124
  122. package/src/providers/codearts.js +1 -1
  123. package/src/providers/dispatcher.js +146 -146
  124. package/src/providers/generic.js +156 -156
  125. package/src/providers/globalqwenwork/account-store.js +141 -141
  126. package/src/providers/globalqwenwork/constants.js +73 -73
  127. package/src/providers/globalqwenwork/crypto.js +2 -2
  128. package/src/providers/globalqwenwork/http.js +2 -2
  129. package/src/providers/globalqwenwork/index.js +331 -331
  130. package/src/providers/globalqwenwork/payload.js +147 -147
  131. package/src/providers/globalqwenwork/rsa.js +2 -2
  132. package/src/providers/globalqwenwork/sse.js +2 -2
  133. package/src/providers/globalqwenwork/stream.js +2 -2
  134. package/src/providers/globalqwenwork.js +1 -1
  135. package/src/providers/globalworkbuddy/account-store.js +198 -198
  136. package/src/providers/globalworkbuddy/auth.js +97 -97
  137. package/src/providers/globalworkbuddy/catalog.js +138 -138
  138. package/src/providers/globalworkbuddy/constants.js +73 -73
  139. package/src/providers/globalworkbuddy/device-auth.js +120 -120
  140. package/src/providers/globalworkbuddy/errors.js +98 -98
  141. package/src/providers/globalworkbuddy/headers.js +89 -89
  142. package/src/providers/globalworkbuddy/models.js +96 -96
  143. package/src/providers/globalworkbuddy/payload.js +80 -80
  144. package/src/providers/index.js +5 -5
  145. package/src/providers/model-id.js +172 -172
  146. package/src/providers/opencode.js +19 -19
  147. package/src/providers/openrouter.js +31 -31
  148. package/src/providers/qoder/account-store.js +106 -106
  149. package/src/providers/qoder/aggregate.js +60 -60
  150. package/src/providers/qoder/baseprompt.json +591 -591
  151. package/src/providers/qoder/checkin.js +160 -160
  152. package/src/providers/qoder/constants.js +44 -44
  153. package/src/providers/qoder/encode.js +41 -41
  154. package/src/providers/qoder/fingerprint.js +38 -38
  155. package/src/providers/qoder/models.js +77 -77
  156. package/src/providers/qoder/oauth.js +84 -84
  157. package/src/providers/qoder/payload.js +162 -162
  158. package/src/providers/qoder/request.js +27 -27
  159. package/src/providers/qoder/session.js +134 -134
  160. package/src/providers/qoder/sticky.js +38 -38
  161. package/src/providers/qoder/stream.js +116 -116
  162. package/src/providers/qoder.js +1 -1
  163. package/src/providers/raccoon/account-store.js +216 -216
  164. package/src/providers/raccoon/auth.js +67 -67
  165. package/src/providers/raccoon/chat.js +228 -228
  166. package/src/providers/raccoon/const.js +180 -165
  167. package/src/providers/raccoon/credits.js +185 -175
  168. package/src/providers/raccoon/crypto.js +43 -0
  169. package/src/providers/raccoon/envelope.js +31 -31
  170. package/src/providers/raccoon/headers.js +45 -45
  171. package/src/providers/raccoon/index.js +216 -216
  172. package/src/providers/raccoon/login.js +279 -279
  173. package/src/providers/raccoon/models.js +160 -160
  174. package/src/providers/raccoon/password-login.js +102 -87
  175. package/src/providers/raccoon/qr-codewords.js +115 -115
  176. package/src/providers/raccoon/qr.js +236 -236
  177. package/src/providers/raccoon/sse.js +135 -135
  178. package/src/providers/responses-channel.js +120 -120
  179. package/src/providers/share-keys.js +53 -53
  180. package/src/providers/traework/account-store.js +234 -234
  181. package/src/providers/traework/chat.js +217 -217
  182. package/src/providers/traework/checkin.js +77 -77
  183. package/src/providers/traework/constants.js +41 -41
  184. package/src/providers/traework/errors.js +52 -52
  185. package/src/providers/traework/headers.js +79 -79
  186. package/src/providers/traework/index.js +132 -132
  187. package/src/providers/traework/market-identity.js +116 -116
  188. package/src/providers/traework/models.js +98 -98
  189. package/src/providers/traework/payload.js +86 -86
  190. package/src/providers/traework/sse.js +184 -184
  191. package/src/providers/traework/token.js +52 -52
  192. package/src/providers/traework.js +2 -2
  193. package/src/providers/workbuddy/account-store.js +186 -186
  194. package/src/providers/workbuddy/auth.js +158 -158
  195. package/src/providers/workbuddy/balance.js +86 -86
  196. package/src/providers/workbuddy/cat-travel.js +125 -125
  197. package/src/providers/workbuddy/checkin.js +75 -75
  198. package/src/providers/workbuddy/desktop-info.js +78 -78
  199. package/src/providers/workbuddy/growth-api.js +67 -67
  200. package/src/providers/workbuddy/growth-plans.js +63 -63
  201. package/src/providers/workbuddy/growth.js +181 -181
  202. package/src/providers/workbuddy/index.js +217 -217
  203. package/src/providers/workbuddy/models.js +126 -126
  204. package/src/providers/workbuddy/payload.js +141 -141
  205. package/src/providers/workbuddy/rotation-log.js +83 -83
  206. package/src/providers/workbuddy/sanitize-tools.js +61 -61
  207. package/src/providers/workbuddy/sdk-chat.js +26 -26
  208. package/src/providers/workbuddy-balance.js +78 -78
  209. package/src/providers/zcode/account-store.js +129 -129
  210. package/src/providers/zcode/auth.js +28 -28
  211. package/src/providers/zcode/chat.js +179 -179
  212. package/src/providers/zcode/const.js +54 -54
  213. package/src/providers/zcode/context-shape.js +162 -162
  214. package/src/providers/zcode/headers.js +93 -93
  215. package/src/providers/zcode/index.js +124 -124
  216. package/src/providers/zcode/models.js +65 -65
  217. package/src/providers/zcode/oauth.js +120 -120
  218. package/src/providers/zcode/quota.js +176 -176
  219. package/src/providers/zcode/sse.js +196 -196
  220. package/src/readline-compat.js +25 -25
  221. package/src/responses/translate.js +311 -311
  222. package/src/routes/chat/broadband-handler.js +72 -72
  223. package/src/routes/chat/empty-turn-report.js +31 -31
  224. package/src/routes/chat/exhausted-handler.js +87 -87
  225. package/src/routes/chat/hedge-handler.js +130 -130
  226. package/src/routes/chat/index.js +1 -1
  227. package/src/routes/chat/local-handler.js +63 -63
  228. package/src/routes/chat/peer-handler.js +81 -81
  229. package/src/routes/chat.js +3 -3
  230. package/src/routes/fallback.js +88 -88
  231. package/src/routes/groups-relay.js +214 -214
  232. package/src/routes/groups.js +122 -122
  233. package/src/routes/helpers.js +83 -83
  234. package/src/routes/index.js +147 -147
  235. package/src/routes/messages-route.js +365 -365
  236. package/src/routes/peers.js +23 -1
  237. package/src/routes/relay-queue.js +181 -181
  238. package/src/routes/relay.js +60 -60
  239. package/src/routes/responses-route.js +213 -213
  240. package/src/routes/stream-body.js +84 -84
  241. package/src/routes/stream-hold.js +337 -337
  242. package/src/routes/stream-scan.js +366 -366
  243. package/src/routes.js +3 -3
  244. package/src/runtime/auto-update.js +113 -113
  245. package/src/runtime/bootstrap.js +8 -0
  246. package/src/runtime/broadband-stream.js +79 -79
  247. package/src/runtime/broadband.js +103 -103
  248. package/src/runtime/group-sync.js +81 -81
  249. package/src/runtime/keep-warm.js +120 -0
  250. package/src/runtime/lifecycle-forensics.js +116 -116
  251. package/src/runtime/lifecycle-log.js +87 -87
  252. package/src/runtime/log-stamp.js +73 -73
  253. package/src/runtime/proc-info.js +137 -137
  254. package/src/runtime/qoder-checkin.js +93 -93
  255. package/src/runtime/raccoon-checkin.js +126 -126
  256. package/src/runtime/server-lifecycle.js +189 -189
  257. package/src/runtime/traework-checkin.js +118 -118
  258. package/src/runtime/workbuddy-checkin.js +120 -120
  259. package/src/runtime/workbuddy-growth.js +133 -133
  260. package/src/server.js +7 -0
  261. package/src/state/facade.js +58 -58
  262. package/src/state/memory.js +162 -162
  263. package/src/state/merge.js +25 -25
  264. package/src/state/migrations/cline-unify.js +94 -94
  265. package/src/state/migrations.js +33 -33
  266. package/src/state/persist.js +42 -42
  267. package/src/state/provider-config.js +142 -142
  268. package/src/state/schemas/group.js +31 -31
  269. package/src/state/schemas/model.js +94 -94
  270. package/src/state/schemas/peer.js +31 -31
  271. package/src/state/schemas/port.js +10 -10
  272. package/src/state/schemas/provider.js +202 -202
  273. package/src/state/schemas/timezone.js +70 -70
  274. package/src/state/schemas/token.js +48 -48
  275. package/src/state/schemas/use-group.js +102 -102
  276. package/src/state/store.js +176 -176
  277. package/src/state.js +1 -1
  278. package/src/sync-claude-desktop.js +234 -234
  279. package/src/sync-claude.js +152 -152
  280. package/src/sync-codex.js +116 -116
  281. package/src/sync-workbuddy.js +158 -158
  282. package/src/talk-full-redact.js +129 -129
  283. package/src/talk-full.js +131 -131
  284. package/src/talk-log.js +257 -257
  285. package/src/text-tokens.js +52 -52
  286. package/src/thinking-effort.js +67 -67
  287. package/src/time.js +106 -106
  288. package/src/timeline.js +26 -26
  289. package/src/transport/index.js +1 -1
  290. package/src/upstream-engine/index.js +74 -72
  291. package/src/upstream-engine/mode.js +15 -15
  292. package/src/upstream-engine/sdk/attempt.js +221 -221
  293. package/src/upstream-engine/sdk/chat.js +28 -28
  294. package/src/upstream-engine/sdk/convert.js +192 -192
  295. package/src/upstream-engine/sdk/diagnose.js +62 -62
  296. package/src/upstream-engine/sdk/dispatch.js +55 -55
  297. package/src/upstream-engine/sdk/responses.js +176 -176
  298. package/src/upstream-engine/sdk/sse.js +166 -166
  299. package/src/upstream-responses.js +305 -305
  300. package/src/upstream.js +53 -1
  301. package/src/usage/record.js +106 -106
  302. package/src/usage/report.js +195 -195
  303. package/src/v6pool/claim.js +157 -157
  304. package/src/v6pool/config.js +65 -65
  305. package/src/v6pool/detect.js +130 -130
  306. package/src/v6pool/index.js +152 -152
  307. package/src/v6pool/prefix.js +140 -140
  308. package/src/v6pool/probe.js +40 -40
  309. package/src/v6pool/report.js +104 -104
  310. package/src/v6pool/rotate.js +263 -239
  311. package/src/warp-pool/config.js +74 -74
  312. package/src/warp-pool/health.js +106 -106
  313. package/src/warp-pool/identity.js +141 -141
  314. package/src/warp-pool/index.js +358 -358
  315. package/src/warp-pool/keys.js +29 -29
  316. package/src/warp-pool/nodes.js +95 -95
  317. package/src/warp-pool/ports.js +160 -160
  318. package/src/warp-pool/register.js +113 -113
  319. package/src/warp-pool/report.js +232 -232
  320. package/src/warp-pool/revive.js +168 -168
  321. package/src/warp-pool/rotate.js +214 -214
  322. package/src/warp-pool/strategy.js +43 -43
  323. package/src/warp-pool/verdict.js +69 -69
  324. package/src/warp-pool/wireproxy.js +162 -162
  325. package/docs/cli_help_mini.md +0 -146
  326. package/src/providers/AGENTS.md +0 -49
  327. package/src/routes/AGENTS.md +0 -37
@@ -1,358 +1,358 @@
1
- // warp-pool · 装配入口。
2
- //
3
- // 定位(MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口;缺省**开**,MSLXDFF_WARP_POOL=0 关。
4
- import { statSync } from "node:fs";
5
- import { resolveWarpConfig } from "./config.js";
6
- import { createRotatingDispatcher } from "./rotate.js";
7
- import { classifyWarpSignal, SIGNAL_STATUSES } from "./verdict.js";
8
- import { createHealthMonitor } from "./health.js";
9
- import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
10
- import { registerWarpDevice } from "./register.js";
11
- import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
12
- import { createReviver, labelExits, tailReason } from "./revive.js";
13
- import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
14
- import { fmtConf, fmtExit, fmtIdentityDetail, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
15
- import { wireRotation } from "./identity.js";
16
- import { findDaemons } from "../runtime/proc-info.js";
17
-
18
- /** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
19
- const REDIAL_MIN_GAP_MS = 5 * 60_000;
20
- const REDIAL_MAX_GAP_MS = 30 * 60_000;
21
-
22
- /** 注册间隔(同类项目实测:密集注册会让设备落同一网段,且触发 CF 429)。 */
23
- function gapMs(cfg, rnd = Math.random) {
24
- const span = Math.max(0, cfg.registerMaxGapMs - cfg.registerMinGapMs);
25
- return Math.round(cfg.registerMinGapMs + rnd() * span);
26
- }
27
-
28
- /** 注册响应 → 节点对象(bootstrap 与重拨共用,避免同一份字段表写两遍)。 */
29
- function nodeFromReg(port, reg, now) {
30
- return { port, id: reg.id, token: reg.token, v4: reg.v4, v6: reg.v6, endpoint: reg.endpoint, peerKey: reg.peerKey, privateKey: reg.privateKey, registeredAt: new Date(now()).toISOString() };
31
- }
32
-
33
- /**
34
- * 建池。开关缺省关,故「未启用」= 未设 MSLXDFF_WARP_POOL=1 或 非 leader;那时返回 null,调用方据此不注入。
35
- * @returns {null | { dispatcher: object, ready: Promise<void>, close: () => Promise<void>, stats: () => object }}
36
- */
37
- export function createWarpPool({
38
- env = process.env,
39
- baseUrl = "https://opencode.ai",
40
- log = (m) => console.error(m),
41
- register = registerWarpDevice,
42
- ensureBin = ensureBinary,
43
- start = startTunnel,
44
- probe = probeTunnel,
45
- reclaim = reclaimPort,
46
- inUse = portInUse,
47
- waitFree = waitPortFree,
48
- now = Date.now,
49
- rnd = Math.random,
50
- wait = (ms) => new Promise((r) => setTimeout(r, ms)),
51
- } = {}) {
52
- const cfg = resolveWarpConfig(env);
53
- if (!cfg.active) return null;
54
-
55
- /** 身份轮换器:上游按出口 IP 计的限额(403 FreeTierError / 429 FreeUsage·GoUsage / 裸 429)时换一台 WARP 设备换出口。 */
56
- let rotator = null;
57
-
58
- const dispatcher = createRotatingDispatcher({
59
- nodes: [],
60
- strategy: cfg.strategy,
61
- cooldownMs: cfg.cooldownMs,
62
- log,
63
- // 判决照抄参照实现 zen-router 的决策表(见 verdict.js 头注):403 FreeTierError 与
64
- // 429 FreeUsage/GoUsage 是同一条件、必须**强制**换出口(跳过冷却);BlackUsageLimitError
65
- // 是账号日配额、绝不换;其余状态码不参与判决、也不许刷日志。
66
- onStatus: (signal, port) => {
67
- const status = Number(signal?.status);
68
- if (!SIGNAL_STATUSES.includes(status)) return;
69
- const v = classifyWarpSignal(signal);
70
- if (!cfg.rotate) {
71
- log(`[warp-pool] :${port} 上游 ${status}(${v.kind})—— 轮换开关关着(MSLXDFF_WARP_ROTATE=0),不换出口`);
72
- return;
73
- }
74
- if (!v.rotate || !rotator) {
75
- log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 不换出口:${v.why}`);
76
- return;
77
- }
78
- log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 换身份换出口${v.forced ? "(强制,跳过冷却)" : ""}:${v.why}`);
79
- void rotator.rotate(port, { force: v.forced })
80
- .then((r) => { if (r && r.skipped) log(`[warp-pool] :${port} 换出口被冷却掐掉(距上次不足最小间隔)—— 这一发仍走原出口`); })
81
- .catch(() => {});
82
- },
83
- });
84
- /** @type {Map<number, {stop: () => void}>} */
85
- const tunnels = new Map();
86
-
87
- /** 复活:用磁盘已有 conf 重起,不重注册(进程挂了 ≠ 设备失效,省 CF 配额)。 */
88
- async function reviveTunnel(port) {
89
- if (closed) return false;
90
- const node = loadNodes(cfg.dir, [port])[0];
91
- if (!node) {
92
- log(`[warp-pool] :${port} 放弃复活:磁盘上没有该节点的身份(node-${port}.json 缺失或缺私钥)`);
93
- return false;
94
- }
95
- try {
96
- await bringUp(node);
97
- } catch (e) {
98
- log(`[warp-pool] :${port} 复活起隧道失败:${String(e?.message || e)}`);
99
- return false;
100
- }
101
- return true; // 真活没活由 createReviver 在 settleMs 后用 isAlive 复查,这里不许冒充成功
102
- }
103
- const reviver = createReviver({ revive: reviveTunnel, isAlive: (port) => { const t = tunnels.get(port); return !!t && t.alive !== false; }, log });
104
- let binPath = "";
105
- let closed = false;
106
- let lastRedialAt = 0;
107
- let redialGapMs = REDIAL_MIN_GAP_MS;
108
-
109
- // 兜底清理:Node 退出不会回收 spawn 的子进程,daemon 突然退出时同步杀掉隧道。
110
- // 同时置 closed:否则「杀子进程 → onExit → 立刻复活」会在退出过程中再 spawn 出一批孤儿(真机:每次重启漏一批)。
111
- process.once("exit", () => {
112
- closed = true;
113
- try {
114
- reviver.stop();
115
- } catch {}
116
- for (const t of tunnels.values()) {
117
- try {
118
- t.stop();
119
- } catch {}
120
- }
121
- });
122
-
123
- /** 起一条隧道的完整动作:确认不是重复起 → 清残留 → 写 conf → 等端口真空出来 → spawn → 记表。 */
124
- async function bringUp(node) {
125
- if (tunnels.has(node.port)) return; // 已归我们管:绝不再清残留(reclaim 会把自己这条隧道当残留杀掉)
126
- await reclaim({ dir: cfg.dir, port: node.port, log }); // 清上一代孤儿(daemon 被强杀时子进程不会自己退)
127
- const { conf, bytes } = writeTunnelConf(cfg.dir, node);
128
- log(fmtConf({ port: node.port, conf, bytes }));
129
- // SIGTERM 之后套接字要几百毫秒才释放,这期间连得上 ≠ 别人的进程占着——先等它真空出来再判
130
- if (!(await waitFree(node.port, { inUse }))) {
131
- const owner = await portOwner({ dir: cfg.dir, port: node.port }).catch(() => ({ ours: [], others: [] }));
132
- throw new Error(fmtPortBlocked({ port: node.port, ...owner }));
133
- }
134
- const t = start({
135
- binPath,
136
- dir: cfg.dir,
137
- port: node.port,
138
- log,
139
- onExit: ({ port, code, signal, tail, pid, aliveMs }) => {
140
- tunnels.delete(port);
141
- log(fmtExit({ port, code, signal, pid, aliveMs, tail: tailReason(tail) }));
142
- if (!closed) void reviver.onExit(port); // 收摊中就别复活了,否则退出过程里又 spawn 出一批孤儿
143
- },
144
- });
145
- tunnels.set(node.port, t);
146
- log(fmtSpawn({ port: node.port, pid: t.pid, binPath, conf }));
147
- }
148
-
149
- // 429 = 上游按出口 IP 计的名额耗尽 ⇒ 换一台设备的身份换出口(换身份=换桶)。
150
- rotator = wireRotation({
151
- dispatcher,
152
- register,
153
- tunnels,
154
- bringUp,
155
- swap: (port, node) => { removeNode(cfg.dir, port); saveNode(cfg.dir, node); },
156
- log,
157
- });
158
-
159
- /** 探活闸门参数:刚起的隧道要等 WireGuard 握手完成才通(真机:一起来就探 ⇒ 4 条全没通 ⇒ 池为空、WARP 白开)。 */
160
- const PROBE_SETTLE_MS = 2_000;
161
- const PROBE_ATTEMPTS = 2;
162
-
163
- /**
164
- * 入池前的实测闸门:给握手留时间 → 逐个探活 → 探不通的绝不进池。
165
- * 为什么必须有:wireproxy 进程活着但隧道转不动流量时,请求会挂到超时(比直连更糟)。
166
- * @returns {Promise<Array<object>>} 通过探活的节点
167
- */
168
- async function probeGate(list) {
169
- const live = [];
170
- await wait(PROBE_SETTLE_MS);
171
- for (const n of list) {
172
- if (!tunnels.has(n.port)) continue;
173
- const url = `${String(baseUrl).replace(/\/+$/, "")}${cfg.probePath}`;
174
- let r = { ok: false };
175
- for (let i = 1; i <= PROBE_ATTEMPTS; i++) {
176
- const t0 = now();
177
- r = await probe({ port: n.port, baseUrl, path: cfg.probePath });
178
- const ms = Number.isFinite(r.ms) ? r.ms : now() - t0;
179
- log(fmtProbe({ port: n.port, attempt: i, attempts: PROBE_ATTEMPTS, url, ok: r.ok, status: r.status, error: r.error, ms }));
180
- if (r.ok) break;
181
- if (i < PROBE_ATTEMPTS) await wait(PROBE_SETTLE_MS);
182
- }
183
- if (r.ok) {
184
- live.push(n);
185
- } else {
186
- log(fmtProbeGiveUp({ port: n.port, attempts: PROBE_ATTEMPTS, url }));
187
- }
188
- }
189
- return live;
190
- }
191
- async function bootstrap() {
192
- if (closed) return;
193
- ensureDir(cfg.dir);
194
- try {
195
- binPath = await ensureBin({ dir: cfg.dir, bin: cfg.bin, autodownload: cfg.autodownload, log });
196
- } catch (e) {
197
- const why = !cfg.bin && !resolveAsset()
198
- ? `本平台(${process.platform}-${process.arch})无预编译 wireproxy,如需启用请设 MSLXDFF_WARP_BIN`
199
- : String(e?.message || e);
200
- log(`[warp-pool] 出口池本次跳过(不启用,请求走直连):${why}`);
201
- return;
202
- }
203
- if (closed) return;
204
-
205
- // 1. 复用磁盘上已注册的节点
206
- let nodes = loadNodes(cfg.dir, cfg.ports);
207
- let binSize = 0;
208
- try { binSize = statSync(binPath).size; } catch {}
209
- await startupReport({ cfg, nodes, binPath, binSize, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
210
- // 身份指纹:4 条隧道只拿到 2 个出口时,只有这组读数能区分「设备写重复」与「CF 共享 NAT 折叠」
211
- for (const line of fmtIdentityDetail(nodes)) log(line);
212
- // 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
213
- const siblings = findDaemons({ self: process.pid });
214
- if (siblings.length) log(fmtSiblings({ list: siblings }));
215
-
216
- // 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
217
- const used = new Set(nodes.map((n) => n.port));
218
- const free = cfg.ports.filter((p) => !used.has(p));
219
- for (const port of free) {
220
- if (closed) return;
221
- if (nodes.length >= cfg.count) break;
222
- try {
223
- if (nodes.length > 0) await wait(gapMs(cfg, rnd));
224
- const reg = await register({ log });
225
- const node = nodeFromReg(port, reg, now);
226
- saveNode(cfg.dir, node);
227
- nodes.push(node);
228
- log(`[warp-pool] 注册新身份 :${port} 成功(设备 v6=${reg.v6},WireGuard 密钥对在本地生成)`);
229
- } catch (e) {
230
- log(`[warp-pool] :${port} 注册身份失败,本轮跳过这个端口:${String(e?.message || e)}`);
231
- }
232
- }
233
-
234
- // 3. 起隧道
235
- for (const n of nodes) {
236
- try {
237
- await bringUp(n);
238
- } catch (e) {
239
- log(`[warp-pool] :${n.port} 起隧道失败,这个端口本轮不可用:${String(e?.message || e)}`);
240
- }
241
- }
242
-
243
- // 入池前实测探活:WARP 握手没完成时 wireproxy 在跑却转不动流量(请求会挂到超时),那种节点进池会黑洞全部请求
244
- const live = await probeGate(nodes);
245
- if (live.length) {
246
- await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
247
- // 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
248
- const exits = await labelExits({ ports: live.map((n) => n.port), dir: cfg.dir, log });
249
- rotator.noteExits(exits);
250
- log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
251
- } else {
252
- log(fmtPoolEmpty({ total: nodes.length, ports: cfg.ports }));
253
- }
254
- }
255
-
256
- const monitor = createHealthMonitor({
257
- dispatcher,
258
- baseUrl,
259
- probePath: cfg.probePath,
260
- log,
261
- infoOf: () => ({ alive: tunnels.size, pooled: dispatcher.size, exits: rotator ? rotator.stats() : [] }),
262
- cooldownMs: cfg.cooldownMs,
263
- onExhausted: async () => {
264
- // 重拨 = 重新注册换出口。只重拨「进程已死」的隧道:全活却全挂 = 网络路径问题,重注册只会把 CF 刷到 429
265
- const plan = redialPlan({ ports: cfg.ports, alivePorts: [...tunnels.keys()], lastRedialAt, redialGapMs, now });
266
- if (plan.reason === "all-alive") {
267
- log("[warp-pool] 隧道均存活但全池不通,判定为网络路径问题,跳过重拨");
268
- return;
269
- }
270
- if (plan.reason === "throttled") {
271
- log(`[warp-pool] 距上次重拨不足 ${Math.round(redialGapMs / 1000)}s,跳过本轮`);
272
- return;
273
- }
274
- const dead = plan.dead;
275
- lastRedialAt = now();
276
- redialGapMs = Math.min(redialGapMs * 2, REDIAL_MAX_GAP_MS);
277
- log(`[warp-pool] ${dead.length} 条隧道已死,重拨(下次最短间隔 ${Math.round(redialGapMs / 1000)}s)`);
278
- for (const port of dead) {
279
- if (closed) return;
280
- try {
281
- tunnels.get(port)?.stop();
282
- tunnels.delete(port);
283
- removeNode(cfg.dir, port);
284
- const reg = await register({ log });
285
- saveNode(cfg.dir, nodeFromReg(port, reg, now));
286
- await bringUp({ port, ...reg });
287
- } catch (e) {
288
- log(`[warp-pool] 重拨 :${port} 失败:${String(e?.message || e)}`);
289
- }
290
- await wait(gapMs(cfg, rnd));
291
- }
292
- if (!closed && tunnels.size) {
293
- // 重拨起来的隧道同样要过闸门:没过探活的隧道进池 = 把请求挂到超时(原先这里直接 setNodes,等于绕过闸门)
294
- const alive = await probeGate([...tunnels.keys()].map((port) => ({ port })));
295
- if (alive.length) {
296
- redialGapMs = REDIAL_MIN_GAP_MS; // 确实有隧道能通 ⇒ 退避复位
297
- await dispatcher.setNodes(alive.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
298
- } else {
299
- log("[warp-pool] 重拨后仍没有一条隧道探通,保持直连(等下一轮健康检查)");
300
- }
301
- }
302
- },
303
- });
304
-
305
- const ready = bootstrap()
306
- .then(() => {
307
- if (!closed) monitor.start();
308
- })
309
- .catch((e) => log(`[warp-pool] bootstrap 失败:${String(e?.message || e)}`));
310
-
311
- return {
312
- dispatcher,
313
- ready,
314
- config: cfg,
315
- async close() {
316
- if (closed) return;
317
- closed = true;
318
- monitor.stop();
319
- reviver.stop();
320
- for (const t of tunnels.values()) {
321
- try {
322
- t.stop();
323
- } catch {}
324
- }
325
- tunnels.clear();
326
- await dispatcher.close();
327
- },
328
- stats() {
329
- return {
330
- enabled: true,
331
- mode: cfg.mode,
332
- strategy: cfg.strategy,
333
- dir: cfg.dir,
334
- tunnels: tunnels.size,
335
- nodes: dispatcher.stats(),
336
- exits: rotator ? rotator.stats() : [],
337
- };
338
- },
339
- };
340
- }
341
-
342
- /** 只取轮换 dispatcher 的便捷入口;未启用时返回 undefined(调用方直接透传即"关闭态")。 */
343
- /** 隧道进程由模块内的 process exit 钩子兜底清理。 */
344
- export function createWarpDispatcher(opts) {
345
- return createWarpPool(opts)?.dispatcher;
346
- }
347
-
348
- /**
349
- * 重拨门(纯函数,便于单测):只「有隧道进程已死」且「距上次重拨超过退避间隔」才放行——全活却全挂是网络问题。
350
- * @returns {{ dead: number[], reason: "ok" | "all-alive" | "throttled" }}
351
- */
352
- export function redialPlan({ ports = [], alivePorts = [], lastRedialAt = 0, redialGapMs = REDIAL_MIN_GAP_MS, now = Date.now } = {}) {
353
- const alive = new Set(alivePorts);
354
- const dead = ports.filter((p) => !alive.has(p));
355
- if (!dead.length) return { dead, reason: "all-alive" };
356
- if (now() - lastRedialAt < redialGapMs) return { dead, reason: "throttled" };
357
- return { dead, reason: "ok" };
358
- }
1
+ // warp-pool · 装配入口。
2
+ //
3
+ // 定位(MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口;缺省**开**,MSLXDFF_WARP_POOL=0 关。
4
+ import { statSync } from "node:fs";
5
+ import { resolveWarpConfig } from "./config.js";
6
+ import { createRotatingDispatcher } from "./rotate.js";
7
+ import { classifyWarpSignal, SIGNAL_STATUSES } from "./verdict.js";
8
+ import { createHealthMonitor } from "./health.js";
9
+ import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
10
+ import { registerWarpDevice } from "./register.js";
11
+ import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
12
+ import { createReviver, labelExits, tailReason } from "./revive.js";
13
+ import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
14
+ import { fmtConf, fmtExit, fmtIdentityDetail, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
15
+ import { wireRotation } from "./identity.js";
16
+ import { findDaemons } from "../runtime/proc-info.js";
17
+
18
+ /** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
19
+ const REDIAL_MIN_GAP_MS = 5 * 60_000;
20
+ const REDIAL_MAX_GAP_MS = 30 * 60_000;
21
+
22
+ /** 注册间隔(同类项目实测:密集注册会让设备落同一网段,且触发 CF 429)。 */
23
+ function gapMs(cfg, rnd = Math.random) {
24
+ const span = Math.max(0, cfg.registerMaxGapMs - cfg.registerMinGapMs);
25
+ return Math.round(cfg.registerMinGapMs + rnd() * span);
26
+ }
27
+
28
+ /** 注册响应 → 节点对象(bootstrap 与重拨共用,避免同一份字段表写两遍)。 */
29
+ function nodeFromReg(port, reg, now) {
30
+ return { port, id: reg.id, token: reg.token, v4: reg.v4, v6: reg.v6, endpoint: reg.endpoint, peerKey: reg.peerKey, privateKey: reg.privateKey, registeredAt: new Date(now()).toISOString() };
31
+ }
32
+
33
+ /**
34
+ * 建池。开关缺省关,故「未启用」= 未设 MSLXDFF_WARP_POOL=1 或 非 leader;那时返回 null,调用方据此不注入。
35
+ * @returns {null | { dispatcher: object, ready: Promise<void>, close: () => Promise<void>, stats: () => object }}
36
+ */
37
+ export function createWarpPool({
38
+ env = process.env,
39
+ baseUrl = "https://opencode.ai",
40
+ log = (m) => console.error(m),
41
+ register = registerWarpDevice,
42
+ ensureBin = ensureBinary,
43
+ start = startTunnel,
44
+ probe = probeTunnel,
45
+ reclaim = reclaimPort,
46
+ inUse = portInUse,
47
+ waitFree = waitPortFree,
48
+ now = Date.now,
49
+ rnd = Math.random,
50
+ wait = (ms) => new Promise((r) => setTimeout(r, ms)),
51
+ } = {}) {
52
+ const cfg = resolveWarpConfig(env);
53
+ if (!cfg.active) return null;
54
+
55
+ /** 身份轮换器:上游按出口 IP 计的限额(403 FreeTierError / 429 FreeUsage·GoUsage / 裸 429)时换一台 WARP 设备换出口。 */
56
+ let rotator = null;
57
+
58
+ const dispatcher = createRotatingDispatcher({
59
+ nodes: [],
60
+ strategy: cfg.strategy,
61
+ cooldownMs: cfg.cooldownMs,
62
+ log,
63
+ // 判决照抄参照实现 zen-router 的决策表(见 verdict.js 头注):403 FreeTierError 与
64
+ // 429 FreeUsage/GoUsage 是同一条件、必须**强制**换出口(跳过冷却);BlackUsageLimitError
65
+ // 是账号日配额、绝不换;其余状态码不参与判决、也不许刷日志。
66
+ onStatus: (signal, port) => {
67
+ const status = Number(signal?.status);
68
+ if (!SIGNAL_STATUSES.includes(status)) return;
69
+ const v = classifyWarpSignal(signal);
70
+ if (!cfg.rotate) {
71
+ log(`[warp-pool] :${port} 上游 ${status}(${v.kind})—— 轮换开关关着(MSLXDFF_WARP_ROTATE=0),不换出口`);
72
+ return;
73
+ }
74
+ if (!v.rotate || !rotator) {
75
+ log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 不换出口:${v.why}`);
76
+ return;
77
+ }
78
+ log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 换身份换出口${v.forced ? "(强制,跳过冷却)" : ""}:${v.why}`);
79
+ void rotator.rotate(port, { force: v.forced })
80
+ .then((r) => { if (r && r.skipped) log(`[warp-pool] :${port} 换出口被冷却掐掉(距上次不足最小间隔)—— 这一发仍走原出口`); })
81
+ .catch(() => {});
82
+ },
83
+ });
84
+ /** @type {Map<number, {stop: () => void}>} */
85
+ const tunnels = new Map();
86
+
87
+ /** 复活:用磁盘已有 conf 重起,不重注册(进程挂了 ≠ 设备失效,省 CF 配额)。 */
88
+ async function reviveTunnel(port) {
89
+ if (closed) return false;
90
+ const node = loadNodes(cfg.dir, [port])[0];
91
+ if (!node) {
92
+ log(`[warp-pool] :${port} 放弃复活:磁盘上没有该节点的身份(node-${port}.json 缺失或缺私钥)`);
93
+ return false;
94
+ }
95
+ try {
96
+ await bringUp(node);
97
+ } catch (e) {
98
+ log(`[warp-pool] :${port} 复活起隧道失败:${String(e?.message || e)}`);
99
+ return false;
100
+ }
101
+ return true; // 真活没活由 createReviver 在 settleMs 后用 isAlive 复查,这里不许冒充成功
102
+ }
103
+ const reviver = createReviver({ revive: reviveTunnel, isAlive: (port) => { const t = tunnels.get(port); return !!t && t.alive !== false; }, log });
104
+ let binPath = "";
105
+ let closed = false;
106
+ let lastRedialAt = 0;
107
+ let redialGapMs = REDIAL_MIN_GAP_MS;
108
+
109
+ // 兜底清理:Node 退出不会回收 spawn 的子进程,daemon 突然退出时同步杀掉隧道。
110
+ // 同时置 closed:否则「杀子进程 → onExit → 立刻复活」会在退出过程中再 spawn 出一批孤儿(真机:每次重启漏一批)。
111
+ process.once("exit", () => {
112
+ closed = true;
113
+ try {
114
+ reviver.stop();
115
+ } catch {}
116
+ for (const t of tunnels.values()) {
117
+ try {
118
+ t.stop();
119
+ } catch {}
120
+ }
121
+ });
122
+
123
+ /** 起一条隧道的完整动作:确认不是重复起 → 清残留 → 写 conf → 等端口真空出来 → spawn → 记表。 */
124
+ async function bringUp(node) {
125
+ if (tunnels.has(node.port)) return; // 已归我们管:绝不再清残留(reclaim 会把自己这条隧道当残留杀掉)
126
+ await reclaim({ dir: cfg.dir, port: node.port, log }); // 清上一代孤儿(daemon 被强杀时子进程不会自己退)
127
+ const { conf, bytes } = writeTunnelConf(cfg.dir, node);
128
+ log(fmtConf({ port: node.port, conf, bytes }));
129
+ // SIGTERM 之后套接字要几百毫秒才释放,这期间连得上 ≠ 别人的进程占着——先等它真空出来再判
130
+ if (!(await waitFree(node.port, { inUse }))) {
131
+ const owner = await portOwner({ dir: cfg.dir, port: node.port }).catch(() => ({ ours: [], others: [] }));
132
+ throw new Error(fmtPortBlocked({ port: node.port, ...owner }));
133
+ }
134
+ const t = start({
135
+ binPath,
136
+ dir: cfg.dir,
137
+ port: node.port,
138
+ log,
139
+ onExit: ({ port, code, signal, tail, pid, aliveMs }) => {
140
+ tunnels.delete(port);
141
+ log(fmtExit({ port, code, signal, pid, aliveMs, tail: tailReason(tail) }));
142
+ if (!closed) void reviver.onExit(port); // 收摊中就别复活了,否则退出过程里又 spawn 出一批孤儿
143
+ },
144
+ });
145
+ tunnels.set(node.port, t);
146
+ log(fmtSpawn({ port: node.port, pid: t.pid, binPath, conf }));
147
+ }
148
+
149
+ // 429 = 上游按出口 IP 计的名额耗尽 ⇒ 换一台设备的身份换出口(换身份=换桶)。
150
+ rotator = wireRotation({
151
+ dispatcher,
152
+ register,
153
+ tunnels,
154
+ bringUp,
155
+ swap: (port, node) => { removeNode(cfg.dir, port); saveNode(cfg.dir, node); },
156
+ log,
157
+ });
158
+
159
+ /** 探活闸门参数:刚起的隧道要等 WireGuard 握手完成才通(真机:一起来就探 ⇒ 4 条全没通 ⇒ 池为空、WARP 白开)。 */
160
+ const PROBE_SETTLE_MS = 2_000;
161
+ const PROBE_ATTEMPTS = 2;
162
+
163
+ /**
164
+ * 入池前的实测闸门:给握手留时间 → 逐个探活 → 探不通的绝不进池。
165
+ * 为什么必须有:wireproxy 进程活着但隧道转不动流量时,请求会挂到超时(比直连更糟)。
166
+ * @returns {Promise<Array<object>>} 通过探活的节点
167
+ */
168
+ async function probeGate(list) {
169
+ const live = [];
170
+ await wait(PROBE_SETTLE_MS);
171
+ for (const n of list) {
172
+ if (!tunnels.has(n.port)) continue;
173
+ const url = `${String(baseUrl).replace(/\/+$/, "")}${cfg.probePath}`;
174
+ let r = { ok: false };
175
+ for (let i = 1; i <= PROBE_ATTEMPTS; i++) {
176
+ const t0 = now();
177
+ r = await probe({ port: n.port, baseUrl, path: cfg.probePath });
178
+ const ms = Number.isFinite(r.ms) ? r.ms : now() - t0;
179
+ log(fmtProbe({ port: n.port, attempt: i, attempts: PROBE_ATTEMPTS, url, ok: r.ok, status: r.status, error: r.error, ms }));
180
+ if (r.ok) break;
181
+ if (i < PROBE_ATTEMPTS) await wait(PROBE_SETTLE_MS);
182
+ }
183
+ if (r.ok) {
184
+ live.push(n);
185
+ } else {
186
+ log(fmtProbeGiveUp({ port: n.port, attempts: PROBE_ATTEMPTS, url }));
187
+ }
188
+ }
189
+ return live;
190
+ }
191
+ async function bootstrap() {
192
+ if (closed) return;
193
+ ensureDir(cfg.dir);
194
+ try {
195
+ binPath = await ensureBin({ dir: cfg.dir, bin: cfg.bin, autodownload: cfg.autodownload, log });
196
+ } catch (e) {
197
+ const why = !cfg.bin && !resolveAsset()
198
+ ? `本平台(${process.platform}-${process.arch})无预编译 wireproxy,如需启用请设 MSLXDFF_WARP_BIN`
199
+ : String(e?.message || e);
200
+ log(`[warp-pool] 出口池本次跳过(不启用,请求走直连):${why}`);
201
+ return;
202
+ }
203
+ if (closed) return;
204
+
205
+ // 1. 复用磁盘上已注册的节点
206
+ let nodes = loadNodes(cfg.dir, cfg.ports);
207
+ let binSize = 0;
208
+ try { binSize = statSync(binPath).size; } catch {}
209
+ await startupReport({ cfg, nodes, binPath, binSize, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
210
+ // 身份指纹:4 条隧道只拿到 2 个出口时,只有这组读数能区分「设备写重复」与「CF 共享 NAT 折叠」
211
+ for (const line of fmtIdentityDetail(nodes)) log(line);
212
+ // 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
213
+ const siblings = findDaemons({ self: process.pid });
214
+ if (siblings.length) log(fmtSiblings({ list: siblings }));
215
+
216
+ // 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
217
+ const used = new Set(nodes.map((n) => n.port));
218
+ const free = cfg.ports.filter((p) => !used.has(p));
219
+ for (const port of free) {
220
+ if (closed) return;
221
+ if (nodes.length >= cfg.count) break;
222
+ try {
223
+ if (nodes.length > 0) await wait(gapMs(cfg, rnd));
224
+ const reg = await register({ log });
225
+ const node = nodeFromReg(port, reg, now);
226
+ saveNode(cfg.dir, node);
227
+ nodes.push(node);
228
+ log(`[warp-pool] 注册新身份 :${port} 成功(设备 v6=${reg.v6},WireGuard 密钥对在本地生成)`);
229
+ } catch (e) {
230
+ log(`[warp-pool] :${port} 注册身份失败,本轮跳过这个端口:${String(e?.message || e)}`);
231
+ }
232
+ }
233
+
234
+ // 3. 起隧道
235
+ for (const n of nodes) {
236
+ try {
237
+ await bringUp(n);
238
+ } catch (e) {
239
+ log(`[warp-pool] :${n.port} 起隧道失败,这个端口本轮不可用:${String(e?.message || e)}`);
240
+ }
241
+ }
242
+
243
+ // 入池前实测探活:WARP 握手没完成时 wireproxy 在跑却转不动流量(请求会挂到超时),那种节点进池会黑洞全部请求
244
+ const live = await probeGate(nodes);
245
+ if (live.length) {
246
+ await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
247
+ // 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
248
+ const exits = await labelExits({ ports: live.map((n) => n.port), dir: cfg.dir, log });
249
+ rotator.noteExits(exits);
250
+ log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
251
+ } else {
252
+ log(fmtPoolEmpty({ total: nodes.length, ports: cfg.ports }));
253
+ }
254
+ }
255
+
256
+ const monitor = createHealthMonitor({
257
+ dispatcher,
258
+ baseUrl,
259
+ probePath: cfg.probePath,
260
+ log,
261
+ infoOf: () => ({ alive: tunnels.size, pooled: dispatcher.size, exits: rotator ? rotator.stats() : [] }),
262
+ cooldownMs: cfg.cooldownMs,
263
+ onExhausted: async () => {
264
+ // 重拨 = 重新注册换出口。只重拨「进程已死」的隧道:全活却全挂 = 网络路径问题,重注册只会把 CF 刷到 429
265
+ const plan = redialPlan({ ports: cfg.ports, alivePorts: [...tunnels.keys()], lastRedialAt, redialGapMs, now });
266
+ if (plan.reason === "all-alive") {
267
+ log("[warp-pool] 隧道均存活但全池不通,判定为网络路径问题,跳过重拨");
268
+ return;
269
+ }
270
+ if (plan.reason === "throttled") {
271
+ log(`[warp-pool] 距上次重拨不足 ${Math.round(redialGapMs / 1000)}s,跳过本轮`);
272
+ return;
273
+ }
274
+ const dead = plan.dead;
275
+ lastRedialAt = now();
276
+ redialGapMs = Math.min(redialGapMs * 2, REDIAL_MAX_GAP_MS);
277
+ log(`[warp-pool] ${dead.length} 条隧道已死,重拨(下次最短间隔 ${Math.round(redialGapMs / 1000)}s)`);
278
+ for (const port of dead) {
279
+ if (closed) return;
280
+ try {
281
+ tunnels.get(port)?.stop();
282
+ tunnels.delete(port);
283
+ removeNode(cfg.dir, port);
284
+ const reg = await register({ log });
285
+ saveNode(cfg.dir, nodeFromReg(port, reg, now));
286
+ await bringUp({ port, ...reg });
287
+ } catch (e) {
288
+ log(`[warp-pool] 重拨 :${port} 失败:${String(e?.message || e)}`);
289
+ }
290
+ await wait(gapMs(cfg, rnd));
291
+ }
292
+ if (!closed && tunnels.size) {
293
+ // 重拨起来的隧道同样要过闸门:没过探活的隧道进池 = 把请求挂到超时(原先这里直接 setNodes,等于绕过闸门)
294
+ const alive = await probeGate([...tunnels.keys()].map((port) => ({ port })));
295
+ if (alive.length) {
296
+ redialGapMs = REDIAL_MIN_GAP_MS; // 确实有隧道能通 ⇒ 退避复位
297
+ await dispatcher.setNodes(alive.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
298
+ } else {
299
+ log("[warp-pool] 重拨后仍没有一条隧道探通,保持直连(等下一轮健康检查)");
300
+ }
301
+ }
302
+ },
303
+ });
304
+
305
+ const ready = bootstrap()
306
+ .then(() => {
307
+ if (!closed) monitor.start();
308
+ })
309
+ .catch((e) => log(`[warp-pool] bootstrap 失败:${String(e?.message || e)}`));
310
+
311
+ return {
312
+ dispatcher,
313
+ ready,
314
+ config: cfg,
315
+ async close() {
316
+ if (closed) return;
317
+ closed = true;
318
+ monitor.stop();
319
+ reviver.stop();
320
+ for (const t of tunnels.values()) {
321
+ try {
322
+ t.stop();
323
+ } catch {}
324
+ }
325
+ tunnels.clear();
326
+ await dispatcher.close();
327
+ },
328
+ stats() {
329
+ return {
330
+ enabled: true,
331
+ mode: cfg.mode,
332
+ strategy: cfg.strategy,
333
+ dir: cfg.dir,
334
+ tunnels: tunnels.size,
335
+ nodes: dispatcher.stats(),
336
+ exits: rotator ? rotator.stats() : [],
337
+ };
338
+ },
339
+ };
340
+ }
341
+
342
+ /** 只取轮换 dispatcher 的便捷入口;未启用时返回 undefined(调用方直接透传即"关闭态")。 */
343
+ /** 隧道进程由模块内的 process exit 钩子兜底清理。 */
344
+ export function createWarpDispatcher(opts) {
345
+ return createWarpPool(opts)?.dispatcher;
346
+ }
347
+
348
+ /**
349
+ * 重拨门(纯函数,便于单测):只「有隧道进程已死」且「距上次重拨超过退避间隔」才放行——全活却全挂是网络问题。
350
+ * @returns {{ dead: number[], reason: "ok" | "all-alive" | "throttled" }}
351
+ */
352
+ export function redialPlan({ ports = [], alivePorts = [], lastRedialAt = 0, redialGapMs = REDIAL_MIN_GAP_MS, now = Date.now } = {}) {
353
+ const alive = new Set(alivePorts);
354
+ const dead = ports.filter((p) => !alive.has(p));
355
+ if (!dead.length) return { dead, reason: "all-alive" };
356
+ if (now() - lastRedialAt < redialGapMs) return { dead, reason: "throttled" };
357
+ return { dead, reason: "ok" };
358
+ }