agent-purge 0.2.10 → 0.2.12

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 (76) 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 +956 -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/persona-import.mjs +351 -0
  42. package/lib/personas.mjs +17 -7
  43. package/lib/private-server.mjs +54 -0
  44. package/lib/project.mjs +134 -0
  45. package/lib/restore.mjs +131 -114
  46. package/lib/rewrite.mjs +124 -0
  47. package/lib/root.mjs +37 -0
  48. package/lib/shim.mjs +203 -160
  49. package/lib/slashes.mjs +227 -628
  50. package/lib/spawn.mjs +182 -126
  51. package/lib/state.mjs +85 -137
  52. package/lib/templates/personas/neutral.md +30 -30
  53. package/lib/update.mjs +137 -0
  54. package/lib/verification.mjs +23 -0
  55. package/lib/window.mjs +58 -0
  56. package/lib/wrappers.mjs +49 -0
  57. package/magpie-plugin/README.md +44 -0
  58. package/matrix.json +325 -341
  59. package/package.json +30 -4
  60. package/web/app.js +590 -0
  61. package/web/assets/agent-purge.ico +0 -0
  62. package/web/assets/claude.ico +0 -0
  63. package/web/assets/codebuddy.png +0 -0
  64. package/web/assets/codex.webp +0 -0
  65. package/web/assets/dsh.ico +0 -0
  66. package/web/assets/grok.ico +0 -0
  67. package/web/assets/hermes.ico +0 -0
  68. package/web/assets/omp.png +0 -0
  69. package/web/assets/opencode.ico +0 -0
  70. package/web/assets/opencode2.ico +0 -0
  71. package/web/assets/pi.png +0 -0
  72. package/web/assets/zcode.png +0 -0
  73. package/web/model.mjs +49 -0
  74. package/web/server.mjs +330 -191
  75. package/web/styles.css +1072 -0
  76. package/web/ui.html +76 -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,119 @@ 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)));
125
+ if (req.method === "POST" && p === "/api/persona/import") return send(res, 200, await handleImportPersonas(await body(req)));
73
126
  if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
127
+ if (req.method === "POST" && p === "/api/harness") return send(res, 200, saveHarnessConfig(await body(req)));
74
128
  if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
75
129
  if (req.method === "POST" && p === "/api/install") return send(res, 200, applyInstall(await body(req)));
76
130
  if (req.method === "POST" && p === "/api/arm") return send(res, 200, applyArm(await body(req)));
77
131
  if (req.method === "POST" && p === "/api/arm-all") return send(res, 200, applyArmAll(await body(req)));
132
+ if (req.method === "POST" && p === "/api/clean") {
133
+ await body(req);
134
+ const { clean } = await import("../lib/clean.mjs");
135
+ const resClean = clean(DATA, { all: false, dryRun: false });
136
+ return send(res, 200, resClean);
137
+ }
138
+ if (req.method === "GET" && p === "/api/update") {
139
+ const { checkForUpdate, canReplaceSelf } = await import("../lib/update.mjs");
140
+ const force = url.searchParams.get("force") === "1";
141
+ const cacheFile = join(DATA, "update-cache.json");
142
+ const info = await checkForUpdate({ force, cacheFile });
143
+ return send(res, 200, {
144
+ ...info,
145
+ isExe: canReplaceSelf(),
146
+ execPath: process.execPath,
147
+ });
148
+ }
149
+ if (req.method === "POST" && p === "/api/update-upgrade") {
150
+ await body(req);
151
+ const { checkForUpdate, downloadFile, replaceSelfExecutable, canReplaceSelf } = await import("../lib/update.mjs");
152
+ const info = await checkForUpdate({ force: true });
153
+ if (!info.ok || !info.hasUpdate) {
154
+ return send(res, 400, { error: info.error || "暂无可用的新版本" });
155
+ }
156
+ const isExe = canReplaceSelf();
157
+ const targetExe = info.assets?.find((a) => a.name.toLowerCase() === "agent-purge.exe");
158
+ const targetSetup = info.assets?.find((a) => a.name.toLowerCase().endsWith("-setup.exe"));
159
+
160
+ const { tmpdir } = await import("node:os");
161
+ if (isExe && targetExe) {
162
+ const tempFile = join(tmpdir(), `agent-purge-download-${randomUUID()}.exe`);
163
+ try {
164
+ await downloadFile(targetExe.downloadUrl, tempFile, undefined, { expectedSize: targetExe.size, digest: targetExe.digest });
165
+ replaceSelfExecutable(tempFile);
166
+ } finally { rmSync(tempFile, { force: true }); }
167
+ return send(res, 200, { ok: true, type: "exe", version: info.latest, message: "便携版 exe 已就地升级成功!下次启动即为新版本。" });
168
+ } else if (process.platform === "win32" && targetSetup) {
169
+ const tempSetup = join(tmpdir(), `agent-purge-setup-${randomUUID()}.exe`);
170
+ await downloadFile(targetSetup.downloadUrl, tempSetup, undefined, { expectedSize: targetSetup.size, digest: targetSetup.digest });
171
+ const { spawn } = await import("node:child_process");
172
+ spawn(tempSetup, [], { detached: true, stdio: "ignore" }).unref();
173
+ return send(res, 200, { ok: true, type: "setup", version: info.latest, message: "安装程序已启动,请按界面向导完成安装。" });
174
+ }
175
+ return send(res, 400, { error: "未在 Release 中找到适配 Windows 的升级安装文件,请前往页面手动下载。" });
176
+ }
177
+ if (req.method === "POST" && p === "/api/verify") {
178
+ const { harness } = await body(req);
179
+ if (!Object.hasOwn(matrix.harnesses ?? {}, harness)) return send(res, 400, { error: `未知 harness:${harness}` });
180
+ if (verifying.has(harness)) return send(res, 409, { error: `${harness} 正在实测,等它跑完` });
181
+ verifying.add(harness);
182
+ const cap = await import("../lib/capture.mjs");
183
+ try {
184
+ const mode = resolveActivation(loadState(DATA).state, harness, { launch: true }).mode;
185
+ const report = await cap.verifyHarnesses([harness], { root: DATA, mode, timeout: 60000 });
186
+ const r = report.harnesses[0] ?? {};
187
+ const roles = [...new Set((r.modes ?? []).flatMap(m => (m.requests ?? []).filter(q => q.kind === "main").flatMap(q => q.roles ?? [])))];
188
+ return send(res, 200, { harness, status: r.status, reason: r.reason ?? null, version: r.version ?? "",
189
+ scope: r.scope, assessment: verificationEvidence(r), roles, processFile: r.processFile,
190
+ injected: r.modes?.[0]?.injected ?? null, chars: r.modes?.[0]?.chars ?? 0 });
191
+ } catch (error) {
192
+ return send(res, 500, { error: `实测失败:${error.message}` });
193
+ } finally {
194
+ verifying.delete(harness);
195
+ cap.cleanupRoots();
196
+ }
197
+ }
78
198
  send(res, 404, { error: "not found" });
79
199
  }
80
200
 
@@ -108,17 +228,24 @@ async function state(withVersions) {
108
228
  const { state: st } = loadState(DATA);
109
229
  const shims = shimStatus();
110
230
  const slashes = slashStatus(DATA);
231
+ const projects = new Map(projectStatus(process.cwd(), DATA).map(r => [r.id, r]));
111
232
  const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
112
233
  const d = found[id] ?? {};
113
234
  const inst = persisted.find((x) => x.name === id);
114
235
  const armed = armedOf(st, id);
236
+ const eff = resolveFor(st, id);
237
+ const caps = capsFor(id, { bin: d.bin ?? null });
238
+ const activation = resolveActivation(st, id, { channel: armed.includes("install") ? "install" : "slash" });
239
+ let override = "no";
240
+ if (m.override === true) override = "yes";
241
+ else if (m.override) override = String(m.override);
115
242
  return {
116
243
  id,
117
244
  class: m.class,
118
- override: m.override === true ? "yes" : m.override ? String(m.override) : "no",
245
+ override,
119
246
  inject: Boolean(m.inject),
120
247
  hotSwitch: Boolean(m.hotSwitch),
121
- chars: m.defaultSystemChars != null ? `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}` : null,
248
+ chars: m.defaultSystemChars == null ? null : `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}`,
122
249
  version: d.version ?? m.measuredVersion ?? null,
123
250
  residue: m.residue ?? null,
124
251
  reason: m.reason ?? null,
@@ -133,31 +260,53 @@ async function state(withVersions) {
133
260
  installed: Boolean(inst?.installed),
134
261
  persistFile: inst?.file ?? null,
135
262
  armed,
263
+ // 这家实际生效的人设/模式 + 来源(override 还是跟随全局)
264
+ persona: eff.persona,
265
+ mode: eff.mode,
266
+ personaFrom: eff.personaFrom,
267
+ modeFrom: eff.modeFrom,
268
+ // 「本项目」按当前目录的选择显示;暂停撤块后仍可取消选择
269
+ projectOn: Boolean(projects.get(id)?.selected || projects.get(id)?.on),
270
+ projectFile: caps.project?.file ?? null,
271
+ projectPath: projects.get(id)?.file ?? null,
272
+ projectDirs: projectDirectories(DATA, id),
273
+ activation,
274
+ auto: id === "pi" ? st.piAuto === true : id === "omp" ? st.ompAuto === true : null,
275
+ profiles: id === "dsh" ? dshProfilesOf(st) ?? DSH_PROFILES : undefined,
136
276
  shim: Boolean(shims.find((s) => s.id === id)?.installed),
137
277
  slash: slashes.find((s) => s.id === id) ?? null,
138
278
  // 每条通道「能不能用 + 为什么不能用」由 lib/caps.mjs 统一裁决:
139
279
  // UI 只负责画,不再自己猜(以前 shim 硬编码可点,zcode 点了必然无效)
140
- caps: capsFor(id, { bin: d.bin ?? null }),
280
+ caps,
141
281
  };
142
282
  });
143
283
  const defaults = defaultsOf(st, harnesses);
144
284
  return {
285
+ version: currentVersion(),
145
286
  harnesses,
146
287
  reconciled,
147
288
  personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
148
289
  active: store.activeId(),
149
290
  persona: st.persona,
291
+ overrides: st.overrides ?? {},
150
292
  defaults,
151
293
  // false = state.json 里还没有默认(defaults 只是 UI 的建议值,裸敲 agent-purge 仍会提示“未设置”)
152
294
  defaultsSet: Boolean(st.default?.harness),
153
295
  armed: st.armed ?? {},
296
+ paused: st.default?.mode === "off",
297
+ prevMode: st.prevMode ?? 'replace',
298
+ pausedInstalls: st.pausedInstalls ?? [],
299
+ lastApply: st.lastApply ?? null,
154
300
  // dsh 的三个 profile(desktop/web/headless);null 就是全开
155
301
  dshProfiles: dshProfilesOf(st) ?? DSH_PROFILES,
156
302
  dshAll: DSH_PROFILES,
303
+ // /purge off|mode 写进来的 profile 级运行时状态(暂停/覆盖模式),UI 要能看到,否则
304
+ // 「装了常驻却没生效」无从解释
305
+ dshRuntime: st.dshRuntime ?? {},
157
306
  modes: MODES,
158
307
  piAuto: st.piAuto === true,
159
308
  ompAuto: st.ompAuto === true,
160
- shimDir: shimStatus()[0]?.dir ?? null,
309
+ shimDir: shims[0]?.dir ?? null,
161
310
  slashes: slashes,
162
311
  };
163
312
  }
@@ -178,34 +327,36 @@ function defaultsOf(st, harnesses) {
178
327
  return { harness: pick?.id ?? null, mode };
179
328
  }
180
329
 
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 };
330
+ function applyChanges(payload) {
331
+ for (const key of Object.keys(payload)) if (!["harness", "persona", "mode", "resumeMode", "enabled"].includes(key)) throw httpError(400, `未知配置字段: ${key}`);
332
+ if (payload.harness !== undefined && payload.harness !== null) {
333
+ knownHarness(payload.harness);
334
+ if (matrix.harnesses[payload.harness].class === "unsupported") throw httpError(400, "默认执行器未开放启动入口");
191
335
  }
336
+ if (payload.persona !== undefined && (typeof payload.persona !== "string" || !store.read(payload.persona))) throw httpError(400, "人设不存在或标识无效");
337
+ if (payload.mode !== undefined && !MODES.includes(payload.mode)) throw httpError(400, "模式无效");
338
+ if (payload.resumeMode !== undefined && !["strict", "replace", "append"].includes(payload.resumeMode)) throw httpError(400, "恢复模式无效");
339
+ if (payload.enabled !== undefined) booleanField(payload, "enabled");
340
+ return applyConfiguration(DATA, payload);
341
+ }
342
+ function saveDefaults(payload) {
343
+ const report = applyChanges(payload);
344
+ return { ...report, defaults: loadState(DATA).state.default };
192
345
  }
193
346
 
194
347
  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
- }
348
+ const profile = payload.profile;
349
+ if (!DSH_PROFILES.includes(profile)) throw httpError(400, `未知 profile: ${profile}`);
350
+ return { ok: true, dshProfiles: setDshProfile(DATA, profile, booleanField(payload, "on"), DSH_PROFILES) };
202
351
  }
203
352
 
204
353
  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 {
354
+ const id = payload.id;
355
+ if (typeof id !== "string" || !isValidId(id) || id !== id.trim()) throw httpError(400, "人设标识无效");
356
+ if (typeof payload.body !== "string" || !payload.body.trim()) throw httpError(400, "正文应为非空文本");
357
+ if (payload.meta !== undefined && !plainObject(payload.meta)) throw httpError(400, "meta 应为对象");
358
+ if (payload.apply !== undefined) booleanField(payload, "apply");
359
+ return withControlLock(DATA, () => {
209
360
  // 只覆盖表单带上来的键,其余 frontmatter(harness / order / description...)原样保留:
210
361
  // 旧实现直接拿表单三个字段重写文件,点一次保存就把手写的键全吃掉
211
362
  const meta = { ...(store.read(id)?.meta ?? {}) };
@@ -231,160 +382,142 @@ function savePersona(payload) {
231
382
  if (list.length) meta.model = list;
232
383
  else delete meta.model;
233
384
  }
385
+ const current = store.activeId() === id;
386
+ const referenced = Object.values(loadState(DATA).state.overrides ?? {}).some(value => value?.persona === id);
234
387
  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
- }
388
+ const applied = current || payload.apply === true ? applyConfiguration(DATA, { persona: id }) : referenced ? applyConfiguration(DATA) : null;
389
+ return { ok: applied?.ok ?? true, applied, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
390
+ });
239
391
  }
240
392
 
241
393
  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() };
394
+ return withControlLock(DATA, () => {
395
+ if (typeof id !== "string" || !isValidId(id)) throw httpError(400, "人设标识无效");
396
+ if (!store.read(id)) throw httpError(400, "找不到这个人设");
397
+ if (store.list().length <= 1) throw httpError(409, "至少保留一个人设");
398
+ const references = Object.entries(loadState(DATA).state.overrides ?? {}).filter(([, value]) => value?.persona === id).map(([harness]) => harness);
399
+ if (references.length) throw httpError(409, `人设仍被引用:${references.join("、")},请先调整分家选择`);
400
+ const current = store.activeId() === id;
401
+ if (!store.remove(id)) throw httpError(400, "人设标识无效");
402
+ const applied = current ? applyConfiguration(DATA, { persona: store.activeId() }) : null;
403
+ return { ok: applied?.ok ?? true, active: store.activeId(), applied };
404
+ });
247
405
  }
248
406
 
249
407
  function usePersona(payload) {
250
- if (!payload.id) return { error: "缺少 id" };
408
+ return { ...applyChanges({ persona: payload.id ?? "" }), active: store.activeId() };
409
+ }
410
+
411
+ async function handleImportPersonas(payload) {
412
+ const source = String(payload?.source || "").trim();
413
+ if (!source) throw httpError(400, "导入源不能为空(请提供 GitHub 仓库如 ZiChuanLan/some-magic 或本地目录)");
414
+ const overwrite = payload?.overwrite !== false;
415
+ const { importPersonas } = await import("../lib/persona-import.mjs");
251
416
  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 };
417
+ const result = await importPersonas(source, DATA, { overwrite });
418
+ let applied = null;
419
+ if (payload?.activateId && store.read(payload.activateId)) {
420
+ applied = applyConfiguration(DATA, { persona: payload.activateId });
421
+ }
422
+ return {
423
+ ok: true,
424
+ result,
425
+ personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
426
+ persona: store.activeId(),
427
+ applied,
428
+ };
261
429
  } catch (e) {
262
- return { error: e.message };
430
+ throw httpError(400, `拉取人设失败: ${e.message}`);
263
431
  }
264
432
  }
265
433
 
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 });
434
+ // 按执行器覆盖人设/模式:值传 null / "" / "follow" = 恢复跟随全局。
435
+ function saveHarnessConfig(payload) {
436
+ const id = knownHarness(payload.harness);
437
+ const patch = {};
438
+ for (const key of Object.keys(payload)) if (!["harness", "persona", "mode"].includes(key)) throw httpError(400, `未知分家字段: ${key}`);
439
+ for (const key of ["persona", "mode"]) {
440
+ if (!Object.hasOwn(payload, key)) continue;
441
+ const value = payload[key];
442
+ if (value !== null && typeof value !== "string") throw httpError(400, `${key} 应为文本或 null`);
443
+ patch[key] = value === null || value === "" || value === "follow" ? null : value;
285
444
  }
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 ?? [] });
297
- }
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 };
445
+ if (!Object.keys(patch).length) throw httpError(400, "缺少分家配置字段");
446
+ if (patch.persona && !store.read(patch.persona)) throw httpError(400, "人设不存在");
447
+ if (patch.mode && !MODES.includes(patch.mode)) throw httpError(400, "模式无效");
448
+ const applied = configureHarness(DATA, id, patch);
449
+ return { ok: applied.ok, applied, overrides: loadState(DATA).state.overrides ?? {}, effective: applied.effective };
301
450
  }
302
451
 
452
+ // 兼容旧客户端的全局开关:只暂停/恢复原范围,不再一键全量注册。
453
+ function applyArmAll(payload) {
454
+ return applyConfiguration(DATA, { enabled: verbOf(payload.action) === "on" });
455
+ }
303
456
  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 };
457
+ return changeChannel(DATA, { harness: knownHarness(payload.harness), channel: "install", enabled: verbOf(payload.action) === "on" });
318
458
  }
319
-
320
- /** UI 传 install/uninstall、CLI 传 on/off —— 统一成 on/off,否则 uninstall 会被当成安装 */
321
459
  const VERB_OFF = new Set(["off", "uninstall", "remove", "stop", "false", false, 0]);
460
+ const VERB_ON = new Set(["on", "install", "add", "start", "true", true, 1]);
322
461
  function verbOf(action) {
323
- return VERB_OFF.has(action) ? "off" : "on";
462
+ if (VERB_OFF.has(action)) return "off";
463
+ if (VERB_ON.has(action)) return "on";
464
+ throw httpError(400, "action 应明确指定 on 或 off");
324
465
  }
325
-
326
- /** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
327
466
  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 };
467
+ knownHarness(payload.harness);
468
+ if (payload.channel !== undefined && ![...ARMS, "auto", "all"].includes(payload.channel)) throw httpError(400, "通道无效");
469
+ const on = verbOf(payload.action) === "on";
470
+ return withControlLock(DATA, () => {
471
+ // auto:开关式语义。开 = 挑这个执行器最省心的可用通道;关 = 只撤它真正装着的那几条。
472
+ if (payload.channel === "auto") {
473
+ const { state: st } = loadState(DATA);
474
+ const current = st.armed?.[payload.harness] ?? [];
475
+ if (on) {
476
+ const caps = capsFor(payload.harness, { bin: detect()[payload.harness]?.bin ?? null });
477
+ const pick = ["install", "slash", "shim"].find(channel => caps[channel]?.ok);
478
+ if (!pick) return { error: "这个执行器暂时没有可用的接管方式,展开细节看原因。" };
479
+ // 已经是这个通道就什么都不做,开关是幂等的
480
+ if (current.includes(pick)) return { ok: true, results: [], channel: pick, done: [], skipped: [] };
481
+ const result = changeChannel(DATA, { harness: payload.harness, channel: pick, enabled: true });
482
+ return { ok: result.ok, results: [result], channel: pick, done: result.ok ? [result] : [], skipped: result.ok ? [] : [result.error] };
373
483
  }
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 };
484
+ const result = disableHarness(DATA, payload.harness);
485
+ return { ...result, channel: null, done: result.results.filter(r => r.ok), skipped: result.results.filter(r => !r.ok).map(r => r.error) };
378
486
  }
379
- return { error: `未知通道: ${channel}` };
487
+ const channels = !payload.channel || payload.channel === "all" ? ["install", "shim", "slash"] : [payload.channel];
488
+ const results = channels.map(channel => changeChannel(DATA, { harness: payload.harness, channel, enabled: on, ...(channel === "project" ? { dir: process.cwd() } : {}) }));
489
+ return { ok: results.every(r => r.ok), results, done: results.filter(r => r.ok), skipped: results.filter(r => !r.ok).map(r => r.error) };
490
+ });
380
491
  }
381
492
 
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));
493
+ const send = (res, code, obj) => {
494
+ if (res.destroyed || res.writableEnded) return;
495
+ 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));
496
+ };
497
+ const plainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
498
+ const httpError = (statusCode, message) => Object.assign(new Error(message), { statusCode });
499
+ function booleanField(payload, key, fallback) {
500
+ if (!Object.hasOwn(payload, key) && fallback !== undefined) return fallback;
501
+ if (typeof payload[key] !== "boolean") throw httpError(400, `${key} 应为布尔值`);
502
+ return payload[key];
503
+ }
504
+ function knownHarness(id) {
505
+ if (typeof id !== "string" || !Object.hasOwn(matrix.harnesses, id)) throw httpError(400, "未知执行器");
506
+ return id;
507
+ }
508
+ function localHost(req) {
509
+ const host = req.headers.host;
510
+ if (typeof host !== "string") return false;
511
+ try {
512
+ const url = new URL(`http://${host}`);
513
+ return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && !url.username && !url.password &&
514
+ url.host === host.toLowerCase() && Number(url.port || 80) === req.socket.localPort;
515
+ } catch { return false; }
516
+ }
384
517
  const html = (res) =>
385
518
  res
386
- .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
387
- .end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
519
+ .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'" })
520
+ .end(readResource("web/ui.html").toString("utf8"));
388
521
 
389
522
  function isMutating(method) {
390
523
  return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
@@ -400,43 +533,49 @@ function sameOrigin(req) {
400
533
  try {
401
534
  const u = new URL(origin);
402
535
  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();
536
+ return loopback && u.protocol === "http:" && u.origin === origin && u.host.toLowerCase() === String(req.headers.host ?? "").toLowerCase();
404
537
  } catch {
405
538
  return false;
406
539
  }
407
540
  }
408
541
 
409
542
  function body(req) {
410
- return new Promise((resolve) => {
411
- let raw = "";
412
- let size = 0;
413
- let done = false;
414
- const finish = (value) => {
543
+ return new Promise((resolve, reject) => {
544
+ const chunks = [];
545
+ let size = 0, done = false;
546
+ const finish = (error, value) => {
415
547
  if (done) return;
416
548
  done = true;
417
- resolve(value);
549
+ chunks.length = 0;
550
+ if (error) reject(error); else resolve(value);
418
551
  };
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;
552
+ const contentType = req.headers["content-type"];
553
+ if (contentType && !/^application\/(?:[a-z0-9.+-]+\+)?json(?:\s*;|$)/i.test(contentType)) {
554
+ req.resume(); finish(httpError(415, "请求应为 JSON")); return;
555
+ }
556
+ req.on("data", chunk => {
557
+ if (done) return;
558
+ size += chunk.length;
559
+ if (size > 2 * 1024 * 1024) { finish(httpError(413, "请求超过 2 MiB")); return; }
560
+ chunks.push(chunk);
427
561
  });
428
- req.on("end", () => {
429
- try {
430
- finish(JSON.parse(raw || "{}"));
431
- } catch {
432
- finish({});
433
- }
562
+ req.once("end", () => {
563
+ if (done) return;
564
+ let value;
565
+ try { value = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
566
+ catch { finish(httpError(400, "JSON 格式无效")); return; }
567
+ if (!plainObject(value)) { finish(httpError(400, "JSON 应为对象")); return; }
568
+ finish(null, value);
434
569
  });
570
+ req.once("aborted", () => finish(httpError(400, "请求已中断")));
571
+ req.once("error", error => finish(httpError(400, `请求读取失败:${error.message}`)));
435
572
  });
436
573
  }
437
574
 
438
575
  // 只有被直接当脚本跑(node web/server.mjs --serve 7788)才自启动;
439
576
  // 被 bin 导入时必须保持安静——否则 `agent-purge web --serve 7789` 会两个端口各起一个实例,
440
577
  // 并在第二个 bind 时吐出误导性的“端口已被占用:老实例还在跑”。
441
- const isEntry = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
578
+ // 编译态 import.meta.url 也是 exe 路径,光比路径会把 exe 误当入口:只认 .mjs 脚本。
579
+ const selfPath = fileURLToPath(import.meta.url);
580
+ const isEntry = Boolean(process.argv[1]) && selfPath.endsWith(".mjs") && resolve(process.argv[1]) === selfPath;
442
581
  if (isEntry && process.argv.includes("--serve")) serveWeb(Number(process.argv[process.argv.indexOf("--serve") + 1] ?? 7788));