mslxdff 0.1.124 → 0.1.126
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/package.json +1 -1
- package/src/cli/commands/group.js +13 -37
- package/src/cli/help.js +3 -1
- package/src/cli/join-core.js +99 -0
- package/src/cli/join-wizard.js +171 -0
- package/src/daemon.js +15 -3
- package/src/routes/peers.js +28 -5
- package/src/runtime/auto-update.js +32 -8
- package/src/runtime/bootstrap.js +2 -0
- package/src/runtime/broadband-stream.js +3 -1
- package/src/runtime/broadband.js +4 -2
- package/src/runtime/lifecycle-log.js +64 -0
- package/src/runtime/server-lifecycle.js +4 -2
- package/src/server.js +9 -2
- package/src/upstream-engine/sdk/convert.js +7 -1
- package/src/upstream-engine/sdk/responses.js +37 -10
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import { createGroupsService, createBansService, refreshGroupMembers, syncPeersF
|
|
|
2
2
|
import { createPeersService } from "../../peers.js";
|
|
3
3
|
import { loadToken, loadGroupsJoined, saveGroupsJoined } from "../../state.js";
|
|
4
4
|
import { groupIs, markJoined, probeHealth, syncAllJoinedGroups } from "../group-helpers.js";
|
|
5
|
+
import { joinGroupCore } from "../join-core.js";
|
|
5
6
|
import { errMsg } from "../util.js";
|
|
6
7
|
import { argValue } from "../policy.js";
|
|
7
8
|
import { compatFetch, timeoutSignal } from "../../compat.js";
|
|
@@ -149,49 +150,24 @@ export async function handleAddToGroup(args) {
|
|
|
149
150
|
const isBroadband = rawArgs.includes("--broadband");
|
|
150
151
|
const filtered = rawArgs.filter((a) => a !== "--broadband");
|
|
151
152
|
const [leaderHost, name] = filtered;
|
|
152
|
-
if (
|
|
153
|
+
if (filtered.length > 2) {
|
|
153
154
|
console.error("usage: mslxdff -addtogroup <leader-host> <name> [--broadband]");
|
|
154
155
|
process.exit(1);
|
|
155
156
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
: `http://${leaderHost}${leaderHost.includes(":") ? "" : ":8989"}`;
|
|
162
|
-
const kind = isBroadband ? "broadband" : "static";
|
|
163
|
-
let joinBody;
|
|
164
|
-
if (isBroadband) {
|
|
165
|
-
const relayId = `relay://${myToken.slice(0, 8)}`;
|
|
166
|
-
joinBody = { name, key: name, leaderUrl, url: relayId, token: myToken, kind: "broadband" };
|
|
167
|
-
} else {
|
|
168
|
-
const { effectivePort } = await import("../policy.js");
|
|
169
|
-
const myPort = effectivePort(args);
|
|
170
|
-
joinBody = { name, key: name, leaderUrl, myPort, token: myToken, kind: "static" };
|
|
157
|
+
if (!leaderHost || !name) {
|
|
158
|
+
// 缺参数 → 手机宽带接入向导(默认 broadband:手机无公网入站,只出站)
|
|
159
|
+
const { runJoinWizard } = await import("../join-wizard.js");
|
|
160
|
+
const r = await runJoinWizard({ leaderInput: leaderHost || null, groupName: name || null });
|
|
161
|
+
process.exit(r.ok ? 0 : 1);
|
|
171
162
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
headers: { "Content-Type": "application/json" },
|
|
176
|
-
body: JSON.stringify(joinBody),
|
|
177
|
-
});
|
|
178
|
-
if (!res.ok) {
|
|
179
|
-
const text = await res.text().catch(() => "");
|
|
180
|
-
throw new Error(`join failed (HTTP ${res.status}): ${text}`);
|
|
181
|
-
}
|
|
182
|
-
const data = await res.json();
|
|
183
|
-
const myUrl = data.you?.url || joinBody.url || "";
|
|
184
|
-
const memberName = isBroadband ? myUrl : myUrl;
|
|
185
|
-
markJoined({ name, leaderUrl, myUrl, memberName, kind });
|
|
186
|
-
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
187
|
-
const s = synced.find((x) => x.name === name);
|
|
188
|
-
console.log(`joined group "${name}" at ${leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
189
|
-
if (s?.error) console.log(` local failover setup failed: ${s.error}`);
|
|
190
|
-
else console.log(` ${s?.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
|
|
191
|
-
} catch (err) {
|
|
192
|
-
console.error(`join failed: ${err.message}`);
|
|
163
|
+
const r = await joinGroupCore({ leaderHost, name, isBroadband, args });
|
|
164
|
+
if (!r.ok) {
|
|
165
|
+
console.error(`join failed: ${r.error}`);
|
|
193
166
|
process.exit(1);
|
|
194
167
|
}
|
|
168
|
+
console.log(`joined group "${name}" at ${r.leaderUrl}${isBroadband ? " [broadband]" : ""}`);
|
|
169
|
+
if (r.syncError) console.log(` local failover setup failed: ${r.syncError}`);
|
|
170
|
+
else console.log(` ${r.added ?? 0} failover target(s) configured${isBroadband ? " (broadband via leader, local 127.0.0.1)" : ""}`);
|
|
195
171
|
process.exit(0);
|
|
196
172
|
}
|
|
197
173
|
|
package/src/cli/help.js
CHANGED
|
@@ -29,7 +29,7 @@ Usage:
|
|
|
29
29
|
mslxdff -workbuddy checkin daily 100 credits(双域幂等,已签 code 10001 视为成功)
|
|
30
30
|
mslxdff -providers list list all configured upstream providers (opencode, openrouter, generic, workbuddy)
|
|
31
31
|
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
32
|
-
mslxdff -addtogroup <
|
|
32
|
+
mslxdff -addtogroup [<host> [<name>]] [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站);省略参数=手机宽带向导(问地址+组名,自动起服务,报出口 IP)
|
|
33
33
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
34
34
|
mslxdff -group leave <name> leave a group (removes its members from this node)
|
|
35
35
|
mslxdff -group list list groups on this node (numbered members)
|
|
@@ -46,6 +46,8 @@ Usage:
|
|
|
46
46
|
mslxdff -use-group [on|off] 本机失败时是否走组员网络(默认 on;off 则所有供应商仅本机,MSLXDFF_USE_GROUP 环境变量可覆盖)
|
|
47
47
|
mslxdff -help show this help
|
|
48
48
|
|
|
49
|
+
手机接入(Android/Termux): pkg install nodejs-lts → npm i -g mslxdff → mslxdff -addtogroup → termux-wake-lock(详见 docs/MOBILE.md)
|
|
50
|
+
|
|
49
51
|
Environment:
|
|
50
52
|
MSLXDFF_PORT listen port (default 8989; use mslxdff -port N to persist)
|
|
51
53
|
MSLXDFF_STATE_FILE token/port state file
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createGroupsService } from "../groups.js";
|
|
2
|
+
import { createPeersService } from "../peers.js";
|
|
3
|
+
import { loadToken } from "../state.js";
|
|
4
|
+
import { markJoined, syncAllJoinedGroups } from "./group-helpers.js";
|
|
5
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
6
|
+
import { errMsg } from "./util.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PORT = 8989;
|
|
9
|
+
const JOIN_TIMEOUT_MS = 8000;
|
|
10
|
+
|
|
11
|
+
/** 组长地址归一化 + 人话校验(ADR-0006 术语 leader-host):`1.2.3.4` → `http://1.2.3.4:8989`。 */
|
|
12
|
+
export function normalizeLeaderUrl(raw, { defaultPort = DEFAULT_PORT } = {}) {
|
|
13
|
+
const s = String(raw ?? "").trim();
|
|
14
|
+
if (!s) return { ok: false, reason: "请填写组长地址(如 1.2.3.4:8989)" };
|
|
15
|
+
if (/\s/.test(s)) return { ok: false, reason: "地址不能包含空格" };
|
|
16
|
+
const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(s);
|
|
17
|
+
if (schemeMatch && !/^https?$/i.test(schemeMatch[1])) {
|
|
18
|
+
return { ok: false, reason: "只支持 http:// 或 https:// 开头的地址" };
|
|
19
|
+
}
|
|
20
|
+
const withScheme = Boolean(schemeMatch);
|
|
21
|
+
let body = withScheme ? s.slice(schemeMatch[0].length) : s;
|
|
22
|
+
if (body.endsWith("/")) body = body.replace(/\/+$/, "");
|
|
23
|
+
if (body.includes("/")) return { ok: false, reason: "暂不支持带路径的地址(只填 ip:端口)" };
|
|
24
|
+
if (!body) return { ok: false, reason: "地址缺少主机名" };
|
|
25
|
+
let host = body;
|
|
26
|
+
let port = null;
|
|
27
|
+
const v6End = body.lastIndexOf("]");
|
|
28
|
+
const ci = body.lastIndexOf(":");
|
|
29
|
+
if (!(v6End >= 0 && v6End === body.length - 1 && ci < v6End)) {
|
|
30
|
+
if (ci >= 0) {
|
|
31
|
+
host = body.slice(0, ci);
|
|
32
|
+
const p = body.slice(ci + 1);
|
|
33
|
+
if (!/^\d{1,5}$/.test(p)) return { ok: false, reason: "端口必须是数字(1-65535)" };
|
|
34
|
+
port = Number(p);
|
|
35
|
+
if (port < 1 || port > 65535) return { ok: false, reason: "端口超出范围(1-65535)" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!host) return { ok: false, reason: "地址缺少主机名" };
|
|
39
|
+
const isV6 = /^\[[0-9a-fA-F:.]+\]$/.test(host);
|
|
40
|
+
if (!isV6 && !/^[\w.-]+$/.test(host)) return { ok: false, reason: "主机名含非法字符(支持 ip、域名或 [ipv6])" };
|
|
41
|
+
const url = withScheme
|
|
42
|
+
? `${schemeMatch[1].toLowerCase()}://${host}${port ? `:${port}` : ""}`
|
|
43
|
+
: `http://${host}:${port ?? defaultPort}`;
|
|
44
|
+
return { ok: true, url };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 加入远端组(宽带/静态共用核心):归一化 → POST /v1/groups/join → markJoined → 同步 peers。
|
|
49
|
+
* 只返回结果对象,不打印、不 exit——由 CLI 命令与交互式向导各自决定展示与退出码。
|
|
50
|
+
*/
|
|
51
|
+
export async function joinGroupCore({ leaderHost, name, isBroadband = false, args = [] }) {
|
|
52
|
+
const norm = normalizeLeaderUrl(leaderHost);
|
|
53
|
+
if (!norm.ok) return { ok: false, error: norm.reason, reason: norm.reason };
|
|
54
|
+
const leaderUrl = norm.url;
|
|
55
|
+
const groupName = String(name ?? "").trim();
|
|
56
|
+
if (!groupName) return { ok: false, leaderUrl, error: "组名不能为空" };
|
|
57
|
+
const groups = createGroupsService({});
|
|
58
|
+
const peers = createPeersService({});
|
|
59
|
+
const myToken = (await loadToken()).token;
|
|
60
|
+
const kind = isBroadband ? "broadband" : "static";
|
|
61
|
+
let joinBody;
|
|
62
|
+
if (isBroadband) {
|
|
63
|
+
const relayId = `relay://${myToken.slice(0, 8)}`;
|
|
64
|
+
joinBody = { name: groupName, key: groupName, leaderUrl, url: relayId, token: myToken, kind };
|
|
65
|
+
} else {
|
|
66
|
+
const { effectivePort } = await import("./policy.js");
|
|
67
|
+
joinBody = { name: groupName, key: groupName, leaderUrl, myPort: effectivePort(args), token: myToken, kind };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const res = await compatFetch(`${leaderUrl}/v1/groups/join`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify(joinBody),
|
|
74
|
+
signal: timeoutSignal(JOIN_TIMEOUT_MS),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const text = await res.text().catch(() => "");
|
|
78
|
+
return { ok: false, leaderUrl, error: `HTTP ${res.status} ${text.slice(0, 200)}` };
|
|
79
|
+
}
|
|
80
|
+
const data = await res.json().catch(() => ({}));
|
|
81
|
+
const myUrl = data.you?.url || joinBody.url || "";
|
|
82
|
+
markJoined({ name: groupName, leaderUrl, myUrl, memberName: myUrl, kind });
|
|
83
|
+
const synced = await syncAllJoinedGroups({ peers, groups });
|
|
84
|
+
const s = synced.find((x) => x.name === groupName);
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
leaderUrl,
|
|
88
|
+
name: groupName,
|
|
89
|
+
myUrl,
|
|
90
|
+
memberName: myUrl,
|
|
91
|
+
kind,
|
|
92
|
+
added: s?.added ?? 0,
|
|
93
|
+
syncError: s?.error || null,
|
|
94
|
+
token: myToken,
|
|
95
|
+
};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, leaderUrl, error: errMsg(err) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createInterface } from "../readline-compat.js";
|
|
2
|
+
import { normalizeLeaderUrl, joinGroupCore } from "./join-core.js";
|
|
3
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
4
|
+
import { errMsg } from "./util.js";
|
|
5
|
+
|
|
6
|
+
const PROBE_TIMEOUT_MS = 6000;
|
|
7
|
+
const SERVICE_HEALTH_TIMEOUT_MS = 6000;
|
|
8
|
+
const MAX_ATTEMPTS = 3;
|
|
9
|
+
|
|
10
|
+
export function isTermux(env = process.env) {
|
|
11
|
+
return Boolean(env?.TERMUX_VERSION) || String(env?.PREFIX || "").includes("com.termux");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createAskSession() {
|
|
15
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
|
|
16
|
+
// 逐行异步迭代而非 rl.question:question 回调在管道/多行同 chunk 时会丢行(绑定前的行被 emit 掉),
|
|
17
|
+
// 且 stdin 结束(管道 EOF / Ctrl+D)时 pending 的 question 永不 resolve,会让顶层 await 悬挂退出(exit 13)。
|
|
18
|
+
const it = rl[Symbol.asyncIterator]();
|
|
19
|
+
return {
|
|
20
|
+
ask: async (q) => {
|
|
21
|
+
process.stdout.write(q);
|
|
22
|
+
const { value, done } = await it.next();
|
|
23
|
+
return done ? null : value;
|
|
24
|
+
},
|
|
25
|
+
close: () => {
|
|
26
|
+
try { rl.close(); } catch {}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 出口 IP:组长侧 clientIp() 视角(ADR-0006:不靠 ifconfig.me)。 */
|
|
32
|
+
export async function probeExitIp({ leaderUrl, token, group }) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await compatFetch(`${leaderUrl}/v1/groups/relay/heartbeat`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
37
|
+
body: JSON.stringify({ name: group, group }),
|
|
38
|
+
signal: timeoutSignal(PROBE_TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) return null;
|
|
41
|
+
const j = await res.json().catch(() => null);
|
|
42
|
+
return j?.ip || null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 确保后台服务在跑:已在跑则复用;否则启动并等 health(daemon 的 10s ensure 会自动接上长连)。 */
|
|
49
|
+
export async function ensureServiceRunning() {
|
|
50
|
+
const { readPid, isPidAlive, startDaemon } = await import("../daemon.js");
|
|
51
|
+
const { waitForHealth, effectivePort } = await import("./policy.js");
|
|
52
|
+
const pid = readPid();
|
|
53
|
+
if (pid && isPidAlive(pid)) return { pid, started: false };
|
|
54
|
+
const newPid = startDaemon([]);
|
|
55
|
+
await waitForHealth(effectivePort([]), SERVICE_HEALTH_TIMEOUT_MS);
|
|
56
|
+
return { pid: newPid, started: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 手机宽带接入向导:问组长地址 → 问组名 → 宽带入组 → 报出口 IP → 确保服务运行 → 保活指引。
|
|
61
|
+
* 依赖全可注入(session/join/probeIp/ensureService/out/env),便于单测与复用。
|
|
62
|
+
* // Note: 为什么默认 broadband、为何用 iterator 而非 rl.question — 见 .agents/notes/implemented/feature/2026-09-16-mobile-broadband-join-wizard.md
|
|
63
|
+
*/
|
|
64
|
+
export async function runJoinWizard(opts = {}) {
|
|
65
|
+
const out = opts.out || ((line) => console.log(line));
|
|
66
|
+
const env = opts.env || process.env;
|
|
67
|
+
const session = opts.session || createAskSession();
|
|
68
|
+
const ask = opts.ask || ((q) => session.ask(q));
|
|
69
|
+
const join = opts.join || joinGroupCore;
|
|
70
|
+
const ensureService = opts.ensureService || ensureServiceRunning;
|
|
71
|
+
const probeIp = opts.probeIp || probeExitIp;
|
|
72
|
+
const maxAttempts = opts.maxAttempts ?? MAX_ATTEMPTS;
|
|
73
|
+
|
|
74
|
+
out("");
|
|
75
|
+
out(" 手机宽带接入 — 让这台设备成为组内出口(不占端口,只出站)");
|
|
76
|
+
out("");
|
|
77
|
+
|
|
78
|
+
let leaderUrl = null;
|
|
79
|
+
let name = String(opts.groupName ?? "").trim();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (opts.leaderInput) {
|
|
83
|
+
const norm = normalizeLeaderUrl(opts.leaderInput);
|
|
84
|
+
if (!norm.ok) {
|
|
85
|
+
out(`✗ 组长地址无效:${norm.reason}`);
|
|
86
|
+
out(" 重试:mslxdff -addtogroup");
|
|
87
|
+
return { ok: false, error: norm.reason };
|
|
88
|
+
}
|
|
89
|
+
leaderUrl = norm.url;
|
|
90
|
+
} else {
|
|
91
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
92
|
+
const raw = await ask("1/2 组长地址(ip:端口,如 149.13.91.10:8989): ");
|
|
93
|
+
if (raw === null) {
|
|
94
|
+
out("· 已取消(输入结束)");
|
|
95
|
+
return { ok: false, error: "cancelled" };
|
|
96
|
+
}
|
|
97
|
+
const norm = normalizeLeaderUrl(raw);
|
|
98
|
+
if (norm.ok) {
|
|
99
|
+
leaderUrl = norm.url;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
out(` ✗ ${norm.reason}`);
|
|
103
|
+
}
|
|
104
|
+
if (!leaderUrl) {
|
|
105
|
+
out("✗ 组长地址始终无效,已退出。重试:mslxdff -addtogroup");
|
|
106
|
+
return { ok: false, error: "invalid leader input" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!name) {
|
|
111
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
112
|
+
const raw = await ask("2/2 组名: ");
|
|
113
|
+
if (raw === null) {
|
|
114
|
+
out("· 已取消(输入结束)");
|
|
115
|
+
return { ok: false, error: "cancelled" };
|
|
116
|
+
}
|
|
117
|
+
const v = String(raw).trim();
|
|
118
|
+
if (v) {
|
|
119
|
+
name = v;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
out(" ✗ 组名不能为空");
|
|
123
|
+
}
|
|
124
|
+
if (!name) {
|
|
125
|
+
out("✗ 未填写组名,已退出。重试:mslxdff -addtogroup");
|
|
126
|
+
return { ok: false, error: "empty group name" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
session.close();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
out(`→ 正在连接组长 ${leaderUrl} ...`);
|
|
134
|
+
const r = await join({ leaderHost: leaderUrl, name, isBroadband: true });
|
|
135
|
+
if (!r?.ok) {
|
|
136
|
+
out(`✗ 加入失败:${r?.error || "未知错误"}`);
|
|
137
|
+
out(" 检查:① 地址与端口是否正确 ② 组长服务是否在跑 ③ 手机网络是否可达");
|
|
138
|
+
out(" 重试:mslxdff -addtogroup");
|
|
139
|
+
return { ok: false, leaderUrl, error: r?.error || "join failed" };
|
|
140
|
+
}
|
|
141
|
+
out(`✓ 已加入组「${name}」(宽带成员 ${r.myUrl})`);
|
|
142
|
+
if (r.syncError) out(` ⚠ 组员列表同步失败(不影响出口贡献):${r.syncError}`);
|
|
143
|
+
|
|
144
|
+
out("→ 正在确认出口 IP ...");
|
|
145
|
+
const exitIp = await probeIp({ leaderUrl: r.leaderUrl || leaderUrl, token: r.token, group: name }).catch(() => null);
|
|
146
|
+
if (exitIp) out(`✓ 出口 IP: ${exitIp}(组长视角,上游分流按这个 IP)`);
|
|
147
|
+
else out("· 出口 IP 待确认(组长暂时不可达,恢复后自动重试)");
|
|
148
|
+
|
|
149
|
+
out("→ 正在启动后台服务 ...");
|
|
150
|
+
const service = await ensureService().catch((err) => ({ error: errMsg(err) }));
|
|
151
|
+
if (service?.error) {
|
|
152
|
+
out(`✗ 后台服务启动失败:${service.error}`);
|
|
153
|
+
out(" 手动启动:mslxdff -d 查看日志:mslxdff -log 50");
|
|
154
|
+
} else if (service?.started) {
|
|
155
|
+
out(`✓ 后台服务已启动(pid ${service.pid})`);
|
|
156
|
+
} else {
|
|
157
|
+
out(`✓ 后台服务已在运行(pid ${service.pid}),已自动接入`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
out("");
|
|
161
|
+
out("保持在线:");
|
|
162
|
+
if (isTermux(env)) {
|
|
163
|
+
out(" • termux-wake-lock 防止系统休眠杀进程(强烈建议)");
|
|
164
|
+
} else {
|
|
165
|
+
out(" • 关机/断网后需重跑本命令");
|
|
166
|
+
}
|
|
167
|
+
out(" • mslxdff -status 查看组与出口状态");
|
|
168
|
+
out(" • mslxdff -stop 停止贡献");
|
|
169
|
+
out("");
|
|
170
|
+
return { ok: true, leaderUrl, name, myUrl: r.myUrl, exitIp, service };
|
|
171
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -16,11 +16,17 @@ export function logFile() {
|
|
|
16
16
|
return join(daemonDir(), "daemon.log");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 当前代码所在的 CLI 入口(bin/mslxdff.js 绝对路径):startDaemon 与
|
|
20
|
+
// auto-update 的 -restart 委托共用(后者从 daemon 进程 spawn 时必须指向同一份代码)。
|
|
21
|
+
export function daemonEntry() {
|
|
20
22
|
const here = fileURLToPath(import.meta.url);
|
|
21
|
-
|
|
23
|
+
return here.endsWith("bin/mslxdff.js")
|
|
22
24
|
? here
|
|
23
25
|
: join(dirname(here), "..", "bin", "mslxdff.js");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function startDaemon(args = []) {
|
|
29
|
+
const entry = daemonEntry();
|
|
24
30
|
const dir = daemonDir();
|
|
25
31
|
mkdirSync(dir, { recursive: true });
|
|
26
32
|
const logFd = openSync(logFile(), "a", 0o600);
|
|
@@ -70,6 +76,12 @@ export function isPidAlive(pid) {
|
|
|
70
76
|
export function stopDaemon() {
|
|
71
77
|
const pid = readPid();
|
|
72
78
|
if (!pid) return { stopped: false, reason: "no pid file" };
|
|
79
|
+
// 杀者留痕(写 daemon.log):Windows 的 SIGTERM 是 TerminateProcess 强杀,被杀的 daemon
|
|
80
|
+
// 在 JS 层收不到任何事件(无法自记),故由"杀者"记录调用方 pid——
|
|
81
|
+
// 没有这行 = 非我方所杀(外部 taskkill/任务管理器/崩溃),配合最后一条 heartbeat 定位死亡时刻。
|
|
82
|
+
try {
|
|
83
|
+
appendFileSync(logFile(), `[lifecycle] stopDaemon called by pid=${process.pid} — killing daemon pid=${pid}\n`);
|
|
84
|
+
} catch {}
|
|
73
85
|
try {
|
|
74
86
|
process.kill(pid, "SIGTERM");
|
|
75
87
|
} catch (err) {
|
package/src/routes/peers.js
CHANGED
|
@@ -8,6 +8,12 @@ import { compatFetch, timeoutSignal, getUndici } from "../compat.js";
|
|
|
8
8
|
const PEER_TIMEOUT_MS = 30_000;
|
|
9
9
|
const PEER_STATUS_TIMEOUT_MS = 2_000;
|
|
10
10
|
const DEFAULT_PEER_CONNECT_TIMEOUT_MS = 3_000;
|
|
11
|
+
// SSE 响应体数据间隔超时(undici bodyTimeout)。慢模型首块后可静默思考远超 30s,
|
|
12
|
+
// 沿用 30s 会把组内转发的中继活流掐成 "terminated"(实测首块后 ~31s 必断,本地直连
|
|
13
|
+
// 无此限制——组内因此比直连"不流畅")。与应用层 MAX_STREAM_MS(120s) 对齐:
|
|
14
|
+
// undici 层只做最后兜底,掐流交给应用层的首块/总时长策略。
|
|
15
|
+
const PEER_BODY_TIMEOUT_MS = 120_000;
|
|
16
|
+
export { PEER_BODY_TIMEOUT_MS };
|
|
11
17
|
|
|
12
18
|
function peerConnectTimeoutMs() {
|
|
13
19
|
const n = Number(process.env.MSLXDFF_PEER_CONNECT_TIMEOUT_MS);
|
|
@@ -25,7 +31,7 @@ function getPeerDispatcher() {
|
|
|
25
31
|
peerDispatcher = new Agent({
|
|
26
32
|
connect: { timeout: peerConnectTimeoutMs() },
|
|
27
33
|
headersTimeout: PEER_TIMEOUT_MS,
|
|
28
|
-
bodyTimeout:
|
|
34
|
+
bodyTimeout: PEER_BODY_TIMEOUT_MS,
|
|
29
35
|
keepAliveTimeout: 30_000,
|
|
30
36
|
keepAliveMaxTimeout: 60_000,
|
|
31
37
|
});
|
|
@@ -104,8 +110,9 @@ export async function peerHealthyModels(peer, { timeoutMs = PEER_STATUS_TIMEOUT_
|
|
|
104
110
|
return p;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
// controller 由调用方持有:赛跑 winner 确定后可主动 abort 其余 in-flight,
|
|
114
|
+
// 避免迟到流挂满 bodyTimeout(并给对方省下无用负载)。30s timer 仅覆盖"等响应头"。
|
|
115
|
+
async function forwardToPeer(peer, body, model, hops, controller = new AbortController()) {
|
|
109
116
|
const timer = setTimeout(() => controller.abort(), PEER_TIMEOUT_MS);
|
|
110
117
|
try {
|
|
111
118
|
const headers = {
|
|
@@ -178,23 +185,39 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
178
185
|
const completed = await new Promise((resolve) => {
|
|
179
186
|
const order = [];
|
|
180
187
|
const total = prepared.length;
|
|
188
|
+
const ctrls = new Map(); // url -> controller(winner 后统一取消其余)
|
|
181
189
|
let settled = false;
|
|
182
190
|
const finish = (winner) => {
|
|
183
191
|
if (settled) return;
|
|
184
192
|
settled = true;
|
|
193
|
+
if (winner) {
|
|
194
|
+
for (const c of ctrls.values()) {
|
|
195
|
+
if (c === winner.ctrl) continue;
|
|
196
|
+
c.killedByUs = true;
|
|
197
|
+
c.abort();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
185
200
|
resolve({ list: order, winner });
|
|
186
201
|
};
|
|
187
202
|
for (const { peer, target } of prepared) {
|
|
188
203
|
tried.add(peer.url);
|
|
204
|
+
const ctrl = new AbortController();
|
|
205
|
+
ctrls.set(peer.url, ctrl);
|
|
189
206
|
ctx.evt("peer-request", { peer: peer.url, model: target, hops: ctx.hops + 1 });
|
|
190
207
|
// 插件 hook:peer:beforeForward — 转发给组员前观察
|
|
191
208
|
if (ctx.plugins?.length) {
|
|
192
209
|
runHook(ctx.plugins, "peer:beforeForward", { reqId: ctx.reqId, peer: peer.url, model: target, hops: ctx.hops + 1 }).catch(() => {});
|
|
193
210
|
}
|
|
194
211
|
const t0 = performance.now();
|
|
195
|
-
forwardToPeer(peer, ctx.body, target, ctx.hops).then((res) => {
|
|
212
|
+
forwardToPeer(peer, ctx.body, target, ctx.hops, ctrl).then((res) => {
|
|
196
213
|
const latencyMs = Math.round(performance.now() - t0);
|
|
197
214
|
const failed = res instanceof Error || res.status >= 400;
|
|
215
|
+
// winner 已定后被我们主动取消:静默,不记账不报错(否则污染健康统计与错误日志)
|
|
216
|
+
if (res instanceof Error && ctrl.killedByUs) {
|
|
217
|
+
order.push({ ok: false, peer, target, res, status: 0, killed: true });
|
|
218
|
+
if (order.length === total) finish(null);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
198
221
|
ctx.evt("peer-forward", { peer: peer.url, model: target, hops: ctx.hops + 1, latencyMs, ok: !failed });
|
|
199
222
|
// 插件 hook:peer:result — 组员响应后观察
|
|
200
223
|
if (ctx.plugins?.length) {
|
|
@@ -211,7 +234,7 @@ export async function racePeerCandidates(candidates, ctx) {
|
|
|
211
234
|
recordLater(() => ctx.peers.recordResult(peer.url, { ok: false }));
|
|
212
235
|
if (order.length === total) finish(null);
|
|
213
236
|
} else {
|
|
214
|
-
const entry = { ok: true, peer, target, res, latencyMs };
|
|
237
|
+
const entry = { ok: true, peer, target, res, latencyMs, ctrl };
|
|
215
238
|
order.push(entry);
|
|
216
239
|
if (!settled) {
|
|
217
240
|
// 第一个成功立即返回:不等慢/黑洞候选(迟到者的记账由各分支自理)
|
|
@@ -1,6 +1,24 @@
|
|
|
1
|
+
import { closeSync, openSync } from "node:fs";
|
|
1
2
|
import { autoUpdateIntervalMs } from "../cli/policy.js";
|
|
2
3
|
import { errMsg, npmCmd, run } from "../cli/util.js";
|
|
3
|
-
|
|
4
|
+
|
|
5
|
+
// 升级后的重启委托:在 daemon 进程内直调 stopDaemon() 是自杀式 SIGTERM,
|
|
6
|
+
// 会打断紧随其后的 startDaemon()/waitForHealth()(2026-09-15 实测:0.1.124→0.1.125
|
|
7
|
+
// 自动升级后 daemon 反复抢占/静默消失 7 分钟)。改为 spawn 一个 CLI 子进程执行
|
|
8
|
+
// `-restart`(成熟路径:杀旧 + 起新 + health 二次确认),daemon 自己不再掌舵重启。
|
|
9
|
+
// env 必须剔除 MSLXDFF_DAEMON,否则子进程的 handleRestart 守卫会把 -restart 当 no-op。
|
|
10
|
+
export function spawnRestartViaCli({ spawnFn, nodePath, entry, logFd, env }) {
|
|
11
|
+
const clean = { ...env };
|
|
12
|
+
delete clean.MSLXDFF_DAEMON;
|
|
13
|
+
delete clean.MSLXDFF_DEBUG;
|
|
14
|
+
const child = spawnFn(nodePath, [entry, "-restart"], {
|
|
15
|
+
detached: true,
|
|
16
|
+
stdio: ["ignore", logFd, logFd],
|
|
17
|
+
env: clean,
|
|
18
|
+
});
|
|
19
|
+
child.unref();
|
|
20
|
+
return child.pid;
|
|
21
|
+
}
|
|
4
22
|
|
|
5
23
|
export function setupAutoUpdate({ VERSION, bus, logs }) {
|
|
6
24
|
const autoUpdateMs = autoUpdateIntervalMs();
|
|
@@ -78,12 +96,18 @@ export function setupAutoUpdate({ VERSION, bus, logs }) {
|
|
|
78
96
|
emitAutoUpdate("auto-update-installed", { current: VERSION, latest, stdout: String(up.stdout || "").slice(0, 500) });
|
|
79
97
|
console.log(`auto-update: installed v${latest}, restarting daemon...`);
|
|
80
98
|
emitAutoUpdate("auto-update-restarting", { current: VERSION, latest });
|
|
81
|
-
const {
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
const { daemonEntry, logFile } = await import("../daemon.js");
|
|
100
|
+
const { spawn } = await import("node:child_process");
|
|
101
|
+
const logFd = openSync(logFile(), "a", 0o600);
|
|
102
|
+
let restarterPid = null;
|
|
103
|
+
try {
|
|
104
|
+
restarterPid = spawnRestartViaCli({ spawnFn: spawn, nodePath: process.execPath, entry: daemonEntry(), logFd, env: process.env });
|
|
105
|
+
} finally {
|
|
106
|
+
try { closeSync(logFd); } catch {}
|
|
107
|
+
}
|
|
108
|
+
// 不再自己 stopDaemon()/startDaemon()/waitForHealth():-restart 会杀旧(我们)并起新版本;
|
|
109
|
+
// 若它失败,本进程保持服务(下轮 60m 检查重试),绝不让重启半途变成"两个都死"。
|
|
110
|
+
console.log(`auto-update: restart handed to CLI -restart (pid ${restarterPid})`);
|
|
111
|
+
emitAutoUpdate("auto-update-restart-spawned", { current: VERSION, latest, pid: restarterPid });
|
|
88
112
|
}
|
|
89
113
|
}
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -11,6 +11,8 @@ const logs = { appendCall, appendError, appendEvent };
|
|
|
11
11
|
* 重活下沉 providers-setup / server-lifecycle / group-sync / broadband(-stream)。
|
|
12
12
|
*/
|
|
13
13
|
export async function startDaemonMain(VERSION) {
|
|
14
|
+
const { installLifecycleLog } = await import("./lifecycle-log.js");
|
|
15
|
+
installLifecycleLog({ version: VERSION, logs });
|
|
14
16
|
const ctx = await setupProviders();
|
|
15
17
|
const { srv, bus } = await startServerLifecycle({ VERSION, ...ctx, logs });
|
|
16
18
|
void srv;
|
|
@@ -25,6 +25,7 @@ export function startBroadbandStream({ token, upstream, execAndPost, broadbandGr
|
|
|
25
25
|
if (!res.ok) throw new Error(`stream ${res.status}`);
|
|
26
26
|
if (!res.body) throw new Error("no body");
|
|
27
27
|
attempts = 0;
|
|
28
|
+
console.log(`broadband relay: connected ${g.name} via ${g.leaderUrl} (SSE)`);
|
|
28
29
|
let buf = "";
|
|
29
30
|
const decodeChunk = (c) => {
|
|
30
31
|
if (typeof c === "string") return c;
|
|
@@ -73,5 +74,6 @@ export function startBroadbandStream({ token, upstream, execAndPost, broadbandGr
|
|
|
73
74
|
for (const g of broadbandGroups()) if (!streamManagers.has(g.name)) startStreamForGroup(g);
|
|
74
75
|
}, 10_000);
|
|
75
76
|
ensureTimer.unref();
|
|
76
|
-
|
|
77
|
+
const n = broadbandGroups().length;
|
|
78
|
+
console.log(n ? `broadband relay: stream (SSE) + ping 25s for ${n} group(s)` : "broadband relay: idle (0 group) — waiting for a group join, checked every 10s");
|
|
77
79
|
}
|
package/src/runtime/broadband.js
CHANGED
|
@@ -6,10 +6,11 @@ import { compatFetch, timeoutSignal } from "../compat.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* 宽带中继 — poll 模式(heartbeat + poll)与 stream 模式分发。
|
|
8
8
|
* stream 长连下沉 broadband-stream.js,本文件保留任务执行与降级分支。
|
|
9
|
+
* 空组不再早退:加组后由 ensure(stream 10s / poll 每次读组)自动接上,无需重启 daemon。
|
|
10
|
+
* // Note: 空组不早退与向导默认 broadband 的取舍 — 见 .agents/notes/implemented/feature/2026-09-16-mobile-broadband-join-wizard.md
|
|
9
11
|
*/
|
|
10
12
|
export function startBroadband({ token, upstream }) {
|
|
11
13
|
const broadbandGroups = () => loadGroupsJoined().filter((g) => g.kind === "broadband" && g.leaderUrl);
|
|
12
|
-
if (!broadbandGroups().length) return;
|
|
13
14
|
const streamEnabled = (() => {
|
|
14
15
|
const v = process.env.MSLXDFF_BROADBAND_STREAM;
|
|
15
16
|
if (v === "0" || v === "false" || v === "off") return false;
|
|
@@ -91,7 +92,8 @@ export function startBroadband({ token, upstream }) {
|
|
|
91
92
|
hbTimer.unref();
|
|
92
93
|
const pollTimer = setInterval(doPoll, 1000);
|
|
93
94
|
pollTimer.unref();
|
|
94
|
-
|
|
95
|
+
const n = broadbandGroups().length;
|
|
96
|
+
console.log(n ? `broadband relay: heartbeat 30s + poll 1s for ${n} group(s) [poll mode]` : "broadband relay: idle (0 group) — waiting for a group join [poll mode]");
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
99
|
startBroadbandStream({ token, upstream, execAndPost, broadbandGroups });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// daemon 生命周期留痕:启动 / 信号 / 顶层异常 / 退出 / 心跳。
|
|
2
|
+
// 目的:daemon "神秘消失"时,daemon.log 能直接区分四种死法——
|
|
3
|
+
// 被信号杀(有 `SIGTERM received` 行)、崩溃(有 uncaughtException 行)、
|
|
4
|
+
// 被 SIGKILL/任务管理器杀(只有未送达的 exit 行 → 看最后一条 heartbeat 定位死亡时刻)、
|
|
5
|
+
// OOM 积累(heartbeat 的 rss 曲线)。
|
|
6
|
+
// 顶层异常只记录不退出(代理服务无持久状态,活着比干净退出更有价值;异常多为请求级漏网)。
|
|
7
|
+
import { appendEvent } from "../logs.js";
|
|
8
|
+
|
|
9
|
+
const HEARTBEAT_MS = Math.max(60_000, Number(process.env.MSLXDFF_HEARTBEAT_MS) || 30 * 60 * 1000);
|
|
10
|
+
|
|
11
|
+
function log(line) {
|
|
12
|
+
try { console.log(line); } catch {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 纯函数:启动行(可测)
|
|
16
|
+
export function lifecycleStartLine({ version, pid, ppid, node, platform, cwd }) {
|
|
17
|
+
return `[lifecycle] start pid=${pid} ppid=${ppid} version=${version} node=${node} platform=${platform} cwd=${cwd}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function lifecycleHeartbeatLine({ pid, uptimeMin, rssMb }) {
|
|
21
|
+
return `[lifecycle] heartbeat pid=${pid} uptime=${uptimeMin}m rss=${rssMb}mb`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function installLifecycleLog({ version, bus, logs } = {}) {
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
const rssMb = () => Math.round((process.memoryUsage?.().rss ?? 0) / 1048576);
|
|
27
|
+
const emit = (type, data) => {
|
|
28
|
+
try { bus?.emit({ ts: Date.now(), type, ...data }); } catch {}
|
|
29
|
+
try { logs?.appendEvent?.({ ts: Date.now(), type, ...data }); } catch {}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
log(lifecycleStartLine({
|
|
33
|
+
version,
|
|
34
|
+
pid: process.pid,
|
|
35
|
+
ppid: process.ppid,
|
|
36
|
+
node: process.version,
|
|
37
|
+
platform: process.platform,
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
}));
|
|
40
|
+
emit("lifecycle-start", { pid: process.pid, ppid: process.ppid, version, node: process.version });
|
|
41
|
+
|
|
42
|
+
process.on("uncaughtException", (e) => {
|
|
43
|
+
const detail = String(e?.stack || e?.message || e).slice(0, 2000);
|
|
44
|
+
log(`[lifecycle] uncaughtException (kept running): ${detail}`);
|
|
45
|
+
emit("lifecycle-uncaught", { detail: detail.slice(0, 500) });
|
|
46
|
+
});
|
|
47
|
+
process.on("unhandledRejection", (r) => {
|
|
48
|
+
const detail = String(r?.stack || r?.message || r).slice(0, 2000);
|
|
49
|
+
log(`[lifecycle] unhandledRejection (kept running): ${detail}`);
|
|
50
|
+
emit("lifecycle-unhandled-rejection", { detail: detail.slice(0, 500) });
|
|
51
|
+
});
|
|
52
|
+
process.on("exit", (code) => {
|
|
53
|
+
const uptimeSec = Math.round((Date.now() - t0) / 1000);
|
|
54
|
+
log(`[lifecycle] exit code=${code} uptime=${uptimeSec}s rss=${rssMb()}mb`);
|
|
55
|
+
});
|
|
56
|
+
process.on("SIGHUP", () => log(`[lifecycle] SIGHUP received (ignored, pid ${process.pid})`));
|
|
57
|
+
|
|
58
|
+
const hb = setInterval(() => {
|
|
59
|
+
// 心跳只落 daemon.log(不写 events.log:48 条/天,避免污染结构化事件流)
|
|
60
|
+
log(lifecycleHeartbeatLine({ pid: process.pid, uptimeMin: Math.round((Date.now() - t0) / 60000), rssMb: rssMb() }));
|
|
61
|
+
}, HEARTBEAT_MS);
|
|
62
|
+
hb.unref?.();
|
|
63
|
+
return { heartbeatMs: HEARTBEAT_MS };
|
|
64
|
+
}
|
|
@@ -66,7 +66,7 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
66
66
|
const msg = String(err?.message || err);
|
|
67
67
|
const code = err?.code || "";
|
|
68
68
|
if (code === "EADDRINUSE" || msg.includes("EADDRINUSE")) {
|
|
69
|
-
console.log(`port ${resolvePort()}
|
|
69
|
+
console.log(`[lifecycle] EADDRINUSE on port ${resolvePort()} — freeing stale holder and retrying...`);
|
|
70
70
|
try {
|
|
71
71
|
const { execFile } = await import("node:child_process");
|
|
72
72
|
const execAsync2 = (f, a) => new Promise((res) => execFile(f, a, { windowsHide: true, timeout: 4000 }, (e, so, se) => res({ e, so: String(so||""), se: String(se||"") })));
|
|
@@ -86,6 +86,8 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
86
86
|
while ((m2 = re2.exec(line))) pids.add(Number(m2[1]));
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
if (pids.size) console.log(`[lifecycle] killing stale holder(s): ${[...pids].join(",")} (self=${process.pid})`);
|
|
90
|
+
else console.log(`[lifecycle] no holder pid discoverable (ss/fuser unavailable on this platform?) — retry may fail`);
|
|
89
91
|
for (const p of pids) { if (p !== process.pid) try { process.kill(p, "SIGTERM"); } catch {} }
|
|
90
92
|
if (pids.size) await new Promise((r2) => setTimeout(r2, 600));
|
|
91
93
|
for (const p of pids) try { const { isPidAlive } = await import("../daemon.js"); if (isPidAlive(p)) process.kill(p, "SIGKILL"); } catch {}
|
|
@@ -147,7 +149,7 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
|
|
|
147
149
|
}
|
|
148
150
|
const addr = srv.server.address();
|
|
149
151
|
const host = addr.address === "0.0.0.0" || addr.address === "::" ? "localhost" : addr.address;
|
|
150
|
-
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port}`);
|
|
152
|
+
console.log(`mslxdff v${VERSION} listening on http://${host}:${addr.port} (pid ${process.pid})`);
|
|
151
153
|
if (created) {
|
|
152
154
|
console.log(`auth token: ${token}`);
|
|
153
155
|
}
|
package/src/server.js
CHANGED
|
@@ -30,8 +30,15 @@ export function startServer({ router, signals = true, host, onBeforeClose }, por
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
if (signals) {
|
|
33
|
-
process.
|
|
34
|
-
|
|
33
|
+
// 关停留痕(Linux 有效:可捕获 SIGTERM/SIGINT 并记录)。Windows 注意:process.kill 的
|
|
34
|
+
// SIGTERM 实为 TerminateProcess 强杀,被杀的 JS 进程收不到该事件,此处不会触发——
|
|
35
|
+
// Windows 的"谁杀了我"靠 src/daemon.js stopDaemon() 的杀者留痕 + 最后一条 heartbeat。
|
|
36
|
+
const onSignal = (sig) => {
|
|
37
|
+
try { console.log(`[lifecycle] ${sig} received — shutting down (pid ${process.pid}, uptime ${Math.round(process.uptime())}s)`); } catch {}
|
|
38
|
+
close();
|
|
39
|
+
};
|
|
40
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
41
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
return { server, ready, close };
|
|
@@ -39,7 +39,7 @@ function parseToolArgs(raw) {
|
|
|
39
39
|
try { return JSON.parse(String(raw)); } catch { return {}; }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export function toModelPrompt(messages) {
|
|
42
|
+
export function toModelPrompt(messages, { dropEncrypted = false } = {}) {
|
|
43
43
|
const out = [];
|
|
44
44
|
for (const m of Array.isArray(messages) ? messages : []) {
|
|
45
45
|
if (!m || typeof m !== "object") continue;
|
|
@@ -55,11 +55,17 @@ export function toModelPrompt(messages) {
|
|
|
55
55
|
// 无加密态时才退回纯文本 reasoning(chat 通道的 reasoning_content)。
|
|
56
56
|
// 空串是 payload 回填的有意标记(requiresReasoningContentOnAssistantMessages):所有 assistant
|
|
57
57
|
// 必须带该字段——AI SDK 以 length>0 判定是否输出,故用 " " 占位而非 ""。
|
|
58
|
+
// dropEncrypted:上游拒收跨 caller 的加密态时的降级重试——只留可读摘要(无摘要则整条跳过,
|
|
59
|
+
// 留给下面的明文 reasoning_content 兜底),不置 pushedEncrypted。
|
|
58
60
|
const items = Array.isArray(m.reasoning_items) ? m.reasoning_items : [];
|
|
59
61
|
let pushedEncrypted = false;
|
|
60
62
|
for (const r of items) {
|
|
61
63
|
if (!r || typeof r !== "object") continue;
|
|
62
64
|
const summaryText = Array.isArray(r.summary) ? r.summary.map((s) => s?.text || "").join("\n") : "";
|
|
65
|
+
if (dropEncrypted) {
|
|
66
|
+
if (summaryText) parts.push({ type: "reasoning", text: summaryText });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
63
69
|
parts.push({
|
|
64
70
|
type: "reasoning",
|
|
65
71
|
text: summaryText || " ",
|
|
@@ -8,6 +8,15 @@ import { ENGINE_MARKER } from "./chat.js";
|
|
|
8
8
|
|
|
9
9
|
export const RESPONSES_CHAT_PATH = "/zen/v1/responses";
|
|
10
10
|
|
|
11
|
+
// 降级阈值:加密思考块(encrypted_content)由上游按 caller(出口)签发,客户端跨出口回传
|
|
12
|
+
// (经组员/换代理/直连切换)会被拒 400 "reasoning `encrypted_content` was not issued to this caller"。
|
|
13
|
+
// 命中即剥掉加密态重试一次;仍失败原样返回,交上层转组员接力(保底不变差)。
|
|
14
|
+
const ENC_CALLER_400_RE = /encrypted_content[^"]*was not issued to this caller/i;
|
|
15
|
+
|
|
16
|
+
export function isEncryptedCallerError(text) {
|
|
17
|
+
return typeof text === "string" && ENC_CALLER_400_RE.test(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
// 上游 encrypted reasoning 只在 output_item.done 里给,而 AI SDK 仅在有 summary 文本时才透出到 parts
|
|
12
21
|
// (muse-spark 这类无 summary 的思考模型会被吞掉)。这里在 fetch 层 tee 一份原始 SSE 自行解析,
|
|
13
22
|
// 侧信道把加密思考交给序列化器,收尾帧补发。
|
|
@@ -104,20 +113,38 @@ export async function attemptOnceResponsesSdk({
|
|
|
104
113
|
},
|
|
105
114
|
};
|
|
106
115
|
let res;
|
|
116
|
+
let encRetry = false;
|
|
117
|
+
const streamOnce = (prompt) => model.doStream({
|
|
118
|
+
prompt,
|
|
119
|
+
...params,
|
|
120
|
+
providerOptions,
|
|
121
|
+
tools: toModelTools(body?.tools),
|
|
122
|
+
toolChoice: toModelToolChoice(body?.tool_choice),
|
|
123
|
+
});
|
|
107
124
|
try {
|
|
108
|
-
res = await
|
|
109
|
-
prompt: toModelPrompt(body?.messages),
|
|
110
|
-
...params,
|
|
111
|
-
providerOptions,
|
|
112
|
-
tools: toModelTools(body?.tools),
|
|
113
|
-
toolChoice: toModelToolChoice(body?.tool_choice),
|
|
114
|
-
});
|
|
125
|
+
res = await streamOnce(toModelPrompt(body?.messages));
|
|
115
126
|
} catch (e) {
|
|
116
|
-
|
|
127
|
+
let mapped = errorResponseFromSdkError(e, { marker });
|
|
128
|
+
if (mapped && mapped.status === 400) {
|
|
129
|
+
let txt = "";
|
|
130
|
+
try { txt = await mapped.clone().text(); } catch {}
|
|
131
|
+
if (isEncryptedCallerError(txt)) {
|
|
132
|
+
try {
|
|
133
|
+
res = await streamOnce(toModelPrompt(body?.messages, { dropEncrypted: true }));
|
|
134
|
+
mapped = null;
|
|
135
|
+
encRetry = true;
|
|
136
|
+
} catch (e2) {
|
|
137
|
+
const mapped2 = errorResponseFromSdkError(e2, { marker });
|
|
138
|
+
if (mapped2) return mapped2;
|
|
139
|
+
throw e2;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
117
143
|
if (mapped) return mapped;
|
|
118
|
-
throw e;
|
|
119
144
|
}
|
|
120
|
-
|
|
145
|
+
const out = streamResponseFromParts(res.stream, { marker, clock, t0, captured });
|
|
146
|
+
if (encRetry) { try { out._t.encRetry = true; } catch {} }
|
|
147
|
+
return out;
|
|
121
148
|
}
|
|
122
149
|
|
|
123
150
|
export function createSdkResponses({
|