agent-purge 0.2.8 → 0.2.10

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/README.md CHANGED
@@ -32,6 +32,16 @@ npm i -g agent-purge # 或 npx agent-purge web
32
32
 
33
33
  要求 Node ≥ 20,零依赖。
34
34
 
35
+ **用 magpie 网关?** 插件不在 npm 包里,在仓库的 `magpie-plugin/` 子目录——不要指向仓库根(根 `package.json` 是 CLI,只有 `bin`,magpie 会拒绝):
36
+
37
+ ```bash
38
+ magpie plugin add /path/to/agent-purge/magpie-plugin
39
+ ```
40
+
41
+ 首次使用先跑一次 `agent-purge web`(打开 http://127.0.0.1:7788 的控制台,先看/选一个人设;不想开界面就用 `agent-purge ls`),它会生成 `~/.agent-purge/personas/`;再跑 `node magpie-plugin/gen-personas.mjs` 生成 Persona 页的人设清单。
42
+
43
+ 详见下文「magpie 网关插件(可选)」。
44
+
35
45
  **数据独立存放**,升级不会被覆盖:默认 `~/.agent-purge/`(Windows `C:\Users\<你>\.agent-purge\`),里面是 `state.json` 和 `personas/`。想换位置就设环境变量:
36
46
 
37
47
  ```bash
@@ -63,7 +73,7 @@ agent-purge pi --persona <id> # 只这一轮用别的
63
73
  | `codex` | 启动参数 `-c model_instructions_file=` | 0.163.0 | 21,026 | 24 |
64
74
  | `claude` | `--system-prompt-file` + 二进制补丁清残留身份句 | 2.1.304 | 6,379 | 156 |
65
75
  | `grok` | `--system-prompt-override` | 1.0.62 | 5,146 | 21 |
66
- | `omp` | `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
76
+ | `omp` | 原生扩展:`before_agent_start` 改写 systemPrompt 数组,会话内 `/purge` 热切;`--ask` 仍走 `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
67
77
  | `hermes` | 临时 HOME 里替换 `SOUL.md` | 0.21.5 | 8,139 | 7,554 |
68
78
  | `zcode` | UserPromptSubmit hook(user 角色)+ `customSystemPrompt` | 0.16.9 | 9,359 | 9,359 |
69
79
  | `opencode` | cwd 下 `.opencode/plugin/*.ts` 原地改 `output.system` | 1.19.3 | 19,332 | 112 |
@@ -89,6 +99,33 @@ agent-purge uninstall <h> # 摘掉,按字节还原
89
99
 
90
100
  dsh 是唯一有多个形态的:desktop / web / headless 共用一套 cordis 插件,装在各自 profile 的 `node_modules/agent-purge/`,并在 `cordis.patch.yml` 里 `- insert` 挂上,不改 `app.asar`。装完重启一次,之后热生效;三个形态还能在 Web 面板分别勾选。
91
101
 
102
+ ### 三个通道与总电源(arm / disarm)
103
+
104
+ 一家 harness 有三条能注入的入口,`arm` 默认三条全开:
105
+
106
+ | 通道 | 管什么 | 只想做这一项 |
107
+ |---|---|---|
108
+ | `shim` | PATH 最前放同名壳,**裸敲** `codex` / `dsh` 也经 agent-purge 启动 | `arm <h> --shim` |
109
+ | `install` | 写 harness 自己的配置/插件,**别人启动的、GUI 里点开的**也生效 | `arm <h> --install` |
110
+ | `slash` | 会话内 `/purge` 热切换(只有 pi / omp / dsh 有 sys 层热切) | `arm <h> --slash` |
111
+
112
+ ```bash
113
+ agent-purge arm <h> # 三条全开(= 注册)
114
+ agent-purge disarm <h> # 撤掉这一家的三条通道
115
+ agent-purge arm --all # 所有支持的 harness 全开 + 总电源打开
116
+ agent-purge disarm --all # 所有 harness 全撤 + 总电源关掉("别管我")
117
+ ```
118
+
119
+ **`--all` 是总电源,不是"全部通道"。** 它改的是 `state.json` 里的 `default.mode`:
120
+
121
+ - `default.mode = off` 时,**所有**入口都不注入——包括裸敲 `agent-purge`、`agent-purge <h>`、PATH 壳;
122
+ - `disarm <h>` 只撤那家的三个通道,**不动总电源**,所以撤完 `agent-purge dsh` 仍然注入——
123
+ 这就是"明明 disarm 了却还在改提示词"的来源;
124
+ - 只想彻底关掉某一家:`agent-purge disarm dsh --all`(撤 dsh 三条通道 + 关总电源,别的 harness 不动);
125
+ `arm` 会把上次的 mode 还回去。
126
+
127
+ 改完用 `agent-purge status` 复核:每家的 `shim / slash / install` 三个格子 + 当前 mode 一张表。
128
+
92
129
  ## magpie 网关插件(可选)
93
130
 
94
131
  [magpie](https://github.com/yetone/magpie) 把各家 agent 的请求收进本地网关。挂上这个插件后,**凡是经过网关的请求**(Claude Code、Codex、OpenCode、pi…)的 system 段都按 agent-purge 的当前人设改写——不用逐个 harness 装,换人设也不用重启 magpie。
@@ -115,7 +152,7 @@ magpie plugin list # 应看到 async gatewa
115
152
 
116
153
  options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
117
154
 
118
- **边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
155
+ **边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。前提是已经运行过 `agent-purge`(如 `agent-purge web` 或 `agent-purge ls`),`~/.agent-purge/personas/` 里有 persona;目录不存在时脚本会直接提示。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
119
156
 
120
157
  ## 目录
121
158
 
@@ -72,13 +72,13 @@ export const backends = {
72
72
  const file = join(dir, "agent-purge.ts");
73
73
  const src = `export const AgentPurge = async () => ({
74
74
  "experimental.chat.system.transform": async (_input, output) => {
75
- output.system.splice(0, output.system.length, ${JSON.stringify(inline(personaText))});
75
+ ${mode === "append" ? `output.system.push(${JSON.stringify(inline(personaText))});` : `output.system.splice(0, output.system.length, ${JSON.stringify(inline(personaText))});`}
76
76
  },
77
77
  });
78
78
  `;
79
79
  return {
80
80
  cmd: "opencode",
81
- note: "[plugin] .opencode/plugin/agent-purge.ts(原地 splice output.system)",
81
+ note: `[plugin] .opencode/plugin/agent-purge.ts(${mode === "append" ? "push" : "原地 splice"} output.system)`,
82
82
  args,
83
83
  setup: () => {
84
84
  mkdirSync(dir, { recursive: true });
@@ -203,7 +203,7 @@ export const oneshots = {
203
203
  }),
204
204
  opencode: ({ prompt, personaText, mode, cwd }) => {
205
205
  if (mode === "off") return { cmd: "opencode", args: ["run", prompt] };
206
- const b = backends.opencode({ personaText, cwd, args: ["run", prompt] });
206
+ const b = backends.opencode({ personaText, mode, cwd, args: ["run", prompt] });
207
207
  return b;
208
208
  },
209
209
  opencode2: ({ prompt, personaText, mode }) => {
@@ -28,6 +28,9 @@ const recovered = recover(); // 上次进程被杀时留下的半改配置,开
28
28
  if (recovered > 0) console.log(`[restore] 回滚了 ${recovered} 个上次残留的配置文件`);
29
29
  if (argv.includes("--help") || argv.includes("-h")) help(); // 永远优先于默认值
30
30
  const flag = (name, fallback) => {
31
+ // 支持 --mode=append 这种写法(以前只认空格分隔,等号形式会被静默忽略)
32
+ const inline = argv.find((a) => a.startsWith(name + "="));
33
+ if (inline !== undefined) return inline.slice(name.length + 1);
31
34
  const i = argv.indexOf(name);
32
35
  return i === -1 ? fallback : argv[i + 1];
33
36
  };
@@ -107,12 +110,13 @@ async function launch(name) {
107
110
  const mode = explicitMode ?? state.default?.mode ?? "replace";
108
111
 
109
112
  // 裸敲 pi 的持久位(写 state.json,扩展下一会话读它)
110
- const autoFlag = name === "pi" ? flag("--auto", null) : null;
113
+ const autoFlag = name === "pi" || name === "omp" ? flag("--auto", null) : null;
111
114
  if (autoFlag !== null) {
112
115
  if (autoFlag !== "on" && autoFlag !== "off") die("--auto 只能是 on / off");
113
- const { setPiAuto } = await import("../lib/state.mjs");
114
- setPiAuto(DATA, autoFlag === "on");
115
- console.log(`[auto] 裸敲 pi ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
116
+ const { setPiAuto, setOmpAuto } = await import("../lib/state.mjs");
117
+ if (name === "pi") setPiAuto(DATA, autoFlag === "on");
118
+ else setOmpAuto(DATA, autoFlag === "on");
119
+ console.log(`[auto] 裸敲 ${name} ${autoFlag === "on" ? "会" : "不会"}自动注入(已写入 state.json)`);
116
120
  }
117
121
 
118
122
  // pi 的人设落在哪一层:sys(默认,真系统提示词)或 user(塞进用户消息,给忽略 system 的模型)
@@ -130,7 +134,8 @@ async function launch(name) {
130
134
 
131
135
  // 实测策略:该 harness 的哪一层真的能注入(数据来自 matrix.json armor 段)
132
136
  const { plan } = await import("../lib/armor.mjs");
133
- const strategy = plan(matrix, name); if (!strategy.error) {
137
+ const strategy = plan(matrix, name);
138
+ if (!strategy.error) {
134
139
  if (strategy.requiresSeed) console.log(`[策略] ${name}:裸注入 sys 层实测 ${strategy.bare.sys}%,带人设词种子 ${strategy.persona.sys}% —— 需要 seed 层`);
135
140
  const noSys = strategy.blocked.find((b) => b.layer === "sys");
136
141
  if (noSys && mode !== "off") console.log(`[策略] ${name}:sys 层不可用(${noSys.reason}),本次只能靠 user 层`);
@@ -172,7 +177,7 @@ async function launch(name) {
172
177
  if (dd !== -1) {
173
178
  passthrough = argv.slice(dd + 1);
174
179
  } else {
175
- const ownWithValue = new Set(["--ask", "--mode", "--persona", "--cwd", "--auto"]);
180
+ const ownWithValue = new Set(["--ask", "--mode", "--persona", "--cwd", "--auto", "--layer"]);
176
181
  const tail = argv.slice(1);
177
182
  for (let i = 0; i < tail.length; i++) {
178
183
  const t = tail[i];
@@ -185,10 +190,12 @@ async function launch(name) {
185
190
  }
186
191
  }
187
192
 
193
+ const askEq = argv.find((a) => a.startsWith("--ask="));
188
194
  const askIdx = argv.indexOf("--ask");
195
+ const askPrompt = askEq !== undefined ? askEq.slice(6) : askIdx !== -1 ? argv[askIdx + 1] : null;
189
196
  let built;
190
- if (askIdx !== -1) {
191
- const raw = argv[askIdx + 1];
197
+ if (askPrompt !== null) {
198
+ const raw = askPrompt;
192
199
  if (!raw || raw.startsWith("--")) die('--ask 需要一个 prompt,例如: agent-purge codex --ask "解释这段代码"');
193
200
  const prompt = raw.trim();
194
201
  const one = oneshots[name];
@@ -210,6 +217,35 @@ async function launch(name) {
210
217
 
211
218
  if (built.note) console.log(built.note);
212
219
  if (built.residueHint) console.log(`[residue] ${built.residueHint}`);
220
+ const dryRun = argv.includes("--dry-run");
221
+ // 启动时把人设塞进扩展(omp / pi):命令行走不通 10K 人设,扩展也没有长度上限。
222
+ // omp 交互式启动时把后端的 --system-prompt 摘掉,否则扩展和命令行会各注入一次。
223
+ // 但 --profile / --no-extensions 下默认扩展目录不会加载:这两个情况保留命令行注入。
224
+ const ompProfile = name === "omp" ? (flag("--profile", null) ?? process.env.OMP_PROFILE ?? null) : null;
225
+ const ompNoExtensions = name === "omp" && (argv.includes("--no-extensions") || argv.some((a) => a.startsWith("--no-extensions=")));
226
+ const useExtension = name !== "omp" || (askPrompt === null && !ompProfile && !ompNoExtensions);
227
+ let extFile = null;
228
+ if ((name === "pi" || name === "omp") && !built.unsupported && !built.manual) {
229
+ const isOmp = name === "omp";
230
+ if (mode === "off") {
231
+ // 不装、也不喊注入:扩展就算在盘上,也会因为 mode=off + 没 FORCE 而沉默
232
+ built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
233
+ } else if (useExtension) {
234
+ if (isOmp && built.args[0] === "--system-prompt") built.args.splice(0, 2);
235
+ if (!dryRun) {
236
+ const { installSlash } = await import("../lib/slashes.mjs");
237
+ const r = installSlash(name, DATA, { personaId: picked.id, personaBody: personaText });
238
+ if (r.error) die(`${name}: ${r.error}`);
239
+ extFile = r.file;
240
+ }
241
+ built.env = { ...built.env, AGENT_PURGE_PERSONA: picked.id, AGENT_PURGE_MODE: mode, AGENT_PURGE_FORCE: "1" };
242
+ } else if (isOmp && askPrompt === null) {
243
+ console.log("[ext] --profile/--no-extensions:本次保留命令行注入,/purge 热切不可用");
244
+ } else if (isOmp) {
245
+ // --ask:命令行已经注入;扩展若在盘上且 ompAuto 开着会再注一遍,这里按 off 静音
246
+ built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
247
+ }
248
+ }
213
249
  // off 有两种来源:全局总电源关了,或者这一轮手动指定。措辞不能混,否则看日志的人会以为总开关坏了
214
250
  if (mode === "off") {
215
251
  const globalOff = (state.default?.mode ?? "") === "off";
@@ -220,32 +256,29 @@ async function launch(name) {
220
256
  );
221
257
  }
222
258
  console.log(`[cmd] ${[built.cmd, ...built.args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a))].join(" ")}`);
223
- if (argv.includes("--dry-run")) process.exit(0);
224
-
225
- // pi:注入活在扩展里(命令行装不下 10K 人设)。
226
- // mode ≠ off:确保扩展在位(没有就装、有就重写成人设最新版),并用 AGENT_PURGE_FORCE=1 明确告诉扩展“这次是启动器要注入”。
227
- // mode = off:不装、也不喊注入。扩展就算挂在盘上,也会因为 mode=off + 没 FORCE 而保持沉默(裸敲 pi 原样跑)。
228
- if (name === "pi" && !built.unsupported && !built.manual) {
229
- const { installSlash } = await import("../lib/slashes.mjs");
230
- if (mode === "off") {
231
- // 不装、不喊注入:扩展就算在盘上,也会因为 mode=off + 没 FORCE 而沉默
232
- built.env = { ...built.env, AGENT_PURGE_MODE: "off" };
233
- } else {
234
- installSlash("pi", DATA, { personaId: picked.id, personaBody: personaText });
235
- built.env = { ...built.env, AGENT_PURGE_PERSONA: picked.id, AGENT_PURGE_MODE: mode, AGENT_PURGE_FORCE: "1" };
236
- }
237
- }
259
+ if (extFile) console.log(`[ext] ${extFile}(/purge 热切换)`);
260
+ if (dryRun) process.exit(0);
238
261
 
239
262
  if (!resolveBin(built.cmd)) {
240
263
  die(`${name}: 找不到可执行文件 ${built.cmd}(PATH 里没有它;agent-purge doctor 可看本机探测结果)`);
241
264
  }
242
265
  built.setup?.();
266
+ // Ctrl-C 时也要清掉一次性插件:process.exit 会触发 exit 监听器,不依赖 child close
267
+ let cleaned = false;
268
+ const cleanup = () => {
269
+ if (cleaned) return;
270
+ cleaned = true;
271
+ try {
272
+ built.cleanup?.();
273
+ } catch {}
274
+ };
275
+ process.once("exit", cleanup);
243
276
  // 一次性问答:stdin 直接给 NUL。否则 codex 这类会先“Reading additional input from stdin...”
244
277
  // 并阻塞到 EOF(非 TTY 环境必现);stdin 关了它就直接跑。
245
- const stdio = askIdx !== -1 ? ["ignore", "inherit", "inherit"] : "inherit";
278
+ const stdio = askPrompt !== null ? ["ignore", "inherit", "inherit"] : "inherit";
246
279
  const child = spawnBin(built.cmd, built.args, { cwd, stdio, env: { ...process.env, ...built.env } });
247
280
  const finish = (code) => {
248
- built.cleanup?.();
281
+ cleanup();
249
282
  process.exit(code ?? 1);
250
283
  };
251
284
  child.on("error", (e) => {
@@ -346,21 +379,25 @@ async function armCmd(verb, rest) {
346
379
 
347
380
  const supported = Object.keys(matrix.harnesses).filter((h) => matrix.harnesses[h].class !== "unsupported");
348
381
  const namedTarget = rest.filter((x) => !x.startsWith("-"));
349
- const targets = rest.includes("--all") || !namedTarget.length ? supported : namedTarget;
382
+ // 点了名的只动那家:`--all` 只管总电源,不再悄悄把别的 harness 一起撤(issue #5)
383
+ const targets = namedTarget.length ? namedTarget : supported;
350
384
 
351
385
  // 全局 --all 就是总电源:撤的时候必须把 default.mode 置 off,
352
386
  // 否则 `agent-purge <h>` 这条入口照样注入(实测:关了总开关还能接管,就是这里漏的)。
353
387
  // 开的时候把上次的 mode 还回去。单家操作不动 mode。
388
+ // 没设过默认 harness 时也要能关:launch() 在 default 为空时会回落成 replace。
354
389
  if (rest.includes("--all")) {
355
390
  const { state: cur } = loadState(DATA);
356
- const m = cur.default?.mode ?? "off";
357
- if (verb === "disarm" && m !== "off" && cur.default?.harness) {
358
- setDefault(DATA, { harness: cur.default.harness, mode: "off" });
359
- patchState(DATA, { prevMode: m });
391
+ const m = cur.default?.mode ?? null; // null = 从没设过,launch() 会回落成 replace
392
+ const h = cur.default?.harness ?? null;
393
+ if (verb === "disarm" && m !== "off") {
394
+ patchState(DATA, { default: { harness: h, mode: "off" }, prevMode: m ?? "replace" });
395
+ console.log(`总电源:mode ${m ?? "(未设,实际按 replace)"} → off(裸敲 agent-purge / agent-purge <h> 都不再注入;arm --all 还回去)`);
360
396
  }
361
- if (verb === "arm" && m === "off" && cur.default?.harness) {
397
+ if (verb === "arm" && cur.default && m === "off") {
362
398
  const back = MODES.includes(cur.prevMode) && cur.prevMode !== "off" ? cur.prevMode : "replace";
363
- setDefault(DATA, { harness: cur.default.harness, mode: back });
399
+ patchState(DATA, { default: { harness: h, mode: back } });
400
+ console.log(`总电源:mode off → ${back}`);
364
401
  }
365
402
  }
366
403
 
@@ -507,7 +544,7 @@ async function statusCmd() {
507
544
  const slashes = slashMod.slashStatus(DATA);
508
545
 
509
546
  console.log(`persona ${state.persona ?? "(未设置)"}`);
510
- console.log(`default ${state.default ? `${state.default.harness} · ${state.default.mode}` : "(未设置)"} 裸敲 agent-purge 用它`);
547
+ console.log(`default ${state.default ? `${state.default.harness ?? "(未选默认)"} · ${state.default.mode}` : "(未设置)"} 裸敲 agent-purge 用它`);
511
548
  console.log(`state ${join(DATA, "state.json")}`);
512
549
  console.log("");
513
550
  console.log("harness shim slash install /purge层 常驻落点");
@@ -711,7 +748,11 @@ function help() {
711
748
  agent-purge arm <h> --shim 只做裸命令接管(写 PATH 最前)
712
749
  agent-purge arm <h> --install 只做配置文件常驻
713
750
  agent-purge arm <h> --slash 只做会话内 /purge
714
- agent-purge disarm <h>|--all 以上全部撤掉
751
+ agent-purge disarm <h>|--all 以上全部撤掉;不带 <h> 就是所有 harness
752
+
753
+ 注:--all 是总电源(state.json 的 default.mode)。带 <h> 时只撤那家,
754
+ 但同样把总电源关掉——不然裸敲 agent-purge、agent-purge <h> 照样注入。
755
+ 彻底别管我:agent-purge disarm --all
715
756
 
716
757
  ④ 看
717
758
  agent-purge status 一张表:每家的 shim / slash / install / 能做什么
package/lib/armed.mjs CHANGED
@@ -20,12 +20,13 @@ export async function reconcileArmed(root) {
20
20
  let alive = true;
21
21
  if (ch === "shim") {
22
22
  const bin = join(home, ".agent-purge", "bin");
23
- alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`));
23
+ alive = existsSync(join(bin, `${h}.cmd`)) || existsSync(join(bin, `${h}.ps1`)) || existsSync(join(bin, h));
24
24
  } else if (ch === "install") {
25
25
  alive = Boolean(installs.get(h));
26
26
  } else if (ch === "slash") {
27
27
  const s = resolveSlash(h, root);
28
- alive = Boolean(s && s.offered !== false && existsSync(s.file));
28
+ // dsh 给 probe(看挂载块,不看固定路径);其余家 slash 就是单个文件
29
+ alive = Boolean(s && s.offered !== false && (s.probe ? s.probe() : existsSync(s.file)));
29
30
  }
30
31
  if (!alive) {
31
32
  setArmed(root, h, ch, false);
package/lib/caps.mjs CHANGED
@@ -23,8 +23,8 @@ function readMatrix() {
23
23
  }
24
24
  }
25
25
 
26
- /** 有 /purge 机制的 harness —— **只算能把内容写进系统提示词的**(pi 的扩展钩子、dsh 的 cordis 插件) */
27
- export const SLASH_IDS = new Set(["pi", "dsh"]);
26
+ /** 有 /purge 机制的 harness —— **只算能把内容写进系统提示词的**(pi/omp 的扩展钩子、dsh 的 cordis 插件) */
27
+ export const SLASH_IDS = new Set(["pi", "dsh", "omp"]);
28
28
  /** 有可写配置文件/插件的 harness(其余家只能点名启动或打补丁) */
29
29
  export const PERSIST_IDS = new Set(Object.keys(persistTargets));
30
30
  /** 能把内容真的送进 system 数组的层;user 层(上下文块/用户消息)一律不对外提供 */
@@ -63,6 +63,7 @@ const BLOCKED = { antigravity: "二进制补丁才能改它的系统提示,配
63
63
  */
64
64
  const HOT = {
65
65
  pi: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start:/purge 或改设置,下一轮生效" },
66
+ omp: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start 改写 systemPrompt 数组:/purge 或改设置,下一轮生效" },
66
67
  opencode: { kind: "state", needs: "install", how: "插件钩子 chat.system.transform 每轮读 state.json + active.md:改设置下一轮生效" },
67
68
  dsh: { kind: "command", needs: "slash", how: "cordis 插件注册 /purge:改 state.json,下一句生效,不产生模型消息" },
68
69
  opencode2: { kind: "state", needs: "install", how: "v2 插件 ctx.session.hook('context') 每轮读 state.json + active.md:改设置下一轮生效" },
@@ -73,7 +74,6 @@ const NOT_HOT = {
73
74
  claude: "system 只在启动时拼一次;会话内只能往 user 消息里塞(实测 0/2)",
74
75
  codex: "config.toml 启动时读;会话内没有能重拼 system 的钩子",
75
76
  grok: "AGENTS.md 启动时读一次",
76
- omp: "rules 启动时读一次",
77
77
  hermes: "SOUL.md 虽然是每轮重读,但没有会话内命令面,只能改文件",
78
78
  zcode: "桌面版闭源,没有插件与命令钩子",
79
79
  codebuddy: "只有启动参数",
@@ -122,7 +122,7 @@ export function capsFor(id, ctx = {}) {
122
122
  const slashWhy = blocked
123
123
  ? blocked
124
124
  : !SLASH_IDS.has(id)
125
- ? `${id} 没有能改系统提示词的 /purge(/purge 展开成用户消息,实测无效;只有 pi 的扩展和 dsh 的 cordis 插件能做到)`
125
+ ? `${id} 没有能改系统提示词的 /purge(/purge 展开成用户消息,实测无效;只有 pi/omp 的扩展和 dsh 的 cordis 插件能做到)`
126
126
  : null;
127
127
 
128
128
  return {
package/lib/dsh.mjs CHANGED
@@ -15,11 +15,45 @@ export const DSH_PROFILES = ["desktop", "web", "headless"];
15
15
  export const RESIDENT = "agent-purge";
16
16
  export const SLASH_RESIDENT = "agent-purge-slash";
17
17
  export const SESSION = "agent-purge-session";
18
+ /** 我们写过的三个块名:stash 用完即弃的判断用 */
19
+ const ALL = [RESIDENT, SLASH_RESIDENT, SESSION];
18
20
 
19
21
  export const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
20
22
  export const profileDir = (p) => join(dshHome(), "profiles", p);
21
23
  export const pluginDir = (p, name = RESIDENT) => join(profileDir(p), "node_modules", name);
22
24
  const patchFile = (p) => join(profileDir(p), "cordis.patch.yml");
25
+ // 原始字节存 profile 级:插件目录会被删,多插件叠在一起时也要能一起还原(`[]` 空壳)
26
+ const stashFile = (p) => join(profileDir(p), ".agent-purge.patch.before");
27
+ const parseStash = (file) => {
28
+ try {
29
+ return JSON.parse(readFileSync(file, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+ const rmStash = (p) => rmSync(stashFile(p), { force: true });
35
+
36
+ /** 旧版前缀匹配会把 `-slash` 尾段留在 patch 里(issue #4)。只认第 0 列的裸行:块标量里的内容
37
+ * 必须缩进,子段更不可能出现在列 0,所以这个模式不会误删用户文本。 */
38
+ const healStraySlash = (text) => text.replace(/^-slash[ \t]*\r?(?:\n|$)/gm, "");
39
+
40
+ /** 剥孤儿行会改字节:换写前留一份原始字节,判断错了还有退路。
41
+ * 只在我们真棭行时写,且不覆盖已有备份(否则每次装/卸都会把真正的原文冲掉)。 */
42
+ const healBackup = (p, raw) => {
43
+ const f = join(profileDir(p), ".agent-purge.patch.healed");
44
+ if (raw === null || healStraySlash(raw) === raw || existsSync(f)) return;
45
+ writeFileSync(f, raw);
46
+ };
47
+
48
+ /** dsh 的 patch 模板就是一个 `[]`:空 flow 数组后面再接 `- insert:` 是非法 YAML
49
+ * (真 dsh 报 `end of the stream or a document separator is expected`),文档根的 `[]` 先摊平。
50
+ * 只认文档根:手写的 flow 数组、缩进里的 `[]` 原样留着。 */
51
+ const flattenEmptyArray = (text) => {
52
+ const lines = text.split("\n");
53
+ const i = lines.findIndex((l) => l.trim() && !/^\s*#/.test(l));
54
+ if (i < 0 || lines[i].trim() !== "[]") return text;
55
+ return [...lines.slice(0, i), ...lines.slice(i + 1)].join("\n");
56
+ };
23
57
 
24
58
  /**
25
59
  * dsh 自己建过的 profile(带 package.json)。
@@ -32,6 +66,16 @@ export function liveProfiles() {
32
66
  }
33
67
 
34
68
  const mark = (name) => [`# >>> agent-purge:${name}`, `# <<< agent-purge:${name}`];
69
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
70
+ // marker 必须**整行**匹配:`agent-purge` 是 `agent-purge-slash` 的前缀,
71
+ // 不锚行的话删常驻块会把 /purge 那段一起吃掉,留下 `-slash` 让 patch 变成非法 YAML(issue #4)。
72
+ const blockRe = (name) => {
73
+ const [begin, end] = mark(name);
74
+ return new RegExp(`(?:^|\\r?\\n)${esc(begin)}[ \\t]*\\r?\\n[\\s\\S]*?(?:^|\\r?\\n)${esc(end)}[ \\t]*\\r?(?:\\n|$)`, "g");
75
+ };
76
+ const hasBlock = (text, name) => blockRe(name).test(text);
77
+ /** 把我们的三段(以及旧版残留的孤儿行)都剥掉,得到“没有 agent-purge 的原样”。 */
78
+ const stripOurs = (text) => ALL.reduce((t, n) => t.replace(blockRe(n), ""), healStraySlash(text));
35
79
 
36
80
  /** 这个 package.json 是 agent-purge 写的吗(别碰同名外来目录)。 */
37
81
  const isOurs = (meta) => existsSync(meta) && /"?agentPurge"?\s*:\s*true/.test(readFileSync(meta, "utf8"));
@@ -284,31 +328,47 @@ export function addPlugin(name, source) {
284
328
  const dir = pluginDir(p, name);
285
329
  const patch = patchFile(p);
286
330
  const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
287
- // 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚
288
- touched.push({ dir, patch, before });
331
+ // 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚(stash 一并回滚)
332
+ touched.push({ dir, patch, before, existedDir: existsSync(dir), stashPath: stashFile(p), stash: existsSync(stashFile(p)) ? readFileSync(stashFile(p), "utf8") : null });
289
333
  mkdirSync(dir, { recursive: true });
290
334
  writeFileSync(join(dir, "package.json"), pkg);
291
335
  writeFileSync(join(dir, "index.js"), source);
292
336
  files.push(dir);
293
- const old = before ?? "# agent-purge 新建的 patch 层\n";
294
- if (old.includes(begin)) continue;
295
- // `[]` 是合法空数组,后面接 `- insert:` 就是非法 YAML:整份换成我们的段
296
- // 不存在也算“空”:走 stash 分支才能把“原本没有这个文件”记下来,卸载后才不会留个空壳
297
- const empty = before === null || /^\s*(\[\s*\]\s*)?$/.test(old);
298
- const next = empty ? block.slice(1) : old + block;
299
- if (empty) {
300
- // 整份替换会把原文端掉:原文 + 我们写出的成品都存下,卸载时对得上才整份还原
301
- // (对不上 = 期间被人改过,那就只剔自己那段)
302
- writeFileSync(join(dir, ".patch.before"), JSON.stringify({ before, after: next }));
337
+ // 旧版前缀匹配留下的孤儿 `-slash` 行会让整份 patch 不是合法 YAML:写入前剥掉
338
+ const stripped = healStraySlash(before ?? "");
339
+ const healed = flattenEmptyArray(stripped);
340
+ healBackup(p, before);
341
+ if (hasBlock(healed, name)) {
342
+ if (healed !== (before ?? "")) {
343
+ // 旧版装坏的 `[]` + 块:把自愈后的形态记成 stash.after,卸载才还原得回原文
344
+ const prev = parseStash(stashFile(p));
345
+ const keep = prev && prev.after === before ? prev.before : stripOurs(before ?? "");
346
+ writeFileSync(stashFile(p), JSON.stringify({ before: keep, after: healed }));
347
+ writeFileSync(patch, healed);
348
+ }
349
+ continue;
303
350
  }
351
+ // 空文档(模板那个 `[]`)直接换成我们的段;stash 记剥孤儿后的原样,卸载还回 `[]`
352
+ const next = healed.trim() ? healed + block : block.slice(1);
353
+ const prev = parseStash(stashFile(p));
354
+ // 还原点只在文件还是我们上次写的样子时有效:被改过就按当前字节重新记,
355
+ // 否则旧 before 会把用户后来写的内容整份覆盖掉(或把文件误删)
356
+ let keep = null;
357
+ if (prev && prev.after === before) keep = prev.before;
358
+ // 旧版写坏的 patch(`[]` + 块)里也含我们的块:还原点得先剥掉自己,否则卸载后会回放旧块
359
+ else if (before !== null) keep = stripOurs(before);
360
+ writeFileSync(stashFile(p), JSON.stringify({ before: keep, after: next }));
304
361
  writeFileSync(patch, next);
305
362
  }
306
363
  } catch (e) {
307
364
  // 回滚:撤销本次已写的文件,宁可完全没装也不留半套
308
- for (const { dir, patch, before } of touched.reverse()) {
309
- rmSync(dir, { recursive: true, force: true });
365
+ for (const { dir, patch, before, existedDir, stashPath, stash } of touched.reverse()) {
366
+ // 只删这一轮新建的目录:重装时目录本来就在,删掉会让还原后的 patch 指向一个不存在的包
367
+ if (!existedDir) rmSync(dir, { recursive: true, force: true });
310
368
  if (before === null) rmSync(patch, { force: true });
311
369
  else writeFileSync(patch, before);
370
+ if (stash === null) rmSync(stashPath, { force: true });
371
+ else writeFileSync(stashPath, stash);
312
372
  }
313
373
  throw e;
314
374
  }
@@ -317,45 +377,77 @@ export function addPlugin(name, source) {
317
377
 
318
378
  /** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
319
379
  export function removePlugin(name) {
320
- const [begin, end] = mark(name);
321
380
  // 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
322
- const re = new RegExp(`(?:^|\\n)${begin}[\\s\\S]*?${end}\\n?`, "g");
381
+ const re = blockRe(name);
323
382
  for (const p of DSH_PROFILES) {
324
383
  const dir = pluginDir(p, name);
325
384
  const meta = join(dir, "package.json");
326
385
  // 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
327
386
  const ours = isOurs(meta);
328
- const stashFile = join(dir, ".patch.before");
329
- // 当初整份替换过的:记录躲在插件目录里,先读出来,不然删目录就丢了
330
- let stash = null;
331
- if (ours && existsSync(stashFile)) {
332
- try {
333
- stash = JSON.parse(readFileSync(stashFile, "utf8"));
334
- } catch {
335
- stash = null;
336
- }
337
- }
387
+ // 当初整份替换过的:先读出来,不然删目录就丢了(新版在 profile 根,旧版在插件目录)
388
+ const stash = parseStash(stashFile(p)) ?? parseStash(join(dir, ".patch.before"));
338
389
  if (ours) rmSync(dir, { recursive: true, force: true });
339
390
  const patch = patchFile(p);
340
391
  if (!existsSync(patch)) continue;
341
392
  const text = readFileSync(patch, "utf8");
342
- // 我们整份写的、且此后没人动过 → 整份还原(原本没这个文件就删掉它)
343
- if (stash && text === stash.after) {
393
+ const healed = healStraySlash(text);
394
+ healBackup(p, text);
395
+ // 我们整份写的、且此后没人动过、且这是最后一个属于自己的块 → 整份还原(原本没这个文件就删掉它)
396
+ // 还有别的块(例如先装的 /purge)时不能整份还原,否则会连带把它抹掉
397
+ const siblings = ALL.filter((n) => n !== name && hasBlock(text, n));
398
+ if (stash && text === stash.after && siblings.length === 0) {
344
399
  if (stash.before === null) rmSync(patch, { force: true });
345
400
  else writeFileSync(patch, stash.before);
401
+ rmStash(p);
346
402
  continue;
347
403
  }
348
- if (text.includes(begin)) writeFileSync(patch, text.replace(re, ""));
404
+ let next = healed;
405
+ if (hasBlock(next, name)) next = next.replace(re, "");
406
+ // 卸到没有我们的块了:剩下的只要等于“原文摊平 `[]` 后的样子”,就把原文逐字节还回去
407
+ // (多块先后装时,第一个块删完 text 就不再等于 stash.after,但剩下的仍然是原文)
408
+ if (stash && !ALL.some((n) => hasBlock(next, n))) {
409
+ const base = stash.before === null ? "" : stash.before;
410
+ const baseFlat = flattenEmptyArray(healStraySlash(base));
411
+ // 只认两种“等于原文”:逐字节相等,或两边都只剩空白(原文是那个 `[]` 空壳)
412
+ if (next === baseFlat || (next.trim() === "" && baseFlat.trim() === "")) {
413
+ if (stash.before === null) rmSync(patch, { force: true });
414
+ else writeFileSync(patch, stash.before);
415
+ rmStash(p);
416
+ continue;
417
+ }
418
+ }
419
+ if (next !== text) writeFileSync(patch, next);
420
+ // 我们三个块都不在了 → 这份 stash 用完即弃
421
+ if (!ALL.some((n) => hasBlock(next, n))) rmStash(p);
422
+ }
423
+ }
424
+
425
+ /** 盘上真有那份插件文件的位置:status / armed 对账用。
426
+ * 不能拿没装过的 desktop 路径当真相:web/headless 装了、desktop 没建过时,
427
+ * 读那个路径就是 ENOENT(issue #3)。 */
428
+ export function pluginFileOnDisk(name) {
429
+ for (const p of liveProfiles()) {
430
+ const f = join(pluginDir(p, name), "index.js");
431
+ if (existsSync(f)) return f;
349
432
  }
433
+ return join(pluginDir("desktop", name), "index.js");
434
+ }
435
+
436
+ /** 任一份 profile 挂着就算能用:/purge 是会话内的,后来新建的 profile 不影响已有会话。 */
437
+ export function hasPluginAny(name) {
438
+ const targets = liveProfiles();
439
+ return targets.length > 0 && targets.some((p) => {
440
+ const patch = patchFile(p);
441
+ return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
442
+ });
350
443
  }
351
444
 
352
445
  /** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
353
446
  export function hasPlugin(name) {
354
- const [begin] = mark(name);
355
447
  const targets = liveProfiles();
356
448
  if (targets.length === 0) return false;
357
449
  return targets.every((p) => {
358
450
  const patch = patchFile(p);
359
- return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && readFileSync(patch, "utf8").includes(begin);
451
+ return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
360
452
  });
361
453
  }