agent-purge 0.2.3 → 0.2.5
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 +13 -1
- package/lib/dsh.mjs +316 -177
- package/lib/persist.mjs +2 -2
- package/lib/state.mjs +155 -140
- package/package.json +1 -1
- package/web/server.mjs +16 -1
- package/web/ui.html +32 -0
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# agent-purge
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/agent-purge)
|
|
4
|
-
[](https://www.npmjs.com/package/agent-purge)
|
|
5
4
|
[](https://github.com/ZiChuanLan/agent-purge/stargazers)
|
|
6
5
|
[](LICENSE)
|
|
7
6
|
|
|
@@ -93,8 +92,21 @@ agent-purge uninstall dsh # 删插件 + 剥离 patch 段,字节还原
|
|
|
93
92
|
|
|
94
93
|
装完要**重启一次 dsh**(patch 层只在启动时读);之后换 persona、切模式、开关全是热生效,不用再重启。
|
|
95
94
|
|
|
95
|
+
三个形态可以**分别开关**:Web 面板的 dsh 卡片下方有 desktop / web / headless 三个小勾,对应 `state.json` 的 `dshProfiles`。比如只在桌面版用,就把 web 和 headless 关掉;勾选只写状态文件,插件每轮现读,**不用重启**。
|
|
96
|
+
|
|
96
97
|
只想临时试一下、不碰常驻 profile:`agent-purge dsh --ask "…"`(一次性会话,进程退出即清理)。
|
|
97
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
|
+
|
|
98
110
|
## magpie 网关插件(可选)
|
|
99
111
|
|
|
100
112
|
[magpie](https://github.com/yetone/magpie) 把各家 agent 的请求收进本地网关。挂上这个插件后,**凡是经过网关的请求**(Claude Code、Codex、OpenCode、pi…)的 system 段都按 agent-purge 的当前人设改写——不用逐个 harness 装,换人设也不用重启 magpie。
|
package/lib/dsh.mjs
CHANGED
|
@@ -1,177 +1,316 @@
|
|
|
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 } from "node:fs";
|
|
34
|
-
const STATE = ${JSON.stringify(state)};
|
|
35
|
-
const ACTIVE = ${JSON.stringify(active)};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
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, writeFileSync } 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
|
+
// ---- /ap 斜杠命令:直接改 state.json,不产生模型消息 ----
|
|
77
|
+
function readState() {
|
|
78
|
+
try { return JSON.parse(readFileSync(STATE, "utf8")) || {}; } catch { return {}; }
|
|
79
|
+
}
|
|
80
|
+
function saveState(st) {
|
|
81
|
+
writeFileSync(STATE, JSON.stringify(st, null, 2) + "\\n");
|
|
82
|
+
}
|
|
83
|
+
function apPatch(fn) {
|
|
84
|
+
const st = readState();
|
|
85
|
+
if (!st.armed || typeof st.armed !== "object") st.armed = {};
|
|
86
|
+
if (!Array.isArray(st.armed.dsh)) st.armed.dsh = [];
|
|
87
|
+
fn(st, st.armed.dsh);
|
|
88
|
+
saveState(st);
|
|
89
|
+
}
|
|
90
|
+
function handleAp(raw) {
|
|
91
|
+
const parts = String(raw || "").trim().split(/\s+/).filter(Boolean);
|
|
92
|
+
const cmd = (parts[0] || "status").toLowerCase();
|
|
93
|
+
const arg = parts.slice(1).join(" ").trim();
|
|
94
|
+
if (cmd === "off") {
|
|
95
|
+
apPatch((st, a) => {
|
|
96
|
+
if (a.includes("install")) {
|
|
97
|
+
st.dshPrevArmed = a.slice();
|
|
98
|
+
st.armed.dsh = a.filter((x) => x !== "install");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return { kind: "success", text: "agent-purge 已关闭(dsh),下一句起不注入" };
|
|
102
|
+
}
|
|
103
|
+
if (cmd === "on") {
|
|
104
|
+
apPatch((st, a) => {
|
|
105
|
+
if (!a.includes("install")) a.push("install");
|
|
106
|
+
delete st.dshPrevArmed;
|
|
107
|
+
});
|
|
108
|
+
return { kind: "success", text: "agent-purge 已开启(dsh),下一句起生效" };
|
|
109
|
+
}
|
|
110
|
+
if (cmd === "mode") {
|
|
111
|
+
const m = arg.toLowerCase();
|
|
112
|
+
if (m !== "strict" && m !== "replace" && m !== "append") {
|
|
113
|
+
return { kind: "error", text: "用法:/purge mode strict|replace|append" };
|
|
114
|
+
}
|
|
115
|
+
apPatch((st) => { if (!st.default || typeof st.default !== "object") st.default = {}; st.default.mode = m; });
|
|
116
|
+
return { kind: "success", text: "模式已切到 " + m };
|
|
117
|
+
}
|
|
118
|
+
if (cmd === "status" || cmd === "help") {
|
|
119
|
+
const st = readState();
|
|
120
|
+
const a = st.armed && Array.isArray(st.armed.dsh) ? st.armed.dsh : [];
|
|
121
|
+
const mode = (st.default && st.default.mode) || "replace";
|
|
122
|
+
return { kind: "success", text: "agent-purge / dsh:" + (a.includes("install") ? "开" : "关") + " · 模式 " + mode + " · 人设 " + (st.persona || "-") + "(可用:on | off | status | mode <strict|replace|append>)" };
|
|
123
|
+
}
|
|
124
|
+
return { kind: "error", text: "用法:/purge on | off | status | mode <strict|replace|append>" };
|
|
125
|
+
}
|
|
126
|
+
export default {
|
|
127
|
+
name: NAME,
|
|
128
|
+
inject: ["systemPrompt"],
|
|
129
|
+
apply(ctx) {
|
|
130
|
+
log("apply", "inject=" + JSON.stringify(["systemPrompt"]));
|
|
131
|
+
// /ap 斜杠命令:等 commands 服务就绪再注册;headless 没有该服务就不会触发,注入不受影响
|
|
132
|
+
try {
|
|
133
|
+
if (typeof ctx.inject === "function") {
|
|
134
|
+
ctx.inject(["commands"], (c) => {
|
|
135
|
+
const spec = {
|
|
136
|
+
description: "agent-purge 人设注入:开关 / 模式",
|
|
137
|
+
input: { hint: "on | off | status | mode <strict|replace|append>" },
|
|
138
|
+
handler: ({ rawInput }) => handleAp(rawInput),
|
|
139
|
+
};
|
|
140
|
+
// 主名 /purge(和 pi 等其他 harness 统一),/ap 留作别名;
|
|
141
|
+
// 与已有同名命令(如 dsh-purge)撞车时只跳过那一个,不影响注入
|
|
142
|
+
for (const n of ["purge", "ap"]) {
|
|
143
|
+
try {
|
|
144
|
+
c.commands.register({ name: n, ...spec });
|
|
145
|
+
log("command", "registered /" + n);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
log("command", "skip /" + n + " " + (e && e.message));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
log("command", "no inject api");
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
log("command", "err " + (e && e.message));
|
|
156
|
+
}
|
|
157
|
+
// 宿主每轮 assemble 走 system-prompt/assemble 瀑布。必须 global:agent 作用域的
|
|
158
|
+
// systemPrompt 是另一个实例,且切预设时宿主会整只换代理,实例上的 monkey-patch 会丢。
|
|
159
|
+
const handler = async (assembly, _context, next) => {
|
|
160
|
+
let assembled = assembly;
|
|
161
|
+
if (typeof next === "function") {
|
|
162
|
+
try {
|
|
163
|
+
assembled = await next();
|
|
164
|
+
} catch {
|
|
165
|
+
assembled = assembly; // 下游抛错也要让上游兜底,不能把整轮发消息弄死
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const c = cfg();
|
|
169
|
+
const out = c ? put(assembled, c) : assembled;
|
|
170
|
+
log("assemble", (c ? "mode=" + c.mode : "off")
|
|
171
|
+
+ " in=" + (Array.isArray(assembled && assembled.sections) ? assembled.sections.length : -1)
|
|
172
|
+
+ " out=" + (Array.isArray(out && out.sections) ? out.sections.length : -1)
|
|
173
|
+
+ " names=" + (Array.isArray(out && out.sections) ? out.sections.map((s) => s && s.name).join(",") : "-"));
|
|
174
|
+
return out;
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
ctx.on("system-prompt/assemble", handler, { global: true, prepend: true });
|
|
178
|
+
log("hook", "system-prompt/assemble global");
|
|
179
|
+
} catch (e) {
|
|
180
|
+
// 老版本没有这个瀑布:退回包 assemble(关闭时同 this、同参数、同返回类型)
|
|
181
|
+
const prompt = ctx.systemPrompt;
|
|
182
|
+
const orig = prompt.assemble;
|
|
183
|
+
prompt.assemble = function (...rest) {
|
|
184
|
+
const c = cfg();
|
|
185
|
+
if (!c) return orig.apply(this, rest);
|
|
186
|
+
const out = orig.apply(this, rest);
|
|
187
|
+
if (out && typeof out.then === "function") return out.then((asm) => put(asm, c));
|
|
188
|
+
return put(out, c);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 一次性插件:人设快照内嵌,进程结束就没。 */
|
|
197
|
+
export function sessionSource(personaText, mode) {
|
|
198
|
+
const put =
|
|
199
|
+
mode === "append"
|
|
200
|
+
? `return { ...asm, sections: [...(Array.isArray(asm.sections) ? asm.sections : []), { name: NAME, text: PERSONA }] };`
|
|
201
|
+
: `return { ...asm, sections: [{ name: NAME, text: PERSONA }] };`;
|
|
202
|
+
return `// 由 agent-purge 生成(一次性,进程退出即删)。
|
|
203
|
+
const PERSONA = ${JSON.stringify(personaText.trim())};
|
|
204
|
+
const NAME = ${JSON.stringify(SESSION)};
|
|
205
|
+
export default {
|
|
206
|
+
name: NAME,
|
|
207
|
+
inject: ["systemPrompt"],
|
|
208
|
+
apply(ctx) {
|
|
209
|
+
const prompt = ctx.systemPrompt;
|
|
210
|
+
const orig = prompt.assemble;
|
|
211
|
+
// 同常驻插件:不包 async,保持宿主的返回类型
|
|
212
|
+
prompt.assemble = function (...rest) {
|
|
213
|
+
const out = orig.apply(this, rest);
|
|
214
|
+
const put = (asm) => {
|
|
215
|
+
if (!asm || typeof asm !== "object") return asm;
|
|
216
|
+
${put}
|
|
217
|
+
};
|
|
218
|
+
if (out && typeof out.then === "function") return out.then(put);
|
|
219
|
+
return put(out);
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 装一个插件名到三个 profile,并在 patch 里挂上(同一段 marker 幂等)。 */
|
|
227
|
+
export function addPlugin(name, source) {
|
|
228
|
+
const pkg = JSON.stringify({ name, version: "0.0.0", type: "module", main: "index.js", agentPurge: true }, null, 1);
|
|
229
|
+
const [begin, end] = mark(name);
|
|
230
|
+
const block = `\n${begin}\n- insert:\n - id: ${name}\n name: ${name}\n${end}\n`;
|
|
231
|
+
const files = [];
|
|
232
|
+
const touched = []; // { dir, patch, before }:中途抛错时按相反顺序回滚
|
|
233
|
+
// 预检:同名目录要么不存在,要么是我们的(package.json 带 agentPurge 标记),否则不碰
|
|
234
|
+
for (const p of DSH_PROFILES) {
|
|
235
|
+
const dir = pluginDir(p, name);
|
|
236
|
+
const meta = join(dir, "package.json");
|
|
237
|
+
if (existsSync(dir) && !isOurs(meta)) throw new Error(`${dir} 已存在且不是 agent-purge 装的,拒绝覆盖`);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
for (const p of DSH_PROFILES) {
|
|
241
|
+
const dir = pluginDir(p, name);
|
|
242
|
+
const patch = patchFile(p);
|
|
243
|
+
const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
|
|
244
|
+
// 先登记再动手:下面任何一步抛错,这一轮的产物都能被回滚
|
|
245
|
+
touched.push({ dir, patch, before });
|
|
246
|
+
mkdirSync(dir, { recursive: true });
|
|
247
|
+
writeFileSync(join(dir, "package.json"), pkg);
|
|
248
|
+
writeFileSync(join(dir, "index.js"), source);
|
|
249
|
+
files.push(dir);
|
|
250
|
+
const old = before ?? "# agent-purge 新建的 patch 层\n";
|
|
251
|
+
if (old.includes(begin)) continue;
|
|
252
|
+
// `[]` 是合法空数组,后面接 `- insert:` 就是非法 YAML:整份换成我们的段
|
|
253
|
+
// 不存在也算“空”:走 stash 分支才能把“原本没有这个文件”记下来,卸载后才不会留个空壳
|
|
254
|
+
const empty = before === null || /^\s*(\[\s*\]\s*)?$/.test(old);
|
|
255
|
+
const next = empty ? block.slice(1) : old + block;
|
|
256
|
+
if (empty) {
|
|
257
|
+
// 整份替换会把原文端掉:原文 + 我们写出的成品都存下,卸载时对得上才整份还原
|
|
258
|
+
// (对不上 = 期间被人改过,那就只剔自己那段)
|
|
259
|
+
writeFileSync(join(dir, ".patch.before"), JSON.stringify({ before, after: next }));
|
|
260
|
+
}
|
|
261
|
+
writeFileSync(patch, next);
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {
|
|
264
|
+
// 回滚:撤销本次已写的文件,宁可完全没装也不留半套
|
|
265
|
+
for (const { dir, patch, before } of touched.reverse()) {
|
|
266
|
+
rmSync(dir, { recursive: true, force: true });
|
|
267
|
+
if (before === null) rmSync(patch, { force: true });
|
|
268
|
+
else writeFileSync(patch, before);
|
|
269
|
+
}
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
return { dirs: files, profiles: DSH_PROFILES };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
|
|
276
|
+
export function removePlugin(name) {
|
|
277
|
+
const [begin, end] = mark(name);
|
|
278
|
+
// 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
|
|
279
|
+
const re = new RegExp(`(?:^|\\n)${begin}[\\s\\S]*?${end}\\n?`, "g");
|
|
280
|
+
for (const p of DSH_PROFILES) {
|
|
281
|
+
const dir = pluginDir(p, name);
|
|
282
|
+
const meta = join(dir, "package.json");
|
|
283
|
+
// 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
|
|
284
|
+
const ours = isOurs(meta);
|
|
285
|
+
const stashFile = join(dir, ".patch.before");
|
|
286
|
+
// 当初整份替换过的:记录躲在插件目录里,先读出来,不然删目录就丢了
|
|
287
|
+
let stash = null;
|
|
288
|
+
if (ours && existsSync(stashFile)) {
|
|
289
|
+
try {
|
|
290
|
+
stash = JSON.parse(readFileSync(stashFile, "utf8"));
|
|
291
|
+
} catch {
|
|
292
|
+
stash = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (ours) rmSync(dir, { recursive: true, force: true });
|
|
296
|
+
const patch = patchFile(p);
|
|
297
|
+
if (!existsSync(patch)) continue;
|
|
298
|
+
const text = readFileSync(patch, "utf8");
|
|
299
|
+
// 我们整份写的、且此后没人动过 → 整份还原(原本没这个文件就删掉它)
|
|
300
|
+
if (stash && text === stash.after) {
|
|
301
|
+
if (stash.before === null) rmSync(patch, { force: true });
|
|
302
|
+
else writeFileSync(patch, stash.before);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (text.includes(begin)) writeFileSync(patch, text.replace(re, ""));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
|
|
310
|
+
export function hasPlugin(name) {
|
|
311
|
+
const [begin] = mark(name);
|
|
312
|
+
return DSH_PROFILES.every((p) => {
|
|
313
|
+
const patch = patchFile(p);
|
|
314
|
+
return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && readFileSync(patch, "utf8").includes(begin);
|
|
315
|
+
});
|
|
316
|
+
}
|
package/lib/persist.mjs
CHANGED
|
@@ -111,11 +111,11 @@ export const AgentPurge = async () => ({
|
|
|
111
111
|
file: () => join(pluginDir("desktop"), "index.js"),
|
|
112
112
|
kind: "file",
|
|
113
113
|
layer: "sys",
|
|
114
|
-
note: "cordis
|
|
114
|
+
note: "cordis 插件包;运行时每轮读 state.json,开关 / 切模式 / 换人设都即时生效;只有首次安装要重启一次",
|
|
115
115
|
probe: () => hasPlugin(DSH_RESIDENT),
|
|
116
116
|
install: ({ root, active }) => {
|
|
117
117
|
const r = addPlugin(DSH_RESIDENT, residentSource(root, active));
|
|
118
|
-
return { file: join(pluginDir("desktop"), "index.js"), note: `三个 profile:${r.profiles.join("/")}
|
|
118
|
+
return { file: join(pluginDir("desktop"), "index.js"), note: `三个 profile:${r.profiles.join("/")};重启一次 DeepSeek Harness 后插件开始工作(之后热切换)` };
|
|
119
119
|
},
|
|
120
120
|
uninstall: () => {
|
|
121
121
|
removePlugin(DSH_RESIDENT);
|
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/package.json
CHANGED
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);
|