agent-purge 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -21
- package/README.md +176 -118
- package/backends/index.mjs +204 -244
- package/bench/bench-armor.mjs +844 -832
- package/bench/capture-hot.mjs +58 -0
- package/bench/capture-provider.mjs +66 -0
- package/bench/capture-report.mjs +15 -0
- package/bench/capture-server-hot.mjs +55 -0
- package/bench/capture-system.mjs +36 -0
- package/bench/judge-armor.mjs +119 -119
- package/bin/agent-purge.mjs +906 -772
- package/docs/LEGAL.md +27 -0
- package/docs/architecture.md +39 -0
- package/docs/compatibility.md +78 -0
- package/docs/images/console-desktop.png +0 -0
- package/docs/images/console-mobile.png +0 -0
- package/docs/releasing.md +87 -0
- package/docs/usage.md +158 -0
- package/lib/activation.mjs +38 -0
- package/lib/apply.mjs +227 -0
- package/lib/armed.mjs +38 -38
- package/lib/armor.mjs +225 -222
- package/lib/caps.mjs +182 -173
- package/lib/capture.mjs +324 -0
- package/lib/clean.mjs +124 -0
- package/lib/control.mjs +60 -0
- package/lib/detect.mjs +74 -70
- package/lib/doctor.mjs +7 -7
- package/lib/dsh.mjs +495 -453
- package/lib/environment.mjs +36 -0
- package/lib/evidence.mjs +41 -0
- package/lib/extension.mjs +140 -0
- package/lib/footprints.mjs +59 -0
- package/lib/home.mjs +47 -47
- package/lib/launch-conflicts.mjs +24 -0
- package/lib/launch.mjs +57 -0
- package/lib/opencode.mjs +62 -0
- package/lib/paths.mjs +25 -0
- package/lib/persist.mjs +439 -280
- package/lib/personas.mjs +17 -7
- package/lib/private-server.mjs +54 -0
- package/lib/project.mjs +134 -0
- package/lib/restore.mjs +131 -114
- package/lib/rewrite.mjs +124 -0
- package/lib/root.mjs +37 -0
- package/lib/shim.mjs +203 -160
- package/lib/slashes.mjs +227 -628
- package/lib/spawn.mjs +182 -126
- package/lib/state.mjs +85 -137
- package/lib/templates/personas/neutral.md +30 -30
- package/lib/update.mjs +137 -0
- package/lib/verification.mjs +23 -0
- package/lib/window.mjs +58 -0
- package/lib/wrappers.mjs +49 -0
- package/magpie-plugin/README.md +44 -0
- package/matrix.json +325 -341
- package/package.json +30 -4
- package/web/app.js +525 -0
- package/web/assets/agent-purge.ico +0 -0
- package/web/assets/claude.ico +0 -0
- package/web/assets/codebuddy.png +0 -0
- package/web/assets/codex.webp +0 -0
- package/web/assets/dsh.ico +0 -0
- package/web/assets/grok.ico +0 -0
- package/web/assets/hermes.ico +0 -0
- package/web/assets/omp.png +0 -0
- package/web/assets/opencode.ico +0 -0
- package/web/assets/opencode2.ico +0 -0
- package/web/assets/pi.png +0 -0
- package/web/assets/zcode.png +0 -0
- package/web/model.mjs +49 -0
- package/web/server.mjs +309 -194
- package/web/styles.css +917 -0
- package/web/ui.html +64 -1163
package/lib/dsh.mjs
CHANGED
|
@@ -1,453 +1,495 @@
|
|
|
1
|
-
// dsh(DeepSeek Harness)的插件通道,三形态(desktop / web / headless)共用一套写法。
|
|
2
|
-
//
|
|
3
|
-
// 三个插件名,互不覆盖:
|
|
4
|
-
// agent-purge 常驻注入:每次 assemble 读 state.json + active.md,off 即时生效
|
|
5
|
-
// agent-purge-slash 会话内热切:只注册 /purge 命令,不碰系统提示词
|
|
6
|
-
// agent-purge-session 一次性:persona 快照编译进源码,进程退出即删
|
|
7
|
-
//
|
|
8
|
-
// 三个 profile 都要装:paths 推导自 profile 的 dirname(src/index.js → package root),
|
|
9
|
-
// 所以包必须放在 <profile>/node_modules/<name> 下。
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export const
|
|
22
|
-
export const
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1
|
+
// dsh(DeepSeek Harness)的插件通道,三形态(desktop / web / headless)共用一套写法。
|
|
2
|
+
//
|
|
3
|
+
// 三个插件名,互不覆盖:
|
|
4
|
+
// agent-purge 常驻注入:每次 assemble 读 state.json + active.md,off 即时生效
|
|
5
|
+
// agent-purge-slash 会话内热切:只注册 /purge 命令,不碰系统提示词
|
|
6
|
+
// agent-purge-session 一次性:persona 快照编译进源码,进程退出即删
|
|
7
|
+
//
|
|
8
|
+
// 三个 profile 都要装:paths 推导自 profile 的 dirname(src/index.js → package root),
|
|
9
|
+
// 所以包必须放在 <profile>/node_modules/<name> 下。
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { personaBodyRuntime, resolveRuntime, rewriteRuntime } from "./rewrite.mjs";
|
|
15
|
+
import { swapFile } from "./restore.mjs";
|
|
16
|
+
import { launchRuntime } from "./launch.mjs";
|
|
17
|
+
import { controlRuntime } from "./control.mjs";
|
|
18
|
+
import { dataHome } from "./home.mjs";
|
|
19
|
+
|
|
20
|
+
export const DSH_PROFILES = ["desktop", "web", "headless", "sdk"];
|
|
21
|
+
export const RESIDENT = "agent-purge";
|
|
22
|
+
export const SLASH_RESIDENT = "agent-purge-slash";
|
|
23
|
+
export const SESSION = "agent-purge-session";
|
|
24
|
+
/** 我们写过的三个块名:stash 用完即弃的判断用 */
|
|
25
|
+
const ALL = [RESIDENT, SLASH_RESIDENT, SESSION];
|
|
26
|
+
|
|
27
|
+
export const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
28
|
+
export const profileDir = (p) => join(dshHome(), "profiles", p);
|
|
29
|
+
export const pluginDir = (p, name = RESIDENT) => join(profileDir(p), "node_modules", name);
|
|
30
|
+
const patchFile = (p) => join(profileDir(p), "cordis.patch.yml");
|
|
31
|
+
// 原始字节存 profile 级:插件目录会被删,多插件叠在一起时也要能一起还原(`[]` 空壳)
|
|
32
|
+
const stashFile = (p) => join(profileDir(p), ".agent-purge.patch.before");
|
|
33
|
+
const parseStash = (file) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const rmStash = (p) => rmSync(stashFile(p), { force: true });
|
|
41
|
+
|
|
42
|
+
/** 旧版前缀匹配会把 `-slash` 尾段留在 patch 里(issue #4)。只认第 0 列的裸行:块标量里的内容
|
|
43
|
+
* 必须缩进,子段更不可能出现在列 0,所以这个模式不会误删用户文本。 */
|
|
44
|
+
const healStraySlash = (text) => text.replace(/^-slash[ \t]*\r?(?:\n|$)/gm, "");
|
|
45
|
+
|
|
46
|
+
/** 剥孤儿行会改字节:换写前留一份原始字节,判断错了还有退路。
|
|
47
|
+
* 只在我们真棭行时写,且不覆盖已有备份(否则每次装/卸都会把真正的原文冲掉)。 */
|
|
48
|
+
const healBackup = (p, raw) => {
|
|
49
|
+
const f = join(profileDir(p), ".agent-purge.patch.healed");
|
|
50
|
+
if (raw === null || healStraySlash(raw) === raw || existsSync(f)) return;
|
|
51
|
+
writeFileSync(f, raw);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** dsh 的 patch 模板就是一个 `[]`:空 flow 数组后面再接 `- insert:` 是非法 YAML
|
|
55
|
+
* (真 dsh 报 `end of the stream or a document separator is expected`),文档根的 `[]` 先摊平。
|
|
56
|
+
* 只认文档根:手写的 flow 数组、缩进里的 `[]` 原样留着。 */
|
|
57
|
+
const flattenEmptyArray = (text) => {
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
const i = lines.findIndex((l) => l.trim() && !/^\s*#/.test(l));
|
|
60
|
+
if (i < 0 || lines[i].trim() !== "[]") return text;
|
|
61
|
+
return [...lines.slice(0, i), ...lines.slice(i + 1)].join("\n");
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* dsh 自己建过的 profile(带 package.json)。
|
|
66
|
+
* 不在这个名单里的目录绝不碰:PROFILE_TEMPLATES 里没有 desktop,我们写个
|
|
67
|
+
* 只有 patch + node_modules 的空目录,dsh 启动时会判定 "profile desktop does not exist" 直接拒绝启动。
|
|
68
|
+
* web / headless 虽然会自动 initProfile,但那是 dsh 的活,不是我们的。
|
|
69
|
+
*/
|
|
70
|
+
export function liveProfiles() {
|
|
71
|
+
return DSH_PROFILES.filter((p) => existsSync(join(profileDir(p), "package.json")));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mark = (name) => [`# >>> agent-purge:${name}`, `# <<< agent-purge:${name}`];
|
|
75
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
// marker 必须**整行**匹配:`agent-purge` 是 `agent-purge-slash` 的前缀,
|
|
77
|
+
// 不锚行的话删常驻块会把 /purge 那段一起吃掉,留下 `-slash` 让 patch 变成非法 YAML(issue #4)。
|
|
78
|
+
const blockRe = (name) => {
|
|
79
|
+
const [begin, end] = mark(name);
|
|
80
|
+
return new RegExp(`(?:^|\\r?\\n)${esc(begin)}[ \\t]*\\r?\\n[\\s\\S]*?(?:^|\\r?\\n)${esc(end)}[ \\t]*\\r?(?:\\n|$)`, "g");
|
|
81
|
+
};
|
|
82
|
+
const hasBlock = (text, name) => blockRe(name).test(text);
|
|
83
|
+
/** 把我们的三段(以及旧版残留的孤儿行)都剥掉,得到“没有 agent-purge 的原样”。 */
|
|
84
|
+
const stripOurs = (text) => ALL.reduce((t, n) => t.replace(blockRe(n), ""), healStraySlash(text));
|
|
85
|
+
|
|
86
|
+
/** 这个 package.json 是 agent-purge 写的吗(别碰同名外来目录)。 */
|
|
87
|
+
const isOurs = (meta) => existsSync(meta) && /"?agentPurge"?\s*:\s*true/.test(readFileSync(meta, "utf8"));
|
|
88
|
+
const FILE_MANIFEST = ".agent-purge.files.json";
|
|
89
|
+
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
90
|
+
function unmodifiedPlugin(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const hashes = JSON.parse(readFileSync(join(dir, FILE_MANIFEST), "utf8"));
|
|
93
|
+
return readdirSync(dir).sort().join("\0") === [FILE_MANIFEST, "index.js", "package.json"].sort().join("\0")
|
|
94
|
+
&& ["index.js", "package.json"].every(file => digest(readFileSync(join(dir, file))) === hashes[file]);
|
|
95
|
+
} catch { return false; }
|
|
96
|
+
}
|
|
97
|
+
function disablePluginDirectory(dir) {
|
|
98
|
+
let backup = `${dir}.disabled-by-agent-purge`, n = 0;
|
|
99
|
+
while (existsSync(backup)) backup = `${dir}.disabled-by-agent-purge-${++n}`;
|
|
100
|
+
renameSync(dir, backup);
|
|
101
|
+
return backup;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 常驻插件:不编译人设,每次请求现读 state;off 就把宿主的返回值原样透传。 */
|
|
105
|
+
export function residentSource(root, activePath, { temporary = false } = {}) {
|
|
106
|
+
const state = join(root, "state.json").replace(/\\/g, "/");
|
|
107
|
+
const personas = join(root, "personas").replace(/\\/g, "/");
|
|
108
|
+
// active.md 是派生物,实际位置是 <root>/personas/active.md(由 createStore().materialize 给出)
|
|
109
|
+
const active = String(activePath).replace(/\\/g, "/");
|
|
110
|
+
return `// 由 agent-purge 生成;删掉本目录 + patch 段即取消。
|
|
111
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
112
|
+
const HOME = ${JSON.stringify(root)};
|
|
113
|
+
const TEMPORARY = ${temporary};
|
|
114
|
+
const STATE = ${JSON.stringify(state)};
|
|
115
|
+
const PERSONAS = ${JSON.stringify(personas)};
|
|
116
|
+
const ACTIVE = ${JSON.stringify(active)};
|
|
117
|
+
const NAME = ${JSON.stringify(temporary ? SESSION : RESIDENT)};
|
|
118
|
+
${rewriteRuntime}
|
|
119
|
+
${resolveRuntime}
|
|
120
|
+
${personaBodyRuntime}
|
|
121
|
+
${launchRuntime}
|
|
122
|
+
// 一行一条的运行日志,出问题先看它。写失败静默。
|
|
123
|
+
const LOG = STATE.replace(/state\.json$/, "dsh.log");
|
|
124
|
+
function log(ev, extra) {
|
|
125
|
+
try {
|
|
126
|
+
appendFileSync(LOG, new Date().toISOString() + " " + PROFILE + " " + ev + (extra ? " " + extra : "") + "\\n");
|
|
127
|
+
} catch { /* 只读环境不抛 */ }
|
|
128
|
+
}
|
|
129
|
+
// 自己在哪个 profile:<dsh>/profiles/<name>/node_modules/<pkg>/index.js
|
|
130
|
+
const PROFILE = (() => {
|
|
131
|
+
try {
|
|
132
|
+
const m = /[\\\\/]profiles[\\\\/]([^\\\\/]+)[\\\\/]/.exec(new URL(import.meta.url).pathname);
|
|
133
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
134
|
+
} catch {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
function cfg() {
|
|
139
|
+
try {
|
|
140
|
+
const st = JSON.parse(readFileSync(STATE, "utf8"));
|
|
141
|
+
const lease = readLaunch(HOME, st, "dsh");
|
|
142
|
+
if (TEMPORARY ? !lease || lease.writer !== "temporary" : lease?.writer === "temporary") return null;
|
|
143
|
+
const local = st.dshRuntime?.[PROFILE] || {};
|
|
144
|
+
const c = resolveActivation(st, "dsh", { channel: "install", profile: PROFILE, enabled: local.enabled,
|
|
145
|
+
launch: TEMPORARY && !!lease, mode: TEMPORARY ? lease?.mode : local.mode, persona: TEMPORARY ? lease?.persona : undefined });
|
|
146
|
+
if (!c.on) return null;
|
|
147
|
+
const body = resolvePersonaBody(PERSONAS, c.persona, ACTIVE);
|
|
148
|
+
return body ? { mode: c.mode, body } : null;
|
|
149
|
+
} catch { return null; }
|
|
150
|
+
}
|
|
151
|
+
function put(asm, c) {
|
|
152
|
+
if (!asm || typeof asm !== "object") return asm;
|
|
153
|
+
const sections = apRewriteSections(asm.sections, c?.body || "", c?.mode || "off", NAME);
|
|
154
|
+
return JSON.stringify(sections) === JSON.stringify(asm.sections) ? asm : { ...asm, sections };
|
|
155
|
+
}
|
|
156
|
+
export default {
|
|
157
|
+
name: NAME,
|
|
158
|
+
inject: ["systemPrompt"],
|
|
159
|
+
apply(ctx) {
|
|
160
|
+
log("apply", "inject=" + JSON.stringify(["systemPrompt"]));
|
|
161
|
+
// 新版 DSH 把 system 更新追加进历史。人设切换必须开启新 request series,
|
|
162
|
+
// 由宿主投影替换首个 system 并清空旧节点,不修改已冻结请求/用户消息。
|
|
163
|
+
const revisions = new WeakMap();
|
|
164
|
+
const resets = new WeakSet();
|
|
165
|
+
if (typeof ctx.on === "function") ctx.on("agent/pre-step", async ({ agent }, next) => {
|
|
166
|
+
const decision = await next();
|
|
167
|
+
if (agent && resets.has(agent) && decision.kind === "enter") {
|
|
168
|
+
resets.delete(agent);
|
|
169
|
+
return { ...decision, startsRequestSeries: true };
|
|
170
|
+
}
|
|
171
|
+
return decision;
|
|
172
|
+
}, { global: true, prepend: true });
|
|
173
|
+
// 宿主每轮 assemble 走 system-prompt/assemble 瀑布。必须 global:agent 作用域的
|
|
174
|
+
// systemPrompt 是另一个实例,且切预设时宿主会整只换代理,实例上的 monkey-patch 会丢。
|
|
175
|
+
const handler = async (assembly, _context, next) => {
|
|
176
|
+
let assembled = assembly;
|
|
177
|
+
if (typeof next === "function") {
|
|
178
|
+
try {
|
|
179
|
+
assembled = await next();
|
|
180
|
+
} catch {
|
|
181
|
+
assembled = assembly; // 下游抛错也要让上游兜底,不能把整轮发消息弄死
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const c = cfg();
|
|
185
|
+
const agent = _context?.agent;
|
|
186
|
+
if (agent) {
|
|
187
|
+
const revision = JSON.stringify(c);
|
|
188
|
+
if (revisions.get(agent) !== revision && (c || revisions.has(agent))) resets.add(agent);
|
|
189
|
+
revisions.set(agent, revision);
|
|
190
|
+
}
|
|
191
|
+
const out = put(assembled, c);
|
|
192
|
+
log("assemble", (c ? "mode=" + c.mode : "off")
|
|
193
|
+
+ " in=" + (Array.isArray(assembled && assembled.sections) ? assembled.sections.length : -1)
|
|
194
|
+
+ " out=" + (Array.isArray(out && out.sections) ? out.sections.length : -1)
|
|
195
|
+
+ " names=" + (Array.isArray(out && out.sections) ? out.sections.map((s) => s && s.name).join(",") : "-"));
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
ctx.on("system-prompt/assemble", handler, { global: true, prepend: true });
|
|
200
|
+
log("hook", "system-prompt/assemble global");
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// 老版本没有这个瀑布:退回包 assemble(关闭时同 this、同参数、同返回类型)
|
|
203
|
+
const prompt = ctx.systemPrompt;
|
|
204
|
+
const orig = prompt.assemble;
|
|
205
|
+
prompt.assemble = function (...rest) {
|
|
206
|
+
const c = cfg();
|
|
207
|
+
const out = orig.apply(this, rest);
|
|
208
|
+
if (out && typeof out.then === "function") return out.then((asm) => put(asm, c));
|
|
209
|
+
return put(out, c);
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 会话内热切插件:只注册 /purge,改 state.json,不产生模型消息、不碰系统提示词。 */
|
|
218
|
+
export function slashSource(root) {
|
|
219
|
+
const state = join(root, "state.json").replace(/\\/g, "/");
|
|
220
|
+
return `// 由 agent-purge 生成(/purge 命令);删掉本目录 + patch 段即取消。
|
|
221
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
222
|
+
${controlRuntime}
|
|
223
|
+
const HOME = ${JSON.stringify(root)};
|
|
224
|
+
const STATE = ${JSON.stringify(state)};
|
|
225
|
+
const NAME = ${JSON.stringify(SLASH_RESIDENT)};
|
|
226
|
+
const LOG = STATE.replace(/state\.json$/, "dsh.log");
|
|
227
|
+
${resolveRuntime}
|
|
228
|
+
function log(ev, extra) {
|
|
229
|
+
try {
|
|
230
|
+
appendFileSync(LOG, new Date().toISOString() + " " + PROFILE + " " + ev + (extra ? " " + extra : "") + "\\n");
|
|
231
|
+
} catch { /* 只读环境不抛 */ }
|
|
232
|
+
}
|
|
233
|
+
const PROFILE = (() => {
|
|
234
|
+
try {
|
|
235
|
+
const m = /[\\\\/]profiles[\\\\/]([^\\\\/]+)[\\\\/]/.exec(new URL(import.meta.url).pathname);
|
|
236
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
237
|
+
} catch {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
})();
|
|
241
|
+
function readState() {
|
|
242
|
+
try { return JSON.parse(readFileSync(STATE, "utf8")) || {}; } catch { return {}; }
|
|
243
|
+
}
|
|
244
|
+
function apPatch(patch) {
|
|
245
|
+
withControlLock(HOME, () => {
|
|
246
|
+
const st = JSON.parse(readFileSync(STATE, "utf8"));
|
|
247
|
+
if (!st.armed?.dsh?.includes("slash")) throw new Error("会话命令通道已撤销");
|
|
248
|
+
atomicControlJSON(STATE, { ...st, dshRuntime: { ...st.dshRuntime, [PROFILE]: { ...st.dshRuntime?.[PROFILE], ...patch } } });
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function handleAp(raw) {
|
|
252
|
+
const parts = String(raw || "").trim().split(/\\s+/).filter(Boolean);
|
|
253
|
+
const cmd = (parts[0] || "status").toLowerCase();
|
|
254
|
+
const arg = parts.slice(1).join(" ").trim();
|
|
255
|
+
if (cmd === "off") {
|
|
256
|
+
apPatch({ enabled: false });
|
|
257
|
+
return { kind: "success", text: "当前 dsh profile 已暂停:下一句起系统提示词恢复宿主原样。注:之前已注入轮次的对话历史无法回改,模型可能还会带一两句旧人设的口吻,新开一个会话即完全干净。" };
|
|
258
|
+
}
|
|
259
|
+
if (cmd === "on") {
|
|
260
|
+
const st = readState();
|
|
261
|
+
const gate = resolveActivation(st, "dsh", { channel: "install", profile: PROFILE });
|
|
262
|
+
if (!gate.on) return { kind: "error", text: "当前未启用: " + gate.blockedBy };
|
|
263
|
+
if (!st.armed?.dsh?.includes("install"))
|
|
264
|
+
return { kind: "error", text: "dsh 的 /purge 只是个开关,注入靠常驻插件:请先在 agent-purge 里启用 dsh 的「常驻配置」,再 /purge on" };
|
|
265
|
+
apPatch({ enabled: true });
|
|
266
|
+
return { kind: "success", text: "当前 dsh profile 的启用选择已保存。已加载的常驻插件下一轮读取;本命令不验证最终请求,历史回答也不会被清除。" };
|
|
267
|
+
}
|
|
268
|
+
if (cmd === "mode") {
|
|
269
|
+
const m = arg.toLowerCase();
|
|
270
|
+
if (m !== "strict" && m !== "replace" && m !== "append") {
|
|
271
|
+
return { kind: "error", text: "用法:/purge mode strict|replace|append" };
|
|
272
|
+
}
|
|
273
|
+
apPatch({ mode: m });
|
|
274
|
+
return { kind: "success", text: "当前 dsh profile 模式 → " + m + ";其他执行器不变" };
|
|
275
|
+
}
|
|
276
|
+
if (cmd === "status" || cmd === "help") {
|
|
277
|
+
const st = readState();
|
|
278
|
+
const a = st.armed && Array.isArray(st.armed.dsh) ? st.armed.dsh : [];
|
|
279
|
+
const local = st.dshRuntime?.[PROFILE] || {};
|
|
280
|
+
// 人设/模式按 dsh 这家的例外解析,不再一律拿全局
|
|
281
|
+
const c = resolveActivation(st, "dsh", { channel: "install", profile: PROFILE, enabled: local.enabled, mode: local.mode });
|
|
282
|
+
const mode = c.mode;
|
|
283
|
+
return { kind: "success", text: "agent-purge / dsh:" + (c.on ? "开" : "关") + " · 模式 " + mode + " · 人设 " + (c.persona || "-") + "(可用:on | off | status | mode <strict|replace|append>)" };
|
|
284
|
+
}
|
|
285
|
+
return { kind: "error", text: "用法:/purge on | off | status | mode <strict|replace|append>" };
|
|
286
|
+
}
|
|
287
|
+
export default {
|
|
288
|
+
name: NAME,
|
|
289
|
+
apply(ctx) {
|
|
290
|
+
log("apply", "slash-only");
|
|
291
|
+
try {
|
|
292
|
+
if (typeof ctx.inject === "function") {
|
|
293
|
+
ctx.inject(["commands"], (c) => {
|
|
294
|
+
const spec = {
|
|
295
|
+
description: "agent-purge 人设注入:开关 / 模式",
|
|
296
|
+
input: { hint: "on | off | status | mode <strict|replace|append>" },
|
|
297
|
+
handler: ({ rawInput }) => handleAp(rawInput),
|
|
298
|
+
};
|
|
299
|
+
// 主名 /purge(和 pi 等其他 harness 统一),/ap 留作别名;
|
|
300
|
+
// 与已有同名命令(如 dsh-purge)撞车时只跳过那一个
|
|
301
|
+
for (const n of ["purge", "ap"]) {
|
|
302
|
+
try {
|
|
303
|
+
c.commands.register({ name: n, ...spec });
|
|
304
|
+
log("command", "registered /" + n);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
log("command", "skip /" + n + " " + (e && e.message));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
log("command", "no inject api");
|
|
312
|
+
}
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log("command", "err " + (e && e.message));
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 临时插件与常驻插件使用同一回调链,仅启用来源不同。 */
|
|
322
|
+
export function sessionSource(_personaText, _mode, root = dataHome()) {
|
|
323
|
+
return residentSource(root, join(root, "personas", "active.md"), { temporary: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 装一个插件名到三个 profile,并在 patch 里挂上(同一段 marker 幂等)。 */
|
|
327
|
+
export function addPlugin(name, source, { temporary = false } = {}) {
|
|
328
|
+
const pkg = JSON.stringify({ name, version: "0.0.0", type: "module", main: "index.js", agentPurge: true }, null, 1);
|
|
329
|
+
const [begin, end] = mark(name);
|
|
330
|
+
const block = `\n${begin}\n- insert:\n - id: ${name}\n name: ${name}\n${end}\n`;
|
|
331
|
+
const files = [];
|
|
332
|
+
const undos = [];
|
|
333
|
+
const created = [];
|
|
334
|
+
const write = (file, content) => {
|
|
335
|
+
if (temporary) { undos.push(swapFile(file, content)); return; }
|
|
336
|
+
const before = existsSync(file) ? readFileSync(file) : null;
|
|
337
|
+
undos.push(() => before === null ? rmSync(file, { force: true }) : writeFileSync(file, before));
|
|
338
|
+
writeFileSync(file, content);
|
|
339
|
+
};
|
|
340
|
+
let cleaned = false;
|
|
341
|
+
const cleanup = () => {
|
|
342
|
+
if (cleaned) return;
|
|
343
|
+
const errors = [];
|
|
344
|
+
for (const undo of [...undos].reverse()) { try { undo(); } catch (e) { errors.push(e); } }
|
|
345
|
+
if (errors.length) throw new AggregateError(errors, `插件回滚未完成:${errors.map((e) => e.message).join(";")}`);
|
|
346
|
+
for (const dir of [...created].reverse()) {
|
|
347
|
+
try { rmdirSync(dir); } catch (e) { if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY" && e.code !== "EEXIST") throw e; }
|
|
348
|
+
}
|
|
349
|
+
cleaned = true;
|
|
350
|
+
};
|
|
351
|
+
// 只装进 dsh 已经建好的 profile;一个都没有说明 dsh 还没跑过
|
|
352
|
+
const targets = liveProfiles();
|
|
353
|
+
if (targets.length === 0) throw new Error("找不到任何 dsh profile(~/.dsh/profiles/*/package.json)—— 先启动一次 dsh,让它把 profile 建好再来装");
|
|
354
|
+
// 预检:同名目录要么不存在,要么是我们的(package.json 带 agentPurge 标记),否则不碰
|
|
355
|
+
for (const p of targets) {
|
|
356
|
+
const dir = pluginDir(p, name);
|
|
357
|
+
const meta = join(dir, "package.json");
|
|
358
|
+
if (existsSync(dir) && !isOurs(meta)) throw new Error(`${dir} 已存在且不是 agent-purge 装的,拒绝覆盖`);
|
|
359
|
+
if (!temporary && existsSync(join(dir, FILE_MANIFEST)) && !unmodifiedPlugin(dir))
|
|
360
|
+
throw new Error(`插件存在外部修改,未覆盖:${dir};请先备份或卸载(将改名保留)后重试`);
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
for (const p of targets) {
|
|
364
|
+
const dir = pluginDir(p, name);
|
|
365
|
+
const patch = patchFile(p);
|
|
366
|
+
const before = existsSync(patch) ? readFileSync(patch, "utf8") : null;
|
|
367
|
+
const existed = existsSync(dir);
|
|
368
|
+
mkdirSync(dir, { recursive: true });
|
|
369
|
+
// mkdir 失败的路径从未归我们所有。POSIX 对父路径为文件的 rmdir
|
|
370
|
+
// 报 ENOTDIR;若提前登记它,会中断回滚并留下此前成功创建的插件。
|
|
371
|
+
if (!existed) created.push(dir);
|
|
372
|
+
write(join(dir, "package.json"), pkg);
|
|
373
|
+
write(join(dir, "index.js"), source);
|
|
374
|
+
if (!temporary) write(join(dir, FILE_MANIFEST), JSON.stringify({ "package.json": digest(pkg), "index.js": digest(source) }));
|
|
375
|
+
files.push(dir);
|
|
376
|
+
// 旧版前缀匹配留下的孤儿 `-slash` 行会让整份 patch 不是合法 YAML:写入前剥掉
|
|
377
|
+
const stripped = healStraySlash(before ?? "");
|
|
378
|
+
const healed = flattenEmptyArray(stripped);
|
|
379
|
+
const healedBackup = join(profileDir(p), ".agent-purge.patch.healed");
|
|
380
|
+
if (before !== null && stripped !== before && !existsSync(healedBackup)) write(healedBackup, before);
|
|
381
|
+
if (hasBlock(healed, name)) {
|
|
382
|
+
if (healed !== (before ?? "")) {
|
|
383
|
+
// 旧版装坏的 `[]` + 块:把自愈后的形态记成 stash.after,卸载才还原得回原文
|
|
384
|
+
const prev = parseStash(stashFile(p));
|
|
385
|
+
const keep = prev && prev.after === before ? prev.before : stripOurs(before ?? "");
|
|
386
|
+
write(stashFile(p), JSON.stringify({ before: keep, after: healed }));
|
|
387
|
+
write(patch, healed);
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// 空文档(模板那个 `[]`)直接换成我们的段;stash 记剥孤儿后的原样,卸载还回 `[]`
|
|
392
|
+
const next = healed.trim() ? healed + block : block.slice(1);
|
|
393
|
+
const prev = parseStash(stashFile(p));
|
|
394
|
+
// 还原点只在文件还是我们上次写的样子时有效:被改过就按当前字节重新记,
|
|
395
|
+
// 否则旧 before 会把用户后来写的内容整份覆盖掉(或把文件误删)
|
|
396
|
+
let keep = null;
|
|
397
|
+
if (prev && prev.after === before) keep = prev.before;
|
|
398
|
+
// 旧版写坏的 patch(`[]` + 块)里也含我们的块:还原点得先剥掉自己,否则卸载后会回放旧块
|
|
399
|
+
else if (before !== null) keep = stripOurs(before);
|
|
400
|
+
write(stashFile(p), JSON.stringify({ before: keep, after: next }));
|
|
401
|
+
write(patch, next);
|
|
402
|
+
}
|
|
403
|
+
} catch (e) {
|
|
404
|
+
try { cleanup(); } catch (rollback) { throw new AggregateError([e, rollback], `${e.message};${rollback.message}`); }
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
return { dirs: files, profiles: targets, ...(temporary ? { cleanup } : {}) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 只撤自己那一段:目录 + patch marker,别的配置原样。 */
|
|
411
|
+
export function removePlugin(name) {
|
|
412
|
+
// 删 install 写入的那一段:前导 \n 可有可无(整份替换的形态没有),尾随 \n 一并吃掉
|
|
413
|
+
const re = blockRe(name);
|
|
414
|
+
const preserved = [], profiles = [];
|
|
415
|
+
for (const p of DSH_PROFILES) {
|
|
416
|
+
const dir = pluginDir(p, name);
|
|
417
|
+
const meta = join(dir, "package.json");
|
|
418
|
+
// 只删自己装的;同名但不是我们的目录留着(可能被用户或其他包占了)
|
|
419
|
+
const ours = isOurs(meta);
|
|
420
|
+
if (ours) profiles.push(p);
|
|
421
|
+
// 当初整份替换过的:先读出来,不然删目录就丢了(新版在 profile 根,旧版在插件目录)
|
|
422
|
+
const stash = parseStash(stashFile(p)) ?? parseStash(join(dir, ".patch.before"));
|
|
423
|
+
if (ours) {
|
|
424
|
+
// 不递归吞掉用户后来添加/修改的文件。无指纹的旧安装也保守改名,
|
|
425
|
+
// patch 撤下后不再加载;目录完整保留,供用户核对。
|
|
426
|
+
if (unmodifiedPlugin(dir)) {
|
|
427
|
+
for (const file of [FILE_MANIFEST, "package.json", "index.js"]) rmSync(join(dir, file));
|
|
428
|
+
rmdirSync(dir);
|
|
429
|
+
} else preserved.push(disablePluginDirectory(dir));
|
|
430
|
+
}
|
|
431
|
+
const patch = patchFile(p);
|
|
432
|
+
if (!existsSync(patch)) continue;
|
|
433
|
+
const text = readFileSync(patch, "utf8");
|
|
434
|
+
const healed = healStraySlash(text);
|
|
435
|
+
healBackup(p, text);
|
|
436
|
+
// 我们整份写的、且此后没人动过、且这是最后一个属于自己的块 → 整份还原(原本没这个文件就删掉它)
|
|
437
|
+
// 还有别的块(例如先装的 /purge)时不能整份还原,否则会连带把它抹掉
|
|
438
|
+
const siblings = ALL.filter((n) => n !== name && hasBlock(text, n));
|
|
439
|
+
if (stash && text === stash.after && siblings.length === 0) {
|
|
440
|
+
if (stash.before === null) rmSync(patch, { force: true });
|
|
441
|
+
else writeFileSync(patch, stash.before);
|
|
442
|
+
rmStash(p);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
let next = healed;
|
|
446
|
+
if (hasBlock(next, name)) next = next.replace(re, "");
|
|
447
|
+
// 卸到没有我们的块了:剩下的只要等于“原文摊平 `[]` 后的样子”,就把原文逐字节还回去
|
|
448
|
+
// (多块先后装时,第一个块删完 text 就不再等于 stash.after,但剩下的仍然是原文)
|
|
449
|
+
if (stash && !ALL.some((n) => hasBlock(next, n))) {
|
|
450
|
+
const base = stash.before === null ? "" : stash.before;
|
|
451
|
+
const baseFlat = flattenEmptyArray(healStraySlash(base));
|
|
452
|
+
// 只认两种“等于原文”:逐字节相等,或两边都只剩空白(原文是那个 `[]` 空壳)
|
|
453
|
+
if (next === baseFlat || (next.trim() === "" && baseFlat.trim() === "")) {
|
|
454
|
+
if (stash.before === null) rmSync(patch, { force: true });
|
|
455
|
+
else writeFileSync(patch, stash.before);
|
|
456
|
+
rmStash(p);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (next !== text) writeFileSync(patch, next);
|
|
461
|
+
// 我们三个块都不在了 → 这份 stash 用完即弃
|
|
462
|
+
if (!ALL.some((n) => hasBlock(next, n))) rmStash(p);
|
|
463
|
+
}
|
|
464
|
+
return { preserved, profiles };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 盘上真有那份插件文件的位置:status / armed 对账用。
|
|
468
|
+
* 不能拿没装过的 desktop 路径当真相:web/headless 装了、desktop 没建过时,
|
|
469
|
+
* 读那个路径就是 ENOENT(issue #3)。 */
|
|
470
|
+
export function pluginFileOnDisk(name) {
|
|
471
|
+
for (const p of liveProfiles()) {
|
|
472
|
+
const f = join(pluginDir(p, name), "index.js");
|
|
473
|
+
if (existsSync(f)) return f;
|
|
474
|
+
}
|
|
475
|
+
return join(pluginDir("desktop", name), "index.js");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 任一份 profile 挂着就算能用:/purge 是会话内的,后来新建的 profile 不影响已有会话。 */
|
|
479
|
+
export function hasPluginAny(name) {
|
|
480
|
+
const targets = liveProfiles();
|
|
481
|
+
return targets.length > 0 && targets.some((p) => {
|
|
482
|
+
const patch = patchFile(p);
|
|
483
|
+
return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** patch 里还挂着这个名字吗(status 用;不许在有其他 insert 时谎报干净)。 */
|
|
488
|
+
export function hasPlugin(name) {
|
|
489
|
+
const targets = liveProfiles();
|
|
490
|
+
if (targets.length === 0) return false;
|
|
491
|
+
return targets.every((p) => {
|
|
492
|
+
const patch = patchFile(p);
|
|
493
|
+
return existsSync(join(pluginDir(p, name), "index.js")) && existsSync(patch) && hasBlock(readFileSync(patch, "utf8"), name);
|
|
494
|
+
});
|
|
495
|
+
}
|