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/spawn.mjs CHANGED
@@ -1,126 +1,182 @@
1
- // Windows 安全的进程启动。
2
- // 为什么需要它:npm 全局 shim 不是 .exe,而 CreateProcess 只认 .exe ——
3
- // spawn("codex", { shell: false }) 在 Windows 上必 ENOENT
4
- // (实测:codex / pi / opencode / codebuddy 全中;claude / grok / omp / hermes 有真 .exe 才没事)。
5
- // 也不用 shell: true:那会把 persona 文本交给 cmd.exe 解析,%VAR% 展开、^ 被吃,全是注入面。
6
- // 做法:先把名字解析成磁盘全路径,再按扩展名分流,优先级:
7
- // .exe → 直接启动(真二进制;Unix 一切照旧)
8
- // .ps1 → powershell -File(npm>=7 与 pi 安装器都生成;$args 数组传参,实测
9
- // 引号/空格/%VAR%/^/&| 全部字节级保真)
10
- // .cmd/.bat → ComSpec /d /s /c 兜底(老 npm 或手写 shim;仍有 cmd 展开风险,故排最后)
11
- import { spawn, spawnSync } from "node:child_process";
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { homedir } from "node:os";
14
- import { delimiter, dirname, extname, join, resolve } from "node:path";
15
-
16
- const WIN = process.platform === "win32";
17
-
18
- /** 我们自己的裸命令目录:扫 PATH 时必须跳过它,否则 spawn 会绕回 wrapper 套娃 */
19
- const OWN_SHIM_DIR = join(process.env.USERPROFILE || process.env.HOME || homedir(), ".agent-purge", "bin");
20
-
21
- /** 在 PATH 上按扩展名优先级找可执行文件;找不到返回 null。 */
22
- export function which(names, path, exts = WIN ? [".exe", ".cmd", ".bat", ".ps1"] : [""]) {
23
- const own = resolve(OWN_SHIM_DIR).toLowerCase();
24
- for (const name of names) {
25
- for (const dir of path.split(delimiter)) {
26
- if (!dir) continue;
27
- if (resolve(dir).toLowerCase() === own) continue;
28
- for (const ext of exts) {
29
- const p = join(dir, name + ext);
30
- if (existsSync(p)) return p;
31
- }
32
- }
33
- }
34
- return null;
35
- }
36
-
37
- // 启动解析优先级:真二进制 > .ps1(保真)> .cmd/.bat(兜底)
38
- const SPAWN_EXTS = [".exe", ".ps1", ".cmd", ".bat"];
39
-
40
- /** 把命令名解析成磁盘全路径;找不到返回 null。 */
41
- export function resolveBin(name) {
42
- if (!name) return null;
43
- if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
44
- // shim 接管:wrapper 会把自己的真身写进 AGENT_PURGE_REAL_BIN,优先用它,
45
- // 否则 PATH 里排第一的是我们自己的 wrapper,会无限套娃。
46
- const real = process.env.AGENT_PURGE_REAL_BIN;
47
- if (real && existsSync(real)) {
48
- const base = real.trim().replace(/^.*[\\/]/, "").replace(/\.(exe|cmd|bat|ps1)$/i, "").toLowerCase();
49
- if (base === String(name).toLowerCase()) return real.trim();
50
- }
51
- return which([name], process.env.PATH || "", WIN ? SPAWN_EXTS : [""]);
52
- }
53
-
54
- /** cmd.exe 侧的单参加引号:含空格或引号时整体包起来,内嵌引号转成 \"(msvcrt argv 认识它) */
55
- const winQuote = (a) => {
56
- const s = String(a);
57
- return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
58
- };
59
-
60
- /**
61
- * cmd.exe 的命令行上限是 8191 字符(真·硬限)。npm 全局 shim 都是 .cmd,
62
- * 一旦参数里夹了整段人设(9K+),裸命令就会报“The command line is too long.”,
63
- * 而 agent-purge <h> 没这条限制 —— 同一个 harness 两种入口行为不一致。
64
- * 解:找出 .cmd 里真正启动的 node 脚本,直接 node 启,绕开 cmd.exe。
65
- * 只认 npm 的 `"%dp0%...node_modules..."` 形态,认不出就老实走 ComSpec。
66
- */
67
- const CMD_LINE_LIMIT = 7000;
68
- function cmdShimNodeTarget(file) {
69
- try {
70
- const txt = readFileSync(file, "utf8");
71
- const m = txt.match(/"%dp0%\\?([^"]*node_modules[^"]*)"/i);
72
- if (!m) return null;
73
- const p = join(dirname(file), m[1].replace(/\\/g, "\\"));
74
- return existsSync(p) ? p : null;
75
- } catch {
76
- return null;
77
- }
78
- }
79
-
80
- function dispatch(name, args, opts) {
81
- // 非 Windows 也要解析成真实路径:否则 shim 目录里的同名壳转回 agent-purge 时,
82
- // spawn("pi") 又会命中最前面的壳,无限套娃。
83
- if (!WIN) return { cmd: resolveBin(name) ?? name, args, opts: { ...opts, shell: false } };
84
- const full = resolveBin(name) ?? name;
85
- const ext = extname(full).toLowerCase();
86
- if (ext === ".ps1") {
87
- return {
88
- cmd: "powershell.exe",
89
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", full, ...args],
90
- opts: { ...opts, shell: false },
91
- };
92
- }
93
- if (ext === ".cmd" || ext === ".bat") {
94
- const comspec = process.env.ComSpec || process.env.COMSPEC || "cmd.exe";
95
- // /s:首尾成对的引号由 cmd 自己剥掉一层,剩下的就是干净的命令行
96
- const line = [`"${full}"`, ...args.map(winQuote)].join(" ");
97
- // 参数太长就不要过 cmd.exe(8191 上限):直接 node 启 .cmd 里那个脚本
98
- if (line.length > CMD_LINE_LIMIT) {
99
- const js = cmdShimNodeTarget(full);
100
- if (js) return { cmd: process.execPath, args: [js, ...args], opts: { ...opts, shell: false } };
101
- }
102
- return {
103
- cmd: comspec,
104
- args: ["/d", "/s", "/c", `"${line}"`],
105
- opts: { ...opts, shell: false, windowsVerbatimArguments: true },
106
- };
107
- }
108
- return { cmd: full, args, opts: { ...opts, shell: false } };
109
- }
110
-
111
- /** 跨平台启动。返回 ChildProcess;调用方照常用 on("error")/on("close")。 */
112
- export function spawnBin(name, args = [], opts = {}) {
113
- const d = dispatch(name, args, opts);
114
- return spawn(d.cmd, d.args, d.opts);
115
- }
116
-
117
- /** 抓一次 stdout(版本探测之类用)。失败/超时返回 null,不抛。 */
118
- export function captureBin(name, args = [], opts = {}) {
119
- const d = dispatch(name, args, { timeout: 4000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts });
120
- try {
121
- const r = spawnSync(d.cmd, d.args, d.opts);
122
- return r.error ? null : (r.stdout ?? null);
123
- } catch {
124
- return null;
125
- }
126
- }
1
+ // shell:false 为默认;标准 npm shim 直接解析为 Node 入口,其他脚本分开处理。
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { delimiter, dirname, extname, join, resolve } from "node:path";
5
+ import { resolveActivation } from "./state.mjs";
6
+ import { ownShimDirectory } from "./environment.mjs";
7
+ import { knownWrapper } from "./wrappers.mjs";
8
+
9
+ const WIN = process.platform === "win32";
10
+ const OWN_SHIM_DIR = ownShimDirectory();
11
+ const norm = (p) => WIN ? resolve(p).toLowerCase() : resolve(p);
12
+ export function which(names, path, exts = WIN ? [".exe", ".cmd", ".bat", ".ps1"] : [""]) {
13
+ for (const name of names) for (const raw of path.split(delimiter)) {
14
+ const dir = raw.replace(/^"|"$/g, "");
15
+ if (!dir || norm(dir) === norm(OWN_SHIM_DIR)) continue;
16
+ for (const ext of exts) { const p = join(dir, name + ext); if (existsSync(p)) return p; }
17
+ }
18
+ return null;
19
+ }
20
+ const SPAWN_EXTS = [".exe", ".ps1", ".cmd", ".bat"];
21
+ export function resolveBin(name, env = process.env) {
22
+ if (!name) return null;
23
+ if (name.includes("/") || name.includes("\\")) return existsSync(name) ? name : null;
24
+ const real = env.AGENT_PURGE_REAL_BIN;
25
+ if (real && existsSync(real)) {
26
+ const base = real.replace(/^.*[\\/]/, "").replace(/\.(exe|cmd|bat|ps1)$/i, "");
27
+ if ((WIN ? base.toLowerCase() : base) === (WIN ? name.toLowerCase() : name)) return real;
28
+ }
29
+ return which([name], env.PATH || env.Path || "", WIN ? SPAWN_EXTS : [""]);
30
+ }
31
+ /** 显式参数 > 分家配置 > 全局;继承的注入环境不是新一次启用。 */
32
+ export function launchConfig(state, id, { persona = null, mode = null } = {}) {
33
+ const activation = resolveActivation(state, id, { launch: true, persona: persona || undefined, mode: mode || undefined });
34
+ return { persona: activation.persona, mode: activation.mode };
35
+ }
36
+ // 仅接受完整的标准模板;不能从任意脚本中捞出一条 node_modules 路径跳过其他逻辑。
37
+ const compact = (s) => s.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).map((l) => l.trim()).filter(Boolean).join("\n");
38
+ function npmTarget(file) {
39
+ let text;
40
+ try { text = readFileSync(file, "utf8"); } catch { return null; }
41
+ const ext = extname(file).toLowerCase();
42
+ let target;
43
+ if (ext === ".ps1") {
44
+ const hits = [...text.matchAll(/"\$basedir\/([^"\r\n]+)" \$args/g)];
45
+ if (hits.length !== 4 || !hits.every((m) => m[1] === hits[0][1])) return null;
46
+ target = hits[0][1];
47
+ const template = `$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
48
+ $exe=""
49
+ if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
50
+ $exe=".exe"
51
+ }
52
+ $ret=0
53
+ if (Test-Path "$basedir/node$exe") {
54
+ if ($MyInvocation.ExpectingInput) {
55
+ $input | & "$basedir/node$exe" "$basedir/${target}" $args
56
+ } else {
57
+ & "$basedir/node$exe" "$basedir/${target}" $args
58
+ }
59
+ $ret=$LASTEXITCODE
60
+ } else {
61
+ if ($MyInvocation.ExpectingInput) {
62
+ $input | & "node$exe" "$basedir/${target}" $args
63
+ } else {
64
+ & "node$exe" "$basedir/${target}" $args
65
+ }
66
+ $ret=$LASTEXITCODE
67
+ }
68
+ exit $ret`;
69
+ if (compact(text) !== compact(template)) return null;
70
+ } else if (ext === ".cmd") {
71
+ const legacy = /"%dp0%\\([^"\r\n]+)" %\*/i.exec(text);
72
+ const modern = /SET "NPM_CLI_JS=%~dp0\\([^"\r\n]+)"[\s\S]*?"%NODE_EXE%" "%NPM_CLI_JS%" %\*/i.exec(text);
73
+ const m = legacy ?? modern; if (!m) return null;
74
+ target = m[1];
75
+ if (modern) {
76
+ // Node 20+ 的 npm.cmd 先解析 npm-prefix.js,再执行 npm-cli.js;只接受这一固定入口。
77
+ if (!/^node_modules[\\/]npm[\\/]bin[\\/]npm-cli\.js$/.test(target)) return null;
78
+ } else {
79
+ const template = `@ECHO off
80
+ GOTO start
81
+ :find_dp0
82
+ SET dp0=%~dp0
83
+ EXIT /b
84
+ :start
85
+ SETLOCAL
86
+ CALL :find_dp0
87
+ IF EXIST "%dp0%\\node.exe" (
88
+ SET "_prog=%dp0%\\node.exe"
89
+ ) ELSE (
90
+ SET "_prog=node"
91
+ SET PATHEXT=%PATHEXT:;.JS;=;%
92
+ )
93
+ endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${target}" %*`;
94
+ if (compact(text) !== compact(template)) return null;
95
+ }
96
+ } else return null;
97
+ if (!/^node_modules[\\/]/.test(target) || /[$%`]/.test(target)) return null;
98
+ const script = join(dirname(file), target);
99
+ return existsSync(script) ? script : null;
100
+ }
101
+ // 已核对的启动器模板:整份匹配,不从未知 batch 中猜入口。
102
+ function nativeShim(file) {
103
+ let source;
104
+ try { source = readFileSync(file, "utf8"); } catch { return null; }
105
+ const text = compact(source), dir = dirname(file);
106
+ if (extname(file).toLowerCase() === ".ps1") {
107
+ const m = /"\$basedir\/(node_modules\/[^"\r\n]+\.exe)"\s+\$args/.exec(source);
108
+ if (!m) return null;
109
+ const template = `$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
110
+ $exe=""
111
+ if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
112
+ $exe=".exe"
113
+ }
114
+ if ($MyInvocation.ExpectingInput) {
115
+ $input | & "$basedir/${m[1]}" $args
116
+ } else {
117
+ & "$basedir/${m[1]}" $args
118
+ }
119
+ exit $LASTEXITCODE`;
120
+ const cmd = join(dir, m[1]);
121
+ return text === compact(template) && existsSync(cmd) ? { cmd, args: [] } : null;
122
+ }
123
+ if (extname(file).toLowerCase() !== ".cmd") return null;
124
+ if (text === '@ECHO off\nnode "%~dp0pi-launcher.js" %*') {
125
+ const entry = join(dir, "pi-launcher.js");
126
+ return existsSync(entry) ? { cmd: "node", args: [entry], hostDirect: false } : null;
127
+ }
128
+ const dsh = `@echo off
129
+ setlocal DisableDelayedExpansion
130
+ set "ELECTRON_RUN_AS_NODE=1"
131
+ "%~dp0..\\..\\..\\..\\DeepSeek Harness.exe" --expose-internals "%~dp0..\\..\\..\\app.asar\\dsh\\node_modules\\@deepseek-ai\\dsh-desktop-host\\lib\\cli.js" %*
132
+ exit /b %errorlevel%`;
133
+ if (text === compact(dsh)) {
134
+ const cmd = resolve(dir, "../../../..", "DeepSeek Harness.exe");
135
+ // app.asar 内部路径由 Electron 读取,普通 existsSync 无法探测。
136
+ const entry = resolve(dir, "../../..", "app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js");
137
+ return existsSync(cmd) ? { cmd, args: ["--expose-internals", entry], env: { ELECTRON_RUN_AS_NODE: "1" } } : null;
138
+ }
139
+ return null;
140
+ }
141
+ function dispatch(name, args, opts) {
142
+ const env = opts.env ?? process.env;
143
+ if (!args.every((a) => typeof a === "string" && !a.includes("\0"))) throw new Error("参数必须是不含 NUL 的字符串");
144
+ const full = resolveBin(name, env) ?? name;
145
+ if (!WIN) return { hostDirect: true, cmd: full, args, opts: { ...opts, shell: false } };
146
+ const ext = extname(full).toLowerCase();
147
+ const wrapper = [".cmd", ".ps1"].includes(ext) ? knownWrapper(full, env, e => which(["node"], e.PATH || e.Path || "", [".exe"])) ?? nativeShim(full) : null;
148
+ if (wrapper) return { hostDirect: wrapper.hostDirect !== false, cmd: wrapper.cmd, args: [...wrapper.args, ...args], opts: { ...opts, env: { ...env, ...wrapper.env }, shell: false, windowsVerbatimArguments: false } };
149
+ const script = [".cmd", ".ps1"].includes(ext) ? npmTarget(full) : null;
150
+ if (script) {
151
+ const localNode = join(dirname(full), "node.exe");
152
+ const node = existsSync(localNode) ? localNode : which(["node"], env.PATH || env.Path || "", [".exe"]);
153
+ if (!node) throw new Error(`npm shim 找不到其 Node 运行时:${full}`);
154
+ return { hostDirect: true, cmd: node, args: [script, ...args], opts: { ...opts, shell: false, windowsVerbatimArguments: false } };
155
+ }
156
+ if (ext === ".ps1") {
157
+ // 不把用户参数插入 PowerShell 源码/命令行;JSON 通过子进程专用环境传输。
158
+ const payload = JSON.stringify({ file: resolve(full), args });
159
+ if (payload.length > 30000) throw new Error("普通 PowerShell 脚本参数超过 30000 字符,请使用原生入口");
160
+ const code = `$ErrorActionPreference='Stop'; $p=ConvertFrom-Json $env:AGENT_PURGE_PS_ARGS; Remove-Item Env:AGENT_PURGE_PS_ARGS; $a=@($p.args | ForEach-Object {[string]$_}); & $p.file @a; if ($null -ne $LASTEXITCODE) {exit $LASTEXITCODE}`;
161
+ return { cmd: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", Buffer.from(code,"utf16le").toString("base64")], opts: { ...opts, env: { ...env, AGENT_PURGE_PS_ARGS: payload }, shell: false } };
162
+ }
163
+ if (ext === ".cmd" || ext === ".bat") {
164
+ // 未知 batch 可能再次展开 %*;只接收无需多层转义的参数,拒绝静默损坏。
165
+ if (args.some((a) => !/^[a-zA-Z0-9_./:\\=+,-]+$/.test(a)) || /[%!^&|<>\r\n"]/.test(full)) throw new Error(`非标准 batch 不能可靠传递这些参数:${full};请指向原生 exe 或 Node 入口`);
166
+ const line = `"${full}" ${args.join(" ")}`;
167
+ if (line.length > 7000) throw new Error(`batch 参数过长:${full}`);
168
+ return { cmd: env.ComSpec || env.COMSPEC || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], opts: { ...opts, shell: false, windowsVerbatimArguments: true } };
169
+ }
170
+ return { hostDirect: ext === ".exe", cmd: full, args, opts: { ...opts, shell: false } };
171
+ }
172
+ export function prepareCommand(name, args = [], opts = {}) { return dispatch(name, args, opts); }
173
+ export function spawnPrepared(descriptor) { return spawn(descriptor.cmd, descriptor.args, descriptor.opts); }
174
+ export function spawnBin(name, args = [], opts = {}) {
175
+ const d = dispatch(name, args, opts); return spawn(d.cmd, d.args, d.opts);
176
+ }
177
+ export function captureBin(name, args = [], opts = {}) {
178
+ try {
179
+ const d = dispatch(name, args, { timeout: 4000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts });
180
+ const r = spawnSync(d.cmd, d.args, d.opts); return r.error || r.status !== 0 ? null : (r.stdout ?? null);
181
+ } catch { return null; }
182
+ }
package/lib/state.mjs CHANGED
@@ -1,60 +1,36 @@
1
- // 唯一状态文件。
2
- //
3
- // 之前是两套:`.defaults.json`(harness+mode)与 `personas/.active`(人设指针),
4
- // 两个文件、两套语义、Web 与 CLI 各写一边 —— 这就是"用起来混乱"的根源:
5
- // 用户改了一处以为全生效,其实另一处还压着旧值。
6
- //
7
- // 现在只有 state.json:
8
- // persona 谁 —— 当前 persona id(人设的唯一指针)
9
- // default 注入谁 —— 裸敲 agent-purge 时启动的 harness + 注入模式
10
- // armed 怎么生效 —— 每家已经武装了哪几条通道:shim / slash / install
11
- //
12
- // personas/active.md 退化为「派生物」:由 launch/arm 时按 persona 生成,
13
- // 供落盘型 harness 与 opencode 插件读取,不再承载状态。
14
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ // 唯一配置文件;已配置的模式与运行时实际启用状态分开解析。
2
+ import { existsSync, readFileSync, renameSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
15
4
  import { join } from "node:path";
16
-
17
- const FILE = "state.json";
18
- const LEGACY_DEFAULTS = ".defaults.json";
19
- const LEGACY_ACTIVE = join("personas", ".active");
5
+ import { withControlLock, atomicControlJSON } from "./control.mjs";
6
+ import { resolveConfigured } from "./activation.mjs";
7
+ export { resolveActivation } from "./activation.mjs";
20
8
 
21
9
  export const MODES = ["strict", "replace", "append", "off"];
22
- /** 三种生效通道,互不冲突,可以叠加 */
23
- export const ARMS = ["shim", "slash", "install"];
24
-
10
+ export const ARMS = ["shim", "slash", "install", "project"];
25
11
  export const ARM_LABEL = {
26
- shim: "裸命令接管(敲 codex 就是接管的)",
27
- slash: "会话内 /purge(热切换)",
28
- install: "配置文件常驻(连别人启动的也生效)",
12
+ shim: "裸命令接管(敲 codex 就是接管的)", slash: "会话内 /purge(热切换)",
13
+ install: "配置文件常驻(连别人启动的也生效)", project: "所选项目的规则文件",
29
14
  };
30
-
31
- export function statePath(root) {
32
- return join(root, FILE);
33
- }
34
-
15
+ export const statePath = root => join(root, "state.json");
16
+ const object = value => value && typeof value === "object" && !Array.isArray(value);
35
17
  function emptyState() {
36
- return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, ompAuto: false, dshProfiles: null };
18
+ return { v: 1, persona: null, default: null, armed: {}, overrides: {}, projectDirs: {}, piAuto: false, piUserLayer: false, ompAuto: false, dshProfiles: null };
37
19
  }
38
20
 
39
- /**
40
- * 读状态,顺带把旧布局迁移过来(旧文件改名 `*.migrated` 留档,之后只认 state.json)。
41
- * 返回 `{ state, migrated[] }`;migrated 是给调用方打印的一行行说明。
42
- */
21
+ /** 损坏配置保持原字节,报告后停止操作;运行时独立读取失败时只透传。 */
43
22
  export function loadState(root) {
44
- const file = statePath(root);
23
+ const file = statePath(root), migrated = [];
45
24
  let state = emptyState();
46
- const migrated = [];
47
-
48
25
  if (existsSync(file)) {
49
26
  try {
50
27
  const raw = JSON.parse(readFileSync(file, "utf8"));
51
- state = { ...state, ...raw, armed: raw?.armed && typeof raw.armed === "object" ? raw.armed : {} };
52
- } catch {
53
- migrated.push("state.json 损坏 → 按空状态重来");
54
- }
28
+ if (!object(raw)) throw new Error("配置必须是对象");
29
+ state = { ...state, ...raw, armed: object(raw.armed) ? raw.armed : {},
30
+ overrides: object(raw.overrides) ? raw.overrides : {}, projectDirs: object(raw.projectDirs) ? raw.projectDirs : {} };
31
+ } catch (error) { throw new Error(`state.json 损坏,原文件已保留:${file}`, { cause: error }); }
55
32
  }
56
-
57
- const legacy = join(root, LEGACY_DEFAULTS);
33
+ const legacy = join(root, ".defaults.json");
58
34
  if (!state.default && existsSync(legacy)) {
59
35
  try {
60
36
  const d = JSON.parse(readFileSync(legacy, "utf8"));
@@ -62,112 +38,84 @@ export function loadState(root) {
62
38
  state.default = { harness: d.harness, mode: MODES.includes(d.mode) ? d.mode : "replace" };
63
39
  migrated.push(`.defaults.json → default=${state.default.harness} · ${state.default.mode}`);
64
40
  }
65
- renameSync(legacy, legacy + ".migrated");
66
- } catch {
67
- /* 坏文件不动它,留给人工看 */
68
- }
41
+ } catch { /* 旧配置保持原样 */ }
69
42
  }
70
-
71
- if (!state.persona) {
72
- const old = join(root, LEGACY_ACTIVE);
73
- if (existsSync(old)) {
74
- const id = readFileSync(old, "utf8").trim();
75
- if (id) {
76
- state.persona = id;
77
- migrated.push(`personas/.active → persona=${id}`);
78
- }
79
- renameSync(old, old + ".migrated");
80
- }
43
+ const oldActive = join(root, "personas", ".active");
44
+ if (!state.persona && existsSync(oldActive)) {
45
+ const id = readFileSync(oldActive, "utf8").trim();
46
+ if (id) { state.persona = id; migrated.push(`personas/.active → persona=${id}`); }
81
47
  }
82
-
83
- // 迁完立即落盘:否则下次调用看不到旧文件,迁移结果直接丢掉
84
- if (migrated.length) saveState(root, state);
85
-
48
+ if (migrated.length) withControlLock(root, () => {
49
+ saveState(root, state);
50
+ if (existsSync(legacy)) renameSync(legacy, legacy + ".migrated");
51
+ if (existsSync(oldActive)) renameSync(oldActive, oldActive + ".migrated");
52
+ });
86
53
  return { state, migrated };
87
54
  }
88
55
 
89
56
  export function saveState(root, state) {
90
- // 先写临时文件再改名:Web 与 CLI 同时写时,读的一方不会看到半截 JSON
91
- const file = statePath(root);
92
- const tmp = `${file}.tmp-${process.pid}`;
93
- try {
94
- writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
95
- renameSync(tmp, file);
96
- } catch (e) {
97
- // rename 失败时别把半截临时文件留在 ~/.agent-purge
98
- try {
99
- unlinkSync(tmp);
100
- } catch {}
101
- throw e;
102
- }
103
- return state;
57
+ return withControlLock(root, () => {
58
+ let before = {};
59
+ try { before = JSON.parse(readFileSync(statePath(root), "utf8")); } catch (error) { if (error.code !== "ENOENT") throw error; }
60
+ const next = { ...state };
61
+ // 随机撤销代次不会因 off→on 复活旧启动;生成器每轮核对这个值。
62
+ if (next.default?.mode === "off" && before.default?.mode !== "off") next.activationEpoch = randomUUID();
63
+ const ids = new Set([...Object.keys(before.overrides ?? {}), ...Object.keys(next.overrides ?? {}), ...(next.disabledHarnesses ?? [])]);
64
+ for (const id of ids) {
65
+ if ((next.overrides?.[id]?.mode === "off" && before.overrides?.[id]?.mode !== "off") ||
66
+ ((next.disabledHarnesses ?? []).includes(id) && !(before.disabledHarnesses ?? []).includes(id)))
67
+ next.harnessEpochs = { ...next.harnessEpochs, [id]: randomUUID() };
68
+ }
69
+ atomicControlJSON(statePath(root), next);
70
+ return next;
71
+ });
104
72
  }
105
-
106
- /** 浅合并;armed 需要整块替换时传完整对象 */
107
- export function patchState(root, patch) {
108
- const { state } = loadState(root);
109
- return saveState(root, { ...state, ...patch });
73
+ export function updateState(root, update) {
74
+ return withControlLock(root, () => saveState(root, update(loadState(root).state)));
110
75
  }
111
-
76
+ export function patchState(root, patch) { return updateState(root, state => ({ ...state, ...patch })); }
112
77
  export function setDefault(root, { harness, mode }) {
113
- if (!harness) throw new Error("harness 不能为空");
114
- const { state } = loadState(root);
115
- const next = { ...state, default: { harness, mode: MODES.includes(mode) ? mode : "replace" } };
116
- return saveState(root, next).default;
117
- }
118
-
119
- export function setPersona(root, id) {
120
- const { state } = loadState(root);
121
- return saveState(root, { ...state, persona: id }).persona;
122
- }
123
-
124
- /** 裸敲 pi 是否也自动注入(装好的扩展直接读这个位) */
125
- export function setPiAuto(root, on) {
126
- const { state } = loadState(root);
127
- return saveState(root, { ...state, piAuto: on === true }).piAuto;
128
- }
129
-
130
- /** 裸敲 omp 是否也自动注入(omp 扩展读这个位) */
131
- export function setOmpAuto(root, on) {
132
- const { state } = loadState(root);
133
- return saveState(root, { ...state, ompAuto: on === true }).ompAuto;
78
+ if (!harness) throw new Error("harness 为空");
79
+ if (!MODES.includes(mode)) throw new Error(`未知模式: ${mode}`);
80
+ return updateState(root, state => ({ ...state, default: { harness, mode } })).default;
134
81
  }
135
-
136
- /** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
137
- export function setPiUserLayer(root, on) {
138
- const { state } = loadState(root);
139
- return saveState(root, { ...state, piUserLayer: on === true }).piUserLayer;
140
- }
141
-
142
- /** armed 变成 {codex: ["shim","install"]};空数组即删键 */
82
+ export function setPersona(root, id) { return patchState(root, { persona: id }).persona; }
83
+ export function setPiAuto(root, on) { return patchState(root, { piAuto: on === true }).piAuto; }
84
+ export function setOmpAuto(root, on) { return patchState(root, { ompAuto: on === true }).ompAuto; }
85
+ export function setPiUserLayer(root, on) { return patchState(root, { piUserLayer: on === true }).piUserLayer; }
86
+ export function armedOf(state, id) { return Array.isArray(state?.armed?.[id]) ? state.armed[id] : []; }
143
87
  export function setArmed(root, id, arm, on) {
144
- const { state } = loadState(root);
145
- const cur = new Set(state.armed?.[id] ?? []);
146
- if (on) cur.add(arm);
147
- else cur.delete(arm);
148
- const list = ARMS.filter((a) => cur.has(a));
149
- const armed = { ...state.armed };
150
- if (list.length) armed[id] = list;
151
- else delete armed[id];
152
- saveState(root, { ...state, armed });
153
- return list;
154
- }
155
-
156
- export function armedOf(state, id) {
157
- return state.armed?.[id] ?? [];
88
+ if (!ARMS.includes(arm)) throw new Error(`未知通道: ${arm}`);
89
+ const state = updateState(root, before => {
90
+ const cur = new Set(armedOf(before, id)); on ? cur.add(arm) : cur.delete(arm);
91
+ const list = ARMS.filter(a => cur.has(a)), armed = { ...before.armed };
92
+ if (list.length) armed[id] = list; else delete armed[id];
93
+ return { ...before, armed };
94
+ });
95
+ return armedOf(state, id);
158
96
  }
159
-
160
- /** dsh 三个 profile 哪些生效:null = 没设置过(全部),[] = 全关 */
161
- export function dshProfilesOf(state) {
162
- return Array.isArray(state?.dshProfiles) ? state.dshProfiles : null;
97
+ export function overridesOf(state) { return object(state?.overrides) ? state.overrides : {}; }
98
+ export function resolveFor(state, id) { return resolveConfigured(state, id); }
99
+ export function setOverride(root, id, patch = {}) {
100
+ const next = updateState(root, state => {
101
+ const cur = { ...(overridesOf(state)[id] ?? {}) };
102
+ for (const [key, value] of Object.entries(patch)) {
103
+ if (!["persona", "mode"].includes(key)) throw new Error(`未知分家字段: ${key}`);
104
+ if (value === null || value === undefined || value === "") delete cur[key];
105
+ else if (key === "mode" && !MODES.includes(value)) throw new Error(`未知模式: ${value}`);
106
+ else cur[key] = value;
107
+ }
108
+ const overrides = { ...overridesOf(state) };
109
+ if (Object.keys(cur).length) overrides[id] = cur; else delete overrides[id];
110
+ return { ...state, overrides };
111
+ });
112
+ return next.overrides[id] ?? null;
163
113
  }
164
-
165
- /** 开/关单个 dsh profile;all 由调用方传入(列表归属 lib/dsh.mjs,别在状态层再写一份) */
114
+ export function dshProfilesOf(state) { return Array.isArray(state?.dshProfiles) ? state.dshProfiles : null; }
166
115
  export function setDshProfile(root, profile, on, all) {
167
- const { state } = loadState(root);
168
- const cur = new Set(dshProfilesOf(state) ?? all);
169
- if (on) cur.add(profile);
170
- else cur.delete(profile);
171
- const list = all.filter((p) => cur.has(p));
172
- return saveState(root, { ...state, dshProfiles: list }).dshProfiles;
116
+ if (!all.includes(profile)) throw new Error(`未知 dsh profile: ${profile}`);
117
+ return updateState(root, state => {
118
+ const cur = new Set(dshProfilesOf(state) ?? all); on ? cur.add(profile) : cur.delete(profile);
119
+ return { ...state, dshProfiles: all.filter(p => cur.has(p)) };
120
+ }).dshProfiles;
173
121
  }