agent-purge 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CONTRIBUTING.md +64 -0
  2. package/LICENSE +21 -21
  3. package/README.md +176 -118
  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 -772
  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
@@ -0,0 +1,36 @@
1
+ // 只传递运行时所需的系统变量;测试、抓包与构建 smoke 共用隔离边界。
2
+ import { mkdirSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ export function ownShimDirectory(env = process.env) {
7
+ return resolve(env.AGENT_PURGE_SHIM_DIR || join(env.AGENT_PURGE_HOME || join(env.USERPROFILE || env.HOME || homedir(), ".agent-purge"), "bin"));
8
+ }
9
+
10
+ const INTERNAL = new Set([
11
+ "AGENT_PURGE_FORCE", "AGENT_PURGE_MODE", "AGENT_PURGE_PERSONA",
12
+ "AGENT_PURGE_LAUNCH", "AGENT_PURGE_LAUNCH_FILE", "AGENT_PURGE_PS_ARGS",
13
+ ]);
14
+
15
+ export function launchEnvironment(env = process.env) {
16
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !INTERNAL.has(key.toUpperCase())));
17
+ }
18
+
19
+ export function isolatedEnv(root, source = process.env) {
20
+ const env = {};
21
+ for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "ComSpec", "COMSPEC", "PATHEXT", "ProgramFiles", "ProgramFiles(x86)"])
22
+ if (source[key]) env[key] = source[key];
23
+ env.HOME = env.USERPROFILE = join(root, "home");
24
+ env.TEMP = env.TMP = join(root, "tmp");
25
+ for (const key of [
26
+ "APPDATA", "LOCALAPPDATA", "AGENT_PURGE_HOME", "AGENT_PURGE_SHIM_DIR", "AGENT_PURGE_JOURNAL_DIR",
27
+ "PI_CODING_AGENT_DIR", "PI_HOME", "OMP_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CODEBUDDY_HOME",
28
+ "GROK_HOME", "HERMES_HOME", "DSH_HOME", "ZCODE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME",
29
+ ]) env[key] = join(root, key);
30
+ for (const value of new Set(Object.values(env))) if (value.startsWith(root)) mkdirSync(value, { recursive: true });
31
+ Object.assign(env, {
32
+ AGENT_PURGE_ISOLATED: "1", NO_COLOR: "1", DO_NOT_TRACK: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_AUTOUPDATER: "1",
33
+ OPENCODE_DISABLE_AUTOUPDATE: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
34
+ });
35
+ return env;
36
+ }
@@ -0,0 +1,41 @@
1
+ // Pure evidence model shared by the Web UI and Node control plane.
2
+ // An isolated capability test is never proof about an already-running session.
3
+ const array = value => Array.isArray(value) ? value : [];
4
+ const mainRequests = mode => array(mode?.requests).filter(request => request && (request.kind === undefined || request.kind === 'main'));
5
+ export const privilegedHit = request => Number(request?.markerCount) > 0 &&
6
+ array(request?.roles).some(role => role === 'system' || role === 'developer');
7
+ export const sysHit = result => array(result?.modes).some(mode => mainRequests(mode).some(privilegedHit));
8
+
9
+ export function captureScopeLabel(scope) {
10
+ if (scope?.kind === 'isolated-cli') return `隔离 CLI${scope.profile ? `(${scope.profile})` : ''}`;
11
+ if (scope?.kind === 'isolated-session') return `隔离同会话${scope.profile ? `(${scope.profile})` : ''}`;
12
+ return '历史隔离测试(未记录入口)';
13
+ }
14
+
15
+ export function verificationEvidence(result) {
16
+ const view = (state, label, kind = 'warn') => ({ state, label, kind, runtimeConfirmed: false });
17
+ if (!result) return view('not-tested', '未实测', '');
18
+ if (['missing', 'unavailable'].includes(result.status)) return view('unavailable', '未找到入口');
19
+ if (result.status === 'blocked') return view('blocked', '验证受阻');
20
+ if (result.status === 'error') return view('error', '验证出错');
21
+ if (result.status === 'no-request') return view('no-request', '未捕获请求');
22
+ const modes = array(result.modes);
23
+ if (!modes.length) return view('invalid', '无有效证据');
24
+ if (modes.some(m => m?.timedOut || (m?.exitCode !== undefined && m.exitCode !== 0) || m?.status === 'error'))
25
+ return view('error', '验证出错');
26
+ const hasRequests = modes.some(m => mainRequests(m).length > 0);
27
+ if (!hasRequests) return view('no-request', '未捕获请求');
28
+ if (modes.some(m => !mainRequests(m).length || m?.status === 'no-request'))
29
+ return view('partial', '验证未完成');
30
+ if (modes.every(m => m?.mode === 'off')) {
31
+ return modes.every(m => m.status === 'captured' && mainRequests(m).every(r => r.markerCount === 0))
32
+ ? view('off-verified', '暂停验证通过', 'blue') : view('mismatch', '暂停仍有残留');
33
+ }
34
+ const active = modes.filter(m => m?.mode !== 'off');
35
+ const hits = active.flatMap(mainRequests);
36
+ if (!hits.some(privilegedHit)) return view('miss', '请求未命中');
37
+ if (result.status !== 'captured' || modes.some(m => m?.status !== 'captured') ||
38
+ hits.some(r => !privilegedHit(r) || r.markerCount !== 1))
39
+ return view('partial', '请求部分命中');
40
+ return view('verified', '隔离测试命中', 'good');
41
+ }
@@ -0,0 +1,140 @@
1
+ // pi/omp 使用同一生成器;源码与生成运行时共用启用、锁和来源追踪实现。
2
+ import { resolveRuntime, rewriteRuntime } from "./rewrite.mjs";
3
+ import { launchRuntime } from "./launch.mjs";
4
+ import { controlRuntime } from "./control.mjs";
5
+
6
+ export function extensionSource(root, harness) {
7
+ return `// agent-purge 托管扩展:每轮读取配置;首次安装/更新后重载扩展。
8
+ import { readFileSync } from "node:fs";
9
+ ${controlRuntime}
10
+ const HOME = ${JSON.stringify(root)};
11
+ const STATE = HOME + "/state.json";
12
+ const PERSONAS = HOME + "/personas/";
13
+ const HARNESS = ${JSON.stringify(harness)};
14
+ const AUTO = HARNESS + "Auto";
15
+ ${rewriteRuntime}
16
+ ${resolveRuntime}
17
+ ${launchRuntime}
18
+ function readState() {
19
+ try {
20
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
21
+ return st && typeof st === "object" && !Array.isArray(st) ? st : null;
22
+ } catch { return null; }
23
+ }
24
+ function loadPersona(id) {
25
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id || "") || id === "active") return { id: null, body: "" };
26
+ try {
27
+ const raw = readFileSync(PERSONAS + id + ".md", "utf8").replace(/^---\\r?\\n[\\s\\S]*?\\r?\\n---(?:\\r?\\n|$)/, "");
28
+ return { id, body: raw.trim() };
29
+ } catch { return { id, body: "" }; }
30
+ }
31
+ export default function agentPurge(api) {
32
+ let forced = null, explicitMode = null, commandEpoch = null;
33
+ let rewrite = createTextRewriter();
34
+ let messageRecords = new Map(), generated = createProvenance(), bridge = null;
35
+ function config() {
36
+ const st = readState();
37
+ const launch = readLaunch(HOME, st, HARNESS);
38
+ const epoch = (st?.activationEpoch || "initial") + ":" + (st?.harnessEpochs?.[HARNESS] || "initial");
39
+ if (commandEpoch && commandEpoch !== epoch) { forced = null; explicitMode = null; commandEpoch = null; }
40
+ const c = resolveActivation(st, HARNESS, { channel: "slash", session: forced, launch: !!launch,
41
+ mode: explicitMode || launch?.mode, persona: launch?.persona });
42
+ // CLI 参数已注入时,这个进程的常驻扩展只负责撤销自己以前写的内容。
43
+ if (launch?.writer === "cli") c.on = false;
44
+ const persona = loadPersona(c.persona);
45
+ return { ...c, st, ...persona, on: c.on && !!persona.body, epoch };
46
+ }
47
+ function recordFor(msg, index) {
48
+ const key = msg.role + ":" + (msg.id ?? msg.timestamp ?? index);
49
+ if (!messageRecords.has(key)) messageRecords.set(key, createProvenance());
50
+ while (messageRecords.size > 64) messageRecords.delete(messageRecords.keys().next().value);
51
+ return messageRecords.get(key);
52
+ }
53
+ api.on("session_start", async () => {
54
+ forced = null; explicitMode = null; commandEpoch = null;
55
+ rewrite = createTextRewriter(); messageRecords = new Map(); generated = createProvenance(); bridge = null;
56
+ });
57
+ api.on("before_agent_start", async event => {
58
+ const c = config();
59
+ const input = Array.isArray(event.systemPrompt) ? event.systemPrompt : [event.systemPrompt ?? ""];
60
+ const restored = rewrite(input, "", "off");
61
+ const next = rewrite(input, c.on ? c.body : "", c.on ? c.mode : "off");
62
+ if (c.on) bridge = { before: restored.join("\\n\\n"), after: next.join("\\n\\n") };
63
+ return { systemPrompt: HARNESS === "omp" ? next : next.join("\\n\\n") };
64
+ });
65
+ if (HARNESS === "pi") {
66
+ try {
67
+ api.on("context_with_system", async event => {
68
+ if (!Array.isArray(event.messages)) return;
69
+ const c = config(), messages = event.messages;
70
+ // 只有完整匹配本会话生成消息的来源记录才删除,宿主/用户同名标签不参与清洗。
71
+ for (let i = messages.length - 1; i >= 0; i--) if (generated.restore(messages[i]) === null) messages.splice(i, 1);
72
+ for (let i = 0; i < messages.length; i++) {
73
+ const msg = messages[i];
74
+ if (msg.role !== "system" && msg.role !== "user") continue;
75
+ const provenance = recordFor(msg, messages.slice(0, i).filter(m => m.role === msg.role).length);
76
+ let host = provenance.restore(msg.content);
77
+ if (msg.role === "system" && bridge) {
78
+ if (host === bridge.after) host = bridge.before;
79
+ else if (Array.isArray(host)) host = host.map(b => b?.text === bridge.after ? { ...b, text: bridge.before } : b);
80
+ }
81
+ let next = host;
82
+ if (msg.role === "system" && c.on) {
83
+ next = typeof host === "string" ? rewriteTexts([host], c.body, c.mode).join("\\n\\n") : createContentRewriter()(host, c.body, c.mode);
84
+ }
85
+ msg.content = provenance.remember(host, next);
86
+ }
87
+ if (c.on && !messages.some(m => m.role === "system")) {
88
+ const own = { role: "system", content: c.body };
89
+ generated.remember(null, own); messages.unshift(own);
90
+ }
91
+ if (c.on && c.st.piUserLayer) {
92
+ const index = messages.findLastIndex(m => m.role === "user");
93
+ if (index >= 0) {
94
+ const msg = messages[index], provenance = recordFor(msg, messages.slice(0, index).filter(m => m.role === msg.role).length);
95
+ const host = provenance.restore(msg.content);
96
+ const prefix = "<agent-purge>\\n" + c.body + "\\n</agent-purge>\\n\\n";
97
+ const next = typeof host === "string" ? prefix + host : [{ type: "text", text: prefix }, ...(Array.isArray(host) ? host : [])];
98
+ msg.content = provenance.remember(host, next);
99
+ }
100
+ }
101
+ });
102
+ } catch { /* 旧宿主只支持 before_agent_start */ }
103
+ }
104
+ api.registerCommand("purge", {
105
+ description: "会话开关、人设与模式(跟随 agent-purge 配置)",
106
+ getArgumentCompletions: prefix => ["on", "off", "always", "status", "reload", "mode"].filter(v => v.startsWith(prefix)).map(value => ({ value, label: value })),
107
+ handler: async (args, ctx) => {
108
+ const [cmd = "", want] = String(args).trim().split(/\\s+/);
109
+ const tell = (text, level = "info") => ctx.ui.notify(text, level);
110
+ const c = config();
111
+ if (cmd === "status" || cmd === "reload") return tell("状态: " + (c.on ? "开启" : "关闭") + "\\n模式: " + c.mode + "\\n人设: " + (c.id || "未设置") + "\\n启用来源: " + (c.source || c.blockedBy || "无") + "\\n配置: " + STATE);
112
+ if (cmd === "off" || (!cmd && c.on)) { forced = false; commandEpoch = c.epoch; return tell("本会话已关闭;下一轮撤销有来源记录的内容"); }
113
+ if (cmd === "on" || !cmd) {
114
+ const activation = resolveActivation(c.st, HARNESS, { channel: "slash", session: true });
115
+ if (!activation.on) return tell("当前未启用: " + activation.blockedBy, "error");
116
+ if (!c.body) return tell("没有可用人设", "error");
117
+ forced = true; commandEpoch = c.epoch;
118
+ return tell("本会话已开启,下一轮读取当前人设");
119
+ }
120
+ if (cmd === "mode") {
121
+ if (!["strict", "replace", "append"].includes(want)) return tell("用法: /purge mode strict|replace|append", "error");
122
+ explicitMode = want; commandEpoch = c.epoch;
123
+ return tell("本会话模式 → " + want + ";暂停和执行器停用仍优先");
124
+ }
125
+ if (cmd === "always") {
126
+ if (!["on", "off"].includes(want)) return tell("用法: /purge always on|off", "error");
127
+ try {
128
+ withControlLock(HOME, () => {
129
+ const st = readState(); if (!st) throw new Error("配置缺失或损坏");
130
+ atomicControlJSON(STATE, { ...st, [AUTO]: want === "on" });
131
+ });
132
+ return tell("裸启动自动应用 → " + want + ";通道选择和暂停保持不变");
133
+ } catch (e) { return tell(e.message, "error"); }
134
+ }
135
+ tell("用法: /purge [on|off|status|reload|mode strict|replace|append|always on|off]", "error");
136
+ },
137
+ });
138
+ }
139
+ `;
140
+ }
@@ -0,0 +1,59 @@
1
+ // 足迹账本保留写入路径、所有者与还原方式;损坏时保留原文件并停止销账。
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { atomicControlJSON, withControlLock } from "./control.mjs";
6
+
7
+ const keyOf = path => process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
8
+ const sameLocation = (a, b) => (a.kind ?? "file") === (b.kind ?? "file") && keyOf(a.path) === keyOf(b.path);
9
+ const sameOwner = (a, b) => sameLocation(a, b) && a.harness === b.harness && a.channel === b.channel;
10
+ const idOf = entry => createHash("sha256").update([entry.kind ?? "file", keyOf(entry.path), entry.harness ?? "", entry.channel ?? ""].join("\0")).digest("hex").slice(0, 24);
11
+ export const ledgerFile = root => join(root, "footprints.json");
12
+
13
+ export function loadLedger(root) {
14
+ const file = ledgerFile(root);
15
+ try {
16
+ const raw = JSON.parse(readFileSync(file, "utf8"));
17
+ if (!Array.isArray(raw?.entries) || raw.entries.some(e => !e || typeof e.path !== "string" || !e.path || typeof e.undo?.type !== "string")) throw new Error("账本结构无效");
18
+ return { version: 1, entries: raw.entries };
19
+ } catch (error) {
20
+ if (error.code === "ENOENT") return { version: 1, entries: [] };
21
+ throw new Error(`足迹账本损坏,原文件已保留:${file}`, { cause: error });
22
+ }
23
+ }
24
+ export function saveLedger(root, entries) {
25
+ return withControlLock(root, () => atomicControlJSON(ledgerFile(root), { version: 1, entries }));
26
+ }
27
+
28
+ /** 同一位置、harness、channel 幂等;共享 PATH 不会互相覆盖所有权。 */
29
+ export function record(root, entry) {
30
+ if (!entry?.path || !entry?.undo?.type) throw new Error("足迹缺少路径或撤销方式");
31
+ return withControlLock(root, () => {
32
+ const ledger = loadLedger(root), at = ledger.entries.findIndex(e => sameOwner(e, entry));
33
+ const previous = at >= 0 ? ledger.entries[at] : null;
34
+ const next = { ...previous, ...entry, id: idOf(entry), createdAt: previous?.createdAt ?? entry.createdAt ?? new Date().toISOString() };
35
+ if (!entry.sha256 && entry.kind !== "path" && existsSync(entry.path)) next.sha256 = createHash("sha256").update(readFileSync(entry.path)).digest("hex");
36
+ if (at >= 0) ledger.entries[at] = next; else ledger.entries.push(next);
37
+ saveLedger(root, ledger.entries);
38
+ return next;
39
+ });
40
+ }
41
+
42
+ /** 精确 id 或位置销账;有 owner 条件时只影响该通道。 */
43
+ export function cancel(root, entry) {
44
+ if (!entry?.path && !entry?.id) return;
45
+ return withControlLock(root, () => {
46
+ const ledger = loadLedger(root);
47
+ const kept = ledger.entries.filter(e => entry.id ? e.id !== entry.id :
48
+ !sameLocation(e, entry) || (entry.harness !== undefined && e.harness !== entry.harness) || (entry.channel !== undefined && e.channel !== entry.channel));
49
+ if (kept.length !== ledger.entries.length) saveLedger(root, kept);
50
+ });
51
+ }
52
+ export function forgetHarness(root, harness, channel = null) {
53
+ return withControlLock(root, () => {
54
+ const ledger = loadLedger(root);
55
+ const kept = ledger.entries.filter(e => e.harness !== harness || (channel !== null && e.channel !== channel));
56
+ if (kept.length !== ledger.entries.length) saveLedger(root, kept);
57
+ });
58
+ }
59
+ export const listLedger = root => loadLedger(root).entries;
package/lib/home.mjs CHANGED
@@ -1,47 +1,47 @@
1
- // 用户数据目录:state.json 与 personas/ 不跟代码走。
2
- //
3
- // 原因:npm 升级是「整个包目录替换」,数据若留在包目录里会连人设一起被清掉。
4
- // 所以代码认 ROOT(装在哪都行),数据认 HOME:
5
- // AGENT_PURGE_HOME 显式指定 > ~/.agent-purge
6
- //
7
- // 老布局(数据躺在包目录里)在首次运行时整份拷过来,原件留着当备份,不删。
8
- import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
9
- import { homedir } from "node:os";
10
- import { isAbsolute, join, resolve } from "node:path";
11
-
12
- export function dataHome() {
13
- const env = process.env.AGENT_PURGE_HOME?.trim();
14
- if (!env) return join(homedir(), ".agent-purge");
15
- return isAbsolute(env) ? env : resolve(env);
16
- }
17
-
18
- /**
19
- * 确保数据目录存在,并把旧布局搬过来。
20
- * @returns {{ home: string, migrated: string[] }}
21
- */
22
- export function ensureDataHome(pkgRoot = null) {
23
- const home = dataHome();
24
- mkdirSync(home, { recursive: true });
25
- const migrated = [];
26
- // 显式指定 AGENT_PURGE_HOME = 用户自己挑的新家,不往里搬旧数据
27
- if (!pkgRoot || process.env.AGENT_PURGE_HOME?.trim() || resolve(pkgRoot) === resolve(home)) {
28
- return { home, migrated };
29
- }
30
-
31
- const state = [join(pkgRoot, "state.json"), join(home, "state.json")];
32
- if (existsSync(state[0]) && !existsSync(state[1])) {
33
- copyFileSync(state[0], state[1]);
34
- migrated.push(`state: ${state[0]} → ${state[1]}`);
35
- }
36
-
37
- const from = join(pkgRoot, "personas");
38
- const to = join(home, "personas");
39
- if (existsSync(from) && !existsSync(to)) {
40
- const md = readdirSync(from).filter((f) => f.endsWith(".md"));
41
- if (md.length) {
42
- cpSync(from, to, { recursive: true });
43
- migrated.push(`personas: ${from} → ${to}(${md.length} 个)`);
44
- }
45
- }
46
- return { home, migrated };
47
- }
1
+ // 用户数据目录:state.json 与 personas/ 不跟代码走。
2
+ //
3
+ // 原因:npm 升级是「整个包目录替换」,数据若留在包目录里会连人设一起被清掉。
4
+ // 所以代码认 ROOT(装在哪都行),数据认 HOME:
5
+ // AGENT_PURGE_HOME 显式指定 > ~/.agent-purge
6
+ //
7
+ // 老布局(数据躺在包目录里)在首次运行时整份拷过来,原件留着当备份,不删。
8
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+
12
+ export function dataHome() {
13
+ const env = process.env.AGENT_PURGE_HOME?.trim();
14
+ if (!env) return join(homedir(), ".agent-purge");
15
+ return isAbsolute(env) ? env : resolve(env);
16
+ }
17
+
18
+ /**
19
+ * 确保数据目录存在,并把旧布局搬过来。
20
+ * @returns {{ home: string, migrated: string[] }}
21
+ */
22
+ export function ensureDataHome(pkgRoot = null) {
23
+ const home = dataHome();
24
+ mkdirSync(home, { recursive: true });
25
+ const migrated = [];
26
+ // 显式指定 AGENT_PURGE_HOME = 用户自己挑的新家,不往里搬旧数据
27
+ if (!pkgRoot || process.env.AGENT_PURGE_HOME?.trim() || resolve(pkgRoot) === resolve(home)) {
28
+ return { home, migrated };
29
+ }
30
+
31
+ const state = [join(pkgRoot, "state.json"), join(home, "state.json")];
32
+ if (existsSync(state[0]) && !existsSync(state[1])) {
33
+ copyFileSync(state[0], state[1]);
34
+ migrated.push(`state: ${state[0]} → ${state[1]}`);
35
+ }
36
+
37
+ const from = join(pkgRoot, "personas");
38
+ const to = join(home, "personas");
39
+ if (existsSync(from) && !existsSync(to)) {
40
+ const md = readdirSync(from).filter((f) => f.endsWith(".md"));
41
+ if (md.length) {
42
+ cpSync(from, to, { recursive: true });
43
+ migrated.push(`personas: ${from} → ${to}(${md.length} 个)`);
44
+ }
45
+ }
46
+ return { home, migrated };
47
+ }
@@ -0,0 +1,24 @@
1
+ // 单次 off 不移除其他来源;有静态托管块时报告精确路径,不靠粗粒度禁用宿主功能。
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, parse, resolve } from "node:path";
4
+ import { targets, stripBlock } from "./persist.mjs";
5
+ import { listLedger } from "./footprints.mjs";
6
+ import { projectFilePath } from "./project.mjs";
7
+
8
+ export function launchConflicts(root, harness, cwd) {
9
+ const files = new Set();
10
+ const target = targets[harness];
11
+ if (target?.kind === "block") files.add(target.file());
12
+ for (const row of listLedger(root)) if (row.kind === "block" && (row.harness === harness || row.channel === "project")) files.add(row.path);
13
+ let dir = resolve(cwd);
14
+ while (true) {
15
+ const file = projectFilePath(harness, dir); if (file) files.add(file);
16
+ if (dir === parse(dir).root) break;
17
+ dir = dirname(dir);
18
+ }
19
+ return [...files].filter(file => {
20
+ if (!existsSync(file)) return false;
21
+ try { const text = readFileSync(file, "utf8"); return stripBlock(text) !== text; }
22
+ catch { throw new Error(`静态配置读取失败:${file}`); }
23
+ });
24
+ }
package/lib/launch.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // 每次显式启动独立凭据;token 继承、孤儿插件、别家的进程均不是启用许可。
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { atomicControlJSON, withControlLock } from "./control.mjs";
6
+ import { loadState } from "./state.mjs";
7
+
8
+ /** 纯核验也生成进插件;bornAt 是宿主运行时实际的进程启动时间。 */
9
+ export function validLaunch(record, state, harness, identity) {
10
+ if (!record || record.version !== 1 || record.harness !== harness || record.token !== identity.token) return false;
11
+ if (!identity.token || record.hostPid !== identity.pid || !Number.isSafeInteger(record.hostPid)) return false;
12
+ if (!Number.isFinite(identity.bornAt) || Math.abs(identity.bornAt - record.startedAt) > 10000) return false;
13
+ if (!Number.isFinite(record.expiresAt) || record.expiresAt < identity.now || record.startedAt > identity.now + 1000) return false;
14
+ if (record.epoch !== (state?.activationEpoch ?? "initial")) return false;
15
+ if (record.harnessEpoch !== (state?.harnessEpochs?.[harness] ?? "initial")) return false;
16
+ return true;
17
+ }
18
+
19
+ export function readLaunch(root, state, harness, token = process.env.AGENT_PURGE_LAUNCH) {
20
+ if (!/^[a-f0-9-]{36}$/i.test(token ?? "")) return null;
21
+ try {
22
+ const record = JSON.parse(readFileSync(root + "/runtime/" + token + ".json", "utf8"));
23
+ if (!validLaunch(record, state, harness, { token, pid: process.pid, bornAt: Date.now() - process.uptime() * 1000, now: Date.now() })) return null;
24
+ try { process.kill(record.ownerPid, 0); } catch { return null; }
25
+ return record;
26
+ } catch { return null; }
27
+ }
28
+
29
+ /** 发布前回调保持透传;发布、停用均与配置事务使用同一把锁。 */
30
+ export function createLaunch(root, harness, { persona = null, mode, writer = "temporary" } = {}) {
31
+ const token = randomUUID(), file = join(root, "runtime", token + ".json");
32
+ const state = loadState(root).state;
33
+ const initial = { epoch: state.activationEpoch ?? "initial", harnessEpoch: state.harnessEpochs?.[harness] ?? "initial" };
34
+ let record = null, timer = null, closed = false;
35
+ return {
36
+ token, file, env: { AGENT_PURGE_LAUNCH: token },
37
+ publish(hostPid, startedAt) {
38
+ if (closed || !Number.isSafeInteger(hostPid)) throw new Error("启动凭据缺少宿主 PID");
39
+ withControlLock(root, () => {
40
+ const current = loadState(root).state;
41
+ if ((current.activationEpoch ?? "initial") !== initial.epoch || (current.harnessEpochs?.[harness] ?? "initial") !== initial.harnessEpoch)
42
+ throw new Error("启动期间配置已撤销,请重新启动");
43
+ mkdirSync(join(root, "runtime"), { recursive: true });
44
+ record = { version: 1, token, harness, ownerPid: process.pid, hostPid, startedAt, persona, mode, writer, ...initial, expiresAt: Date.now() + 60000 };
45
+ atomicControlJSON(file, record);
46
+ });
47
+ timer = setInterval(() => {
48
+ if (closed || !existsSync(file)) return;
49
+ try { record.expiresAt = Date.now() + 60000; atomicControlJSON(file, record); } catch { /* 丢失凭据则停止,而非借 FORCE 回退 */ }
50
+ }, 10000);
51
+ timer.unref();
52
+ },
53
+ close() { if (timer) clearInterval(timer); closed = true; rmSync(file, { force: true }); },
54
+ };
55
+ }
56
+
57
+ export const launchRuntime = [validLaunch, readLaunch].map(fn => fn.toString()).join("\n");
@@ -0,0 +1,62 @@
1
+ // OpenCode 两代 API 共用相同的启用/撤销实现;临时插件只接受父进程发布的凭据。
2
+ import { join } from "node:path";
3
+ import { personaBodyRuntime, resolveRuntime, rewriteRuntime } from "./rewrite.mjs";
4
+ import { launchRuntime } from "./launch.mjs";
5
+
6
+ export function opencodeSource(root, harness, { temporary = false, active = join(root, "personas", "active.md") } = {}) {
7
+ const common = `// agent-purge 托管文件;${temporary ? "仅本次显式启动" : "常驻配置"},每轮检查实时状态。
8
+ import { readFileSync } from "node:fs";
9
+ const HOME = ${JSON.stringify(root)};
10
+ const STATE = HOME + "/state.json";
11
+ const PERSONAS = HOME + "/personas";
12
+ const ACTIVE = ${JSON.stringify(active)};
13
+ const HARNESS = ${JSON.stringify(harness)};
14
+ const TEMPORARY = ${temporary};
15
+ ${rewriteRuntime}
16
+ ${resolveRuntime}
17
+ ${personaBodyRuntime}
18
+ ${launchRuntime}
19
+ function cfg() {
20
+ try {
21
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
22
+ const lease = readLaunch(HOME, st, HARNESS);
23
+ if (TEMPORARY ? !lease || lease.writer !== "temporary" : lease?.writer === "temporary") return null;
24
+ const c = resolveActivation(st, HARNESS, { channel: "install", launch: TEMPORARY && !!lease,
25
+ mode: TEMPORARY ? lease?.mode : undefined, persona: TEMPORARY ? lease?.persona : undefined });
26
+ if (!c.on) return null;
27
+ const body = resolvePersonaBody(PERSONAS, c.persona, ACTIVE);
28
+ return body ? { mode: c.mode, body } : null;
29
+ } catch { return null; }
30
+ }
31
+ const sessions = new Map();
32
+ function rewriter(id) {
33
+ const key = id ?? "default";
34
+ if (!sessions.has(key)) sessions.set(key, ${harness === "opencode" ? "createTextRewriter" : "createContentRewriter"}());
35
+ while (sessions.size > 64) sessions.delete(sessions.keys().next().value);
36
+ return sessions.get(key);
37
+ }
38
+ `;
39
+ if (harness === "opencode") return common + `
40
+ export const AgentPurge = async () => ({
41
+ "experimental.chat.system.transform": async (input, output) => {
42
+ if (!Array.isArray(output.system)) return;
43
+ const c = cfg();
44
+ const next = rewriter(input?.sessionID)(output.system, c?.body || "", c?.mode || "off");
45
+ if (JSON.stringify(next) !== JSON.stringify(output.system)) output.system.splice(0, output.system.length, ...next);
46
+ },
47
+ });
48
+ `;
49
+ return common + `
50
+ export default {
51
+ id: ${JSON.stringify(temporary ? "agent-purge-v2-session" : "agent-purge")},
52
+ async setup(ctx) {
53
+ await ctx.session.hook("context", event => {
54
+ if (!Array.isArray(event.system)) return;
55
+ const c = cfg();
56
+ const next = rewriter(event.sessionID ?? event.session?.id)(event.system, c?.body || "", c?.mode || "off");
57
+ if (JSON.stringify(next) !== JSON.stringify(event.system)) event.system.splice(0, event.system.length, ...next);
58
+ });
59
+ },
60
+ };
61
+ `;
62
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // 配置落点只在这里解释。XDG_CONFIG_HOME 是配置根,PI_CODING_AGENT_DIR 是 agent 目录。
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export function userHome() {
6
+ return process.env.USERPROFILE || process.env.HOME || homedir();
7
+ }
8
+ export function configHome(id) {
9
+ const home = userHome();
10
+ const env = process.env;
11
+ const roots = {
12
+ pi: env.PI_CODING_AGENT_DIR || env.PI_HOME || join(home, ".pi", "agent"),
13
+ omp: env.OMP_HOME || join(home, ".omp"),
14
+ codex: env.CODEX_HOME || join(home, ".codex"),
15
+ claude: env.CLAUDE_CONFIG_DIR || join(home, ".claude"),
16
+ codebuddy: env.CODEBUDDY_HOME || join(home, ".codebuddy"),
17
+ grok: env.GROK_HOME || join(home, ".grok"),
18
+ dsh: env.DSH_HOME || join(home, ".dsh"),
19
+ zcode: env.ZCODE_HOME || join(home, ".zcode"),
20
+ hermes: env.HERMES_HOME || (process.platform === "win32" ? join(home, "AppData", "Local", "hermes") : join(home, ".hermes")),
21
+ opencode: join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode"),
22
+ };
23
+ const root = roots[id === "opencode2" ? "opencode" : id];
24
+ return root ? resolve(root) : null;
25
+ }