agent-purge 0.2.10 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +169 -138
- package/backends/index.mjs +204 -244
- package/bench/bench-armor.mjs +844 -832
- package/bench/capture-hot.mjs +58 -0
- package/bench/capture-provider.mjs +66 -0
- package/bench/capture-report.mjs +15 -0
- package/bench/capture-server-hot.mjs +55 -0
- package/bench/capture-system.mjs +36 -0
- package/bench/judge-armor.mjs +119 -119
- package/bin/agent-purge.mjs +956 -780
- package/docs/LEGAL.md +27 -0
- package/docs/architecture.md +39 -0
- package/docs/compatibility.md +78 -0
- package/docs/images/console-desktop.png +0 -0
- package/docs/images/console-mobile.png +0 -0
- package/docs/releasing.md +87 -0
- package/docs/usage.md +158 -0
- package/lib/activation.mjs +38 -0
- package/lib/apply.mjs +227 -0
- package/lib/armed.mjs +38 -38
- package/lib/armor.mjs +225 -222
- package/lib/caps.mjs +182 -173
- package/lib/capture.mjs +324 -0
- package/lib/clean.mjs +124 -0
- package/lib/control.mjs +60 -0
- package/lib/detect.mjs +74 -70
- package/lib/doctor.mjs +7 -7
- package/lib/dsh.mjs +495 -453
- package/lib/environment.mjs +36 -0
- package/lib/evidence.mjs +41 -0
- package/lib/extension.mjs +140 -0
- package/lib/footprints.mjs +59 -0
- package/lib/home.mjs +47 -47
- package/lib/launch-conflicts.mjs +24 -0
- package/lib/launch.mjs +57 -0
- package/lib/opencode.mjs +62 -0
- package/lib/paths.mjs +25 -0
- package/lib/persist.mjs +439 -280
- package/lib/persona-import.mjs +351 -0
- package/lib/personas.mjs +17 -7
- package/lib/private-server.mjs +54 -0
- package/lib/project.mjs +134 -0
- package/lib/restore.mjs +131 -114
- package/lib/rewrite.mjs +124 -0
- package/lib/root.mjs +37 -0
- package/lib/shim.mjs +203 -160
- package/lib/slashes.mjs +227 -628
- package/lib/spawn.mjs +182 -126
- package/lib/state.mjs +85 -137
- package/lib/templates/personas/neutral.md +30 -30
- package/lib/update.mjs +137 -0
- package/lib/verification.mjs +23 -0
- package/lib/window.mjs +58 -0
- package/lib/wrappers.mjs +49 -0
- package/magpie-plugin/README.md +44 -0
- package/matrix.json +325 -341
- package/package.json +30 -4
- package/web/app.js +590 -0
- package/web/assets/agent-purge.ico +0 -0
- package/web/assets/claude.ico +0 -0
- package/web/assets/codebuddy.png +0 -0
- package/web/assets/codex.webp +0 -0
- package/web/assets/dsh.ico +0 -0
- package/web/assets/grok.ico +0 -0
- package/web/assets/hermes.ico +0 -0
- package/web/assets/omp.png +0 -0
- package/web/assets/opencode.ico +0 -0
- package/web/assets/opencode2.ico +0 -0
- package/web/assets/pi.png +0 -0
- package/web/assets/zcode.png +0 -0
- package/web/model.mjs +49 -0
- package/web/server.mjs +330 -191
- package/web/styles.css +1072 -0
- package/web/ui.html +76 -1163
package/lib/spawn.mjs
CHANGED
|
@@ -1,126 +1,182 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
28
|
-
install: "配置文件常驻(连别人启动的也生效)",
|
|
12
|
+
shim: "裸命令接管(敲 codex 就是接管的)", slash: "会话内 /purge(热切换)",
|
|
13
|
+
install: "配置文件常驻(连别人启动的也生效)", project: "所选项目的规则文件",
|
|
29
14
|
};
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
66
|
-
} catch {
|
|
67
|
-
/* 坏文件不动它,留给人工看 */
|
|
68
|
-
}
|
|
41
|
+
} catch { /* 旧配置保持原样 */ }
|
|
69
42
|
}
|
|
70
|
-
|
|
71
|
-
if (!state.persona) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
137
|
-
export function
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
export function
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
}
|