dsh-plugin-tool-management 0.19.0 → 0.20.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +868 -798
  2. package/README.md +71 -59
  3. package/README_EN.md +91 -80
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/images/logo.png +0 -0
  21. package/docs/images/promo-cover-EN.png +0 -0
  22. package/docs/images/promo-cover.png +0 -0
  23. package/docs/update.md +31 -520
  24. package/lib/client.js +8308 -6307
  25. package/lib/hub.js +96 -6
  26. package/lib/index.js +54 -2
  27. package/lib/mcp/manager.js +24 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/memories/constants.js +9 -2
  30. package/lib/memories/service.js +6 -1
  31. package/lib/op-registry.js +22 -7
  32. package/lib/ops/prompts.js +64 -7
  33. package/lib/ops/sessions.js +62 -7
  34. package/lib/ops/trash-retention.js +55 -0
  35. package/lib/ops/trash.js +62 -3
  36. package/lib/prompts/service.js +191 -25
  37. package/lib/scene-prompt-sync.js +1 -1
  38. package/lib/sessions/history.js +32 -22
  39. package/lib/skills/core.js +68 -2
  40. package/lib/skills/manager-state.js +32 -1
  41. package/lib/skills/service.js +7 -1
  42. package/lib/tools/mcp.js +6 -0
  43. package/lib/tools/prompt.js +14 -2
  44. package/lib/tools/scene.js +146 -25
  45. package/lib/trash-retention.js +256 -0
  46. package/package.json +1 -1
  47. package/lib/mcp/inspect-probe.js +0 -199
@@ -0,0 +1,55 @@
1
+ // 回收站保留期的两条 op(0.19.1)。
2
+ //
3
+ // trash-retention-get 只读:当前保留期天数(`0` = 永久保留)
4
+ // trash-retention-set 写:改保留期,成功后**立即扫一次**(与 `history-retention-set` 同一条)
5
+ //
6
+ // 与 `history-retention-get/set` 逐字同形(ops/sessions.ts:599-618):同一个
7
+ // `retentionDays` 入参、同一条「非负整数」校验、同一条「改完立刻扫,让界面反映新策略」。
8
+ // 两处保留期是同一个功能出现在两个页面,回执形状分家的话,客户端就得写两套解析。
9
+ //
10
+ // 读侧为什么是纯读:客户端 boot 时必问一次,用来渲染下拉框的当前值。那一次不能要令牌,
11
+ // 否则没配令牌的宿主上这一格永远是空的(与 `slash-settings` 同一条理由)。
12
+ export function buildTrashRetentionOps(deps) {
13
+ return {
14
+ 'trash-retention-get': async () => {
15
+ const { retentionDays } = await deps.read();
16
+ return { ok: true, retentionDays };
17
+ },
18
+ 'trash-retention-set': async (args) => {
19
+ const days = Number((args && args.retentionDays) ?? -1);
20
+ // 错误文案承诺的是「非负整数」,就按整数校验:1.5 这类小数以前在 History 页会被
21
+ // 静默取整成 1,用户看到的回执与输入不符。这里用同一道判据。
22
+ if (!Number.isSafeInteger(days) || days < 0) {
23
+ return {
24
+ ok: false,
25
+ code: 'error.trash.retention.invalid',
26
+ error: `retentionDays 需为非负整数(0=永久保留),收到: ${String((args && args.retentionDays))}`,
27
+ params: { value: String((args && args.retentionDays)) },
28
+ };
29
+ }
30
+ try {
31
+ await deps.write(days);
32
+ }
33
+ catch (e) {
34
+ return {
35
+ ok: false,
36
+ code: 'error.trash.retention.writeFailed',
37
+ error: `写入保留期失败: ${String(e?.message ?? e)}`,
38
+ params: { reason: String(e?.message ?? e) },
39
+ };
40
+ }
41
+ // 设置变更后立即扫一次:界面上的条目数要与新策略一致,不能等到 6 小时后的下一轮。
42
+ // 清扫自己吞异常(见 createTrashRetentionDomain),所以这里不会因为删除失败而回滚设置
43
+ // —— 设置已经写进侧车了,如实回执「已保存,清理时 N 项失败」比报一个假失败诚实。
44
+ const res = await deps.sweep();
45
+ return {
46
+ ok: true,
47
+ retentionDays: days,
48
+ // 回执带上这次实际清掉的数量:用户改完设置马上想知道「清掉了什么」,
49
+ // 而 `failed > 0` 说明有条目删不掉(IO 错误),要能说出来而不是假装干净。
50
+ swept: res.swept.length,
51
+ failed: res.failed,
52
+ };
53
+ },
54
+ };
55
+ }
package/lib/ops/trash.js CHANGED
@@ -4,9 +4,10 @@
4
4
  // 但共用 copyIntoMemoriesTrash 等入口,经 MemoriesOpsCtx 显式传入。
5
5
  import { copyFile, cp, lstat, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { isValidTrashId } from '../hub.js';
7
+ import { isValidTrashId, readTrashPayloadFiles, trashEntryPath } from '../hub.js';
8
8
  import { LEGACY_BUNDLE_DOC, bundleDocName, isValidGroupSegment, isValidGroupPath, fail, MEMORIES_TRASH_DIR } from '../memories/constants.js';
9
9
  import { readIndex, writeIndex } from '../memories/index-io.js';
10
+ import { isValidSegment } from '../paths.js';
10
11
  /** 组装本域 op。rc 由 createMemoriesService 组装,契约见 ./ctx.ts。 */
11
12
  export function buildTrashOps(rc) {
12
13
  const { buildProjected, copyIntoMemoriesTrash, invalidateSnapshot, locateRule, parseId, refuseOutsideRoot, memoriesRoot, stateDir } = rc;
@@ -104,7 +105,7 @@ export function buildTrashOps(rc) {
104
105
  return { ok: true, rule: await buildProjected(manifest.group ? `${manifest.group}/${manifest.name}` : manifest.name) };
105
106
  }
106
107
  /**
107
- * 记忆回收站列表(只读):`<stateDir>/memories-trash/<trashId>/`(`rulesRemove` 移入,
108
+ * 记忆回收站列表(只读):`<stateDir>/trash/memories-trash/<trashId>/`(`rulesRemove` 移入,
108
109
  * `rulesRestore` 恢复)。损坏或内容缺失的条目跳过,不让一个坏条目挡住整份列表。
109
110
  */
110
111
  async function rulesTrashList() {
@@ -172,5 +173,63 @@ export function buildTrashOps(rc) {
172
173
  await rm(dir, { recursive: true, force: true });
173
174
  return { ok: true };
174
175
  }
175
- return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove };
176
+ /**
177
+ * 回收站「详情」(只读):把一条条目的内容读出来给界面看一眼"删掉的到底是什么"。
178
+ *
179
+ * 用户 2026-10-12:「回收站的每条信息应该增加一个详情按键,点开就能看这个信息是什么样子,
180
+ * 不能更改内容,没有保存按键」。所以这里**只有读**:不写盘、不改名、不动回收站条目。
181
+ *
182
+ * 为什么是一条跨域的 op 而不是五个域各加一条:五个回收站落在**三套不同的目录布局**里 ——
183
+ * hub 四类:`<hub>/trash/<kind>-trash/<id>/`(`trashEntryPath`)
184
+ * 记忆: `<stateDir>/trash/memories-trash/<trashId>/`
185
+ * 技能: `<hub>/trash/skills-trash/<id>/`(skills 域的 `trashRootPath()`,由 index 注入)
186
+ * 但"读出来给人看"这件事完全一样,交给 `readTrashPayloadFiles` 一个函数做完,
187
+ * 上限 / 二进制判据 / 哪些名字算清单只写一遍。
188
+ *
189
+ * 条目目录不存在 / 不是目录 → 明确报"条目不存在"(与"目录在、但里面没有可显示的文件"
190
+ * 分开:前者是条死链,后者是一条正常但没内容的记录)。读不动的单个文件由
191
+ * `readTrashPayloadFiles` 自己跳过,不抛 —— 详情打不开不该影响用户对这条目做恢复 / 永久删除。
192
+ */
193
+ async function trashPreview(args) {
194
+ const domain = String((args && args.domain) || '');
195
+ const id = String((args && args.id) || '').trim();
196
+ const hubKinds = { presets: 'prompts', quick: 'quick-prompts', agents: 'subagents', scenes: 'scenes' };
197
+ let dir = null;
198
+ if (hubKinds[domain] || domain === 'memories') {
199
+ // hub 四类与记忆回收站的条目名都由 `newTrashId()` 生成(时间戳 base36 + uuid 前 8 位),
200
+ // 走那一套严格白名单 —— 它同时也是 `rulesRestore` / 硬删除用的同一道闸。
201
+ if (!isValidTrashId(id))
202
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
203
+ dir = hubKinds[domain]
204
+ ? trashEntryPath(hubKinds[domain], id)
205
+ : join(stateDir, MEMORIES_TRASH_DIR, id);
206
+ }
207
+ else if (domain === 'skills') {
208
+ // ⚠️ 技能回收站的条目名是 `${Date.now()}-${randomUUID()}`(见 core.ts 的移入回收站那段),
209
+ // **不满足** `isValidTrashId` 那条白名单(多段连字符 + 大写十六进制)—— 拿它去判会把
210
+ // 每一条技能条目都判成"不存在",界面上就是"详情点了没反应"。
211
+ // 这里换成与 `skill-trash.ts` 的 `entryPath` 同一个口径:能安全当**一个路径段**即可
212
+ //(不含分隔符 / 不是 `.` `..` / 首尾无空白与点)。以点开头的一律拒 —— `listTrash()`
213
+ // 本来就把 `.stage-*` 那类临时目录排除在外,界面拿不到它们,这里也不该读得到。
214
+ if (!isValidSegment(id, 128) || id.startsWith('.'))
215
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
216
+ dir = rc.skillsTrashDir ? join(rc.skillsTrashDir, id) : null;
217
+ }
218
+ else
219
+ return fail('error.rules.notFound', `这个回收站不支持查看详情:${domain || '(空)'}`);
220
+ if (!dir)
221
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
222
+ // 条目目录本身必须真实存在(`readTrashPayloadFiles` 对不存在的目录返回空数组,
223
+ // 那种"空"与"条目不存在"要分开:前者能恢复、后者是条死链)。
224
+ try {
225
+ const st = await lstat(dir);
226
+ if (!st.isDirectory() || st.isSymbolicLink())
227
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
228
+ }
229
+ catch {
230
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
231
+ }
232
+ return { ok: true, id, domain, files: await readTrashPayloadFiles(dir) };
233
+ }
234
+ return { rulesRemove, rulesRestore, rulesTrashList, rulesTrashRemove, trashPreview };
176
235
  }
@@ -52,6 +52,28 @@ const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
52
52
  * `isDirectory()` 过滤天然把它排除在预设之外。
53
53
  */
54
54
  const APPLIED_FILE = '__applied__.json';
55
+ /**
56
+ * 「应用前 AGENTS.md 根本不存在」这个**事实**的标记文件,与备份内容同放在 `__last-applied__/`。
57
+ *
58
+ * 为什么需要它(0.19.1 修,用户 2026-10-10 反馈「全局提示词取消应用,AGENTS.md 还是存在」):
59
+ * `backupGlobal()` 原先在 `current === null` 时直接 `return false` —— **一个字节都不写**。
60
+ * 于是"应用前没有这个文件"在磁盘上没留下任何痕迹,`unapply()` 便永远读不到备份、恒判
61
+ * `noBackup`,取消应用**永久关不掉**;而界面给出的出路「删除这份预设」根本不成立
62
+ * (`remove()` 只把预设目录移进回收站,不碰 AGENTS.md)。出路不成立 = 死路。
63
+ *
64
+ * 有了它,「取消应用」才是「应用」的**真逆操作**:
65
+ * 应用前有文件 → 写回那份内容;应用前没文件 → 把文件删掉。
66
+ * 与内容槽 `AGENTS.md` **同时只应有一个**:两个都在时 `unapplyState()` 优先判 `'restore'`,
67
+ * 就会把一份不相干的内容写进用户的文件。
68
+ *
69
+ * **但"只能有一个"不等于"每次写都刷掉另一个"**(0.19.1 再修,见 `backupGlobal` 的注释):
70
+ * 这两个槽位合起来记的是**链起点**,链走到中途(应用 a 之后又应用 b)不许动它 ——
71
+ * 原先 `backupGlobal` 每写一笔都顺手 `rm` 掉另一个槽,于是「应用 a → 应用 b」这一步就把
72
+ * "起点不存在"永久抹掉了,取消应用再也回不到"没有文件"。
73
+ * 放在 `__last-applied__/` 里是安全的 —— 该目录已被 `list()` 按 `LAST_APPLIED_ID` 排除,
74
+ * 放什么进去都不会被当成预设。
75
+ */
76
+ const ABSENT_FILE = 'absent.json';
55
77
  /** 新建预设的初始正文(空模板);用户也可以直接粘贴自己的内容。 */
56
78
  const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
57
79
  /** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
@@ -106,29 +128,34 @@ export function createPromptsService(_ctx, deps) {
106
128
  }
107
129
  catch { /* 描述写不进去不该让保存失败 */ }
108
130
  }
109
- // 幂等初始化:仅当库完全无预设且全局 AGENTS.md 存在时,拷贝全局为 default
110
- // 预设(首次运行引导)。用户已 create/apply 过任何预设则不插手,避免把
111
- // 当前生效内容误存成 default 与真正生效的预设冲突。
131
+ // 幂等初始化:**只在库目录本身不存在时**把全局 AGENTS.md 拷成 default
132
+ // 预设(首次运行引导)。
133
+ //
134
+ // 判据为什么是「目录在不在」而不是「目录里有没有预设」(0.19.1 修):后者把
135
+ // 「用户把预设删光了」误判成「首次运行的空库」,于是 list() / getCurrent() 每次都重新
136
+ // 播种 —— 用户删掉最后一个预设、刷新列表,同名 default 又回来了(内容 = 当前
137
+ // AGENTS.md,所以 md5 相同),删除看起来「不持久」。库目录一旦存在就说明这个库已经
138
+ // 被初始化过;空目录是用户的决定,不是我们的空库。
139
+ //
140
+ // 只在**目录不存在**时播种还有一层必要:库目录由 create / apply / 备份 / 应用记录
141
+ // 各自按需创建,所以「已存在」这个信号在任何一次真实使用之后都成立;反过来,
142
+ // 全新安装时没有任何一处会先建出这个目录(hub 布局迁移只搬已存在的东西),
143
+ // 首次运行引导因此仍然会发生。
112
144
  async function ensureInit() {
113
- const [presetsDir, globalPath] = await Promise.all([deps.presetsDir, deps.getGlobalAgentsMdPath()]);
114
- let existing = [];
115
145
  try {
116
- existing = await readdir(presetsDir, { withFileTypes: true });
146
+ await stat(deps.presetsDir);
147
+ return; // 库目录已存在(哪怕空)→ 不插手
117
148
  }
118
- catch { /* 库不存在 */ }
119
- const hasPreset = existing.some((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID);
120
- if (hasPreset)
121
- return;
149
+ catch { /* 库目录不存在 → 真·首次运行,继续播种 */ }
122
150
  let globalContent;
123
151
  try {
124
- globalContent = await readFile(globalPath, 'utf8');
152
+ globalContent = await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
125
153
  }
126
154
  catch {
127
155
  return;
128
156
  }
129
157
  try {
130
- await mkdir(presetsDir, { recursive: true });
131
- const defaultDir = join(presetsDir, 'default');
158
+ const defaultDir = join(deps.presetsDir, 'default');
132
159
  await mkdir(defaultDir, { recursive: true });
133
160
  await writeFile(join(defaultDir, FILENAME), globalContent, 'utf8');
134
161
  }
@@ -263,19 +290,52 @@ export function createPromptsService(_ctx, deps) {
263
290
  return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
264
291
  }
265
292
  /**
266
- * 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份(多代 + `__last-applied__` 维护)。
267
- * **必须留多代**:只有单个 `__last-applied__` 槽位时,连续写两次就会把用户原始手写内容
268
- * 永久覆盖掉(第一次备份原始内容 → 第二次备份第一个预设),而「应用」只是点两下按钮。
269
- * @returns 是否真的备份了(当前文件存在才备份)。
293
+ * 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份。
294
+ *
295
+ * **两个槽位(内容槽 `AGENTS.md` / `ABSENT_FILE`)合起来记的是「链起点」** —— 即
296
+ * "连续应用/取消往复"这一整串动作**开始之前**的文件状态。所以**只在链起点写一次**:
297
+ * 一旦记下,后面每一笔写入都原样保留它,直到链结束(取消应用 / 写回基线)才清掉。
298
+ *
299
+ * 为什么不是"每笔覆盖"(0.19.1 修,用户 2026-10-10 报:「原本没有 AGENTS.md,新建 a、b
300
+ * 两份提示词,启动 a,再启动 b;不启动 a 也不启动 b,`~/.dsh/AGENTS.md` 没有消失」):
301
+ * ① 起点 文件不存在 槽位=空
302
+ * ② 应用 a 文件="# a" 槽位=absent("应用前不存在")
303
+ * ③ 应用 b 文件="# b" 槽位=**被刷成 "# a"** ← 每笔覆盖把"起点不存在"永久抹掉了
304
+ * ④ 取消应用 文件="# a" 槽位="# a" ← 回不到"没有文件",两颗开关还互相点亮
305
+ * ⑤ 此后无论点多少次,文件只在 "# a" / "# b" 之间来回,**永远回不到起点**。
306
+ *
307
+ * 与场景基线同一条口径:`scene-prompt-sync` 的 `writeBaseline` 也是「已有快照就不覆盖」
308
+ * (连续切场景时基线始终是"进场景之前"那一份)。
309
+ *
310
+ * **多代 `.bak-<stamp>` 快照照旧每笔都写**:它记的不是链起点,而是"每一笔被覆盖前的内容"
311
+ * (审计 / 抢救用),两者互不干扰 —— 链起点保证"退得回起点",多代快照保证"中途每一份都还
312
+ * 在磁盘上"。**必须留多代**:只有单个槽位时,连续写两次就会把用户原始手写内容永久覆盖掉,
313
+ * 而「应用」只是点两下按钮。
314
+ *
315
+ * @returns 是否真的备份了内容(`current` 非空才为 `true`)。文件不存在时返回 `false`,但
316
+ * **不再是"什么都不做"** —— 见 `ABSENT_FILE` 的注释:那个"不存在"本身就是链起点状态。
270
317
  */
271
318
  async function backupGlobal(current) {
272
- if (current === null)
273
- return false;
274
319
  const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
275
320
  await mkdir(backupDir, { recursive: true });
321
+ // 还没有链起点 → 现在记下它;记过就绝不再动(这正是 bug 的修法)。
322
+ if (await unapplyState() === null) {
323
+ if (current === null) {
324
+ // 链起点 = "文件不存在"。写不进去不该让「应用」失败 —— 代价只是取消应用退回"关不掉"。
325
+ try {
326
+ await writeFile(join(backupDir, ABSENT_FILE), JSON.stringify({ at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
327
+ }
328
+ catch { /* 见上 */ }
329
+ }
330
+ else {
331
+ await writeFile(join(backupDir, FILENAME), current, 'utf8');
332
+ }
333
+ }
334
+ if (current === null)
335
+ return false;
336
+ // 审计快照:每一笔被覆盖前的内容都留一代(链起点不受它影响)。
276
337
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
277
338
  await writeFile(join(backupDir, `${FILENAME}.bak-${stamp}`), current, 'utf8');
278
- await writeFile(join(backupDir, FILENAME), current, 'utf8');
279
339
  try {
280
340
  const gens = (await readdir(backupDir)).filter((n) => n.startsWith(FILENAME + '.bak-')).sort();
281
341
  for (const stale of gens.slice(0, Math.max(0, gens.length - KEEP_GLOBAL_BACKUPS))) {
@@ -285,6 +345,15 @@ export function createPromptsService(_ctx, deps) {
285
345
  catch { /* 轮转失败不影响本次写入 */ }
286
346
  return true;
287
347
  }
348
+ /**
349
+ * 链结束(取消应用 / 写回基线)时清掉链起点 —— 下一笔「应用」重新开一条链。
350
+ * 与场景退出后 `writeBaseline({presetId: null, content: null})` 把快照清空是同一个口径。
351
+ */
352
+ async function clearChainStart() {
353
+ const dir = join(deps.presetsDir, LAST_APPLIED_ID);
354
+ await rm(join(dir, FILENAME), { force: true });
355
+ await rm(join(dir, ABSENT_FILE), { force: true });
356
+ }
288
357
  async function readGlobal() {
289
358
  try {
290
359
  return await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
@@ -332,8 +401,9 @@ export function createPromptsService(_ctx, deps) {
332
401
  }
333
402
  /**
334
403
  * 按**原文**写回全局基线(不经过任何预设)。
335
- * 用途:场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
336
- * 不一定对应任何预设。备份纪律与 `apply` 完全相同。
404
+ * 用途:① 场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
405
+ * 不一定对应任何预设;② 「取消应用」的 `'restore'` 分支。
406
+ * 备份纪律与 `apply` 完全相同;写回即**链结束**,链起点一并清掉(见 `clearChainStart`)。
337
407
  */
338
408
  async function restore(content) {
339
409
  const text = String(content ?? '');
@@ -347,18 +417,109 @@ export function createPromptsService(_ctx, deps) {
347
417
  }
348
418
  // 基线内容可能来自用户手写(场景退出恢复)→ 不绑定任何预设。
349
419
  await writeAppliedId(null);
420
+ await clearChainStart();
350
421
  return { ok: true, backedUp };
351
422
  }
423
+ /**
424
+ * 「链起点是什么状态」—— 界面据此决定「取消应用」可不可点、点下去会发生什么。
425
+ * `'restore'` → 起点有一份内容,取消应用 = 把它写回去;
426
+ * `'remove'` → 起点 AGENTS.md 根本不存在,取消应用 = 删掉文件;
427
+ * `null` → 没有链起点(从没「应用」过,或链已结束),关不掉。
428
+ *
429
+ * 两个槽位同时存在只可能是手改磁盘造成的(`backupGlobal` 记链起点时只会写其中一个),
430
+ * 那种情况优先 `'restore'`:**写回内容比删文件保守**,猜错时用户丢的是"文件里的旧内容"
431
+ * 而不是整个文件。
432
+ */
433
+ async function unapplyState() {
434
+ const dir = join(deps.presetsDir, LAST_APPLIED_ID);
435
+ try {
436
+ if ((await stat(join(dir, FILENAME))).isFile())
437
+ return 'restore';
438
+ }
439
+ catch { /* 没有内容槽 */ }
440
+ try {
441
+ if ((await stat(join(dir, ABSENT_FILE))).isFile())
442
+ return 'remove';
443
+ }
444
+ catch { /* 没有"不存在"标记 */ }
445
+ return null;
446
+ }
447
+ /**
448
+ * 「取消应用」= **回到链起点**:把这一串应用/取消往复开始之前的文件状态恢复出来。
449
+ *
450
+ * 语义边界(写清楚是因为它决定了开关什么时候可关):
451
+ * - 恢复的是**链起点**,不是"上一步"。连续应用过 A、B 时,取消应用直接回到"应用 A 之前"
452
+ * —— 文件不存在就删掉文件、有一份手写内容就写回那份内容(0.19.1 修,见 `backupGlobal`)。
453
+ * 想从 B 切回 A 就点 A 的开关,效果等价,不必先退回 A 再取消。
454
+ * - **没有链起点就拒绝**(`noBackup`)。没有链起点只有一种成因:AGENTS.md 的当前内容不是
455
+ * 由「应用」写进去的(首次打开提示词页时播种出来的 `default` 就是这样,它内容与 AGENTS.md
456
+ * 相同、显示「生效中」,却从未发生过写入)。此时"起点"无从谈起,写空文件等于抹掉用户的
457
+ * 全局指令 —— 所以宁可拒绝,让界面去说清「要让它不再生效,请删除这份预设或应用别的预设」。
458
+ *
459
+ * `'restore'` 分支复用 `restore()`:备份纪律与「应用」完全一致。`'remove'` 分支(起点文件
460
+ * 不存在)先 `backupGlobal(prev)` 把当前内容存进多代快照,所以删掉的内容仍然拿得回来
461
+ * (`__last-applied__/AGENTS.md.bak-*`),可撤销性与另一支对称。
462
+ * 两支结束都清链起点(`'restore'` 在 `restore()` 里清)—— 链到此为止。
463
+ */
464
+ async function unapply() {
465
+ const mode = await unapplyState();
466
+ if (mode === null) {
467
+ return {
468
+ ok: false,
469
+ noBackup: true,
470
+ error: '没有可恢复的「链起点」:__last-applied__ 里既没有备份内容,也没有「起点不存在」的标记(AGENTS.md 当前内容不是由「应用」写进去的)。',
471
+ };
472
+ }
473
+ if (mode === 'remove') {
474
+ const prev = await readGlobal();
475
+ const backedUp = await backupGlobal(prev);
476
+ try {
477
+ await rm(await deps.getGlobalAgentsMdPath(), { force: true });
478
+ }
479
+ catch (e) {
480
+ return { ok: false, error: '删除全局 AGENTS.md 失败:' + message(e) };
481
+ }
482
+ await writeAppliedId(null);
483
+ await clearChainStart();
484
+ return { ok: true, backedUp, removed: true };
485
+ }
486
+ let content;
487
+ try {
488
+ content = await readFile(join(deps.presetsDir, LAST_APPLIED_ID, FILENAME), 'utf8');
489
+ }
490
+ catch {
491
+ // `unapplyState()` 刚判过它存在,这里读不到只能是并发下被清掉(或权限问题)。
492
+ return {
493
+ ok: false,
494
+ noBackup: true,
495
+ error: '没有可恢复的「链起点内容」:__last-applied__ 里没有备份(AGENTS.md 当前内容不是由「应用」写进去的)。',
496
+ };
497
+ }
498
+ return restore(content);
499
+ }
352
500
  async function getCurrent() {
353
501
  try {
354
502
  await ensureInit();
355
503
  const [globalPath, presetsDir] = await Promise.all([deps.getGlobalAgentsMdPath(), deps.presetsDir]);
504
+ // `lastApplied` 与 `presetId` 是**两个不同的事实**,删除保护要的是前者:
505
+ // `presetId` = 内容逐字节相同(谁的内容此刻躺在文件里)
506
+ // `lastApplied`= 最近一次「应用」写进去的是谁(用户**主动做过这个选择**)
507
+ // 两者在"首次打开提示词页时自动播种出来的 default"上分道扬镳:它内容与 AGENTS.md
508
+ // 相同(presetId 命中),却从未发生过写入(lastApplied 为 null)。那份副本删掉完全
509
+ // 合理 —— 拦它反而会变成死路(取消应用也关不掉)。见 `ops/prompts.ts` 的删除保护。
510
+ const lastApplied = await readAppliedId();
511
+ // 与内容一起报「链起点是什么状态」—— 界面用它决定那颗「生效中」的开关点下去是
512
+ // 「取消应用」还是「说明为什么关不掉」,以及取消应用的确认弹窗要说"写回内容"还是
513
+ // **"删掉文件"**(后者是不可逆感很强的动作,必须提前说清,不能点下去才知道)。
514
+ const unapplyMode = await unapplyState();
515
+ const unapplyAvailable = unapplyMode !== null;
516
+ const unapplyRemoves = unapplyMode === 'remove';
356
517
  let content;
357
518
  try {
358
519
  content = await readFile(globalPath, 'utf8');
359
520
  }
360
521
  catch {
361
- return { ok: true, content: '', presetId: null, exists: false };
522
+ return { ok: true, content: '', presetId: null, lastApplied, exists: false, unapplyAvailable, unapplyRemoves };
362
523
  }
363
524
  const globalHash = createHash('sha256').update(content).digest('hex');
364
525
  let presetId = null;
@@ -382,7 +543,7 @@ export function createPromptsService(_ctx, deps) {
382
543
  break;
383
544
  }
384
545
  }
385
- return { ok: true, content, presetId, exists: true };
546
+ return { ok: true, content, presetId, lastApplied, exists: true, unapplyAvailable, unapplyRemoves };
386
547
  }
387
548
  catch (e) {
388
549
  return { ok: false, error: message(e) };
@@ -409,6 +570,11 @@ export function createPromptsService(_ctx, deps) {
409
570
  const moved = await moveToTrash('prompts', safeId, [{ from: dir, dest: 'preset' }]);
410
571
  if (moved.ok === false)
411
572
  return { ok: false, error: '移入回收站失败:' + moved.error };
573
+ // 删掉的正是「最近一次应用」的那一份 → 记录跟着清掉。与 `update()` 改名时跟着改记录对称。
574
+ // 不清的后果:之后再新建一个同 id 的预设,界面会凭这条陈旧记录错误地标上「最近一次应用」
575
+ // (`list()` 只做 `appliedId === id` 的比对,认不出「这是另一个预设」)。
576
+ if (await readAppliedId() === safeId)
577
+ await writeAppliedId(null);
412
578
  return { ok: true, id: safeId };
413
579
  }
414
580
  async function trashList() {
@@ -462,5 +628,5 @@ export function createPromptsService(_ctx, deps) {
462
628
  await writeDescription(safeId, description);
463
629
  return { ok: true, id: safeId };
464
630
  }
465
- return { list, read, create, update, apply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
631
+ return { list, read, create, update, apply, unapply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
466
632
  }
@@ -156,7 +156,7 @@ export function createScenePromptSync(deps) {
156
156
  if (!cur || cur.ok !== true)
157
157
  complete = false;
158
158
  else if (cur.presetId)
159
- add(String(cur.presetId), { kind: 'file' });
159
+ add(String(cur.presetId), { kind: 'file', applied: cur.lastApplied === cur.presetId });
160
160
  }
161
161
  catch (e) {
162
162
  complete = false;
@@ -205,6 +205,24 @@ export function createHistoryDomain(deps) {
205
205
  const liveByPath = new Map();
206
206
  const owned = new Map();
207
207
  const liveRecords = [];
208
+ // 「目录真实存在」检查缓存:活登记与重建组共用同一份探测,同一目录只 stat 一次;
209
+ // 结果按归一化键缓存,避免每次刷新重复探盘。
210
+ const dirExistsCache = new Map();
211
+ async function directoryExists(p) {
212
+ const k = workspacePathKey(p);
213
+ const hit = dirExistsCache.get(k);
214
+ if (hit !== undefined)
215
+ return hit;
216
+ let ok = false;
217
+ try {
218
+ ok = (await stat(p)).isDirectory();
219
+ }
220
+ catch {
221
+ ok = false;
222
+ }
223
+ dirExistsCache.set(k, ok);
224
+ return ok;
225
+ }
208
226
  const table = typeof registry.requireTable === 'function' ? registry.requireTable() : undefined;
209
227
  const state = typeof registry.requireState === 'function' ? registry.requireState() : undefined;
210
228
  if (table) {
@@ -224,7 +242,14 @@ export function createHistoryDomain(deps) {
224
242
  liveByPath.set(workspacePathKey(path), wid);
225
243
  liveRecords.push({ id: String(wid), path, title });
226
244
  }
227
- const view = { id: 'ws:' + wid, title, kind: 'live', registered: true, ...(path ? { path } : {}) };
245
+ // 活登记也要探一次盘:登记还在、但磁盘上的工作区文件夹被删掉时,界面必须能及时
246
+ // 标出来(用户 2026-10-12:「要怎么及时知道这个工作区实际文件夹已经被删除了」)。
247
+ const liveDirMissing = path !== undefined && !(await directoryExists(path));
248
+ const view = {
249
+ id: 'ws:' + wid, title, kind: 'live', registered: true,
250
+ ...(path ? { path } : {}),
251
+ ...(liveDirMissing ? { dirMissing: true } : {}),
252
+ };
228
253
  groupsById.set(view.id, view);
229
254
  groups.push(view);
230
255
  workspaces[wid] = { title, ...(path ? { path } : {}) };
@@ -240,24 +265,6 @@ export function createHistoryDomain(deps) {
240
265
  }
241
266
  const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
242
267
  const headers = registry.headers instanceof Map ? registry.headers : undefined;
243
- // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
244
- // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
245
- const dirExistsCache = new Map();
246
- async function directoryExists(p) {
247
- const k = workspacePathKey(p);
248
- const hit = dirExistsCache.get(k);
249
- if (hit !== undefined)
250
- return hit;
251
- let ok = false;
252
- try {
253
- ok = (await stat(p)).isDirectory();
254
- }
255
- catch {
256
- ok = false;
257
- }
258
- dirExistsCache.set(k, ok);
259
- return ok;
260
- }
261
268
  for (const it of items) {
262
269
  const sid = it.sessionId;
263
270
  let wid = owned.get(sid);
@@ -287,14 +294,17 @@ export function createHistoryDomain(deps) {
287
294
  const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
288
295
  const existing = groupsById.get(key);
289
296
  if (existing) {
290
- // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
291
- if (canonical !== undefined || (await directoryExists(path))) {
297
+ // 同目录的多个会话:只要磁盘目录真实存在,即可重新登记。
298
+ // 注意:不能只看宿主 sessionPaths 命中——索引里可能残留已被删除目录的 sessionId。
299
+ if (await directoryExists(path)) {
292
300
  existing.canRegister = true;
293
301
  existing.dirMissing = false;
294
302
  }
295
303
  continue;
296
304
  }
297
- const canRegister = canonical !== undefined || (await directoryExists(path));
305
+ // canRegister 必须落到「目录真实存在」:仅靠 canonical/sessionPaths 命中会把
306
+ // 已被删除的工作区目录误判为可登记,界面继续显示「重新登记」。
307
+ const canRegister = await directoryExists(path);
298
308
  const view = {
299
309
  id: key,
300
310
  title: known?.title || workspaceBaseName(path) || path,
@@ -124,7 +124,15 @@ export async function addCustomRoot(inputPath, label, log) {
124
124
  if (current.writable === false)
125
125
  return invalidManagerStateWrite();
126
126
  const existing = customRootsFromState(current.state);
127
+ // 「永久删除」过的来源不再参与重叠判定:用户在回收站里把它彻底删掉之后,要能把同一个
128
+ // 目录**作为自定义来源重新加回来**(`confirm.source.forget.desc` 那句「需要时只能重新
129
+ // 添加这个目录」说的就是这条路)。不放过它的话,那句提示就是一句空话 —— 路径仍被
130
+ // `userRoots()` 认领,添加只会吃一个「目录与已有技能来源重叠」。
131
+ const forgotten = new Set(Array.isArray(current.state.forgottenSources)
132
+ ? current.state.forgottenSources
133
+ : []);
127
134
  for (const known of userRoots()
135
+ .filter((root) => !forgotten.has(root.key))
128
136
  .map((root) => root.path)
129
137
  .concat(existing.map((root) => root.path))) {
130
138
  if (await pathsOverlap(canonical, known))
@@ -189,6 +197,59 @@ export async function removeCustomRoot(key, log) {
189
197
  log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
190
198
  return { key: clean, path: target.path };
191
199
  }
200
+ /**
201
+ * 永久删除一条**按约定发现**的来源记录(回收站 → 目录 里那颗「永久删除」)。
202
+ *
203
+ * 与 `removeCustomRoot` 的分工:那条管的是"用户手加的自定义目录",删掉 `customRoots` 里
204
+ * 的记录就完事;这条管的是 `agents` / `codex` / `claude` 这类**由 `userRoots()` 按约定推导**
205
+ * 出来的来源 —— 它们不在状态文件的 customRoots 里,路径每次启动都会重新算出来,所以只把 key
206
+ * 从 `removedSources` 里摘掉的话,下一轮它又原样回来了。
207
+ *
208
+ * 于是另记一份 `forgottenSources`:进了这份名单的来源既不读取(`activeUserRoots` 摘掉它)、
209
+ * 也不列出(`state()` 的 roots 过滤掉它)—— 等于从插件里彻底消失。用户想反悔有两条路:
210
+ * 清掉状态文件里这个 key,或者把它**作为自定义来源重新添加**(`addCustomRoot` 的重叠判定
211
+ * 已经跳过已忘记的来源,所以那条路真的走得通)。
212
+ *
213
+ * **磁盘上的源文件一个字节都不动** —— 这条函数自始至终只写状态文件。
214
+ */
215
+ export async function forgetSource(key, log) {
216
+ const clean = String(key == null ? "" : key).trim();
217
+ const root = userRoots().find((item) => item.key === clean);
218
+ if (!root)
219
+ return {
220
+ ok: false,
221
+ error: `未知的来源: ${clean}`,
222
+ code: "error.root.unknown",
223
+ params: { root: clean },
224
+ };
225
+ // 默认来源(dsh / hub / 官方)必须读取:它们不在移除候选里,也不该能被永久删除。
226
+ if (isDefaultSkillSource(clean))
227
+ return {
228
+ ok: false,
229
+ error: `默认来源不能永久删除: ${clean}`,
230
+ code: "error.root.default",
231
+ params: { root: clean },
232
+ };
233
+ const current = await readManagerState();
234
+ if (current.writable === false)
235
+ return invalidManagerStateWrite();
236
+ const forgotten = new Set(Array.isArray(current.state.forgottenSources)
237
+ ? current.state.forgottenSources
238
+ : []);
239
+ forgotten.add(clean);
240
+ current.state.forgottenSources = [...forgotten].sort();
241
+ // 「已移除」那条记录一起摘掉:留着它只会在状态文件里多一条指向"已经不存在"的脏键,
242
+ // 而且归一化那一步本来也会把它去掉 —— 没理由写进去(与 removeCustomRoot 同一口径)。
243
+ if (Array.isArray(current.state.removedSources))
244
+ current.state.removedSources = current.state.removedSources.filter((item) => item !== clean);
245
+ delete current.state.sources[clean];
246
+ delete current.state.disabledSkills[clean];
247
+ delete current.state.enabledSkills[clean];
248
+ await writeManagerState(current.state);
249
+ if (log)
250
+ log("source-forget", `永久删除来源记录 ${clean}: ${root.path}`);
251
+ return { root: clean, path: root.path };
252
+ }
192
253
  /**
193
254
  * 从活动 Session cwd 推导项目技能根。只接受宿主上可解析的绝对目录,
194
255
  * 同一项目的多个 Session 会折叠到同一组稳定 key,避免跨 workspace 合并同名技能。
@@ -434,17 +495,22 @@ async function visibleEntryForRoot(root, name) {
434
495
  */
435
496
  /** 状态文件已存在但不可用时一律关闭外部来源,避免损坏配置重新暴露技能。 */
436
497
  /**
437
- * 参与扫描的来源 = 全部来源 − 用户已移除的。
498
+ * 参与扫描的来源 = 全部来源 − 用户已移除的 − 用户已永久删除的。
438
499
  *
439
500
  * 「移除来源」与「停用来源」是两件事:
440
501
  * - 停用(`sources[key] = false`):仍然读取并列出技能,只是不可调用;
441
502
  * - 移除(出现在 `removedSources`):**连目录都不读**,技能不出现在列表里,
442
503
  * 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
504
+ *
505
+ * 「永久删除」(`forgottenSources`)是移除的**终点**:这条来源从插件里彻底消失 ——
506
+ * 不再读取(与移除同)、并且连"已移除"那行记录也不再出现(见 `state()` 的 roots 过滤)。
507
+ * 两者都**不碰磁盘**。
443
508
  */
444
509
  export function activeUserRoots(stateValue) {
445
510
  const state = stateValue;
446
511
  const removed = new Set(state && Array.isArray(state.removedSources) ? state.removedSources : []);
447
- return userRoots().filter((root) => !removed.has(root.key));
512
+ const forgotten = new Set(state && Array.isArray(state.forgottenSources) ? state.forgottenSources : []);
513
+ return userRoots().filter((root) => !removed.has(root.key) && !forgotten.has(root.key));
448
514
  }
449
515
  /** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
450
516
  /**