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/lib/apply.mjs ADDED
@@ -0,0 +1,227 @@
1
+ // CLI 与 Web 的唯一应用入口。配置已写入 ≠ 已被正在运行的 harness 读取。
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { withControlLock } from "./control.mjs";
4
+ import { join } from "node:path";
5
+ import { createStore } from "./personas.mjs";
6
+ import { ARMS, MODES, loadState, patchState, resolveFor, resolveActivation, setArmed, setOverride } from "./state.mjs";
7
+ import { install, uninstall, status as installStatus } from "./persist.mjs";
8
+ import { installSlash, uninstallSlash } from "./slashes.mjs";
9
+ import { installShim, uninstallShim } from "./shim.mjs";
10
+ import { projectDirectories, projectFilePath, projectInstall, projectUninstall } from "./project.mjs";
11
+ import { capsFor } from "./caps.mjs";
12
+ import { verifiedHarnesses } from "./verification.mjs";
13
+ import { readResource } from "./root.mjs";
14
+ const HARNESS_IDS = (() => {
15
+ try { return new Set(Object.keys(JSON.parse(readResource("matrix.json").toString("utf8")).harnesses)); }
16
+ catch (error) { throw new Error(`执行器清单读取失败:${error.message}`, { cause: error }); }
17
+ })();
18
+
19
+ const DYNAMIC = new Set(["dsh", "opencode", "opencode2"]);
20
+
21
+ const exclusive = withControlLock;
22
+
23
+ export function effectFor(harness, channel, mode) {
24
+ if (channel === "shim") return { when: "next-launch", message: "新启动的命令生效;已运行会话不变" };
25
+ if (channel === "slash") return { when: "next-turn", message: "已加载扩展下一轮读取;首次安装需重启,手动通道需 /purge on" };
26
+ if (channel === "project") return { when: "next-session", message: "已处理所选项目的规则文件;对应目录里新开的会话读取" };
27
+ if (DYNAMIC.has(harness)) return { when: "next-turn", message: "已加载插件下一轮读取;首次安装/更新插件代码需重启" };
28
+ return { when: "next-session", message: mode === "off" ? "静态内容已撤下;已启动会话需重开" : "配置已写入;新会话读取" };
29
+ }
30
+
31
+ function attempt(harness, channel, mode, work) {
32
+ try {
33
+ const result = work() ?? {};
34
+ if (result.error) throw new Error(result.error);
35
+ return { harness, channel, status: "written", runtimeConfirmed: false, ...effectFor(harness, channel, mode), ...result };
36
+ } catch (e) {
37
+ return { harness, channel, status: "failed", runtimeConfirmed: false, error: e.message };
38
+ }
39
+ }
40
+
41
+ function sync(root, options = {}) {
42
+ const store = createStore(root);
43
+ const { state: before } = loadState(root);
44
+ if (options.mode !== undefined && !MODES.includes(options.mode)) throw new Error(`未知模式: ${options.mode}`);
45
+ const persona = options.persona ?? store.activeId();
46
+ let mode = options.mode ?? before.default?.mode ?? "replace";
47
+ if (options.enabled === false) mode = "off";
48
+ if (before.default?.mode === "off" && options.enabled !== true && mode !== "off") mode = "off";
49
+ if (options.enabled === true && mode === "off") mode = MODES.includes(before.prevMode) && before.prevMode !== "off" ? before.prevMode : "replace";
50
+ const picked = store.read(persona);
51
+ if (!picked && mode !== "off") throw new Error("没有可用人设,无法应用");
52
+ if (options.persona !== undefined && !picked) throw new Error(`没有人设: ${options.persona}`);
53
+ if (options.resumeMode !== undefined && !["strict", "replace", "append"].includes(options.resumeMode)) throw new Error(`未知恢复模式: ${options.resumeMode}`);
54
+ const prevMode = options.resumeMode ?? (mode === "off" && options.mode && options.mode !== "off" ? options.mode : null) ?? (mode === "off" && before.default?.mode !== "off" ? before.default?.mode ?? "replace" : before.prevMode);
55
+ // 先发布 off,确保静态清理有一项失败时,动态通道仍然停止。
56
+ patchState(root, { default: { harness: options.harness !== undefined ? options.harness : before.default?.harness ?? null, mode }, prevMode });
57
+ if (picked) store.setActive(picked.id);
58
+ const active = join(root, "personas", "active.md");
59
+ const revision = createHash("sha256").update(JSON.stringify([picked?.id ?? null, picked?.body.trim() ?? "", mode])).digest("hex").slice(0, 16);
60
+ const paused = new Set(before.pausedInstalls ?? []);
61
+ const selected = before.armed ?? {};
62
+ // 每家生效值 = 全局默认 + 该家例外(例外存在 state.json.overrides,运行时插件读同一份)
63
+ const view = {
64
+ ...before,
65
+ persona: picked?.id ?? before.persona,
66
+ default: { harness: options.harness !== undefined ? options.harness : before.default?.harness ?? null, mode },
67
+ };
68
+ const forHarness = (harness) => {
69
+ const eff = resolveFor(view, harness);
70
+ const who = store.read(eff.persona) ?? (eff.personaFrom === "override" ? null : picked);
71
+ return {
72
+ persona: who ?? null,
73
+ active: who && picked && who.id !== picked.id ? store.materialize(who.id) : active,
74
+ // 暂停是总开关:全局 off 压过任何例外
75
+ mode: resolveActivation(view, harness, { launch: true }).mode,
76
+ };
77
+ };
78
+ const failures = options.onlyFailed ? (before.lastApply?.results ?? []).filter(r => r.status === "failed") : null;
79
+ const retry = failures ? new Set(failures.map(r => `${r.harness}:${r.channel}`)) : null;
80
+ const results = [];
81
+ for (const [harness, channels] of Object.entries(selected)) {
82
+ if (!Array.isArray(channels)) continue;
83
+ for (const channel of channels) {
84
+ if (retry && !retry.has(`${harness}:${channel}`)) continue;
85
+ const h = forHarness(harness);
86
+ if (channel === "project") {
87
+ const dirs = projectDirectories(root, harness);
88
+ if (!dirs.length) {
89
+ results.push({ ...attempt(harness, channel, h.mode, () => ({ error: "没有已登记的项目目录,请重新启用项目通道并指定目录" })), persona: h.persona?.id ?? null, mode: h.mode });
90
+ }
91
+ for (const dir of dirs) {
92
+ const file = projectFilePath(harness, dir);
93
+ if (failures && !failures.some(r => r.harness === harness && r.channel === channel && (!r.file || r.file === file))) continue;
94
+ const r = attempt(harness, channel, h.mode, () => h.mode === "off"
95
+ ? projectUninstall(harness, { root, dir, keepSelection: true })
96
+ : projectInstall(harness, { root, dir, active: String(h.persona?.body ?? "").trim() }));
97
+ results.push({ file, ...r, persona: h.persona?.id ?? null, mode: h.mode });
98
+ }
99
+ continue;
100
+ }
101
+ const r = attempt(harness, channel, h.mode, () => {
102
+ if (channel === "install") {
103
+ if (h.mode === "off" && !DYNAMIC.has(harness)) {
104
+ const r = uninstall(harness, { root });
105
+ if (!r.error) paused.add(harness);
106
+ return r;
107
+ }
108
+ if (h.mode === "off") return { action: "disabled-by-state" };
109
+ const r = install(harness, { root, active: h.active });
110
+ if (!r.error) paused.delete(harness);
111
+ return r;
112
+ }
113
+ if (channel === "slash") {
114
+ if (h.mode === "off") return { action: "disabled-by-state" };
115
+ return installSlash(harness, root, { personaId: h.persona?.id, personaBody: String(h.persona?.body ?? "").trim() });
116
+ }
117
+
118
+ if (channel === "shim") return { action: "reads-on-launch" };
119
+ return { error: `未知通道: ${channel}` };
120
+ });
121
+ results.push({ ...r, persona: h.persona?.id ?? null, mode: h.mode });
122
+ }
123
+ }
124
+ // 旧版本可能写了静态文件却漏 armed。off 也清理这些已识别的托管块,但不扩大恢复范围。
125
+ if (mode === "off" && !retry) {
126
+ for (const row of installStatus(root)) {
127
+ if (!row.installed || DYNAMIC.has(row.name) || selected[row.name]?.includes("install")) continue;
128
+ results.push(attempt(row.name, "install", mode, () => uninstall(row.name, { root })));
129
+ }
130
+ }
131
+ // runtimeConfirmed 不是写死在代码里的常量:只有本机实测抓包证明人设进了特权层才算确认。
132
+ const verified = verifiedHarnesses(root);
133
+ for (const r of results) { r.historicalCapabilityVerified = verified.has(r.harness); r.runtimeConfirmed = false; }
134
+ const failed = results.filter(r => r.status === "failed");
135
+ const report = { id: randomUUID(), at: new Date().toISOString(), revision, persona: picked?.id ?? null, mode,
136
+ ok: failed.length === 0, partial: failed.length > 0 && results.some(r => r.status !== "failed"), results,
137
+ runtimeConfirmed: results.some(r => r.runtimeConfirmed) };
138
+ patchState(root, { pausedInstalls: [...paused].filter(h => selected[h]?.includes("install")), lastApply: report });
139
+ return report;
140
+ }
141
+
142
+ export function applyConfiguration(root, options = {}) {
143
+ return exclusive(root, () => sync(root, options));
144
+ }
145
+
146
+ /** 安装范围与电源分开:暂停时增加静态通道仅记选择,不写入人设;卸载不要求人设存在。 */
147
+ export function changeChannel(root, { harness, channel, enabled, force = false, dir }) {
148
+ return exclusive(root, () => {
149
+ if (!ARMS.includes(channel)) throw new Error(`未知通道: ${channel}`);
150
+ if (!HARNESS_IDS.has(harness)) throw new Error(`未知执行器: ${harness}`);
151
+ if (typeof enabled !== "boolean") throw new Error("enabled 必须是布尔值");
152
+ const { state } = loadState(root);
153
+ // 通道路径也走同一套解析:被例外指定了其他人设/模式的那家,写进去的必须是它的值
154
+ const eff = resolveFor(state, harness);
155
+ const mode = resolveActivation(enabled ? { ...state, disabledHarnesses: (state.disabledHarnesses ?? []).filter(id => id !== harness) } : state, harness, { launch: true }).mode;
156
+ if (!enabled && !(state.armed?.[harness] ?? []).some(c => c !== channel)
157
+ && !(channel === "project" && dir !== undefined && projectDirectories(root, harness).length > 1)) {
158
+ patchState(root, { disabledHarnesses: [...new Set([...(state.disabledHarnesses ?? []), harness])] });
159
+ }
160
+ // 不指定目录的撤销用于关闭整个执行器/clean;指定目录时只撤这一处。
161
+ if (channel === "project" && !enabled && dir === undefined) {
162
+ const results = projectDirectories(root, harness).map(dir => ({
163
+ file: projectFilePath(harness, dir),
164
+ ...attempt(harness, channel, mode, () => projectUninstall(harness, { root, dir })),
165
+ }));
166
+ setArmed(root, harness, channel, projectDirectories(root, harness).length > 0);
167
+ const failed = results.filter(r => r.status === "failed");
168
+ return { harness, channel, ...effectFor(harness, channel, mode), ok: !failed.length,
169
+ status: failed.length ? "failed" : "written", runtimeConfirmed: false, results,
170
+ ...(failed.length ? { error: failed.map(r => `${r.file}: ${r.error}`).join(";") } : {}),
171
+ };
172
+ }
173
+ const result = attempt(harness, channel, mode, () => {
174
+ if (enabled && !force) {
175
+ const cap = capsFor(harness)[channel];
176
+ if (!cap?.ok) return { error: cap?.why ?? "不支持该通道" };
177
+ }
178
+ if (!enabled) {
179
+ if (channel === "install") return uninstall(harness, { root });
180
+ if (channel === "slash") return uninstallSlash(harness, root);
181
+ if (channel === "project") return projectUninstall(harness, { dir, root });
182
+ return uninstallShim(root, harness);
183
+ }
184
+ if (channel === "shim") return installShim(root, harness);
185
+ if (channel === "install" && mode === "off" && !DYNAMIC.has(harness)) return { action: "selected-while-paused" };
186
+ if (channel === "project" && mode === "off") return projectInstall(harness, { root, dir, paused: true });
187
+ const store = createStore(root);
188
+ const picked = store.read(eff.persona) ?? (eff.personaFrom === "override" ? null : store.read(store.activeId()));
189
+ if (!picked) return { error: "没有可用人设" };
190
+ const activePath = store.activeId() && picked.id !== store.activeId() ? store.materialize(picked.id) : store.refreshActive();
191
+ if (channel === "install") return install(harness, { root, active: activePath });
192
+ if (channel === "project") return projectInstall(harness, { root, active: picked.body.trim(), dir });
193
+ return installSlash(harness, root, { personaId: picked.id, personaBody: picked.body.trim() });
194
+ });
195
+ if (result.status !== "failed") {
196
+ if (enabled) patchState(root, { disabledHarnesses: (loadState(root).state.disabledHarnesses ?? []).filter(id => id !== harness) });
197
+ setArmed(root, harness, channel, channel === "project" ? projectDirectories(root, harness).length > 0 : enabled);
198
+ const paused = new Set(state.pausedInstalls ?? []);
199
+ if (channel === "install") {
200
+ if (enabled && result.action === "selected-while-paused") paused.add(harness);
201
+ else paused.delete(harness);
202
+ patchState(root, { pausedInstalls: [...paused] });
203
+ }
204
+ }
205
+ return { ok: result.status !== "failed", ...result };
206
+ });
207
+ }
208
+
209
+ /** 分家修改与所有相关落点同步在同一事务内。 */
210
+ export function configureHarness(root, harness, patch) {
211
+ return exclusive(root, () => {
212
+ if (!HARNESS_IDS.has(harness)) throw new Error(`未知执行器: ${harness}`);
213
+ if (patch.persona && !createStore(root).read(patch.persona)) throw new Error(`没有人设: ${patch.persona}`);
214
+ setOverride(root, harness, patch);
215
+ return { effective: resolveFor(loadState(root).state, harness), ...sync(root) };
216
+ });
217
+ }
218
+
219
+ export function disableHarness(root, harness) {
220
+ return exclusive(root, () => {
221
+ if (!HARNESS_IDS.has(harness)) throw new Error(`未知执行器: ${harness}`);
222
+ const state = loadState(root).state;
223
+ patchState(root, { disabledHarnesses: [...new Set([...(state.disabledHarnesses ?? []), harness])] });
224
+ const results = (state.armed?.[harness] ?? []).map(channel => changeChannel(root, { harness, channel, enabled: false }));
225
+ return { ok: results.every(r => r.ok), results };
226
+ });
227
+ }
package/lib/armed.mjs CHANGED
@@ -1,38 +1,38 @@
1
- // armed 的真相是磁盘,`state.json` 的 armed 只是缓存。
2
- // 读之前先对账:手工删了 shim 文件、旧版本写错的 install、slash 文件被清掉 —— 都在这层纠正。
3
- // (以前 Web 的 install 通道不看 install() 的 error 就 setArmed(true),留下了假记录)
4
- import { existsSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { loadState, setArmed } from "./state.mjs";
7
-
8
- const home = process.env.USERPROFILE || process.env.HOME;
9
-
10
- /** 返回被纠正掉的条目,如 ["pi:install"],方便 doctor/status 说人话 */
11
- export async function reconcileArmed(root) {
12
- const { status } = await import("./persist.mjs");
13
- const { resolveSlash } = await import("./slashes.mjs");
14
- const { state } = loadState(root);
15
- const armed = state.armed ?? {};
16
- const fixed = [];
17
- const installs = new Map(status(root).map((s) => [s.name, s.installed]));
18
- for (const [h, chans] of Object.entries(armed)) {
19
- for (const ch of [...chans]) {
20
- let alive = true;
21
- if (ch === "shim") {
22
- const bin = join(home, ".agent-purge", "bin");
23
- alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`)) || existsSync(join(bin, h));
24
- } else if (ch === "install") {
25
- alive = Boolean(installs.get(h));
26
- } else if (ch === "slash") {
27
- const s = resolveSlash(h, root);
28
- // dsh 给 probe(看挂载块,不看固定路径);其余家 slash 就是单个文件
29
- alive = Boolean(s && s.offered !== false && (s.probe ? s.probe() : existsSync(s.file)));
30
- }
31
- if (!alive) {
32
- setArmed(root, h, ch, false);
33
- fixed.push(`${h}:${ch}`);
34
- }
35
- }
36
- }
37
- return fixed;
38
- }
1
+ // armed 的真相是磁盘,`state.json` 的 armed 只是缓存。
2
+ // 读之前先对账:手工删了 shim 文件、旧版本写错的 install、slash 文件被清掉 —— 都在这层纠正。
3
+ // (以前 Web 的 install 通道不看 install() 的 error 就 setArmed(true),留下了假记录)
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { loadState, setArmed, resolveActivation } from "./state.mjs";
7
+
8
+ import { shimDir } from "./shim.mjs";
9
+
10
+ /** 返回被纠正掉的条目,如 ["pi:install"],方便 doctor/status 说人话 */
11
+ export async function reconcileArmed(root) {
12
+ const { status } = await import("./persist.mjs");
13
+ const { resolveSlash } = await import("./slashes.mjs");
14
+ const { state } = loadState(root);
15
+ const armed = state.armed ?? {};
16
+ const fixed = [];
17
+ const installs = new Map(status(root).map((s) => [s.name, s.installed]));
18
+ for (const [h, chans] of Object.entries(armed)) {
19
+ for (const ch of [...chans]) {
20
+ let alive = true;
21
+ if (ch === "shim") {
22
+ const bin = shimDir();
23
+ alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`)) || existsSync(join(bin, h));
24
+ } else if (ch === "install") {
25
+ alive = Boolean(installs.get(h)) || (resolveActivation(state, h, { launch: true }).mode === "off" && state.pausedInstalls?.includes(h));
26
+ } else if (ch === "slash") {
27
+ const s = resolveSlash(h, root);
28
+ // dsh 给 probe(看挂载块,不看固定路径);其余家 slash 就是单个文件
29
+ alive = Boolean(s && s.offered !== false && (s.probe ? s.probe() : existsSync(s.file)));
30
+ }
31
+ if (!alive) {
32
+ setArmed(root, h, ch, false);
33
+ fixed.push(`${h}:${ch}`);
34
+ }
35
+ }
36
+ }
37
+ return fixed;
38
+ }