agent-purge 0.2.10 → 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.
Files changed (75) hide show
  1. package/CONTRIBUTING.md +64 -0
  2. package/LICENSE +21 -21
  3. package/README.md +169 -138
  4. package/backends/index.mjs +204 -244
  5. package/bench/bench-armor.mjs +844 -832
  6. package/bench/capture-hot.mjs +58 -0
  7. package/bench/capture-provider.mjs +66 -0
  8. package/bench/capture-report.mjs +15 -0
  9. package/bench/capture-server-hot.mjs +55 -0
  10. package/bench/capture-system.mjs +36 -0
  11. package/bench/judge-armor.mjs +119 -119
  12. package/bin/agent-purge.mjs +906 -780
  13. package/docs/LEGAL.md +27 -0
  14. package/docs/architecture.md +39 -0
  15. package/docs/compatibility.md +78 -0
  16. package/docs/images/console-desktop.png +0 -0
  17. package/docs/images/console-mobile.png +0 -0
  18. package/docs/releasing.md +87 -0
  19. package/docs/usage.md +158 -0
  20. package/lib/activation.mjs +38 -0
  21. package/lib/apply.mjs +227 -0
  22. package/lib/armed.mjs +38 -38
  23. package/lib/armor.mjs +225 -222
  24. package/lib/caps.mjs +182 -173
  25. package/lib/capture.mjs +324 -0
  26. package/lib/clean.mjs +124 -0
  27. package/lib/control.mjs +60 -0
  28. package/lib/detect.mjs +74 -70
  29. package/lib/doctor.mjs +7 -7
  30. package/lib/dsh.mjs +495 -453
  31. package/lib/environment.mjs +36 -0
  32. package/lib/evidence.mjs +41 -0
  33. package/lib/extension.mjs +140 -0
  34. package/lib/footprints.mjs +59 -0
  35. package/lib/home.mjs +47 -47
  36. package/lib/launch-conflicts.mjs +24 -0
  37. package/lib/launch.mjs +57 -0
  38. package/lib/opencode.mjs +62 -0
  39. package/lib/paths.mjs +25 -0
  40. package/lib/persist.mjs +439 -280
  41. package/lib/personas.mjs +17 -7
  42. package/lib/private-server.mjs +54 -0
  43. package/lib/project.mjs +134 -0
  44. package/lib/restore.mjs +131 -114
  45. package/lib/rewrite.mjs +124 -0
  46. package/lib/root.mjs +37 -0
  47. package/lib/shim.mjs +203 -160
  48. package/lib/slashes.mjs +227 -628
  49. package/lib/spawn.mjs +182 -126
  50. package/lib/state.mjs +85 -137
  51. package/lib/templates/personas/neutral.md +30 -30
  52. package/lib/update.mjs +137 -0
  53. package/lib/verification.mjs +23 -0
  54. package/lib/window.mjs +58 -0
  55. package/lib/wrappers.mjs +49 -0
  56. package/magpie-plugin/README.md +44 -0
  57. package/matrix.json +325 -341
  58. package/package.json +30 -4
  59. package/web/app.js +525 -0
  60. package/web/assets/agent-purge.ico +0 -0
  61. package/web/assets/claude.ico +0 -0
  62. package/web/assets/codebuddy.png +0 -0
  63. package/web/assets/codex.webp +0 -0
  64. package/web/assets/dsh.ico +0 -0
  65. package/web/assets/grok.ico +0 -0
  66. package/web/assets/hermes.ico +0 -0
  67. package/web/assets/omp.png +0 -0
  68. package/web/assets/opencode.ico +0 -0
  69. package/web/assets/opencode2.ico +0 -0
  70. package/web/assets/pi.png +0 -0
  71. package/web/assets/zcode.png +0 -0
  72. package/web/model.mjs +49 -0
  73. package/web/server.mjs +309 -194
  74. package/web/styles.css +917 -0
  75. 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";import { readFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
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, patchState, setArmed, setDefault, setPersona, setPiAuto, setOmpAuto, setDshProfile } from "../lib/state.mjs";
10
- import { install, status as persistStatus, uninstall } from "../lib/persist.mjs";
11
- import { plans, summary as armorSummary } from "../lib/armor.mjs";
12
- import { installShim, shimStatus, uninstallShim } from "../lib/shim.mjs";
13
- import { installSlash, refreshSlashes, slashStatus, uninstallSlash } from "../lib/slashes.mjs";
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 = ensureDataHome(ROOT).home;
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 = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
31
- const store = createStore(DATA);
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: `服务端错误: ${e.message}` });
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)).on === true) });
70
- if (req.method === "POST" && p === "/api/omp-auto") return send(res, 200, { ompAuto: setOmpAuto(DATA, (await body(req)).on === true) });
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: m.override === true ? "yes" : m.override ? String(m.override) : "no",
244
+ override,
119
245
  inject: Boolean(m.inject),
120
246
  hotSwitch: Boolean(m.hotSwitch),
121
- chars: m.defaultSystemChars != null ? `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}` : null,
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: capsFor(id, { bin: d.bin ?? null }),
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: shimStatus()[0]?.dir ?? null,
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 saveDefaults(payload) {
182
- const { harness, mode } = payload;
183
- const spec = matrix.harnesses[harness];
184
- if (!spec) return { error: `未知 harness: ${harness}` };
185
- if (spec.class === "unsupported") return { error: `${harness} 做不到:${spec.reason}` };
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 = String(payload.profile || "").trim();
196
- if (!DSH_PROFILES.includes(profile)) return { error: `未知 profile: ${profile}(${DSH_PROFILES.join(" / ")})` };
197
- try {
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 = String(payload.id || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
206
- if (!id) return { error: "id 不能为空(小写字母/数字/-/_)" };
207
- if (!payload.body?.trim()) return { error: "正文不能为空" };
208
- try {
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
- return { ok: true, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
236
- } catch (e) {
237
- return { error: e.message };
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
- if (!id) return { error: "缺少 id" };
243
- if (store.list().length <= 1) return { error: "至少保留一个 persona" };
244
- if (!store.read(id)) return { error: "找不到这个 persona" };
245
- if (!store.remove(id)) return { error: "非法 persona id" };
246
- return { ok: true, active: store.activeId() };
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
- if (!payload.id) return { error: "缺少 id" };
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
- /** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
267
- function applyArmAll(payload) {
268
- if (!payload.action) return { error: "缺少 action" };
269
- const { action } = payload;
270
- const channel = payload.channel ?? "all";
271
- const verb = verbOf(action);
272
-
273
- // 全局总闸 = 电源,不只是注册表:撤的时候把 default.mode 置 off(记下 prevMode),
274
- // 否则 `agent-purge <h>` 这条入口永远注入 —— 用户实测“关了也能直接接管”就出在这。
275
- // 开的时候把上次的 mode 还回去。
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 (!perHarness.length) return { error: failed.join(";") || "没有可注册的 harness" };
299
- const after = loadState(DATA).state.default;
300
- return { ok: true, action: verb, channel, mode: after?.mode ?? null, harnesses: perHarness, failed };
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
- const { harness, action } = payload;
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
- return VERB_OFF.has(action) ? "off" : "on";
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
- if (!payload.action) return { error: "缺少 action" };
329
- const { harness, channel, action } = payload;
330
- const id = payload.persona ?? store.activeId();
331
- if (!store.read(id)) return { error: "没有可用 persona" };
332
- const verb = verbOf(action);
333
- const channels = !channel || channel === "all" ? ["install", "shim", "slash"] : [channel];
334
- const done = [];
335
- const skipped = [];
336
- for (const ch of channels) {
337
- const r = armChannel(harness, ch, verb, id);
338
- if (r.error) skipped.push(`${ch}: ${r.error}`);
339
- else done.push({ channel: ch, ...r });
340
- }
341
- if (!done.length) return { error: skipped.join(";") || "没有可用通道" };
342
- return { ok: true, harness, action: verb, done, skipped };
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 r = installSlash(harness, DATA, { personaId: id, personaBody: store.read(id).body.trim() });
375
- if (r.error) return { error: r.error };
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
- return { error: `未知通道: ${channel}` };
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.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(obj));
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(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
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
- let raw = "";
412
- let size = 0;
413
- let done = false;
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
- resolve(value);
525
+ chunks.length = 0;
526
+ if (error) reject(error); else resolve(value);
418
527
  };
419
- req.on("data", (c) => {
420
- size += c.length;
421
- // 本机也不该接无上限的 body:一个坏页面能靠大 POST 把内存吃光
422
- if (size > 2 * 1024 * 1024) {
423
- req.destroy();
424
- return finish({});
425
- }
426
- raw += c;
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.on("end", () => {
429
- try {
430
- finish(JSON.parse(raw || "{}"));
431
- } catch {
432
- finish({});
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
- const isEntry = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
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));