agent-purge 0.2.9 → 0.2.11
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/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +176 -118
- package/backends/index.mjs +204 -244
- package/bench/bench-armor.mjs +844 -832
- package/bench/capture-hot.mjs +58 -0
- package/bench/capture-provider.mjs +66 -0
- package/bench/capture-report.mjs +15 -0
- package/bench/capture-server-hot.mjs +55 -0
- package/bench/capture-system.mjs +36 -0
- package/bench/judge-armor.mjs +119 -119
- package/bin/agent-purge.mjs +906 -772
- package/docs/LEGAL.md +27 -0
- package/docs/architecture.md +39 -0
- package/docs/compatibility.md +78 -0
- package/docs/images/console-desktop.png +0 -0
- package/docs/images/console-mobile.png +0 -0
- package/docs/releasing.md +87 -0
- package/docs/usage.md +158 -0
- package/lib/activation.mjs +38 -0
- package/lib/apply.mjs +227 -0
- package/lib/armed.mjs +38 -38
- package/lib/armor.mjs +225 -222
- package/lib/caps.mjs +182 -173
- package/lib/capture.mjs +324 -0
- package/lib/clean.mjs +124 -0
- package/lib/control.mjs +60 -0
- package/lib/detect.mjs +74 -70
- package/lib/doctor.mjs +7 -7
- package/lib/dsh.mjs +495 -453
- package/lib/environment.mjs +36 -0
- package/lib/evidence.mjs +41 -0
- package/lib/extension.mjs +140 -0
- package/lib/footprints.mjs +59 -0
- package/lib/home.mjs +47 -47
- package/lib/launch-conflicts.mjs +24 -0
- package/lib/launch.mjs +57 -0
- package/lib/opencode.mjs +62 -0
- package/lib/paths.mjs +25 -0
- package/lib/persist.mjs +439 -280
- package/lib/personas.mjs +17 -7
- package/lib/private-server.mjs +54 -0
- package/lib/project.mjs +134 -0
- package/lib/restore.mjs +131 -114
- package/lib/rewrite.mjs +124 -0
- package/lib/root.mjs +37 -0
- package/lib/shim.mjs +203 -160
- package/lib/slashes.mjs +227 -628
- package/lib/spawn.mjs +182 -126
- package/lib/state.mjs +85 -137
- package/lib/templates/personas/neutral.md +30 -30
- package/lib/update.mjs +137 -0
- package/lib/verification.mjs +23 -0
- package/lib/window.mjs +58 -0
- package/lib/wrappers.mjs +49 -0
- package/magpie-plugin/README.md +44 -0
- package/matrix.json +325 -341
- package/package.json +30 -4
- package/web/app.js +525 -0
- package/web/assets/agent-purge.ico +0 -0
- package/web/assets/claude.ico +0 -0
- package/web/assets/codebuddy.png +0 -0
- package/web/assets/codex.webp +0 -0
- package/web/assets/dsh.ico +0 -0
- package/web/assets/grok.ico +0 -0
- package/web/assets/hermes.ico +0 -0
- package/web/assets/omp.png +0 -0
- package/web/assets/opencode.ico +0 -0
- package/web/assets/opencode2.ico +0 -0
- package/web/assets/pi.png +0 -0
- package/web/assets/zcode.png +0 -0
- package/web/model.mjs +49 -0
- package/web/server.mjs +309 -194
- package/web/styles.css +917 -0
- package/web/ui.html +64 -1163
package/web/server.mjs
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
// 零依赖 Web 服务:配置台。选默认 harness/mode、管理 persona、常驻安装、探测。
|
|
2
2
|
// 刻意不做"运行":交互式 harness 属于终端/桌面,Web 只负责选(见 ARCHITECTURE.md)。
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import {
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFileSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { detect } from "../lib/detect.mjs";
|
|
7
9
|
import { oneshots } from "../backends/index.mjs";
|
|
8
|
-
import { createStore } from "../lib/personas.mjs";
|
|
9
|
-
import { MODES, armedOf, dshProfilesOf, loadState,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
10
|
+
import { createStore, isValidId } from "../lib/personas.mjs";
|
|
11
|
+
import { MODES, ARMS, armedOf, dshProfilesOf, loadState, resolveFor, resolveActivation, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
|
|
12
|
+
import { withControlLock } from "../lib/control.mjs";
|
|
13
|
+
import { status as persistStatus } from "../lib/persist.mjs";
|
|
14
|
+
import { projectStatus, projectDirectories } from "../lib/project.mjs";
|
|
15
|
+
import { plans } from "../lib/armor.mjs";
|
|
16
|
+
import { shimStatus } from "../lib/shim.mjs";
|
|
17
|
+
import { slashStatus } from "../lib/slashes.mjs";
|
|
14
18
|
import { reconcileArmed } from "../lib/armed.mjs";
|
|
15
19
|
import { capsFor } from "../lib/caps.mjs";
|
|
16
|
-
import { ensureDataHome } from "../lib/home.mjs";
|
|
20
|
+
import { dataHome, ensureDataHome } from "../lib/home.mjs";
|
|
17
21
|
import { DSH_PROFILES } from "../lib/dsh.mjs";
|
|
22
|
+
import { applyConfiguration, changeChannel, configureHarness, disableHarness } from "../lib/apply.mjs";
|
|
23
|
+
import { ROOT, isCompiled, readResource } from "../lib/root.mjs";
|
|
24
|
+
import { currentVersion } from "../lib/update.mjs";
|
|
25
|
+
import { verificationEvidence } from "../lib/evidence.mjs";
|
|
18
26
|
|
|
19
|
-
const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
27
|
// 数据(state.json + personas/)在用户目录,不在安装目录
|
|
21
|
-
const DATA =
|
|
28
|
+
const DATA = dataHome();
|
|
22
29
|
// 运行期真正被改写的层(≠ bench 能测到的层):每家只有它自己暴露的那一个写入点。
|
|
23
30
|
// sys = 系统提示/配置文件/插件 splice;user = 只落在用户消息里(zcode 的 hook)。
|
|
24
31
|
const LAYER = {
|
|
@@ -27,15 +34,25 @@ const LAYER = {
|
|
|
27
34
|
// zcode 只有文档说它会读 AGENTS.md,桌面版闭源、无法复核,层就不claim(UI 显示“—”+「文档」)
|
|
28
35
|
zcode: "",
|
|
29
36
|
};
|
|
30
|
-
const matrix =
|
|
31
|
-
|
|
37
|
+
const matrix = (() => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readResource("matrix.json").toString("utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`无法加载执行器矩阵 matrix.json:${error.message}`, { cause: error });
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
let store = null;
|
|
45
|
+
// 同一次实测会拉起真 harness 子进程:同一家同时只能跑一份。
|
|
46
|
+
const verifying = new Set();
|
|
32
47
|
|
|
33
48
|
export function serveWeb(port = 7788) {
|
|
49
|
+
ensureDataHome(isCompiled() ? null : ROOT);
|
|
50
|
+
store ??= createStore(DATA);
|
|
34
51
|
// handle 是 async:任何异常都在这里收口,否则请求会挂着不响应
|
|
35
52
|
const server = createServer((req, res) => {
|
|
36
53
|
handle(req, res).catch((e) => {
|
|
37
54
|
try {
|
|
38
|
-
send(res, 500, { error:
|
|
55
|
+
send(res, e.statusCode ?? (/另一个配置操作|锁/.test(e.message) ? 409 : 500), { error: e.message });
|
|
39
56
|
} catch {}
|
|
40
57
|
});
|
|
41
58
|
});
|
|
@@ -50,11 +67,13 @@ export function serveWeb(port = 7788) {
|
|
|
50
67
|
process.exit(1);
|
|
51
68
|
});
|
|
52
69
|
server.listen(port, "127.0.0.1", () => {
|
|
53
|
-
console.log(`agent-purge web → http://127.0.0.1:${port}`);
|
|
70
|
+
console.log(`agent-purge web → http://127.0.0.1:${server.address().port}`);
|
|
54
71
|
});
|
|
72
|
+
return server;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
async function handle(req, res) {
|
|
76
|
+
if (!localHost(req)) return send(res, 403, { error: "只接受本机 Host" });
|
|
58
77
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
59
78
|
const p = url.pathname;
|
|
60
79
|
|
|
@@ -63,18 +82,118 @@ async function handle(req, res) {
|
|
|
63
82
|
if (isMutating(req.method) && !sameOrigin(req)) return send(res, 403, { error: "只接受本机同源请求" });
|
|
64
83
|
|
|
65
84
|
if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
|
|
85
|
+
const assets = { '/app.js': 'text/javascript', '/model.mjs': 'text/javascript', '/evidence.mjs': 'text/javascript', '/styles.css': 'text/css' };
|
|
86
|
+
if (req.method === "GET" && Object.hasOwn(assets, p)) {
|
|
87
|
+
res.writeHead(200, { 'content-type': `${assets[p]}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
88
|
+
return res.end(readResource(p === '/evidence.mjs' ? 'lib/evidence.mjs' : `web/${p.slice(1)}`));
|
|
89
|
+
}
|
|
90
|
+
// web/assets/ 下的真实品牌图标(png/jpg/webp/ico),固定白名单后缀 + 只认单层文件名,不走路径拼接。
|
|
91
|
+
if (req.method === "GET" && p.startsWith("/assets/")) {
|
|
92
|
+
const name = p.slice("/assets/".length);
|
|
93
|
+
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon' };
|
|
94
|
+
const ext = name.slice(name.lastIndexOf('.'));
|
|
95
|
+
if (!name.includes('/') && !name.includes('..') && Object.hasOwn(mime, ext)) {
|
|
96
|
+
try {
|
|
97
|
+
// 图标会被换(官方图源改了/我改错了),一律 no-store:本地工具不值得为 24h 缓存换来“改了看不到”。
|
|
98
|
+
res.writeHead(200, { 'content-type': mime[ext], 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
99
|
+
return res.end(readResource(`web/assets/${name}`));
|
|
100
|
+
} catch { /* 落到 404 */ }
|
|
101
|
+
}
|
|
102
|
+
return send(res, 404, { error: "not found" });
|
|
103
|
+
}
|
|
104
|
+
if (req.method === "GET" && p === "/api/verification") {
|
|
105
|
+
const file = join(DATA, 'verification', 'latest.json');
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(file)) return send(res, 200, { report: null });
|
|
108
|
+
const report = JSON.parse(readFileSync(file, 'utf8'));
|
|
109
|
+
if (!report || !Array.isArray(report.results)) throw new Error('缺少 results 数组');
|
|
110
|
+
return send(res, 200, { report });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return send(res, 500, { error: `验证报告读取失败:${error.message}` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
66
115
|
if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
|
|
67
116
|
if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
|
|
117
|
+
if (req.method === "POST" && p === "/api/apply") return send(res, 200, applyChanges(await body(req)));
|
|
118
|
+
if (req.method === "POST" && p === "/api/power") return send(res, 200, applyConfiguration(DATA, { enabled: booleanField(await body(req), "on") }));
|
|
119
|
+
if (req.method === "POST" && p === "/api/sync") { const payload = await body(req); return send(res, 200, applyConfiguration(DATA, { onlyFailed: booleanField(payload, "onlyFailed", false) })); }
|
|
68
120
|
if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
|
|
69
|
-
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)
|
|
70
|
-
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)
|
|
121
|
+
if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, booleanField(await body(req), "on")) });
|
|
122
|
+
if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, booleanField(await body(req), "on")) });
|
|
71
123
|
if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
|
|
72
124
|
if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
|
|
73
125
|
if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
|
|
126
|
+
if (req.method === "POST" && p === "/api/harness") return send(res, 200, saveHarnessConfig(await body(req)));
|
|
74
127
|
if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
|
|
75
128
|
if (req.method === "POST" && p === "/api/install") return send(res, 200, applyInstall(await body(req)));
|
|
76
129
|
if (req.method === "POST" && p === "/api/arm") return send(res, 200, applyArm(await body(req)));
|
|
77
130
|
if (req.method === "POST" && p === "/api/arm-all") return send(res, 200, applyArmAll(await body(req)));
|
|
131
|
+
if (req.method === "POST" && p === "/api/clean") {
|
|
132
|
+
await body(req);
|
|
133
|
+
const { clean } = await import("../lib/clean.mjs");
|
|
134
|
+
const resClean = clean(DATA, { all: false, dryRun: false });
|
|
135
|
+
return send(res, 200, resClean);
|
|
136
|
+
}
|
|
137
|
+
if (req.method === "GET" && p === "/api/update") {
|
|
138
|
+
const { checkForUpdate, canReplaceSelf } = await import("../lib/update.mjs");
|
|
139
|
+
const force = url.searchParams.get("force") === "1";
|
|
140
|
+
const cacheFile = join(DATA, "update-cache.json");
|
|
141
|
+
const info = await checkForUpdate({ force, cacheFile });
|
|
142
|
+
return send(res, 200, {
|
|
143
|
+
...info,
|
|
144
|
+
isExe: canReplaceSelf(),
|
|
145
|
+
execPath: process.execPath,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (req.method === "POST" && p === "/api/update-upgrade") {
|
|
149
|
+
await body(req);
|
|
150
|
+
const { checkForUpdate, downloadFile, replaceSelfExecutable, canReplaceSelf } = await import("../lib/update.mjs");
|
|
151
|
+
const info = await checkForUpdate({ force: true });
|
|
152
|
+
if (!info.ok || !info.hasUpdate) {
|
|
153
|
+
return send(res, 400, { error: info.error || "暂无可用的新版本" });
|
|
154
|
+
}
|
|
155
|
+
const isExe = canReplaceSelf();
|
|
156
|
+
const targetExe = info.assets?.find((a) => a.name.toLowerCase() === "agent-purge.exe");
|
|
157
|
+
const targetSetup = info.assets?.find((a) => a.name.toLowerCase().endsWith("-setup.exe"));
|
|
158
|
+
|
|
159
|
+
const { tmpdir } = await import("node:os");
|
|
160
|
+
if (isExe && targetExe) {
|
|
161
|
+
const tempFile = join(tmpdir(), `agent-purge-download-${randomUUID()}.exe`);
|
|
162
|
+
try {
|
|
163
|
+
await downloadFile(targetExe.downloadUrl, tempFile, undefined, { expectedSize: targetExe.size, digest: targetExe.digest });
|
|
164
|
+
replaceSelfExecutable(tempFile);
|
|
165
|
+
} finally { rmSync(tempFile, { force: true }); }
|
|
166
|
+
return send(res, 200, { ok: true, type: "exe", version: info.latest, message: "便携版 exe 已就地升级成功!下次启动即为新版本。" });
|
|
167
|
+
} else if (process.platform === "win32" && targetSetup) {
|
|
168
|
+
const tempSetup = join(tmpdir(), `agent-purge-setup-${randomUUID()}.exe`);
|
|
169
|
+
await downloadFile(targetSetup.downloadUrl, tempSetup, undefined, { expectedSize: targetSetup.size, digest: targetSetup.digest });
|
|
170
|
+
const { spawn } = await import("node:child_process");
|
|
171
|
+
spawn(tempSetup, [], { detached: true, stdio: "ignore" }).unref();
|
|
172
|
+
return send(res, 200, { ok: true, type: "setup", version: info.latest, message: "安装程序已启动,请按界面向导完成安装。" });
|
|
173
|
+
}
|
|
174
|
+
return send(res, 400, { error: "未在 Release 中找到适配 Windows 的升级安装文件,请前往页面手动下载。" });
|
|
175
|
+
}
|
|
176
|
+
if (req.method === "POST" && p === "/api/verify") {
|
|
177
|
+
const { harness } = await body(req);
|
|
178
|
+
if (!Object.hasOwn(matrix.harnesses ?? {}, harness)) return send(res, 400, { error: `未知 harness:${harness}` });
|
|
179
|
+
if (verifying.has(harness)) return send(res, 409, { error: `${harness} 正在实测,等它跑完` });
|
|
180
|
+
verifying.add(harness);
|
|
181
|
+
const cap = await import("../lib/capture.mjs");
|
|
182
|
+
try {
|
|
183
|
+
const mode = resolveActivation(loadState(DATA).state, harness, { launch: true }).mode;
|
|
184
|
+
const report = await cap.verifyHarnesses([harness], { root: DATA, mode, timeout: 60000 });
|
|
185
|
+
const r = report.harnesses[0] ?? {};
|
|
186
|
+
const roles = [...new Set((r.modes ?? []).flatMap(m => (m.requests ?? []).filter(q => q.kind === "main").flatMap(q => q.roles ?? [])))];
|
|
187
|
+
return send(res, 200, { harness, status: r.status, reason: r.reason ?? null, version: r.version ?? "",
|
|
188
|
+
scope: r.scope, assessment: verificationEvidence(r), roles, processFile: r.processFile,
|
|
189
|
+
injected: r.modes?.[0]?.injected ?? null, chars: r.modes?.[0]?.chars ?? 0 });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return send(res, 500, { error: `实测失败:${error.message}` });
|
|
192
|
+
} finally {
|
|
193
|
+
verifying.delete(harness);
|
|
194
|
+
cap.cleanupRoots();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
78
197
|
send(res, 404, { error: "not found" });
|
|
79
198
|
}
|
|
80
199
|
|
|
@@ -108,17 +227,24 @@ async function state(withVersions) {
|
|
|
108
227
|
const { state: st } = loadState(DATA);
|
|
109
228
|
const shims = shimStatus();
|
|
110
229
|
const slashes = slashStatus(DATA);
|
|
230
|
+
const projects = new Map(projectStatus(process.cwd(), DATA).map(r => [r.id, r]));
|
|
111
231
|
const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
|
|
112
232
|
const d = found[id] ?? {};
|
|
113
233
|
const inst = persisted.find((x) => x.name === id);
|
|
114
234
|
const armed = armedOf(st, id);
|
|
235
|
+
const eff = resolveFor(st, id);
|
|
236
|
+
const caps = capsFor(id, { bin: d.bin ?? null });
|
|
237
|
+
const activation = resolveActivation(st, id, { channel: armed.includes("install") ? "install" : "slash" });
|
|
238
|
+
let override = "no";
|
|
239
|
+
if (m.override === true) override = "yes";
|
|
240
|
+
else if (m.override) override = String(m.override);
|
|
115
241
|
return {
|
|
116
242
|
id,
|
|
117
243
|
class: m.class,
|
|
118
|
-
override
|
|
244
|
+
override,
|
|
119
245
|
inject: Boolean(m.inject),
|
|
120
246
|
hotSwitch: Boolean(m.hotSwitch),
|
|
121
|
-
chars: m.defaultSystemChars
|
|
247
|
+
chars: m.defaultSystemChars == null ? null : `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}`,
|
|
122
248
|
version: d.version ?? m.measuredVersion ?? null,
|
|
123
249
|
residue: m.residue ?? null,
|
|
124
250
|
reason: m.reason ?? null,
|
|
@@ -133,31 +259,53 @@ async function state(withVersions) {
|
|
|
133
259
|
installed: Boolean(inst?.installed),
|
|
134
260
|
persistFile: inst?.file ?? null,
|
|
135
261
|
armed,
|
|
262
|
+
// 这家实际生效的人设/模式 + 来源(override 还是跟随全局)
|
|
263
|
+
persona: eff.persona,
|
|
264
|
+
mode: eff.mode,
|
|
265
|
+
personaFrom: eff.personaFrom,
|
|
266
|
+
modeFrom: eff.modeFrom,
|
|
267
|
+
// 「本项目」按当前目录的选择显示;暂停撤块后仍可取消选择
|
|
268
|
+
projectOn: Boolean(projects.get(id)?.selected || projects.get(id)?.on),
|
|
269
|
+
projectFile: caps.project?.file ?? null,
|
|
270
|
+
projectPath: projects.get(id)?.file ?? null,
|
|
271
|
+
projectDirs: projectDirectories(DATA, id),
|
|
272
|
+
activation,
|
|
273
|
+
auto: id === "pi" ? st.piAuto === true : id === "omp" ? st.ompAuto === true : null,
|
|
274
|
+
profiles: id === "dsh" ? dshProfilesOf(st) ?? DSH_PROFILES : undefined,
|
|
136
275
|
shim: Boolean(shims.find((s) => s.id === id)?.installed),
|
|
137
276
|
slash: slashes.find((s) => s.id === id) ?? null,
|
|
138
277
|
// 每条通道「能不能用 + 为什么不能用」由 lib/caps.mjs 统一裁决:
|
|
139
278
|
// UI 只负责画,不再自己猜(以前 shim 硬编码可点,zcode 点了必然无效)
|
|
140
|
-
caps
|
|
279
|
+
caps,
|
|
141
280
|
};
|
|
142
281
|
});
|
|
143
282
|
const defaults = defaultsOf(st, harnesses);
|
|
144
283
|
return {
|
|
284
|
+
version: currentVersion(),
|
|
145
285
|
harnesses,
|
|
146
286
|
reconciled,
|
|
147
287
|
personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
|
|
148
288
|
active: store.activeId(),
|
|
149
289
|
persona: st.persona,
|
|
290
|
+
overrides: st.overrides ?? {},
|
|
150
291
|
defaults,
|
|
151
292
|
// false = state.json 里还没有默认(defaults 只是 UI 的建议值,裸敲 agent-purge 仍会提示“未设置”)
|
|
152
293
|
defaultsSet: Boolean(st.default?.harness),
|
|
153
294
|
armed: st.armed ?? {},
|
|
295
|
+
paused: st.default?.mode === "off",
|
|
296
|
+
prevMode: st.prevMode ?? 'replace',
|
|
297
|
+
pausedInstalls: st.pausedInstalls ?? [],
|
|
298
|
+
lastApply: st.lastApply ?? null,
|
|
154
299
|
// dsh 的三个 profile(desktop/web/headless);null 就是全开
|
|
155
300
|
dshProfiles: dshProfilesOf(st) ?? DSH_PROFILES,
|
|
156
301
|
dshAll: DSH_PROFILES,
|
|
302
|
+
// /purge off|mode 写进来的 profile 级运行时状态(暂停/覆盖模式),UI 要能看到,否则
|
|
303
|
+
// 「装了常驻却没生效」无从解释
|
|
304
|
+
dshRuntime: st.dshRuntime ?? {},
|
|
157
305
|
modes: MODES,
|
|
158
306
|
piAuto: st.piAuto === true,
|
|
159
307
|
ompAuto: st.ompAuto === true,
|
|
160
|
-
shimDir:
|
|
308
|
+
shimDir: shims[0]?.dir ?? null,
|
|
161
309
|
slashes: slashes,
|
|
162
310
|
};
|
|
163
311
|
}
|
|
@@ -178,34 +326,36 @@ function defaultsOf(st, harnesses) {
|
|
|
178
326
|
return { harness: pick?.id ?? null, mode };
|
|
179
327
|
}
|
|
180
328
|
|
|
181
|
-
function
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (mode && !MODES.includes(mode)) return { error: `mode 只能是 ${MODES.join(" / ")}` };
|
|
187
|
-
try {
|
|
188
|
-
return { ok: true, defaults: setDefault(DATA, { harness, mode: mode ?? "replace" }) };
|
|
189
|
-
} catch (e) {
|
|
190
|
-
return { error: e.message };
|
|
329
|
+
function applyChanges(payload) {
|
|
330
|
+
for (const key of Object.keys(payload)) if (!["harness", "persona", "mode", "resumeMode", "enabled"].includes(key)) throw httpError(400, `未知配置字段: ${key}`);
|
|
331
|
+
if (payload.harness !== undefined && payload.harness !== null) {
|
|
332
|
+
knownHarness(payload.harness);
|
|
333
|
+
if (matrix.harnesses[payload.harness].class === "unsupported") throw httpError(400, "默认执行器未开放启动入口");
|
|
191
334
|
}
|
|
335
|
+
if (payload.persona !== undefined && (typeof payload.persona !== "string" || !store.read(payload.persona))) throw httpError(400, "人设不存在或标识无效");
|
|
336
|
+
if (payload.mode !== undefined && !MODES.includes(payload.mode)) throw httpError(400, "模式无效");
|
|
337
|
+
if (payload.resumeMode !== undefined && !["strict", "replace", "append"].includes(payload.resumeMode)) throw httpError(400, "恢复模式无效");
|
|
338
|
+
if (payload.enabled !== undefined) booleanField(payload, "enabled");
|
|
339
|
+
return applyConfiguration(DATA, payload);
|
|
340
|
+
}
|
|
341
|
+
function saveDefaults(payload) {
|
|
342
|
+
const report = applyChanges(payload);
|
|
343
|
+
return { ...report, defaults: loadState(DATA).state.default };
|
|
192
344
|
}
|
|
193
345
|
|
|
194
346
|
function saveDshProfile(payload) {
|
|
195
|
-
const profile =
|
|
196
|
-
if (!DSH_PROFILES.includes(profile))
|
|
197
|
-
|
|
198
|
-
return { ok: true, dshProfiles: setDshProfile(DATA, profile, payload.on === true, DSH_PROFILES) };
|
|
199
|
-
} catch (e) {
|
|
200
|
-
return { error: e.message };
|
|
201
|
-
}
|
|
347
|
+
const profile = payload.profile;
|
|
348
|
+
if (!DSH_PROFILES.includes(profile)) throw httpError(400, `未知 profile: ${profile}`);
|
|
349
|
+
return { ok: true, dshProfiles: setDshProfile(DATA, profile, booleanField(payload, "on"), DSH_PROFILES) };
|
|
202
350
|
}
|
|
203
351
|
|
|
204
352
|
function savePersona(payload) {
|
|
205
|
-
const id =
|
|
206
|
-
if (!id)
|
|
207
|
-
if (!payload.body
|
|
208
|
-
|
|
353
|
+
const id = payload.id;
|
|
354
|
+
if (typeof id !== "string" || !isValidId(id) || id !== id.trim()) throw httpError(400, "人设标识无效");
|
|
355
|
+
if (typeof payload.body !== "string" || !payload.body.trim()) throw httpError(400, "正文应为非空文本");
|
|
356
|
+
if (payload.meta !== undefined && !plainObject(payload.meta)) throw httpError(400, "meta 应为对象");
|
|
357
|
+
if (payload.apply !== undefined) booleanField(payload, "apply");
|
|
358
|
+
return withControlLock(DATA, () => {
|
|
209
359
|
// 只覆盖表单带上来的键,其余 frontmatter(harness / order / description...)原样保留:
|
|
210
360
|
// 旧实现直接拿表单三个字段重写文件,点一次保存就把手写的键全吃掉
|
|
211
361
|
const meta = { ...(store.read(id)?.meta ?? {}) };
|
|
@@ -231,160 +381,119 @@ function savePersona(payload) {
|
|
|
231
381
|
if (list.length) meta.model = list;
|
|
232
382
|
else delete meta.model;
|
|
233
383
|
}
|
|
384
|
+
const current = store.activeId() === id;
|
|
385
|
+
const referenced = Object.values(loadState(DATA).state.overrides ?? {}).some(value => value?.persona === id);
|
|
234
386
|
const saved = store.save(id, { meta: { ...meta, id }, body: payload.body });
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
387
|
+
const applied = current || payload.apply === true ? applyConfiguration(DATA, { persona: id }) : referenced ? applyConfiguration(DATA) : null;
|
|
388
|
+
return { ok: applied?.ok ?? true, applied, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
|
|
389
|
+
});
|
|
239
390
|
}
|
|
240
391
|
|
|
241
392
|
function deletePersona(id) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
393
|
+
return withControlLock(DATA, () => {
|
|
394
|
+
if (typeof id !== "string" || !isValidId(id)) throw httpError(400, "人设标识无效");
|
|
395
|
+
if (!store.read(id)) throw httpError(400, "找不到这个人设");
|
|
396
|
+
if (store.list().length <= 1) throw httpError(409, "至少保留一个人设");
|
|
397
|
+
const references = Object.entries(loadState(DATA).state.overrides ?? {}).filter(([, value]) => value?.persona === id).map(([harness]) => harness);
|
|
398
|
+
if (references.length) throw httpError(409, `人设仍被引用:${references.join("、")},请先调整分家选择`);
|
|
399
|
+
const current = store.activeId() === id;
|
|
400
|
+
if (!store.remove(id)) throw httpError(400, "人设标识无效");
|
|
401
|
+
const applied = current ? applyConfiguration(DATA, { persona: store.activeId() }) : null;
|
|
402
|
+
return { ok: applied?.ok ?? true, active: store.activeId(), applied };
|
|
403
|
+
});
|
|
247
404
|
}
|
|
248
405
|
|
|
249
406
|
function usePersona(payload) {
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
store.setActive(payload.id);
|
|
253
|
-
// 两个指针必须一起写,否则 UI 选的“当前人设”和 `agent-purge` 实际用的不是同一个
|
|
254
|
-
setPersona(DATA, payload.id);
|
|
255
|
-
const { state: st } = loadState(DATA);
|
|
256
|
-
const slashTargets = Object.entries(st.armed ?? {})
|
|
257
|
-
.filter(([, a]) => a.includes("slash"))
|
|
258
|
-
.map(([k]) => k);
|
|
259
|
-
const refreshed = refreshSlashes(DATA, slashTargets, { personaId: payload.id, personaBody: store.read(payload.id).body.trim() });
|
|
260
|
-
return { ok: true, active: payload.id, refreshed };
|
|
261
|
-
} catch (e) {
|
|
262
|
-
return { error: e.message };
|
|
263
|
-
}
|
|
407
|
+
return { ...applyChanges({ persona: payload.id ?? "" }), active: store.activeId() };
|
|
264
408
|
}
|
|
265
409
|
|
|
266
|
-
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const { state: cur } = loadState(DATA);
|
|
277
|
-
const m = cur.default?.mode ?? "off";
|
|
278
|
-
if (verb === "off" && m !== "off" && cur.default?.harness) {
|
|
279
|
-
setDefault(DATA, { harness: cur.default.harness, mode: "off" });
|
|
280
|
-
patchState(DATA, { prevMode: m });
|
|
281
|
-
}
|
|
282
|
-
if (verb === "on" && m === "off" && cur.default?.harness) {
|
|
283
|
-
const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
|
|
284
|
-
setDefault(DATA, { harness: cur.default.harness, mode: back });
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const perHarness = [];
|
|
288
|
-
const failed = [];
|
|
289
|
-
for (const [h, spec] of Object.entries(matrix.harnesses)) {
|
|
290
|
-
if (spec.class === "unsupported") {
|
|
291
|
-
failed.push(`${h}: 做不到(${spec.reason})`);
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const r = applyArm({ harness: h, channel, action: verb, persona: payload.persona });
|
|
295
|
-
if (r.error) failed.push(`${h}: ${r.error}`);
|
|
296
|
-
else perHarness.push({ harness: h, done: (r.done ?? []).map((d) => d.channel), skipped: r.skipped ?? [] });
|
|
410
|
+
// 按执行器覆盖人设/模式:值传 null / "" / "follow" = 恢复跟随全局。
|
|
411
|
+
function saveHarnessConfig(payload) {
|
|
412
|
+
const id = knownHarness(payload.harness);
|
|
413
|
+
const patch = {};
|
|
414
|
+
for (const key of Object.keys(payload)) if (!["harness", "persona", "mode"].includes(key)) throw httpError(400, `未知分家字段: ${key}`);
|
|
415
|
+
for (const key of ["persona", "mode"]) {
|
|
416
|
+
if (!Object.hasOwn(payload, key)) continue;
|
|
417
|
+
const value = payload[key];
|
|
418
|
+
if (value !== null && typeof value !== "string") throw httpError(400, `${key} 应为文本或 null`);
|
|
419
|
+
patch[key] = value === null || value === "" || value === "follow" ? null : value;
|
|
297
420
|
}
|
|
298
|
-
if (!
|
|
299
|
-
|
|
300
|
-
|
|
421
|
+
if (!Object.keys(patch).length) throw httpError(400, "缺少分家配置字段");
|
|
422
|
+
if (patch.persona && !store.read(patch.persona)) throw httpError(400, "人设不存在");
|
|
423
|
+
if (patch.mode && !MODES.includes(patch.mode)) throw httpError(400, "模式无效");
|
|
424
|
+
const applied = configureHarness(DATA, id, patch);
|
|
425
|
+
return { ok: applied.ok, applied, overrides: loadState(DATA).state.overrides ?? {}, effective: applied.effective };
|
|
301
426
|
}
|
|
302
427
|
|
|
428
|
+
// 兼容旧客户端的全局开关:只暂停/恢复原范围,不再一键全量注册。
|
|
429
|
+
function applyArmAll(payload) {
|
|
430
|
+
return applyConfiguration(DATA, { enabled: verbOf(payload.action) === "on" });
|
|
431
|
+
}
|
|
303
432
|
function applyInstall(payload) {
|
|
304
|
-
|
|
305
|
-
if (action === "uninstall") {
|
|
306
|
-
const r = uninstall(harness);
|
|
307
|
-
setArmed(DATA, harness, "install", false);
|
|
308
|
-
return { ok: true, result: r };
|
|
309
|
-
}
|
|
310
|
-
// 装谁就把谁设为 active:active.md 与 install 指向始终是同一份正文
|
|
311
|
-
const wanted = payload.persona ?? store.activeId();
|
|
312
|
-
if (!store.read(wanted)) return { error: "没有可用 persona" };
|
|
313
|
-
store.setActive(wanted);
|
|
314
|
-
const r = install(harness, { root: DATA, active: store.refreshActive() });
|
|
315
|
-
if (r.error) return { error: r.error };
|
|
316
|
-
setArmed(DATA, harness, "install", true);
|
|
317
|
-
return { ok: true, result: r };
|
|
433
|
+
return changeChannel(DATA, { harness: knownHarness(payload.harness), channel: "install", enabled: verbOf(payload.action) === "on" });
|
|
318
434
|
}
|
|
319
|
-
|
|
320
|
-
/** UI 传 install/uninstall、CLI 传 on/off —— 统一成 on/off,否则 uninstall 会被当成安装 */
|
|
321
435
|
const VERB_OFF = new Set(["off", "uninstall", "remove", "stop", "false", false, 0]);
|
|
436
|
+
const VERB_ON = new Set(["on", "install", "add", "start", "true", true, 1]);
|
|
322
437
|
function verbOf(action) {
|
|
323
|
-
|
|
438
|
+
if (VERB_OFF.has(action)) return "off";
|
|
439
|
+
if (VERB_ON.has(action)) return "on";
|
|
440
|
+
throw httpError(400, "action 应明确指定 on 或 off");
|
|
324
441
|
}
|
|
325
|
-
|
|
326
|
-
/** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
|
|
327
442
|
function applyArm(payload) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function armChannel(harness, channel, verb, id) {
|
|
346
|
-
// 开之前先问能力表:这条通道在这家身上本来就无效的,不写任何文件、直接说明原因
|
|
347
|
-
if (verb === "on") {
|
|
348
|
-
const cap = capsFor(harness)[channel];
|
|
349
|
-
if (cap && !cap.ok) return { error: cap.why };
|
|
350
|
-
}
|
|
351
|
-
if (channel === "install") {
|
|
352
|
-
const r = verb === "off" ? applyInstall({ harness, action: "uninstall" }) : applyInstall({ harness, action: "install", persona: id });
|
|
353
|
-
return r.error ? { error: r.error } : (r.result ?? { ok: true });
|
|
354
|
-
}
|
|
355
|
-
if (channel === "shim") {
|
|
356
|
-
if (verb === "off") {
|
|
357
|
-
const r = uninstallShim(DATA, harness);
|
|
358
|
-
if (r.error) return { error: r.error };
|
|
359
|
-
setArmed(DATA, harness, "shim", false);
|
|
360
|
-
return { removed: r.removed };
|
|
361
|
-
}
|
|
362
|
-
const r = installShim(DATA, harness);
|
|
363
|
-
if (r.error) return { error: r.error };
|
|
364
|
-
setArmed(DATA, harness, "shim", true);
|
|
365
|
-
return { file: r.file, real: r.real };
|
|
366
|
-
}
|
|
367
|
-
if (channel === "slash") {
|
|
368
|
-
if (verb === "off") {
|
|
369
|
-
const r = uninstallSlash(harness, DATA);
|
|
370
|
-
if (r.error) return { error: r.error };
|
|
371
|
-
setArmed(DATA, harness, "slash", false);
|
|
372
|
-
return { removed: r.removed };
|
|
443
|
+
knownHarness(payload.harness);
|
|
444
|
+
if (payload.channel !== undefined && ![...ARMS, "auto", "all"].includes(payload.channel)) throw httpError(400, "通道无效");
|
|
445
|
+
const on = verbOf(payload.action) === "on";
|
|
446
|
+
return withControlLock(DATA, () => {
|
|
447
|
+
// auto:开关式语义。开 = 挑这个执行器最省心的可用通道;关 = 只撤它真正装着的那几条。
|
|
448
|
+
if (payload.channel === "auto") {
|
|
449
|
+
const { state: st } = loadState(DATA);
|
|
450
|
+
const current = st.armed?.[payload.harness] ?? [];
|
|
451
|
+
if (on) {
|
|
452
|
+
const caps = capsFor(payload.harness, { bin: detect()[payload.harness]?.bin ?? null });
|
|
453
|
+
const pick = ["install", "slash", "shim"].find(channel => caps[channel]?.ok);
|
|
454
|
+
if (!pick) return { error: "这个执行器暂时没有可用的接管方式,展开细节看原因。" };
|
|
455
|
+
// 已经是这个通道就什么都不做,开关是幂等的
|
|
456
|
+
if (current.includes(pick)) return { ok: true, results: [], channel: pick, done: [], skipped: [] };
|
|
457
|
+
const result = changeChannel(DATA, { harness: payload.harness, channel: pick, enabled: true });
|
|
458
|
+
return { ok: result.ok, results: [result], channel: pick, done: result.ok ? [result] : [], skipped: result.ok ? [] : [result.error] };
|
|
373
459
|
}
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
setArmed(DATA, harness, "slash", true);
|
|
377
|
-
return { file: r.file, layer: r.layer };
|
|
460
|
+
const result = disableHarness(DATA, payload.harness);
|
|
461
|
+
return { ...result, channel: null, done: result.results.filter(r => r.ok), skipped: result.results.filter(r => !r.ok).map(r => r.error) };
|
|
378
462
|
}
|
|
379
|
-
|
|
463
|
+
const channels = !payload.channel || payload.channel === "all" ? ["install", "shim", "slash"] : [payload.channel];
|
|
464
|
+
const results = channels.map(channel => changeChannel(DATA, { harness: payload.harness, channel, enabled: on, ...(channel === "project" ? { dir: process.cwd() } : {}) }));
|
|
465
|
+
return { ok: results.every(r => r.ok), results, done: results.filter(r => r.ok), skipped: results.filter(r => !r.ok).map(r => r.error) };
|
|
466
|
+
});
|
|
380
467
|
}
|
|
381
468
|
|
|
382
|
-
const send = (res, code, obj) =>
|
|
383
|
-
res.
|
|
469
|
+
const send = (res, code, obj) => {
|
|
470
|
+
if (res.destroyed || res.writableEnded) return;
|
|
471
|
+
res.writeHead(code === 200 && obj?.error ? 400 : code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }).end(JSON.stringify(obj));
|
|
472
|
+
};
|
|
473
|
+
const plainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
474
|
+
const httpError = (statusCode, message) => Object.assign(new Error(message), { statusCode });
|
|
475
|
+
function booleanField(payload, key, fallback) {
|
|
476
|
+
if (!Object.hasOwn(payload, key) && fallback !== undefined) return fallback;
|
|
477
|
+
if (typeof payload[key] !== "boolean") throw httpError(400, `${key} 应为布尔值`);
|
|
478
|
+
return payload[key];
|
|
479
|
+
}
|
|
480
|
+
function knownHarness(id) {
|
|
481
|
+
if (typeof id !== "string" || !Object.hasOwn(matrix.harnesses, id)) throw httpError(400, "未知执行器");
|
|
482
|
+
return id;
|
|
483
|
+
}
|
|
484
|
+
function localHost(req) {
|
|
485
|
+
const host = req.headers.host;
|
|
486
|
+
if (typeof host !== "string") return false;
|
|
487
|
+
try {
|
|
488
|
+
const url = new URL(`http://${host}`);
|
|
489
|
+
return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && !url.username && !url.password &&
|
|
490
|
+
url.host === host.toLowerCase() && Number(url.port || 80) === req.socket.localPort;
|
|
491
|
+
} catch { return false; }
|
|
492
|
+
}
|
|
384
493
|
const html = (res) =>
|
|
385
494
|
res
|
|
386
|
-
.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
387
|
-
.end(
|
|
495
|
+
.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" })
|
|
496
|
+
.end(readResource("web/ui.html").toString("utf8"));
|
|
388
497
|
|
|
389
498
|
function isMutating(method) {
|
|
390
499
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
@@ -400,43 +509,49 @@ function sameOrigin(req) {
|
|
|
400
509
|
try {
|
|
401
510
|
const u = new URL(origin);
|
|
402
511
|
const loopback = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1" || u.hostname === "[::1]";
|
|
403
|
-
return loopback && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
|
|
512
|
+
return loopback && u.protocol === "http:" && u.origin === origin && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
|
|
404
513
|
} catch {
|
|
405
514
|
return false;
|
|
406
515
|
}
|
|
407
516
|
}
|
|
408
517
|
|
|
409
518
|
function body(req) {
|
|
410
|
-
return new Promise((resolve) => {
|
|
411
|
-
|
|
412
|
-
let size = 0;
|
|
413
|
-
|
|
414
|
-
const finish = (value) => {
|
|
519
|
+
return new Promise((resolve, reject) => {
|
|
520
|
+
const chunks = [];
|
|
521
|
+
let size = 0, done = false;
|
|
522
|
+
const finish = (error, value) => {
|
|
415
523
|
if (done) return;
|
|
416
524
|
done = true;
|
|
417
|
-
|
|
525
|
+
chunks.length = 0;
|
|
526
|
+
if (error) reject(error); else resolve(value);
|
|
418
527
|
};
|
|
419
|
-
req.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
528
|
+
const contentType = req.headers["content-type"];
|
|
529
|
+
if (contentType && !/^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(contentType)) {
|
|
530
|
+
req.resume(); finish(httpError(415, "请求应为 JSON")); return;
|
|
531
|
+
}
|
|
532
|
+
req.on("data", chunk => {
|
|
533
|
+
if (done) return;
|
|
534
|
+
size += chunk.length;
|
|
535
|
+
if (size > 2 * 1024 * 1024) { finish(httpError(413, "请求超过 2 MiB")); return; }
|
|
536
|
+
chunks.push(chunk);
|
|
427
537
|
});
|
|
428
|
-
req.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
538
|
+
req.once("end", () => {
|
|
539
|
+
if (done) return;
|
|
540
|
+
let value;
|
|
541
|
+
try { value = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
|
|
542
|
+
catch { finish(httpError(400, "JSON 格式无效")); return; }
|
|
543
|
+
if (!plainObject(value)) { finish(httpError(400, "JSON 应为对象")); return; }
|
|
544
|
+
finish(null, value);
|
|
434
545
|
});
|
|
546
|
+
req.once("aborted", () => finish(httpError(400, "请求已中断")));
|
|
547
|
+
req.once("error", error => finish(httpError(400, `请求读取失败:${error.message}`)));
|
|
435
548
|
});
|
|
436
549
|
}
|
|
437
550
|
|
|
438
551
|
// 只有被直接当脚本跑(node web/server.mjs --serve 7788)才自启动;
|
|
439
552
|
// 被 bin 导入时必须保持安静——否则 `agent-purge web --serve 7789` 会两个端口各起一个实例,
|
|
440
553
|
// 并在第二个 bind 时吐出误导性的“端口已被占用:老实例还在跑”。
|
|
441
|
-
|
|
554
|
+
// 编译态 import.meta.url 也是 exe 路径,光比路径会把 exe 误当入口:只认 .mjs 脚本。
|
|
555
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
556
|
+
const isEntry = Boolean(process.argv[1]) && selfPath.endsWith(".mjs") && resolve(process.argv[1]) === selfPath;
|
|
442
557
|
if (isEntry && process.argv.includes("--serve")) serveWeb(Number(process.argv[process.argv.indexOf("--serve") + 1] ?? 7788));
|