agent-purge 0.2.0 → 0.2.2

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/web/server.mjs CHANGED
@@ -1,329 +1,375 @@
1
- // 零依赖 Web 服务:配置台。选默认 harness/mode、管理 persona、常驻安装、探测。
2
- // 刻意不做"运行":交互式 harness 属于终端/桌面,Web 只负责选(见 ARCHITECTURE.md)。
3
- import { createServer } from "node:http";import { readFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { detect } from "../lib/detect.mjs";
7
- import { oneshots } from "../backends/index.mjs";
8
- import { createStore } from "../lib/personas.mjs";
9
- import { MODES, armedOf, loadState, patchState, setArmed, setDefault, setPersona } 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";
14
- import { reconcileArmed } from "../lib/armed.mjs";
15
- import { capsFor } from "../lib/caps.mjs";
16
- import { ensureDataHome } from "../lib/home.mjs";
17
-
18
- const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
19
- // 数据(state.json + personas/)在用户目录,不在安装目录
20
- const DATA = ensureDataHome(ROOT).home;
21
- // 运行期真正被改写的层(≠ bench 能测到的层):每家只有它自己暴露的那一个写入点。
22
- // sys = 系统提示/配置文件/插件 splice;user = 只落在用户消息里(zcode 的 hook)。
23
- const LAYER = {
24
- pi: "sys", codex: "sys", codebuddy: "sys", claude: "sys", grok: "sys",
25
- omp: "sys", opencode: "sys", hermes: "sys", antigravity: "sys",
26
- // zcode 只有文档说它会读 AGENTS.md,桌面版闭源、无法复核,层就不claim(UI 显示“—”+「文档」)
27
- zcode: "",
28
- };
29
- const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
30
- const store = createStore(DATA);
31
-
32
- export function serveWeb(port = 7788) {
33
- const server = createServer(handle);
34
- server.on("error", (e) => {
35
- // 端口被旧实例占着的时候,--detach 是静默的 → 这里必须说人话,否则用户看到的是旧 UI
36
- if (e.code === "EADDRINUSE") {
37
- console.error(`${port} 已被占用:老实例还在跑(可能是改动前的版本,UI 会是旧的)。`);
38
- console.error(`清掉再启:Get-NetTCPConnection -LocalPort ${port} -State Listen | % { Stop-Process -Id $_.OwningProcess -Force }`);
39
- } else {
40
- console.error(`web 启动失败:${e.message}`);
41
- }
42
- process.exit(1);
43
- });
44
- server.listen(port, "127.0.0.1", () => {
45
- console.log(`agent-purge web → http://127.0.0.1:${port}`);
46
- });
47
- }
48
-
49
- async function handle(req, res) {
50
- const url = new URL(req.url, "http://127.0.0.1");
51
- const p = url.pathname;
52
-
53
- if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
54
- if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
55
- if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
56
- if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
57
- if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
58
- if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
59
- if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
60
- if (req.method === "POST" && p === "/api/install") return send(res, 200, applyInstall(await body(req)));
61
- if (req.method === "POST" && p === "/api/arm") return send(res, 200, applyArm(await body(req)));
62
- if (req.method === "POST" && p === "/api/arm-all") return send(res, 200, applyArmAll(await body(req)));
63
- send(res, 404, { error: "not found" });
64
- }
65
-
66
- function armorState() {
67
- const rows = plans(matrix).map((x) => ({
68
- id: x.id,
69
- class: x.class,
70
- bare: x.bare,
71
- persona: x.persona,
72
- recommended: x.recommended,
73
- blocked: x.blocked,
74
- warnings: x.warnings,
75
- requiresSeed: x.requiresSeed,
76
- mode: x.mode ?? null,
77
- defaultLayers: x.defaultLayers ?? [],
78
- cmd: x.cmd,
79
- }));
80
- return {
81
- target: matrix.armorTarget ?? null,
82
- measuredAt: matrix.armorMeasuredAt ?? null,
83
- judge: matrix.armorJudge ?? null,
84
- rows,
85
- };
86
- }
87
-
88
- async function state(withVersions) {
89
- // 先跟磁盘对账,再报数:armed 是缓存,磁盘才是真相
90
- const reconciled = await reconcileArmed(DATA);
91
- const found = detect({ versions: withVersions });
92
- const persisted = persistStatus(DATA);
93
- const { state: st } = loadState(DATA);
94
- const shims = shimStatus();
95
- const slashes = slashStatus(DATA);
96
- const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
97
- const d = found[id] ?? {};
98
- const inst = persisted.find((x) => x.name === id);
99
- const armed = armedOf(st, id);
100
- return {
101
- id,
102
- class: m.class,
103
- override: m.override === true ? "yes" : m.override ? String(m.override) : "no",
104
- inject: Boolean(m.inject),
105
- hotSwitch: Boolean(m.hotSwitch),
106
- chars: m.defaultSystemChars != null ? `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}` : null,
107
- version: d.version ?? m.measuredVersion ?? null,
108
- residue: m.residue ?? null,
109
- reason: m.reason ?? null,
110
- supported: m.class !== "unsupported",
111
- oneshot: Boolean(oneshots[id]),
112
- layer: LAYER[id] ?? (m.class === "unsupported" ? null : "sys"),
113
- found: Boolean(d.found),
114
- bin: d.bin ?? null,
115
- home: d.home ?? null,
116
- homeExists: Boolean(d.homeExists),
117
- persistable: Boolean(inst),
118
- installed: Boolean(inst?.installed),
119
- persistFile: inst?.file ?? null,
120
- armed,
121
- shim: Boolean(shims.find((s) => s.id === id)?.installed),
122
- slash: slashes.find((s) => s.id === id) ?? null,
123
- // 每条通道「能不能用 + 为什么不能用」由 lib/caps.mjs 统一裁决:
124
- // UI 只负责画,不再自己猜(以前 shim 硬编码可点,zcode 点了必然无效)
125
- caps: capsFor(id, { bin: d.bin ?? null }),
126
- };
127
- });
128
- return {
129
- harnesses,
130
- reconciled,
131
- personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
132
- active: store.activeId(),
133
- persona: st.persona,
134
- defaults: st.default,
135
- armed: st.armed ?? {},
136
- modes: MODES,
137
- shimDir: shimStatus()[0]?.dir ?? null,
138
- slashes: slashes,
139
- };
140
- }
141
-
142
- function saveDefaults(payload) {
143
- const { harness, mode } = payload;
144
- const spec = matrix.harnesses[harness];
145
- if (!spec) return { error: `未知 harness: ${harness}` };
146
- if (spec.class === "unsupported") return { error: `${harness} 做不到:${spec.reason}` };
147
- if (mode && !MODES.includes(mode)) return { error: `mode 只能是 ${MODES.join(" / ")}` };
148
- try {
149
- return { ok: true, defaults: setDefault(DATA, { harness, mode: mode ?? "replace" }) };
150
- } catch (e) {
151
- return { error: e.message };
152
- }
153
- }
154
-
155
- function savePersona(payload) {
156
- const id = String(payload.id || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
157
- if (!id) return { error: "id 不能为空(小写字母/数字/-/_)" };
158
- if (!payload.body?.trim()) return { error: "正文不能为空" };
159
- try {
160
- const saved = store.save(id, { meta: { ...payload.meta, id }, body: payload.body });
161
- return { ok: true, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
162
- } catch (e) {
163
- return { error: e.message };
164
- }
165
- }
166
-
167
- function deletePersona(id) {
168
- if (!id) return { error: "缺少 id" };
169
- if (store.list().length <= 1) return { error: "至少保留一个 persona" };
170
- store.remove(id); // remove 内部会处理“删的就是 active”的指针改道
171
- return { ok: true, active: store.activeId() };
172
- }
173
-
174
- function usePersona(payload) {
175
- if (!payload.id) return { error: "缺少 id" };
176
- try {
177
- store.setActive(payload.id);
178
- // 两个指针必须一起写,否则 UI 选的“当前人设”和 `agent-purge` 实际用的不是同一个
179
- setPersona(DATA, payload.id);
180
- const { state: st } = loadState(DATA);
181
- const slashTargets = Object.entries(st.armed ?? {})
182
- .filter(([, a]) => a.includes("slash"))
183
- .map(([k]) => k);
184
- const refreshed = refreshSlashes(DATA, slashTargets, { personaId: payload.id, personaBody: store.read(payload.id).body.trim() });
185
- return { ok: true, active: payload.id, refreshed };
186
- } catch (e) {
187
- return { error: e.message };
188
- }
189
- }
190
-
191
- /** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
192
- function applyArmAll(payload) {
193
- const { action } = payload;
194
- const channel = payload.channel ?? "all";
195
- const verb = verbOf(action);
196
-
197
- // 全局总闸 = 电源,不只是注册表:撤的时候把 default.mode 置 off(记下 prevMode),
198
- // 否则 `agent-purge <h>` 这条入口永远注入 —— 用户实测“关了也能直接接管”就出在这。
199
- // 开的时候把上次的 mode 还回去。
200
- const { state: cur } = loadState(DATA);
201
- const m = cur.default?.mode ?? "off";
202
- if (verb === "off" && m !== "off" && cur.default?.harness) {
203
- setDefault(DATA, { harness: cur.default.harness, mode: "off" });
204
- patchState(DATA, { prevMode: m });
205
- }
206
- if (verb === "on" && m === "off" && cur.default?.harness) {
207
- const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
208
- setDefault(DATA, { harness: cur.default.harness, mode: back });
209
- }
210
-
211
- const perHarness = [];
212
- const failed = [];
213
- for (const [h, spec] of Object.entries(matrix.harnesses)) {
214
- if (spec.class === "unsupported") {
215
- failed.push(`${h}: 做不到(${spec.reason})`);
216
- continue;
217
- }
218
- const r = applyArm({ harness: h, channel, action: verb, persona: payload.persona });
219
- if (r.error) failed.push(`${h}: ${r.error}`);
220
- else perHarness.push({ harness: h, done: (r.done ?? []).map((d) => d.channel), skipped: r.skipped ?? [] });
221
- }
222
- if (!perHarness.length) return { error: failed.join(";") || "没有可注册的 harness" };
223
- const after = loadState(DATA).state.default;
224
- return { ok: true, action: verb, channel, mode: after?.mode ?? null, harnesses: perHarness, failed };
225
- }
226
-
227
- function applyInstall(payload) {
228
- const { harness, action } = payload;
229
- if (action === "uninstall") {
230
- const r = uninstall(harness);
231
- setArmed(DATA, harness, "install", false);
232
- return { ok: true, result: r };
233
- }
234
- // 装谁就把谁设为 active:active.md 与 install 指向始终是同一份正文
235
- const wanted = payload.persona ?? store.activeId();
236
- if (!store.read(wanted)) return { error: "没有可用 persona" };
237
- store.setActive(wanted);
238
- const r = install(harness, { root: DATA, active: store.refreshActive() });
239
- if (r.error) return { error: r.error };
240
- setArmed(DATA, harness, "install", true);
241
- return { ok: true, result: r };
242
- }
243
-
244
- /** UI 传 install/uninstall、CLI 传 on/off —— 统一成 on/off,否则 uninstall 会被当成安装 */
245
- const VERB_OFF = new Set(["off", "uninstall", "remove", "stop", "false", false, 0]);
246
- function verbOf(action) {
247
- return VERB_OFF.has(action) ? "off" : "on";
248
- }
249
-
250
- /** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
251
- function applyArm(payload) {
252
- const { harness, channel, action } = payload;
253
- const id = payload.persona ?? store.activeId();
254
- if (!store.read(id)) return { error: "没有可用 persona" };
255
- const verb = verbOf(action);
256
- const channels = !channel || channel === "all" ? ["install", "shim", "slash"] : [channel];
257
- const done = [];
258
- const skipped = [];
259
- for (const ch of channels) {
260
- const r = armChannel(harness, ch, verb, id);
261
- if (r.error) skipped.push(`${ch}: ${r.error}`);
262
- else done.push({ channel: ch, ...r });
263
- }
264
- if (!done.length) return { error: skipped.join(";") || "没有可用通道" };
265
- return { ok: true, harness, action: verb, done, skipped };
266
- }
267
-
268
- function armChannel(harness, channel, verb, id) {
269
- // 开之前先问能力表:这条通道在这家身上本来就无效的,不写任何文件、直接说明原因
270
- if (verb === "on") {
271
- const cap = capsFor(harness)[channel];
272
- if (cap && !cap.ok) return { error: cap.why };
273
- }
274
- if (channel === "install") {
275
- const r = verb === "off" ? applyInstall({ harness, action: "uninstall" }) : applyInstall({ harness, action: "install", persona: id });
276
- return r.error ? { error: r.error } : (r.result ?? { ok: true });
277
- }
278
- if (channel === "shim") {
279
- if (verb === "off") {
280
- const r = uninstallShim(DATA, harness);
281
- if (r.error) return { error: r.error };
282
- setArmed(DATA, harness, "shim", false);
283
- return { removed: r.removed };
284
- }
285
- const r = installShim(DATA, harness);
286
- if (r.error) return { error: r.error };
287
- setArmed(DATA, harness, "shim", true);
288
- return { file: r.file, real: r.real };
289
- }
290
- if (channel === "slash") {
291
- if (verb === "off") {
292
- const r = uninstallSlash(harness, DATA);
293
- if (r.error) return { error: r.error };
294
- setArmed(DATA, harness, "slash", false);
295
- return { removed: r.removed };
296
- }
297
- const r = installSlash(harness, DATA, { personaId: id, personaBody: store.read(id).body.trim() });
298
- if (r.error) return { error: r.error };
299
- setArmed(DATA, harness, "slash", true);
300
- return { file: r.file, layer: r.layer };
301
- }
302
- return { error: `未知通道: ${channel}` };
303
- }
304
-
305
- const send = (res, code, obj) => res.writeHead(code, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(obj));
306
- const html = (res) =>
307
- res
308
- .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
309
- .end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
310
-
311
- function body(req) {
312
- return new Promise((resolve) => {
313
- let raw = "";
314
- req.on("data", (c) => (raw += c));
315
- req.on("end", () => {
316
- try {
317
- resolve(JSON.parse(raw || "{}"));
318
- } catch {
319
- resolve({});
320
- }
321
- });
322
- });
323
- }
324
-
325
- // 只有被直接当脚本跑(node web/server.mjs --serve 7788)才自启动;
326
- // 被 bin 导入时必须保持安静——否则 `agent-purge web --serve 7789` 会两个端口各起一个实例,
327
- // 并在第二个 bind 时吐出误导性的“端口已被占用:老实例还在跑”。
328
- const isEntry = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
329
- if (isEntry && process.argv.includes("--serve")) serveWeb(Number(process.argv[process.argv.indexOf("--serve") + 1] ?? 7788));
1
+ // 零依赖 Web 服务:配置台。选默认 harness/mode、管理 persona、常驻安装、探测。
2
+ // 刻意不做"运行":交互式 harness 属于终端/桌面,Web 只负责选(见 ARCHITECTURE.md)。
3
+ import { createServer } from "node:http";import { readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { detect } from "../lib/detect.mjs";
7
+ import { oneshots } from "../backends/index.mjs";
8
+ import { createStore } from "../lib/personas.mjs";
9
+ import { MODES, armedOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto } 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";
14
+ import { reconcileArmed } from "../lib/armed.mjs";
15
+ import { capsFor } from "../lib/caps.mjs";
16
+ import { ensureDataHome } from "../lib/home.mjs";
17
+
18
+ const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
19
+ // 数据(state.json + personas/)在用户目录,不在安装目录
20
+ const DATA = ensureDataHome(ROOT).home;
21
+ // 运行期真正被改写的层(≠ bench 能测到的层):每家只有它自己暴露的那一个写入点。
22
+ // sys = 系统提示/配置文件/插件 splice;user = 只落在用户消息里(zcode 的 hook)。
23
+ const LAYER = {
24
+ pi: "sys", codex: "sys", codebuddy: "sys", claude: "sys", grok: "sys",
25
+ omp: "sys", opencode: "sys", hermes: "sys", antigravity: "sys",
26
+ // zcode 只有文档说它会读 AGENTS.md,桌面版闭源、无法复核,层就不claim(UI 显示“—”+「文档」)
27
+ zcode: "",
28
+ };
29
+ const matrix = JSON.parse(readFileSync(join(ROOT, "matrix.json"), "utf8"));
30
+ const store = createStore(DATA);
31
+
32
+ export function serveWeb(port = 7788) {
33
+ const server = createServer(handle);
34
+ server.on("error", (e) => {
35
+ // 端口被旧实例占着的时候,--detach 是静默的 → 这里必须说人话,否则用户看到的是旧 UI
36
+ if (e.code === "EADDRINUSE") {
37
+ console.error(`${port} 已被占用:老实例还在跑(可能是改动前的版本,UI 会是旧的)。`);
38
+ console.error(`清掉再启:Get-NetTCPConnection -LocalPort ${port} -State Listen | % { Stop-Process -Id $_.OwningProcess -Force }`);
39
+ } else {
40
+ console.error(`web 启动失败:${e.message}`);
41
+ }
42
+ process.exit(1);
43
+ });
44
+ server.listen(port, "127.0.0.1", () => {
45
+ console.log(`agent-purge web → http://127.0.0.1:${port}`);
46
+ });
47
+ }
48
+
49
+ async function handle(req, res) {
50
+ const url = new URL(req.url, "http://127.0.0.1");
51
+ const p = url.pathname;
52
+
53
+ if (req.method === "GET" && (p === "/" || p === "/index.html")) return html(res);
54
+ if (req.method === "GET" && p === "/api/state") return send(res, 200, await state(url.searchParams.get("versions") === "1"));
55
+ if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
56
+ if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
57
+ if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
58
+ if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
59
+ if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
60
+ if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
61
+ if (req.method === "POST" && p === "/api/install") return send(res, 200, applyInstall(await body(req)));
62
+ if (req.method === "POST" && p === "/api/arm") return send(res, 200, applyArm(await body(req)));
63
+ if (req.method === "POST" && p === "/api/arm-all") return send(res, 200, applyArmAll(await body(req)));
64
+ send(res, 404, { error: "not found" });
65
+ }
66
+
67
+ function armorState() {
68
+ const rows = plans(matrix).map((x) => ({
69
+ id: x.id,
70
+ class: x.class,
71
+ bare: x.bare,
72
+ persona: x.persona,
73
+ recommended: x.recommended,
74
+ blocked: x.blocked,
75
+ warnings: x.warnings,
76
+ requiresSeed: x.requiresSeed,
77
+ mode: x.mode ?? null,
78
+ defaultLayers: x.defaultLayers ?? [],
79
+ cmd: x.cmd,
80
+ }));
81
+ return {
82
+ target: matrix.armorTarget ?? null,
83
+ measuredAt: matrix.armorMeasuredAt ?? null,
84
+ judge: matrix.armorJudge ?? null,
85
+ rows,
86
+ };
87
+ }
88
+
89
+ async function state(withVersions) {
90
+ // 先跟磁盘对账,再报数:armed 是缓存,磁盘才是真相
91
+ const reconciled = await reconcileArmed(DATA);
92
+ const found = detect({ versions: withVersions });
93
+ const persisted = persistStatus(DATA);
94
+ const { state: st } = loadState(DATA);
95
+ const shims = shimStatus();
96
+ const slashes = slashStatus(DATA);
97
+ const harnesses = Object.entries(matrix.harnesses).map(([id, m]) => {
98
+ const d = found[id] ?? {};
99
+ const inst = persisted.find((x) => x.name === id);
100
+ const armed = armedOf(st, id);
101
+ return {
102
+ id,
103
+ class: m.class,
104
+ override: m.override === true ? "yes" : m.override ? String(m.override) : "no",
105
+ inject: Boolean(m.inject),
106
+ hotSwitch: Boolean(m.hotSwitch),
107
+ chars: m.defaultSystemChars != null ? `${m.defaultSystemChars} → ${m.purgedSystemChars ?? "?"}` : null,
108
+ version: d.version ?? m.measuredVersion ?? null,
109
+ residue: m.residue ?? null,
110
+ reason: m.reason ?? null,
111
+ supported: m.class !== "unsupported",
112
+ oneshot: Boolean(oneshots[id]),
113
+ layer: LAYER[id] ?? (m.class === "unsupported" ? null : "sys"),
114
+ found: Boolean(d.found),
115
+ bin: d.bin ?? null,
116
+ home: d.home ?? null,
117
+ homeExists: Boolean(d.homeExists),
118
+ persistable: Boolean(inst),
119
+ installed: Boolean(inst?.installed),
120
+ persistFile: inst?.file ?? null,
121
+ armed,
122
+ shim: Boolean(shims.find((s) => s.id === id)?.installed),
123
+ slash: slashes.find((s) => s.id === id) ?? null,
124
+ // 每条通道「能不能用 + 为什么不能用」由 lib/caps.mjs 统一裁决:
125
+ // UI 只负责画,不再自己猜(以前 shim 硬编码可点,zcode 点了必然无效)
126
+ caps: capsFor(id, { bin: d.bin ?? null }),
127
+ };
128
+ });
129
+ const defaults = defaultsOf(st, harnesses);
130
+ return {
131
+ harnesses,
132
+ reconciled,
133
+ personas: store.list().map((x) => ({ id: x.id, meta: x.meta, body: x.body, chars: x.body.trim().length })),
134
+ active: store.activeId(),
135
+ persona: st.persona,
136
+ defaults,
137
+ // false = state.json 里还没有默认(defaults 只是 UI 的建议值,裸敲 agent-purge 仍会提示“未设置”)
138
+ defaultsSet: Boolean(st.default?.harness),
139
+ armed: st.armed ?? {},
140
+ modes: MODES,
141
+ piAuto: st.piAuto === true,
142
+ shimDir: shimStatus()[0]?.dir ?? null,
143
+ slashes: slashes,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * state.json 里 default 为 null(全新安装、从没设过)时,给 UI 一个能画出来的值:
149
+ * 第一个“装了 + 被支持 + 可注入”的 harness + replace。
150
+ * 只用于展示,不落盘——裸敲 agent-purge 该提示“还没设置”照样提示(UI 看 defaultsSet)。
151
+ */
152
+ function defaultsOf(st, harnesses) {
153
+ const cur = st.default?.harness;
154
+ const mode = MODES.includes(st.default?.mode) ? st.default.mode : "replace";
155
+ if (cur && harnesses.some((h) => h.id === cur)) return { harness: cur, mode };
156
+ const pick =
157
+ harnesses.find((h) => h.found && h.supported && h.inject) ??
158
+ harnesses.find((h) => h.supported) ??
159
+ harnesses[0];
160
+ return { harness: pick?.id ?? null, mode };
161
+ }
162
+
163
+ function saveDefaults(payload) {
164
+ const { harness, mode } = payload;
165
+ const spec = matrix.harnesses[harness];
166
+ if (!spec) return { error: `未知 harness: ${harness}` };
167
+ if (spec.class === "unsupported") return { error: `${harness} 做不到:${spec.reason}` };
168
+ if (mode && !MODES.includes(mode)) return { error: `mode 只能是 ${MODES.join(" / ")}` };
169
+ try {
170
+ return { ok: true, defaults: setDefault(DATA, { harness, mode: mode ?? "replace" }) };
171
+ } catch (e) {
172
+ return { error: e.message };
173
+ }
174
+ }
175
+
176
+ function savePersona(payload) {
177
+ const id = String(payload.id || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
178
+ if (!id) return { error: "id 不能为空(小写字母/数字/-/_)" };
179
+ if (!payload.body?.trim()) return { error: "正文不能为空" };
180
+ try {
181
+ // 只覆盖表单带上来的键,其余 frontmatter(harness / order / description...)原样保留:
182
+ // 旧实现直接拿表单三个字段重写文件,点一次保存就把手写的键全吃掉
183
+ const meta = { ...(store.read(id)?.meta ?? {}) };
184
+ const given = payload.meta && typeof payload.meta === "object" ? payload.meta : {};
185
+ for (const [k, v] of Object.entries(given)) {
186
+ if (k === "id" || v === undefined) continue;
187
+ if (k === "desc" || k === "description") continue;
188
+ if (k === "model") continue;
189
+ meta[k] = typeof v === "string" ? v.trim() : v;
190
+ }
191
+ // 说明:README/模板的键是 description;旧 UI 发的是 desc,两边都认,写回只留 description
192
+ const desc = given.description ?? given.desc;
193
+ if (desc !== undefined) {
194
+ meta.description = String(desc ?? "").trim();
195
+ delete meta.desc;
196
+ if (!meta.description) delete meta.description;
197
+ }
198
+ // 建议模型:多值用逗号分隔(UI 是单行 input),空 = 不限制(读时 asArray 兜底 ["*"])
199
+ if (given.model !== undefined) {
200
+ const list = (Array.isArray(given.model) ? given.model : String(given.model ?? "").split(","))
201
+ .map((s) => String(s).trim())
202
+ .filter(Boolean);
203
+ if (list.length) meta.model = list;
204
+ else delete meta.model;
205
+ }
206
+ const saved = store.save(id, { meta: { ...meta, id }, body: payload.body });
207
+ return { ok: true, persona: { id: saved.id, meta: saved.meta, body: saved.body } };
208
+ } catch (e) {
209
+ return { error: e.message };
210
+ }
211
+ }
212
+
213
+ function deletePersona(id) {
214
+ if (!id) return { error: "缺少 id" };
215
+ if (store.list().length <= 1) return { error: "至少保留一个 persona" };
216
+ store.remove(id); // remove 内部会处理“删的就是 active”的指针改道
217
+ return { ok: true, active: store.activeId() };
218
+ }
219
+
220
+ function usePersona(payload) {
221
+ if (!payload.id) return { error: "缺少 id" };
222
+ try {
223
+ store.setActive(payload.id);
224
+ // 两个指针必须一起写,否则 UI 选的“当前人设”和 `agent-purge` 实际用的不是同一个
225
+ setPersona(DATA, payload.id);
226
+ const { state: st } = loadState(DATA);
227
+ const slashTargets = Object.entries(st.armed ?? {})
228
+ .filter(([, a]) => a.includes("slash"))
229
+ .map(([k]) => k);
230
+ const refreshed = refreshSlashes(DATA, slashTargets, { personaId: payload.id, personaBody: store.read(payload.id).body.trim() });
231
+ return { ok: true, active: payload.id, refreshed };
232
+ } catch (e) {
233
+ return { error: e.message };
234
+ }
235
+ }
236
+
237
+ /** 全局总开关:所有 harness × 选定通道一次折腾完(= `agent-purge 注册 --all` / `disarm --all`)*/
238
+ function applyArmAll(payload) {
239
+ const { action } = payload;
240
+ const channel = payload.channel ?? "all";
241
+ const verb = verbOf(action);
242
+
243
+ // 全局总闸 = 电源,不只是注册表:撤的时候把 default.mode 置 off(记下 prevMode),
244
+ // 否则 `agent-purge <h>` 这条入口永远注入 —— 用户实测“关了也能直接接管”就出在这。
245
+ // 开的时候把上次的 mode 还回去。
246
+ const { state: cur } = loadState(DATA);
247
+ const m = cur.default?.mode ?? "off";
248
+ if (verb === "off" && m !== "off" && cur.default?.harness) {
249
+ setDefault(DATA, { harness: cur.default.harness, mode: "off" });
250
+ patchState(DATA, { prevMode: m });
251
+ }
252
+ if (verb === "on" && m === "off" && cur.default?.harness) {
253
+ const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
254
+ setDefault(DATA, { harness: cur.default.harness, mode: back });
255
+ }
256
+
257
+ const perHarness = [];
258
+ const failed = [];
259
+ for (const [h, spec] of Object.entries(matrix.harnesses)) {
260
+ if (spec.class === "unsupported") {
261
+ failed.push(`${h}: 做不到(${spec.reason})`);
262
+ continue;
263
+ }
264
+ const r = applyArm({ harness: h, channel, action: verb, persona: payload.persona });
265
+ if (r.error) failed.push(`${h}: ${r.error}`);
266
+ else perHarness.push({ harness: h, done: (r.done ?? []).map((d) => d.channel), skipped: r.skipped ?? [] });
267
+ }
268
+ if (!perHarness.length) return { error: failed.join(";") || "没有可注册的 harness" };
269
+ const after = loadState(DATA).state.default;
270
+ return { ok: true, action: verb, channel, mode: after?.mode ?? null, harnesses: perHarness, failed };
271
+ }
272
+
273
+ function applyInstall(payload) {
274
+ const { harness, action } = payload;
275
+ if (action === "uninstall") {
276
+ const r = uninstall(harness);
277
+ setArmed(DATA, harness, "install", false);
278
+ return { ok: true, result: r };
279
+ }
280
+ // 装谁就把谁设为 active:active.md 与 install 指向始终是同一份正文
281
+ const wanted = payload.persona ?? store.activeId();
282
+ if (!store.read(wanted)) return { error: "没有可用 persona" };
283
+ store.setActive(wanted);
284
+ const r = install(harness, { root: DATA, active: store.refreshActive() });
285
+ if (r.error) return { error: r.error };
286
+ setArmed(DATA, harness, "install", true);
287
+ return { ok: true, result: r };
288
+ }
289
+
290
+ /** UI 传 install/uninstall、CLI 传 on/off —— 统一成 on/off,否则 uninstall 会被当成安装 */
291
+ const VERB_OFF = new Set(["off", "uninstall", "remove", "stop", "false", false, 0]);
292
+ function verbOf(action) {
293
+ return VERB_OFF.has(action) ? "off" : "on";
294
+ }
295
+
296
+ /** 裸命令接管(shim)、/purge(slash)、常驻(install):Web 上一个开关全部搞定 */
297
+ function applyArm(payload) {
298
+ const { harness, channel, action } = payload;
299
+ const id = payload.persona ?? store.activeId();
300
+ if (!store.read(id)) return { error: "没有可用 persona" };
301
+ const verb = verbOf(action);
302
+ const channels = !channel || channel === "all" ? ["install", "shim", "slash"] : [channel];
303
+ const done = [];
304
+ const skipped = [];
305
+ for (const ch of channels) {
306
+ const r = armChannel(harness, ch, verb, id);
307
+ if (r.error) skipped.push(`${ch}: ${r.error}`);
308
+ else done.push({ channel: ch, ...r });
309
+ }
310
+ if (!done.length) return { error: skipped.join(";") || "没有可用通道" };
311
+ return { ok: true, harness, action: verb, done, skipped };
312
+ }
313
+
314
+ function armChannel(harness, channel, verb, id) {
315
+ // 开之前先问能力表:这条通道在这家身上本来就无效的,不写任何文件、直接说明原因
316
+ if (verb === "on") {
317
+ const cap = capsFor(harness)[channel];
318
+ if (cap && !cap.ok) return { error: cap.why };
319
+ }
320
+ if (channel === "install") {
321
+ const r = verb === "off" ? applyInstall({ harness, action: "uninstall" }) : applyInstall({ harness, action: "install", persona: id });
322
+ return r.error ? { error: r.error } : (r.result ?? { ok: true });
323
+ }
324
+ if (channel === "shim") {
325
+ if (verb === "off") {
326
+ const r = uninstallShim(DATA, harness);
327
+ if (r.error) return { error: r.error };
328
+ setArmed(DATA, harness, "shim", false);
329
+ return { removed: r.removed };
330
+ }
331
+ const r = installShim(DATA, harness);
332
+ if (r.error) return { error: r.error };
333
+ setArmed(DATA, harness, "shim", true);
334
+ return { file: r.file, real: r.real };
335
+ }
336
+ if (channel === "slash") {
337
+ if (verb === "off") {
338
+ const r = uninstallSlash(harness, DATA);
339
+ if (r.error) return { error: r.error };
340
+ setArmed(DATA, harness, "slash", false);
341
+ return { removed: r.removed };
342
+ }
343
+ const r = installSlash(harness, DATA, { personaId: id, personaBody: store.read(id).body.trim() });
344
+ if (r.error) return { error: r.error };
345
+ setArmed(DATA, harness, "slash", true);
346
+ return { file: r.file, layer: r.layer };
347
+ }
348
+ return { error: `未知通道: ${channel}` };
349
+ }
350
+
351
+ const send = (res, code, obj) => res.writeHead(code, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(obj));
352
+ const html = (res) =>
353
+ res
354
+ .writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
355
+ .end(readFileSync(join(ROOT, "web", "ui.html"), "utf8"));
356
+
357
+ function body(req) {
358
+ return new Promise((resolve) => {
359
+ let raw = "";
360
+ req.on("data", (c) => (raw += c));
361
+ req.on("end", () => {
362
+ try {
363
+ resolve(JSON.parse(raw || "{}"));
364
+ } catch {
365
+ resolve({});
366
+ }
367
+ });
368
+ });
369
+ }
370
+
371
+ // 只有被直接当脚本跑(node web/server.mjs --serve 7788)才自启动;
372
+ // 被 bin 导入时必须保持安静——否则 `agent-purge web --serve 7789` 会两个端口各起一个实例,
373
+ // 并在第二个 bind 时吐出误导性的“端口已被占用:老实例还在跑”。
374
+ const isEntry = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
375
+ if (isEntry && process.argv.includes("--serve")) serveWeb(Number(process.argv[process.argv.indexOf("--serve") + 1] ?? 7788));