agent-purge 0.2.2 → 0.2.4

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
@@ -1,6 +1,10 @@
1
1
  # agent-purge
2
2
 
3
- 一份系统提示词,喂给多个 coding agent。支持替换(replace)、追加(append)、清空(off)三种模式,可热切换,全程可逆。
3
+ [![npm](https://img.shields.io/npm/v/agent-purge)](https://www.npmjs.com/package/agent-purge)
4
+ [![stars](https://img.shields.io/github/stars/ZiChuanLan/agent-purge)](https://github.com/ZiChuanLan/agent-purge/stargazers)
5
+ [![license](https://img.shields.io/npm/l/agent-purge)](LICENSE)
6
+
7
+ 一份系统提示词,喂给多个 coding agent。支持替换(replace)、只发人设(strict)、追加(append)、清空(off)四种模式,可热切换,全程可逆。
4
8
 
5
9
  ```
6
10
  agent-purge web # 本地控制台 http://127.0.0.1:7788
@@ -52,10 +56,85 @@ agent-purge pi --persona <id> # 只这一轮用别的
52
56
 
53
57
  ## 支持情况
54
58
 
59
+ 每个数字都是实测值:本地假 provider 抓真实请求体,不是估算。
60
+
61
+ | harness | 机制 | 实测版本 | 默认 system | 注入后 |
62
+ |---|---|---|---|---|
63
+ | `codex` | 启动参数 `-c model_instructions_file=` | 0.163.0 | 21,026 | 24 |
64
+ | `claude` | `--system-prompt-file` + 二进制补丁清残留身份句 | 2.1.304 | 6,379 | 156 |
65
+ | `grok` | `--system-prompt-override` | 1.0.62 | 5,146 | 21 |
66
+ | `omp` | `--system-prompt` | 17.4.2 | 34,511 | 10,106 |
67
+ | `hermes` | 临时 HOME 里替换 `SOUL.md` | 0.21.5 | 8,139 | 7,554 |
68
+ | `zcode` | UserPromptSubmit hook(user 角色)+ `customSystemPrompt` | 0.16.9 | 9,359 | 9,359 |
69
+ | `opencode` | cwd 下 `.opencode/plugin/*.ts` 原地改 `output.system` | 1.19.3 | 19,332 | 112 |
70
+ | `opencode2` | `plugins/agent-purge-v2.ts` 的 `ctx.session.hook("context")` | 0.0.0-beta-17823 | 8,279 | 3,189 |
71
+ | `dsh` | cordis 插件(三个 profile 各一份) | 0.2.1-rc.2 | 5,713 | 68 |
72
+ | `pi` | 原生扩展,会话内 `/purge` | — | — | — |
73
+ | `antigravity` | 等长替换二进制里的身份句 | 1.23.x | ≈20,498 | — |
74
+ | `codebuddy` | `--system-prompt-file` | 2.171.0 | — | — |
75
+ | `qoder` / `workbuddy` | 不支持(提示词在服务端) | — | — | — |
76
+
77
+ `opencode` 和 `opencode2` 是两个不同的 npm 包(`opencode-ai` 1.x 与 `@opencode/cli` 2.x),插件接口完全不同,各自独立支持。
78
+
55
79
  `agent-purge ls` 和 Web 控制台显示的是**运行期真值**:每个 harness 有没有装、版本、注入层(SYS / USER)、三个通道各自可用性。`matrix.json` 里是静态能力表,两者冲突时以运行期为准。
56
80
 
57
81
  闭源桌面端(提示词在服务端或二进制里)只做原样启动,不做任何注入——`agent-purge <h>` 会明确打印原因,不会假装生效。
58
82
 
83
+ ### dsh(DeepSeek Harness)
84
+
85
+ desktop / web / headless 三个形态共用一套插件:往每个 profile 的 `node_modules/agent-purge/` 放一个 cordis 插件,在 `cordis.patch.yml` 里用 `- insert` 挂上。**不改 `app.asar`,不碰安装目录,不依赖任何第三方插件。**
86
+
87
+ ```bash
88
+ agent-purge install dsh # 写插件 + 挂载(三个 profile 一起)
89
+ agent-purge dsh # 按当前 persona 启动桌面版
90
+ agent-purge uninstall dsh # 删插件 + 剥离 patch 段,字节还原
91
+ ```
92
+
93
+ 装完要**重启一次 dsh**(patch 层只在启动时读);之后换 persona、切模式、开关全是热生效,不用再重启。
94
+
95
+ 三个形态可以**分别开关**:Web 面板的 dsh 卡片下方有 desktop / web / headless 三个小勾,对应 `state.json` 的 `dshProfiles`。比如只在桌面版用,就把 web 和 headless 关掉;勾选只写状态文件,插件每轮现读,**不用重启**。
96
+
97
+ 只想临时试一下、不碰常驻 profile:`agent-purge dsh --ask "…"`(一次性会话,进程退出即清理)。
98
+
99
+ #### 为什么不做「文件夹级人设」
100
+
101
+ 很容易想到的下一步是:同一个 dsh 里,A 项目一套人设、B 项目另一套。技术上做得到——插件在 `assemble` 时能拿到 cwd,按目录前缀匹配 persona 即可。但这是**破坏性**的设计,所以刻意不做:
102
+
103
+ - **唯一真相被打破**。现在所有人设共用一个 `state.json` 指针,换一次、所有入口跟着变。按目录分叉等于把「配置」升级成「规则引擎」,之后每次改人设都要先回答「改的是哪一层」。
104
+ - **规则会自我繁殖**。前缀匹配必然要处理嵌套目录、glob、优先级、冲突兜底。今天一个前缀,明天就要 `.agent-purge/` 文件,后天要继承与覆盖——最后没人说得清当前生效的是哪一条。
105
+ - **跨入口语义分裂**。codex / claude / opencode 这些入口没有「项目」概念(有的是 PATH shim,有的是配置文件)。dsh 里按目录切、别处全局,用户无法解释「为什么这里是人设 A,那里是 B」。
106
+ - **卸载不再是字节还原**。一旦引入目录规则,卸载就要判断哪些规则是用户写的、哪些是工具写的,回滚成本陡增。
107
+
108
+ 一句话:**agent-purge 卖的是「一份人设,所有入口,一起切换」**。做文件夹级隔离,赚的是灵活性,赔的是这句话还成不成立。真有这个需求,给每个项目放不同的人设文件、用 `agent-purge use` 手动切,比在插件里塞一套匹配引擎干净得多。
109
+
110
+ ## magpie 网关插件(可选)
111
+
112
+ [magpie](https://github.com/yetone/magpie) 把各家 agent 的请求收进本地网关。挂上这个插件后,**凡是经过网关的请求**(Claude Code、Codex、OpenCode、pi…)的 system 段都按 agent-purge 的当前人设改写——不用逐个 harness 装,换人设也不用重启 magpie。
113
+
114
+ 插件在仓库里,不进 npm 包(`files` 白名单),所以按路径装:
115
+
116
+ ```bash
117
+ git clone https://github.com/ZiChuanLan/agent-purge
118
+ magpie plugin add /path/to/agent-purge/magpie-plugin # 按路径装
119
+ magpie plugin options magpie-plugin '{"mode":"append"}' # 插件名 = 目录名
120
+ magpie plugin list # 应看到 async gateway hook + Persona 页
121
+ ```
122
+
123
+ 插件读的还是 `~/.agent-purge/`(同样认 `AGENT_PURGE_HOME`):`agent-purge use <id>` 之后下一个请求就换人设。magpie 里也会多出 **Persona** 页,点一下就把人设和模式写进插件的 options。
124
+
125
+ 四种模式和 CLI 一致,差别在网关手里只有文本、没有各家启动参数:
126
+
127
+ | 模式 | 网关插件做什么 |
128
+ |---|---|
129
+ | `off` | 不动请求 |
130
+ | `strict` | system 整段换成人设(工具说明也一起没了,harness 可能变笨) |
131
+ | `append` | 人设挂在最前,宿主 system 原样 |
132
+ | `replace` | 人设在前,再删掉宿主 system 里报名字的那句(近似;要干净用 `strict`) |
133
+
134
+ options 三键:`mode`、`persona`(人设 id)、`agents`(只对这些 magpie agent 生效,如 `["claude","codex"]`)。
135
+
136
+ **边界**:改的是请求体里的文本,harness 在客户端就拼死的东西拦不住;`replace` 只删「一行里同时有自称和点名模型/厂商」的行,比 CLI 那套 per-harness 处理粗。插件页是沙箱(没有 fetch、读不到磁盘),人设清单由 `gen-personas.mjs` 按你本机的人设生成到本地(不进仓库),**装完先跑一次**,之后增删人设再跑:`node magpie-plugin/gen-personas.mjs`。自测不依赖 magpie:`npm test`(钩子 26 项断言:四种协议 × 四种模式;页面逻辑 18 项:搜索过滤、options 合并、失败回滚)。
137
+
59
138
  ## 目录
60
139
 
61
140
  ```
@@ -64,6 +143,7 @@ lib/ 状态、persona、注入、通道、检测
64
143
  backends/ 每个 harness 的启动参数与身份句处理
65
144
  web/ 控制台(server.mjs + ui.html)
66
145
  bench/ 注入成功率实测与裁判脚本(开发用,不参与运行)
146
+ magpie-plugin/ magpie 网关插件(可选,不进 npm 包)
67
147
  matrix.json 能力矩阵
68
148
  ```
69
149
 
@@ -112,6 +192,10 @@ matrix.json 能力矩阵
112
192
  **第三方独立性声明**:本项目属于完全独立的开源安全评测研究项目,与 pi 官方或其关联主体无任何隶属、商业合作、授权或官方背书关系。文中「官方」仅指评测对象为使用者本机安装的官方 harness 软件包,不代表其官方开发、认可或担保本插件。
113
193
 
114
194
  **必须署名**:借用本项目的名称、思路、代码或提示词,必须署名并标明本仓库。不署名将依法追究责任。
195
+ ## Star History
196
+
197
+ [![Star History Chart](https://api.star-history.com/svg?repos=ZiChuanLan/agent-purge&type=Date)](https://star-history.com/#ZiChuanLan/agent-purge&Date)
198
+
115
199
  ## License
116
200
 
117
201
  MIT
@@ -2,6 +2,8 @@
2
2
  // setup/cleanup 由入口统一调用,保证能按哈希回滚。
3
3
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { SESSION as DSH_SESSION, addPlugin, removePlugin, sessionSource } from "../lib/dsh.mjs";
6
+ import { opencodePlugins } from "../lib/persist.mjs";
5
7
 
6
8
  const inline = (text) => text.replace(/\s+$/, "");
7
9
 
@@ -86,6 +88,57 @@ export const backends = {
86
88
  };
87
89
  },
88
90
 
91
+ // dsh(DeepSeek Harness):CLI 只能起 headless(desktop 被 Electron 独占)。
92
+ // 一次性注入用 agent-purge-session 这个独立插件名,不会覆盖常驻的 agent-purge。
93
+ dsh: ({ personaText, mode, args }) => {
94
+ if (mode === "off") return { cmd: "dsh", args: ["--profile", "headless", ...args] };
95
+ return {
96
+ cmd: "dsh",
97
+ args: ["--profile", "headless", ...args],
98
+ note: `[plugin] ${DSH_SESSION}(三个 profile;patch: - insert)`,
99
+ residueHint: "desktop profile 由 Electron 独占,一次性注入只对本次 CLI 进程有效",
100
+ setup: () => addPlugin(DSH_SESSION, sessionSource(personaText, mode)),
101
+ cleanup: () => removePlugin(DSH_SESSION),
102
+ };
103
+ },
104
+
105
+ // opencode v2(@opencode/cli,bin 名 opencode2):新插件 API,V1 的
106
+ // experimental.chat.system.transform 在 2.x 已废。插件放全局 plugins 目录。
107
+ opencode2: ({ personaText, mode, args }) => {
108
+ if (mode === "off") return { cmd: "opencode2", args: [...args] };
109
+ const dir = opencodePlugins();
110
+ // 与 V1 常驻插件(agent-purge.ts)分开,否则一次性注入会顶掉常驻接管
111
+ const file = join(dir, "agent-purge-v2-session.ts");
112
+ const persona = inline(personaText);
113
+ const mutate =
114
+ mode === "append"
115
+ ? `event.system.push({ type: "text", text: PERSONA });`
116
+ : `event.system.splice(0, event.system.length, { type: "text", text: PERSONA });`;
117
+ const src = `// 由 agent-purge 生成;删除本文件即恢复宿主人设。
118
+ const PERSONA = ${JSON.stringify(persona)};
119
+
120
+ export default {
121
+ id: "agent-purge-v2-session",
122
+ async setup(ctx) {
123
+ await ctx.session.hook("context", (event) => {
124
+ ${mutate}
125
+ });
126
+ },
127
+ };
128
+ `;
129
+ const write = () => {
130
+ mkdirSync(dir, { recursive: true });
131
+ writeFileSync(file, src);
132
+ };
133
+ return {
134
+ cmd: "opencode2",
135
+ args,
136
+ note: `[plugin] ${file.replace(/\\/g, "/")}(ctx.session.hook("context") 改 event.system)`,
137
+ setup: write,
138
+ cleanup: () => rmSync(file, { force: true }),
139
+ };
140
+ },
141
+
89
142
  // 需要落盘或调用方配合,CLI 不代劳,只给可复制的配方
90
143
  zcode: () => ({
91
144
  manual: [
@@ -153,6 +206,16 @@ export const oneshots = {
153
206
  const b = backends.opencode({ personaText, cwd, args: ["run", prompt] });
154
207
  return b;
155
208
  },
209
+ opencode2: ({ prompt, personaText, mode }) => {
210
+ if (mode === "off") return { cmd: "opencode2", args: ["run", prompt] };
211
+ const b = backends.opencode2({ personaText, mode, args: ["run", prompt] });
212
+ return b;
213
+ },
214
+ dsh: ({ prompt, personaText, mode }) => {
215
+ if (mode === "off") return { cmd: "dsh", args: ["--profile", "headless", prompt] };
216
+ // backends.dsh 会自己补 --profile headless,这里只给 prompt(否则重复)
217
+ return backends.dsh({ personaText, mode, args: [prompt] });
218
+ },
156
219
  zcode: ({ prompt }) => ({
157
220
  cmd: "zcode",
158
221
  args: ["--prompt", prompt, "--mode", "yolo", "--output-format", "stream-json", "--no-color"],
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // agent-purge — 一个入口,把各家 harness 的系统提示词替换收进来。
3
3
  // 能力与实测数字见 matrix.json。
4
4
  //
@@ -309,22 +309,27 @@ function modeCmd() {
309
309
  async function installCmd(verb, rest) {
310
310
  const id = rest[0];
311
311
  const ids = id === "--all" || !id ? Object.keys(matrix.harnesses).filter((h) => matrix.harnesses[h].class !== "unsupported") : [id];
312
- const { backends: be } = await import("../backends/index.mjs");
312
+ // 常驻只走 lib/persist.mjs 这一个控制面(caps / Web 开关 / status 都读它的 targets)
313
+ const { install, uninstall, targets } = await import("../lib/persist.mjs");
314
+ const { createStore } = await import("../lib/personas.mjs");
315
+ const store = createStore(DATA);
313
316
  for (const h of ids) {
314
- const b = be[h];
315
- if (!b) die(`未知 harness: ${h}`);
316
- const fn = verb === "install" ? b.install : b.uninstall;
317
- if (!fn) {
317
+ if (!matrix.harnesses[h]) die(`未知 harness: ${h}`);
318
+ if (!targets[h]) {
318
319
  console.log(`${h}: 没有常驻模式(用 arm 走裸命令接管 / slash 走会话内)`);
319
320
  continue;
320
321
  }
321
- const spec = matrix.harnesses[h];
322
- const { createStore } = await import("../lib/personas.mjs");
323
- const store = createStore(DATA);
324
- const pid = store.activeId();
325
- const r = fn({ personaPath: store.materialize(pid), personaText: store.read(pid).body.trim(), root: DATA, reason: spec.reason }) ?? {};
326
- const ok = verb === "install" ? r.ok !== false : r.ok !== false;
327
- console.log(`${ok ? "✓" : "✗"} ${verb} ${h}${r.file ? ` → ${r.file}` : ""}${r.note ? ` (${r.note})` : ""}`);
322
+ let r;
323
+ if (verb === "install") {
324
+ const pid = store.activeId();
325
+ if (!pid) die("还没有 persona:先 agent-purge use <id>");
326
+ r = install(h, { root: DATA, active: store.refreshActive() }) ?? {};
327
+ } else {
328
+ // 卸载不能依赖 persona 还在:人设删了也必须能关
329
+ r = uninstall(h) ?? {};
330
+ }
331
+ const ok = !r.error;
332
+ console.log(`${ok ? "✓" : "✗"} ${verb} ${h}${r.file ? ` → ${r.file}` : ""}${r.note ? ` (${r.note})` : ""}${r.error ? ` ${r.error}` : ""}`);
328
333
  if (ok) setArmed(DATA, h, "install", verb === "install");
329
334
  }
330
335
  }
@@ -720,7 +725,8 @@ function help() {
720
725
  其他家没有这个能力:/purge 展开成用户消息,bench 实测 0/2 无效,所以不提供
721
726
  注入强度:只有 sys 层算。user 层(上下文块/用户消息)在 codex/claude/opencode/grok/hermes 实测都是 0/2,一律不提供。
722
727
 
723
- harness: pi, opencode, codex, claude, grok, omp, hermes, zcode, antigravity, qoder, workbuddy, codebuddy
728
+ harness: pi, opencode, opencode2, dsh, codex, claude, grok, omp, hermes, zcode, antigravity, qoder, workbuddy, codebuddy
729
+ opencode2 = @opencode/cli 2.x(v2 插件 API),dsh = DeepSeek Harness(cordis 插件,desktop/web/headless 三形态)。
724
730
  qoder / workbuddy 无 prompt 组装入口(提示词在服务端),只能改上下文规则文件。
725
731
  `);
726
732
  }
package/lib/caps.mjs CHANGED
@@ -46,6 +46,8 @@ const DESKTOP_FIRST = new Set(["zcode", "antigravity", "qoder", "workbuddy"]);
46
46
  },
47
47
  qoder: { shim: "提示词在服务端,本地拦不到" },
48
48
  workbuddy: { shim: "提示词在服务端,本地拦不到" },
49
+ // dsh 的 CLI 只能起 headless:接管裸命令会把桌面/网页入口静默改道
50
+ dsh: { shim: "dsh 命令行只能起 headless:接管裸命令会让双击/网页入口变成终端会话;要三形态就装常驻插件(install)" },
49
51
  codebuddy: {
50
52
  install: "只有 settings.json(插件开关/模型),没有能把文本塞进提示的词条;实测只能走启动参数或壳",
51
53
  },
@@ -62,6 +64,8 @@ const BLOCKED = { antigravity: "二进制补丁才能改它的系统提示,配
62
64
  const HOT = {
63
65
  pi: { kind: "command", needs: "slash", how: "扩展钩子 before_agent_start:/purge 或改设置,下一轮生效" },
64
66
  opencode: { kind: "state", needs: "install", how: "插件钩子 chat.system.transform 每轮读 state.json + active.md:改设置下一轮生效" },
67
+ dsh: { kind: "state", needs: "install", how: "cordis 插件每轮 assemble 读 state.json + active.md:改设置下一轮生效(desktop 装完要重启一次)" },
68
+ opencode2: { kind: "state", needs: "install", how: "v2 插件 ctx.session.hook('context') 每轮读 state.json + active.md:改设置下一轮生效" },
65
69
  };
66
70
 
67
71
  /** 没有每轮拼装点的:说明为什么只能重启 */
@@ -105,7 +109,7 @@ export function capsFor(id, ctx = {}) {
105
109
  : `本机 PATH 里找不到 ${id}(先把它装到 PATH 上)`
106
110
  : desktop
107
111
  ? `只对终端里敲 \`${id}\` 生效;桌面图标启动绕开 PATH`
108
- : null;
112
+ : reason(id, "shim");
109
113
 
110
114
  const t = persistTargets[id];
111
115
  // user 层不算能力:写下去也压不住 harness 自己的身份,装了等于白装
package/lib/detect.mjs CHANGED
@@ -14,6 +14,8 @@ export const specs = {
14
14
  codebuddy: { bins: ["codebuddy"], homes: [["CODEBUDDY_HOME", ".codebuddy"]], version: ["--version"], marker: "history.jsonl" },
15
15
  claude: { bins: ["claude"], homes: [["CLAUDE_CONFIG_DIR", ".claude"]], version: ["--version"], marker: "settings.json" },
16
16
  opencode: { bins: ["opencode"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
17
+ opencode2: { bins: ["opencode2"], homes: [["XDG_CONFIG_HOME", ".config/opencode"]], version: ["--version"], marker: "opencode.json" },
18
+ dsh: { bins: ["dsh"], homes: [["DSH_HOME", ".dsh"]], version: ["--version"], marker: "profiles" },
17
19
  grok: { bins: ["grok"], homes: [["GROK_HOME", ".grok"]], version: ["--version"], marker: "config.toml" },
18
20
  omp: { bins: ["omp"], homes: [["OMP_HOME", ".omp"]], version: ["--version"], marker: "agent/config.yml" },
19
21
  hermes: { bins: ["hermes"], homes: [["HERMES_HOME", "AppData/Local/hermes"]], version: ["--version"], marker: "hermes-agent" },
package/lib/dsh.mjs ADDED
@@ -0,0 +1,240 @@
1
+ // dsh(DeepSeek Harness)的插件通道,三形态(desktop / web / headless)共用一套写法。
2
+ //
3
+ // 两个插件名,互不覆盖:
4
+ // agent-purge 常驻:每次 assemble 读 state.json + active.md,off 即时生效
5
+ // agent-purge-session 一次性:persona 快照编译进源码,进程退出即删
6
+ //
7
+ // 三个 profile 都要装:paths 推导自 profile 的 dirname(src/index.js → package root),
8
+ // 所以包必须放在 <profile>/node_modules/<name> 下。
9
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ export const DSH_PROFILES = ["desktop", "web", "headless"];
14
+ export const RESIDENT = "agent-purge";
15
+ export const SESSION = "agent-purge-session";
16
+
17
+ export const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
18
+ export const profileDir = (p) => join(dshHome(), "profiles", p);
19
+ export const pluginDir = (p, name = RESIDENT) => join(profileDir(p), "node_modules", name);
20
+ const patchFile = (p) => join(profileDir(p), "cordis.patch.yml");
21
+
22
+ const mark = (name) => [`# >>> agent-purge:${name}`, `# <<< agent-purge:${name}`];
23
+
24
+ /** 这个 package.json 是 agent-purge 写的吗(别碰同名外来目录)。 */
25
+ const isOurs = (meta) => existsSync(meta) && /"?agentPurge"?\s*:\s*true/.test(readFileSync(meta, "utf8"));
26
+
27
+ /** 常驻插件:不编译人设,每次请求现读 state;off 就把宿主的返回值原样透传。 */
28
+ export function residentSource(root, activePath) {
29
+ const state = join(root, "state.json").replace(/\\/g, "/");
30
+ // active.md 是派生物,实际位置是 <root>/personas/active.md(由 createStore().materialize 给出)
31
+ const active = String(activePath).replace(/\\/g, "/");
32
+ return `// 由 agent-purge 生成;删掉本目录 + patch 段即取消。
33
+ import { readFileSync, appendFileSync } from "node:fs";
34
+ const STATE = ${JSON.stringify(state)};
35
+ const ACTIVE = ${JSON.stringify(active)};
36
+ const NAME = ${JSON.stringify(RESIDENT)};
37
+ // 一行一条的运行日志,出问题先看它。写失败静默。
38
+ const LOG = STATE.replace(/state\.json$/, "dsh.log");
39
+ function log(ev, extra) {
40
+ try {
41
+ appendFileSync(LOG, new Date().toISOString() + " " + PROFILE + " " + ev + (extra ? " " + extra : "") + "\\n");
42
+ } catch { /* 只读环境不抛 */ }
43
+ }
44
+ // 自己在哪个 profile:<dsh>/profiles/<name>/node_modules/<pkg>/index.js
45
+ const PROFILE = (() => {
46
+ try {
47
+ const m = /[\\\\/]profiles[\\\\/]([^\\\\/]+)[\\\\/]/.exec(new URL(import.meta.url).pathname);
48
+ return m ? decodeURIComponent(m[1]) : "";
49
+ } catch {
50
+ return "";
51
+ }
52
+ })();
53
+ function cfg() {
54
+ try {
55
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
56
+ // 磁盘卸载后内存里的回调还在:armed 里没有任何 dsh 条目就当成已关闭
57
+ const armed = st && st.armed && st.armed.dsh;
58
+ if (!Array.isArray(armed) || !armed.includes("install")) return null;
59
+ // dshProfiles:没设置过 = desktop/web/headless 全开;设了 = 只认列出的那几个(可热改)
60
+ const on = st && Array.isArray(st.dshProfiles) ? st.dshProfiles : null;
61
+ if (on && (!PROFILE || !on.includes(PROFILE))) return null;
62
+ const mode = (st && st.default && st.default.mode) || "replace";
63
+ if (mode === "off") return null;
64
+ const body = readFileSync(ACTIVE, "utf8").trim();
65
+ return body ? { mode, body } : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+ function put(asm, c) {
71
+ if (!asm || typeof asm !== "object") return asm;
72
+ const sections = Array.isArray(asm.sections) ? asm.sections : [];
73
+ if (c.mode === "append") return { ...asm, sections: [...sections, { name: NAME, text: c.body }] };
74
+ return { ...asm, sections: [{ name: NAME, text: c.body }] };
75
+ }
76
+ export default {
77
+ name: NAME,
78
+ inject: ["systemPrompt"],
79
+ apply(ctx) {
80
+ log("apply", "inject=" + JSON.stringify(["systemPrompt"]));
81
+ // 宿主每轮 assemble 走 system-prompt/assemble 瀑布。必须 global:agent 作用域的
82
+ // systemPrompt 是另一个实例,且切预设时宿主会整只换代理,实例上的 monkey-patch 会丢。
83
+ const handler = async (assembly, _context, next) => {
84
+ let assembled = assembly;
85
+ if (typeof next === "function") {
86
+ try {
87
+ assembled = await next();
88
+ } catch {
89
+ assembled = assembly; // 下游抛错也要让上游兜底,不能把整轮发消息弄死
90
+ }
91
+ }
92
+ const c = cfg();
93
+ const out = c ? put(assembled, c) : assembled;
94
+ log("assemble", (c ? "mode=" + c.mode : "off")
95
+ + " in=" + (Array.isArray(assembled && assembled.sections) ? assembled.sections.length : -1)
96
+ + " out=" + (Array.isArray(out && out.sections) ? out.sections.length : -1)
97
+ + " names=" + (Array.isArray(out && out.sections) ? out.sections.map((s) => s && s.name).join(",") : "-"));
98
+ return out;
99
+ };
100
+ try {
101
+ ctx.on("system-prompt/assemble", handler, { global: true, prepend: true });
102
+ log("hook", "system-prompt/assemble global");
103
+ } catch (e) {
104
+ // 老版本没有这个瀑布:退回包 assemble(关闭时同 this、同参数、同返回类型)
105
+ const prompt = ctx.systemPrompt;
106
+ const orig = prompt.assemble;
107
+ prompt.assemble = function (...rest) {
108
+ const c = cfg();
109
+ if (!c) return orig.apply(this, rest);
110
+ const out = orig.apply(this, rest);
111
+ if (out && typeof out.then === "function") return out.then((asm) => put(asm, c));
112
+ return put(out, c);
113
+ };
114
+ }
115
+ },
116
+ };
117
+ `;
118
+ }
119
+
120
+ /** 一次性插件:人设快照内嵌,进程结束就没。 */
121
+ export function sessionSource(personaText, mode) {
122
+ const put =
123
+ mode === "append"
124
+ ? `return { ...asm, sections: [...(Array.isArray(asm.sections) ? asm.sections : []), { name: NAME, text: PERSONA }] };`
125
+ : `return { ...asm, sections: [{ name: NAME, text: PERSONA }] };`;
126
+ return `// 由 agent-purge 生成(一次性,进程退出即删)。
127
+ const PERSONA = ${JSON.stringify(personaText.trim())};
128
+ const NAME = ${JSON.stringify(SESSION)};
129
+ export default {
130
+ name: NAME,
131
+ inject: ["systemPrompt"],
132
+ apply(ctx) {
133
+ const prompt = ctx.systemPrompt;
134
+ const orig = prompt.assemble;
135
+ // 同常驻插件:不包 async,保持宿主的返回类型
136
+ prompt.assemble = function (...rest) {
137
+ const out = orig.apply(this, rest);
138
+ const put = (asm) => {
139
+ if (!asm || typeof asm !== "object") return asm;
140
+ ${put}
141
+ };
142
+ if (out && typeof out.then === "function") return out.then(put);
143
+ return put(out);
144
+ };
145
+ },
146
+ };
147
+ `;
148
+ }
149
+
150
+ /** 装一个插件名到三个 profile,并在 patch 里挂上(同一段 marker 幂等)。 */
151
+ export function addPlugin(name, source) {
152
+ const pkg = JSON.stringify({ name, version: "0.0.0", type: "module", main: "index.js", agentPurge: true }, null, 1);
153
+ const [begin, end] = mark(name);
154
+ const block = `\n${begin}\n- insert:\n - id: ${name}\n name: ${name}\n${end}\n`;
155
+ const files = [];
156
+ const touched = []; // { dir, patch, before }:中途抛错时按相反顺序回滚
157
+ // 预检:同名目录要么不存在,要么是我们的(package.json 带 agentPurge 标记),否则不碰
158
+ for (const p of DSH_PROFILES) {
159
+ const dir = pluginDir(p, name);
160
+ const meta = join(dir, "package.json");
161
+ if (existsSync(dir) && !isOurs(meta)) throw new Error(`${dir} 已存在且不是 agent-purge 装的,拒绝覆盖`);
162
+ }
163
+ try {
164
+ for (const p of DSH_PROFILES) {
165
+ const dir = pluginDir(p, name);
166
+ const patch = patchFile(p);
167
+ const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
168
+ // 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚
169
+ touched.push({ dir, patch, before });
170
+ mkdirSync(dir, { recursive: true });
171
+ writeFileSync(join(dir, "package.json"), pkg);
172
+ writeFileSync(join(dir, "index.js"), source);
173
+ files.push(dir);
174
+ const old = before ?? "# agent-purge 新建的 patch 层\n";
175
+ if (old.includes(begin)) continue;
176
+ // `[]` 是合法空数组,后面接 `- insert:` 就是非法 YAML:整份换成我们的段
177
+ // 不存在也算“空”:走 stash 分支才能把“原本没有这个文件”记下来,卸载后才不会留个空壳
178
+ const empty = before === null || /^\s*(\[\s*\]\s*)?$/.test(old);
179
+ const next = empty ? block.slice(1) : old + block;
180
+ if (empty) {
181
+ // 整份替换会把原文端掉:原文 + 我们写出的成品都存下,卸载时对得上才整份还原
182
+ // (对不上 = 期间被人改过,那就只剔自己那段)
183
+ writeFileSync(join(dir, ".patch.before"), JSON.stringify({ before, after: next }));
184
+ }
185
+ writeFileSync(patch, next);
186
+ }
187
+ } catch (e) {
188
+ // 回滚:撤销本次已写的文件,宁可完全没装也不留半套
189
+ for (const { dir, patch, before } of touched.reverse()) {
190
+ rmSync(dir, { recursive: true, force: true });
191
+ if (before === null) rmSync(patch, { force: true });
192
+ else writeFileSync(patch, before);
193
+ }
194
+ throw e;
195
+ }
196
+ return { dirs: files, profiles: DSH_PROFILES };
197
+ }
198
+
199
+ /** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
200
+ export function removePlugin(name) {
201
+ const [begin, end] = mark(name);
202
+ // 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
203
+ const re = new RegExp(`(?:^|\\n)${begin}[\\s\\S]*?${end}\\n?`, "g");
204
+ for (const p of DSH_PROFILES) {
205
+ const dir = pluginDir(p, name);
206
+ const meta = join(dir, "package.json");
207
+ // 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
208
+ const ours = isOurs(meta);
209
+ const stashFile = join(dir, ".patch.before");
210
+ // 当初整份替换过的:记录躲在插件目录里,先读出来,不然删目录就丢了
211
+ let stash = null;
212
+ if (ours && existsSync(stashFile)) {
213
+ try {
214
+ stash = JSON.parse(readFileSync(stashFile, "utf8"));
215
+ } catch {
216
+ stash = null;
217
+ }
218
+ }
219
+ if (ours) rmSync(dir, { recursive: true, force: true });
220
+ const patch = patchFile(p);
221
+ if (!existsSync(patch)) continue;
222
+ const text = readFileSync(patch, "utf8");
223
+ // 我们整份写的、且此后没人动过 → 整份还原(原本没这个文件就删掉它)
224
+ if (stash && text === stash.after) {
225
+ if (stash.before === null) rmSync(patch, { force: true });
226
+ else writeFileSync(patch, stash.before);
227
+ continue;
228
+ }
229
+ if (text.includes(begin)) writeFileSync(patch, text.replace(re, ""));
230
+ }
231
+ }
232
+
233
+ /** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
234
+ export function hasPlugin(name) {
235
+ const [begin] = mark(name);
236
+ return DSH_PROFILES.every((p) => {
237
+ const patch = patchFile(p);
238
+ return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && readFileSync(patch, "utf8").includes(begin);
239
+ });
240
+ }
package/lib/persist.mjs CHANGED
@@ -3,10 +3,13 @@
3
3
  // 不动二进制——这里只做可逆的配置改动。
4
4
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
+ import { RESIDENT as DSH_RESIDENT, SESSION as DSH_SESSION, addPlugin, hasPlugin, pluginDir, residentSource, removePlugin } from "./dsh.mjs";
6
7
 
7
8
  const BEGIN = "# >>> agent-purge";
8
9
  const END = "# <<< agent-purge";
9
10
  const home = process.env.USERPROFILE || process.env.HOME;
11
+ // opencode 认 XDG_CONFIG_HOME(lib/detect.mjs 也是这么探测的):两处必须同一个落点,否则装了白装
12
+ export const opencodePlugins = () => join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "opencode", "plugins");
10
13
 
11
14
  export const targets = {
12
15
  pi: {
@@ -67,8 +70,10 @@ export const targets = {
67
70
  },
68
71
  opencode: {
69
72
  // 写入用全局单一位置;读取/删除要扫全部历史位置,否则老版本留下的项目级插件会继续注入
70
- file: () => join(home, ".config", "opencode", "plugins", "agent-purge.ts"),
73
+ file: () => join(opencodePlugins(), "agent-purge.ts"),
71
74
  files: () => [
75
+ join(opencodePlugins(), "agent-purge.ts"),
76
+ // 旧落点:设了 XDG 的机器上,老版本写在 home/.config 的那份也得能被扫到并清掉
72
77
  join(home, ".config", "opencode", "plugins", "agent-purge.ts"),
73
78
  join(home, ".config", "opencode", "plugin", "agent-purge.ts"),
74
79
  join(process.cwd(), ".opencode", "plugin", "agent-purge.ts"),
@@ -99,6 +104,62 @@ export const AgentPurge = async () => ({
99
104
  else output.system.splice(0, output.system.length, c.body);
100
105
  },
101
106
  });
107
+ `,
108
+ },
109
+ dsh: {
110
+ // cordis 插件挂 systemPrompt 层;三个 profile 各一份(desktop 由 Electron 独占,装完要重启一次)
111
+ file: () => join(pluginDir("desktop"), "index.js"),
112
+ kind: "file",
113
+ layer: "sys",
114
+ note: "cordis 插件包;运行时读 state.json,off 即时生效;desktop 需重启一次",
115
+ probe: () => hasPlugin(DSH_RESIDENT),
116
+ install: ({ root, active }) => {
117
+ const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
118
+ return { file: join(pluginDir("desktop"), "index.js"), note: `三个 profile:${r.profiles.join("/")};desktop 重启 DeepSeek Harness 生效` };
119
+ },
120
+ uninstall: () => {
121
+ removePlugin(DSH_RESIDENT);
122
+ // 一次性插件被 SIGKILL 时不会自己清,一并扫掉
123
+ removePlugin(DSH_SESSION);
124
+ return { file: join(pluginDir("desktop"), "index.js") };
125
+ },
126
+ },
127
+ opencode2: {
128
+ // v2 新版插件 API(V1 的 experimental.chat.system.transform 在 2.x 已废),与 V1 插件文件物理分开
129
+ file: () => join(opencodePlugins(), "agent-purge-v2.ts"),
130
+ files: () => [join(opencodePlugins(), "agent-purge-v2.ts"), join(opencodePlugins(), "agent-purge-v2-session.ts")],
131
+ kind: "file",
132
+ layer: "sys",
133
+ note: "v2 插件 ctx.session.hook(\"context\");运行时读 state.json,热切",
134
+ body: (active, root) => `import { readFileSync } from "node:fs";
135
+ // agent-purge 托管文件:删除即可取消
136
+ const STATE = ${JSON.stringify(join(root, "state.json").replace(/\\/g, "/"))};
137
+ const ACTIVE = ${JSON.stringify(String(active).replace(/\\/g, "/"))};
138
+ function cfg() {
139
+ try {
140
+ const st = JSON.parse(readFileSync(STATE, "utf8"));
141
+ // 磁盘卸载后服务里已加载的回调还在:armed 没了就当成已关闭
142
+ const armed = st && st.armed && st.armed.opencode2;
143
+ if (!Array.isArray(armed) || !armed.includes("install")) return null;
144
+ const mode = (st && st.default && st.default.mode) || "replace";
145
+ if (mode === "off") return null;
146
+ const body = readFileSync(ACTIVE, "utf8").trim();
147
+ return body ? { mode, body } : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+ export default {
153
+ id: "agent-purge",
154
+ async setup(ctx) {
155
+ await ctx.session.hook("context", (event) => {
156
+ const c = cfg();
157
+ if (!c) return;
158
+ if (c.mode === "append") event.system.push({ type: "text", text: c.body });
159
+ else event.system.splice(0, event.system.length, { type: "text", text: c.body });
160
+ });
161
+ },
162
+ };
102
163
  `,
103
164
  },
104
165
  };
@@ -106,6 +167,8 @@ export const AgentPurge = async () => ({
106
167
  export function status(_root) {
107
168
  return Object.entries(targets).map(([name, t]) => {
108
169
  const file = t.file();
170
+ // 自定义探针(多文件目标用),优先于默认的存在性判断
171
+ if (t.probe) return { name, installed: t.probe(), file };
109
172
  const candidates = t.files ? t.files() : [file];
110
173
  const present = candidates.filter((f) => existsSync(f));
111
174
  if (!present.length) return { name, installed: false, file };
@@ -121,6 +184,15 @@ export function status(_root) {
121
184
  export function install(name, { root, active }) {
122
185
  const t = targets[name];
123
186
  if (!t) return { error: `${name} 没有持久化后端(见 notes:grok/omp 只有 CLI 开关,claude/zcode/antigravity 要打补丁)` };
187
+ // 多文件/自定义布局的 harness 自带安装函数(dsh 三个 profile、opencode2 v2 插件)
188
+ // 它们的失败信号是异常(同名外来目录拒绝覆盖等),这里转成调用方约好的 {error}
189
+ if (t.install) {
190
+ try {
191
+ return { ...t.install({ root, active }), action: "custom" };
192
+ } catch (e) {
193
+ return { error: e.message };
194
+ }
195
+ }
124
196
  const file = t.file();
125
197
  mkdirSync(dirname(file), { recursive: true });
126
198
  if (t.kind === "file") {
@@ -136,6 +208,13 @@ export function install(name, { root, active }) {
136
208
  export function uninstall(name) {
137
209
  const t = targets[name];
138
210
  if (!t) return { error: `${name} 没有持久化后端` };
211
+ if (t.uninstall) {
212
+ try {
213
+ return { ...t.uninstall(), action: "custom" };
214
+ } catch (e) {
215
+ return { error: e.message };
216
+ }
217
+ }
139
218
  if (t.kind === "file") {
140
219
  // 清掉所有历史位置,保证“关掉”之后没有任何残留插件还在注入
141
220
  const candidates = t.files ? t.files() : [t.file()];
package/lib/state.mjs CHANGED
@@ -1,140 +1,155 @@
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, writeFileSync } from "node:fs";
15
- import { join } from "node:path";
16
-
17
- const FILE = "state.json";
18
- const LEGACY_DEFAULTS = ".defaults.json";
19
- const LEGACY_ACTIVE = join("personas", ".active");
20
-
21
- export const MODES = ["strict", "replace", "append", "off"];
22
- /** 三种生效通道,互不冲突,可以叠加 */
23
- export const ARMS = ["shim", "slash", "install"];
24
-
25
- export const ARM_LABEL = {
26
- shim: "裸命令接管(敲 codex 就是接管的)",
27
- slash: "会话内 /purge(热切换)",
28
- install: "配置文件常驻(连别人启动的也生效)",
29
- };
30
-
31
- export function statePath(root) {
32
- return join(root, FILE);
33
- }
34
-
35
- function emptyState() {
36
- return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false };
37
- }
38
-
39
- /**
40
- * 读状态,顺带把旧布局迁移过来(旧文件改名 `*.migrated` 留档,之后只认 state.json)。
41
- * 返回 `{ state, migrated[] }`;migrated 是给调用方打印的一行行说明。
42
- */
43
- export function loadState(root) {
44
- const file = statePath(root);
45
- let state = emptyState();
46
- const migrated = [];
47
-
48
- if (existsSync(file)) {
49
- try {
50
- 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
- }
55
- }
56
-
57
- const legacy = join(root, LEGACY_DEFAULTS);
58
- if (!state.default && existsSync(legacy)) {
59
- try {
60
- const d = JSON.parse(readFileSync(legacy, "utf8"));
61
- if (d?.harness) {
62
- state.default = { harness: d.harness, mode: MODES.includes(d.mode) ? d.mode : "replace" };
63
- migrated.push(`.defaults.json → default=${state.default.harness} · ${state.default.mode}`);
64
- }
65
- renameSync(legacy, legacy + ".migrated");
66
- } catch {
67
- /* 坏文件不动它,留给人工看 */
68
- }
69
- }
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
- }
81
- }
82
-
83
- // 迁完立即落盘:否则下次调用看不到旧文件,迁移结果直接丢掉
84
- if (migrated.length) writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
85
-
86
- return { state, migrated };
87
- }
88
-
89
- export function saveState(root, state) {
90
- writeFileSync(statePath(root), JSON.stringify(state, null, 2) + "\n");
91
- return state;
92
- }
93
-
94
- /** 浅合并;armed 需要整块替换时传完整对象 */
95
- export function patchState(root, patch) {
96
- const { state } = loadState(root);
97
- return saveState(root, { ...state, ...patch });
98
- }
99
-
100
- export function setDefault(root, { harness, mode }) {
101
- if (!harness) throw new Error("harness 不能为空");
102
- const { state } = loadState(root);
103
- const next = { ...state, default: { harness, mode: MODES.includes(mode) ? mode : "replace" } };
104
- return saveState(root, next).default;
105
- }
106
-
107
- export function setPersona(root, id) {
108
- const { state } = loadState(root);
109
- return saveState(root, { ...state, persona: id }).persona;
110
- }
111
-
112
- /** 裸敲 pi 是否也自动注入(装好的扩展直接读这个位) */
113
- export function setPiAuto(root, on) {
114
- const { state } = loadState(root);
115
- return saveState(root, { ...state, piAuto: on === true }).piAuto;
116
- }
117
-
118
- /** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
119
- export function setPiUserLayer(root, on) {
120
- const { state } = loadState(root);
121
- return saveState(root, { ...state, piUserLayer: on === true }).piUserLayer;
122
- }
123
-
124
- /** armed 变成 {codex: ["shim","install"]};空数组即删键 */
125
- export function setArmed(root, id, arm, on) {
126
- const { state } = loadState(root);
127
- const cur = new Set(state.armed?.[id] ?? []);
128
- if (on) cur.add(arm);
129
- else cur.delete(arm);
130
- const list = ARMS.filter((a) => cur.has(a));
131
- const armed = { ...state.armed };
132
- if (list.length) armed[id] = list;
133
- else delete armed[id];
134
- saveState(root, { ...state, armed });
135
- return list;
136
- }
137
-
138
- export function armedOf(state, id) {
139
- return state.armed?.[id] ?? [];
140
- }
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, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ const FILE = "state.json";
18
+ const LEGACY_DEFAULTS = ".defaults.json";
19
+ const LEGACY_ACTIVE = join("personas", ".active");
20
+
21
+ export const MODES = ["strict", "replace", "append", "off"];
22
+ /** 三种生效通道,互不冲突,可以叠加 */
23
+ export const ARMS = ["shim", "slash", "install"];
24
+
25
+ export const ARM_LABEL = {
26
+ shim: "裸命令接管(敲 codex 就是接管的)",
27
+ slash: "会话内 /purge(热切换)",
28
+ install: "配置文件常驻(连别人启动的也生效)",
29
+ };
30
+
31
+ export function statePath(root) {
32
+ return join(root, FILE);
33
+ }
34
+
35
+ function emptyState() {
36
+ return { v: 1, persona: null, default: null, armed: {}, piAuto: false, piUserLayer: false, dshProfiles: null };
37
+ }
38
+
39
+ /**
40
+ * 读状态,顺带把旧布局迁移过来(旧文件改名 `*.migrated` 留档,之后只认 state.json)。
41
+ * 返回 `{ state, migrated[] }`;migrated 是给调用方打印的一行行说明。
42
+ */
43
+ export function loadState(root) {
44
+ const file = statePath(root);
45
+ let state = emptyState();
46
+ const migrated = [];
47
+
48
+ if (existsSync(file)) {
49
+ try {
50
+ 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
+ }
55
+ }
56
+
57
+ const legacy = join(root, LEGACY_DEFAULTS);
58
+ if (!state.default && existsSync(legacy)) {
59
+ try {
60
+ const d = JSON.parse(readFileSync(legacy, "utf8"));
61
+ if (d?.harness) {
62
+ state.default = { harness: d.harness, mode: MODES.includes(d.mode) ? d.mode : "replace" };
63
+ migrated.push(`.defaults.json → default=${state.default.harness} · ${state.default.mode}`);
64
+ }
65
+ renameSync(legacy, legacy + ".migrated");
66
+ } catch {
67
+ /* 坏文件不动它,留给人工看 */
68
+ }
69
+ }
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
+ }
81
+ }
82
+
83
+ // 迁完立即落盘:否则下次调用看不到旧文件,迁移结果直接丢掉
84
+ if (migrated.length) writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
85
+
86
+ return { state, migrated };
87
+ }
88
+
89
+ export function saveState(root, state) {
90
+ writeFileSync(statePath(root), JSON.stringify(state, null, 2) + "\n");
91
+ return state;
92
+ }
93
+
94
+ /** 浅合并;armed 需要整块替换时传完整对象 */
95
+ export function patchState(root, patch) {
96
+ const { state } = loadState(root);
97
+ return saveState(root, { ...state, ...patch });
98
+ }
99
+
100
+ export function setDefault(root, { harness, mode }) {
101
+ if (!harness) throw new Error("harness 不能为空");
102
+ const { state } = loadState(root);
103
+ const next = { ...state, default: { harness, mode: MODES.includes(mode) ? mode : "replace" } };
104
+ return saveState(root, next).default;
105
+ }
106
+
107
+ export function setPersona(root, id) {
108
+ const { state } = loadState(root);
109
+ return saveState(root, { ...state, persona: id }).persona;
110
+ }
111
+
112
+ /** 裸敲 pi 是否也自动注入(装好的扩展直接读这个位) */
113
+ export function setPiAuto(root, on) {
114
+ const { state } = loadState(root);
115
+ return saveState(root, { ...state, piAuto: on === true }).piAuto;
116
+ }
117
+
118
+ /** pi 的人设走哪一层:false = system(默认),true = 额外塞进用户消息(给忽略 system 的模型用) */
119
+ export function setPiUserLayer(root, on) {
120
+ const { state } = loadState(root);
121
+ return saveState(root, { ...state, piUserLayer: on === true }).piUserLayer;
122
+ }
123
+
124
+ /** armed 变成 {codex: ["shim","install"]};空数组即删键 */
125
+ export function setArmed(root, id, arm, on) {
126
+ const { state } = loadState(root);
127
+ const cur = new Set(state.armed?.[id] ?? []);
128
+ if (on) cur.add(arm);
129
+ else cur.delete(arm);
130
+ const list = ARMS.filter((a) => cur.has(a));
131
+ const armed = { ...state.armed };
132
+ if (list.length) armed[id] = list;
133
+ else delete armed[id];
134
+ saveState(root, { ...state, armed });
135
+ return list;
136
+ }
137
+
138
+ export function armedOf(state, id) {
139
+ return state.armed?.[id] ?? [];
140
+ }
141
+
142
+ /** dsh 三个 profile 哪些生效:null = 没设置过(全部),[] = 全关 */
143
+ export function dshProfilesOf(state) {
144
+ return Array.isArray(state?.dshProfiles) ? state.dshProfiles : null;
145
+ }
146
+
147
+ /** 开/关单个 dsh profile;all 由调用方传入(列表归属 lib/dsh.mjs,别在状态层再写一份) */
148
+ export function setDshProfile(root, profile, on, all) {
149
+ const { state } = loadState(root);
150
+ const cur = new Set(dshProfilesOf(state) ?? all);
151
+ if (on) cur.add(profile);
152
+ else cur.delete(profile);
153
+ const list = all.filter((p) => cur.has(p));
154
+ return saveState(root, { ...state, dshProfiles: list }).dshProfiles;
155
+ }
package/matrix.json CHANGED
@@ -2,6 +2,30 @@
2
2
  "note": "每个数字都来自 agent-purge 的方法:本地假 provider 抓请求,或 harness 自带日志。更新时改这里,不要改 README 表格。",
3
3
  "personasDir": "personas",
4
4
  "harnesses": {
5
+ "dsh": {
6
+ "class": "plugin",
7
+ "override": true,
8
+ "inject": true,
9
+ "hotSwitch": true,
10
+ "patch": false,
11
+ "launcher": "dsh(三个 profile 各放 node_modules/agent-purge 插件,cordis.patch.yml 里 - insert 挂载;不碰 app.asar)",
12
+ "defaultSystemChars": 5713,
13
+ "purgedSystemChars": 68,
14
+ "measuredVersion": "0.2.1-rc.2",
15
+ "verify": "本地假 provider 抓请求体:headless / desktop / web 三形态各验一遍,插件 canary 进 system(本机走 developer 角色)"
16
+ },
17
+ "opencode2": {
18
+ "class": "plugin",
19
+ "override": true,
20
+ "inject": true,
21
+ "hotSwitch": true,
22
+ "patch": false,
23
+ "launcher": "opencode2(~/.config/opencode/plugins/agent-purge-v2.ts,ctx.session.hook(\"context\") 改 event.system)",
24
+ "defaultSystemChars": 8279,
25
+ "purgedSystemChars": 3189,
26
+ "measuredVersion": "0.0.0-beta-17823",
27
+ "verify": "本地假 provider 抓请求体:canary 进 role=system 消息(--print-logs 可看到 v2 插件加载)"
28
+ },
5
29
  "pi": {
6
30
  "class": "native-extension",
7
31
  "override": true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-purge",
3
- "version": "0.2.2",
4
- "description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode, grok, omp, hermes, pi, zcode.",
3
+ "version": "0.2.4",
4
+ "description": "One persona, many coding agents: replace or inject system prompts across codex, claude, opencode (v1/v2), grok, omp, hermes, pi, zcode, dsh.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "agent-purge": "bin/agent-purge.mjs"
@@ -23,6 +23,7 @@
23
23
  "web": "node bin/agent-purge.mjs web",
24
24
  "matrix": "node bin/agent-purge.mjs matrix",
25
25
  "detect": "node bin/agent-purge.mjs detect",
26
+ "test": "node magpie-plugin/selftest.mjs && node magpie-plugin/test-page.mjs && node test/harness-adapters.test.mjs",
26
27
  "build:exe": "node scripts/build-exe.mjs",
27
28
  "prepack": "node -e \"require('node:fs').rmSync('personas/.build',{recursive:true,force:true})\""
28
29
  },
@@ -31,6 +32,8 @@
31
32
  "codex",
32
33
  "claude",
33
34
  "opencode",
35
+ "dsh",
36
+ "deepseek-harness",
34
37
  "coding-agent",
35
38
  "pi",
36
39
  "persona",
package/web/server.mjs CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { detect } from "../lib/detect.mjs";
7
7
  import { oneshots } from "../backends/index.mjs";
8
8
  import { createStore } from "../lib/personas.mjs";
9
- import { MODES, armedOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto } from "../lib/state.mjs";
9
+ import { MODES, armedOf, dshProfilesOf, loadState, patchState, setArmed, setDefault, setPersona, setPiAuto, setDshProfile } from "../lib/state.mjs";
10
10
  import { install, status as persistStatus, uninstall } from "../lib/persist.mjs";
11
11
  import { plans, summary as armorSummary } from "../lib/armor.mjs";
12
12
  import { installShim, shimStatus, uninstallShim } from "../lib/shim.mjs";
@@ -14,6 +14,7 @@ import { installSlash, refreshSlashes, slashStatus, uninstallSlash } from "../li
14
14
  import { reconcileArmed } from "../lib/armed.mjs";
15
15
  import { capsFor } from "../lib/caps.mjs";
16
16
  import { ensureDataHome } from "../lib/home.mjs";
17
+ import { DSH_PROFILES } from "../lib/dsh.mjs";
17
18
 
18
19
  const ROOT = process.versions.bun ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url)));
19
20
  // 数据(state.json + personas/)在用户目录,不在安装目录
@@ -55,6 +56,7 @@ async function handle(req, res) {
55
56
  if (req.method === "GET" && p === "/api/armor") return send(res, 200, armorState());
56
57
  if (req.method === "POST" && p === "/api/defaults") return send(res, 200, saveDefaults(await body(req)));
57
58
  if (req.method === "POST" && p === "/api/pi-auto") return send(res, 200, { piAuto: setPiAuto(DATA, (await body(req)).on === true) });
59
+ if (req.method === "POST" && p === "/api/dsh-profile") return send(res, 200, saveDshProfile(await body(req)));
58
60
  if (req.method === "POST" && p === "/api/persona") return send(res, 200, savePersona(await body(req)));
59
61
  if (req.method === "DELETE" && p === "/api/persona") return send(res, 200, deletePersona(url.searchParams.get("id")));
60
62
  if (req.method === "POST" && p === "/api/use") return send(res, 200, usePersona(await body(req)));
@@ -137,6 +139,9 @@ async function state(withVersions) {
137
139
  // false = state.json 里还没有默认(defaults 只是 UI 的建议值,裸敲 agent-purge 仍会提示“未设置”)
138
140
  defaultsSet: Boolean(st.default?.harness),
139
141
  armed: st.armed ?? {},
142
+ // dsh 的三个 profile(desktop/web/headless);null 就是全开
143
+ dshProfiles: dshProfilesOf(st) ?? DSH_PROFILES,
144
+ dshAll: DSH_PROFILES,
140
145
  modes: MODES,
141
146
  piAuto: st.piAuto === true,
142
147
  shimDir: shimStatus()[0]?.dir ?? null,
@@ -173,6 +178,16 @@ function saveDefaults(payload) {
173
178
  }
174
179
  }
175
180
 
181
+ function saveDshProfile(payload) {
182
+ const profile = String(payload.profile || "").trim();
183
+ if (!DSH_PROFILES.includes(profile)) return { error: `未知 profile: ${profile}(${DSH_PROFILES.join(" / ")})` };
184
+ try {
185
+ return { ok: true, dshProfiles: setDshProfile(DATA, profile, payload.on === true, DSH_PROFILES) };
186
+ } catch (e) {
187
+ return { error: e.message };
188
+ }
189
+ }
190
+
176
191
  function savePersona(payload) {
177
192
  const id = String(payload.id || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
178
193
  if (!id) return { error: "id 不能为空(小写字母/数字/-/_)" };
package/web/ui.html CHANGED
@@ -260,6 +260,11 @@ h3{margin:0 0 10px;font-family:var(--font-display);font-size:16px;font-weight:60
260
260
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left}
261
261
  .chan.on{border-color:var(--accent);background:var(--accent-soft)}
262
262
  .chan.on .dot{background:var(--accent)}
263
+ .dprof{display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap}
264
+ .dprof-k{font-size:11.5px;color:var(--ink-2);letter-spacing:.02em}
265
+ .chan.mini{padding:7px 11px;align-items:center;gap:7px}
266
+ .chan.mini .dot{margin-top:0}
267
+ .chan.mini b{font-size:12px;font-weight:600}
263
268
  .chan.on b u{color:var(--accent);border-color:var(--accent-line)}
264
269
  .chan.dis{opacity:.62;cursor:not-allowed}
265
270
  /* 结构性做不到(不是“没开”):虚线 + 不跟手,和“关了”区分开 */
@@ -820,6 +825,19 @@ function harnessCard(h) {
820
825
 
821
826
  const noChannel = h.caps ? !(h.caps.shim.ok || h.caps.install.ok || h.caps.slash.ok) : false;
822
827
 
828
+ // dsh 的 desktop / web / headless 是三个独立 profile,插件分别装在三处;
829
+ // 这里只改 state.json,插件每次 assemble 现读,下一轮就生效,不用重启。
830
+ const dshList = S.dshAll || ["desktop", "web", "headless"];
831
+ const profileRow = h.id === "dsh"
832
+ ? `<div class="dprof">
833
+ <span class="dprof-k">生效形态 <i class="hint" title="dsh 三个形态各用一个 profile 目录,插件装在三处。勾选只改 state.json 的 dshProfiles,下一轮即生效">?</i></span>
834
+ ${dshList.map((p) => {
835
+ const on = (S.dshProfiles || []).includes(p);
836
+ return `<button class="chan mini ${on ? "on" : ""}" data-dshprofile="${esc(p)}" data-on="${on ? "off" : "on"}" type="button"><span class="dot"></span><span><b>${esc(p)}</b></span></button>`;
837
+ }).join("")}
838
+ </div>`
839
+ : "";
840
+
823
841
  return `<div class="hc ${h.found ? "" : "dead"}">
824
842
  <div class="hhead">
825
843
  <span class="hname"><span class="st ${h.armed && h.armed.length ? "on" : ""}"></span>${esc(h.id)}</span>
@@ -839,6 +857,7 @@ function harnessCard(h) {
839
857
  </div>
840
858
  ${notes.length ? `<div class="hnote">${notes.map(esc).join("<br>")}</div>` : ""}
841
859
  <div class="chans">${chans}</div>
860
+ ${profileRow}
842
861
  </div>`;
843
862
  }
844
863
  function recommendMode(h) {
@@ -1051,6 +1070,19 @@ $("#selbar").addEventListener("click", async (e) => {
1051
1070
  });
1052
1071
  });
1053
1072
  async function onChan(e) {
1073
+ // dsh 的三个形态:勾/取消只改 state.json,不动磁盘,所以是即时可逆的
1074
+ const pb = e.target.closest("[data-dshprofile]");
1075
+ if (pb) {
1076
+ const profile = pb.dataset.dshprofile;
1077
+ const on = pb.dataset.on === "on";
1078
+ try {
1079
+ await api("/api/dsh-profile", { method: "POST", headers: { "content-type": "application/json" },
1080
+ body: JSON.stringify({ profile, on }) });
1081
+ toast(`dsh · ${profile} ${on ? "已开" : "已关"}`);
1082
+ await load();
1083
+ } catch (err) { toast("失败:" + err.message, true); }
1084
+ return;
1085
+ }
1054
1086
  const b = e.target.closest("[data-chan]"); if (!b) return;
1055
1087
  const { chan, h } = b.dataset;
1056
1088
  const on = S.harnesses.find((x) => x.id === h)?.armed?.includes(chan);