dsh-plugin-tool-management 0.5.1 → 0.7.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 (49) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +185 -238
  3. package/README_EN.md +185 -304
  4. package/cordis.patch.yml +9 -55
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +55 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +180 -57
  24. package/lib/client.js +4710 -3560
  25. package/lib/compat/preset-reach.js +425 -0
  26. package/lib/compat/probe.js +665 -0
  27. package/lib/history/bridge.js +293 -0
  28. package/lib/history/workspace.js +498 -52
  29. package/lib/http-fence.js +94 -0
  30. package/lib/hub.js +160 -2
  31. package/lib/index.js +759 -157
  32. package/lib/mcp/override-blocks.js +195 -0
  33. package/lib/rules/provider.js +3 -3
  34. package/lib/rules/service.js +342 -31
  35. package/lib/scene-prompt-sync.js +112 -0
  36. package/lib/skills/core.js +123 -35
  37. package/lib/skills/service.js +9 -1
  38. package/lib/subagents/service.js +197 -23
  39. package/lib/subagents/tools.js +8 -2
  40. package/package.json +6 -3
  41. package/screenshots.json +10 -9
  42. package/docs/Changelog.md +0 -517
  43. package/docs/images/MCP.png +0 -0
  44. package/docs/images//344/274/232/350/257/235.png +0 -0
  45. package/docs/images//345/234/272/346/231/257.png +0 -0
  46. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  47. package/docs/images//346/212/200/350/203/275.png +0 -0
  48. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  49. package/docs/images//350/256/260/345/277/206.png +0 -0
package/cordis.patch.yml CHANGED
@@ -1,60 +1,14 @@
1
- # Bundle patch layer for the official CLI install:
1
+ # Install only this plugin. History composes the already active host services.
2
+ # Do NOT disable workspace/session-projection-cache, insert competing service
3
+ # providers, or infer capabilities from another package's name.
4
+ # Existing explicit user-layer overrides remain the user's responsibility.
2
5
  #
3
- # dsh plugin --profile web add dsh-plugin-tool-management@latest
6
+ # Double-mount guard: inspect raw options only. Entry.disabled is a computed
7
+ # getter and reading it from another disabled expression can recurse at boot.
4
8
  #
5
- # `dsh plugin add` reconciles `dsh.profile.bundles` against installed packages
6
- # and, seeing the `dsh.bundle.patch` declaration in package.json, appends
7
- # `dsh-plugin-tool-management` to the bundle stack. The profile boot then merges
8
- # THIS patch (a single `insert` of the plugin row). No profile file edits
9
- # needed — one command installs and mounts.
10
- #
11
- # Double-mount guard: an aggregate bundle (or a hand-written loader row) may
12
- # already mount this plugin under a different entry id. Two mounts both register the
13
- # /dsh-plugin-tool-management/api route and fail the whole plugin tree at boot
14
- # ("duplicate prefix route"). The `!!js` disabled expression backs THIS row
15
- # off when another *enabled* entry already mounts the plugin; the existing
16
- # instance then owns the API. The loader evaluates the expression at entry
17
- # activation — the full entry tree is composed by then, so user-layer
18
- # (profile cordis.patch.yml) rows are visible too.
19
- #
20
- # ORDER MATTERS — do not move `!e.disabled` ahead of the id/name checks.
21
- # `Entry.disabled` is an uncached getter that re-evaluates the expression,
22
- # so touching `e.disabled` of THIS entry re-enters this very expression
23
- # (infinite recursion → "Maximum call stack size exceeded" at boot). The
24
- # `id !==` check short-circuits on our own row and the `name` check
25
- # short-circuits on every unrelated entry (e.g. dsh-better-sidebar's own
26
- # guard), so `!e.disabled` is only ever read on a genuinely matching
27
- # legacy/aggregate row (a plain boolean, no re-entry).
28
- # 折叠 dsh-archive-manager:替换官方 workspace 与 session-projection-cache
29
- # 服务为归档感知子类(永久删除 + unarchive + 批量 + archivedAt 保留期账本)。
30
- # 官方 ui-workspace 保持启用;归档菜单仍由官方 UI 提供,删除/恢复/保留期
31
- # 由本插件的 TOOLS → History 页面经 HTTP API 驱动。
32
- #
33
- # 冲突告警:不要同时安装独立的 @michengai/dsh-archive-manager——两个补丁都
34
- # 禁用官方 workspace 行并插入替换,会导致重复服务注册、启动失败。
35
- #
36
- # spawn provider(子智能体)挂载:本文件**故意不**插入
37
- # `@deepseek-ai/dsh-subagent-spawn-in-process` 行。取舍理由:
38
- # 1) insert 行在启动期解析:换台没有该包的宿主会因单行加载失败拖垮整棵插件树,
39
- # 而缺 provider 时受影响面只有一个 subagent_run 调用;
40
- # 2) 该行与本插件运行时的 ctx.plugin 兜底挂载存在双挂载窗口(同名 provider 重复注册)。
41
- # 改由 src/subagents/service.ts 走官方通道:ctx.subagents.list() 探测到 spawn 即跳过;
42
- # 缺失才经 createRequire + apply(ctx, {providerName:'spawn'}) 挂载;失败把原因原样抛给模型。
43
- # 该包在 package.json 声明为**可选** peerDependency(peerDependenciesMeta.optional)——
44
- # 宿主/profile 自带时可以照常挂载。设计 §3.2 字面要求「挂载声明进 cordis.patch.yml」,
45
- # 这里按上述风险裁定为有意偏离(记录见 docs/审查/2026-09-13-场景档案v2双轴评审.md)。
46
- - id: workspace
47
- disabled: true
48
- - id: session-projection-cache
49
- disabled: true
9
+ # The optional subagent spawn provider is resolved at runtime by the subagent
10
+ # service; an unavailable optional package must not prevent the host from booting.
50
11
  - insert:
51
12
  - id: dsh-plugin-tool-management
52
13
  name: 'dsh-plugin-tool-management'
53
- disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.id !== 'dsh-plugin-tool-management' && e.options.name === 'dsh-plugin-tool-management' && !e.disabled)"
54
- - id: workspace-tool-management-history
55
- name: 'dsh-plugin-tool-management/workspace'
56
- - id: session-projection-cache-tool-management-history
57
- name: 'dsh-plugin-tool-management/projcache'
58
- config:
59
- writeEveryEvents: 200
60
- writeIntervalMs: 5000
14
+ disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.id !== 'dsh-plugin-tool-management' && e.options.name === 'dsh-plugin-tool-management' && e.options.disabled !== true && typeof e.options.disabled !== 'object')"
Binary file
Binary file
package/docs/update.md ADDED
@@ -0,0 +1,55 @@
1
+ # 版本更新概要
2
+
3
+ 每个版本大致改了什么——想知道「升上去会变什么」看这里就够了。逐条细节见 [Changelog](../CHANGELOG.md),功能说明见 [README](../README.md) / [README_EN](../README_EN.md)。
4
+
5
+ | 版本 | 发布 | 主题 |
6
+ |---|---|---|
7
+ | [0.7.0](#070---2026-09-15) | 2026-09-15 | 回收站全量 + 预设注入可见性 + 密钥令牌化 |
8
+ | [0.6.0](#060---2026-09-14) | 2026-09-14 | 宿主兼容体检 + 兼容页 |
9
+ | [0.5.1](#051---2026-09-13) | 2026-09-13 | 场景档案默认不勾选 + 场景页排版重做 |
10
+ | [0.5.0](#050---2026-09-13) | 2026-09-13 | 数据目录统一 + 场景显式化 + 界面重做 |
11
+ | [0.2.0](#020---2026-09-12) | 2026-09-12 | 五域合并进一个「工具」面板 |
12
+
13
+ ---
14
+
15
+ ## 0.7.0 - 2026-09-15
16
+
17
+ - 回收站扩到子智能体 / 场景 / 提示词预设(删除都进回收站,可恢复)。
18
+ - 子智能体工具限制按 Agent 预设(每预设一份白/黑名单)。
19
+ - 兼容页「预设注入边界」五列标签(绿/红/灰框)。
20
+ - 明文密钥改为一律要令牌(没配 `token` 就不给明文)。
21
+ - MCP「整理补丁」重新上线(删多余启停块,状态不变)。
22
+
23
+ ## 0.6.0 - 2026-09-14
24
+
25
+ - 新增「兼容」页(第 8 栏)+ doctor/host-deps 脚本。
26
+ - 兼容判定从源码文本比对改为模块实体 + 能力探测。
27
+ - 归档会话不再丢工作区归属(登记被删后重建分组 + 一键重新登记)。
28
+ - 技能来源权限语义修正(默认来源必须读取但技能可删)。
29
+ - 场景可绑提示词预设,切换场景直接改写 AGENTS.md(关掉自动恢复)。
30
+ - 场景启用改单选,新场景默认不启动。
31
+
32
+ ## 0.5.1 - 2026-09-13
33
+
34
+ - 场景档案弹窗新加段默认不勾选。
35
+ - 档案「记忆」段只列本场景的记忆。
36
+ - 场景页排版重做(卡片只留名字+描述+动作)。
37
+ - 保留场景「全局」不再出现在场景页与档案弹窗。
38
+
39
+ ## 0.5.0 - 2026-09-13
40
+
41
+ - 数据全部收进 `~/.dsh/tool-management/`(旧位置自动搬入)。
42
+ - 场景升级为显式记录;根层裸 `.md` 不再注入。
43
+ - 新增「导入技能」来源 + 「移除来源」操作。
44
+ - 场景档案新增第 4 段「记忆」。
45
+ - 修掉三个真故障:整页白屏、导出静默丢失、Skills 页锁死。
46
+ - 斜杠命令全部下线。
47
+
48
+ ## 0.2.0 - 2026-09-12
49
+
50
+ - 五域合并进一个「工具」面板:MCP / Skills / AGENTS.md 预设 / History。
51
+ - MCP 工具级启停、重启只重连、密钥打码、补丁备份。
52
+ - Skills 接入 ~/.agents / ~/.codex / ~/.claude + 自定义目录。
53
+ - History 按项目分组、批量操作、保留期清理、对话导入导出。
54
+
55
+ > 0.1.1 / 0.1.2 / 0.1.3 无独立记录(仅 git tag)。
@@ -0,0 +1,49 @@
1
+ // dsh-plugin-tool-management —— 提示词预设 id 的唯一口径。
2
+ //
3
+ // 用户裁定(2026-09-15):「id 应该什么都能写」——不再限定小写字母/数字/连字符,
4
+ // 中文、空格、下划线、点都可以。id 同时也是**磁盘目录名**,所以只保留两类硬约束:
5
+ // ① 文件系统安全:不含路径分隔符与 Windows 非法字符、不以点开头/结尾、
6
+ // 不是 `.` / `..`、不是 Windows 保留设备名;
7
+ // ② 一个保留字:`__last-applied__`(apply 的备份槽,不是用户预设)。
8
+ // 这套口径由 agents-md 服务与场景绑定(`scenes[].prompt`)共用,避免两处漂移。
9
+ // 约定镜像 @deepseek-ai/dsh-agent-presets 的「id 即目录名」,但放宽了字符集。
10
+ /** id 长度上限(字符数;目录名过长在 Windows 上还受 MAX_PATH 约束)。 */
11
+ export const PRESET_ID_MAX = 64;
12
+ /** apply 的备份槽:不是用户预设,任何入口都不得当作预设 id。 */
13
+ export const LAST_APPLIED_PRESET_ID = '__last-applied__';
14
+ /** Windows 保留设备名(不区分大小写,带扩展名同样非法)。 */
15
+ const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
16
+ /**
17
+ * 校验并归一化一个预设 id。
18
+ * @param raw - 用户输入或来自索引的原始值。
19
+ * @returns `{ ok: true, id }`(已 trim)或 `{ ok: false, error }`(给人看的中文原因)。
20
+ */
21
+ export function normalizePresetId(raw) {
22
+ const id = String(raw ?? '').trim();
23
+ if (id === '')
24
+ return { ok: false, error: 'id 不能为空' };
25
+ if (id.length > PRESET_ID_MAX)
26
+ return { ok: false, error: `id 过长(≤${PRESET_ID_MAX} 字符,当前 ${id.length})` };
27
+ if (id === LAST_APPLIED_PRESET_ID)
28
+ return { ok: false, error: `「${LAST_APPLIED_PRESET_ID}」是「应用」的备份槽,不能用作预设 id` };
29
+ if (id === '.' || id === '..')
30
+ return { ok: false, error: 'id 不能是「.」或「..」' };
31
+ if (id.startsWith('.'))
32
+ return { ok: false, error: 'id 不能以「.」开头' };
33
+ if (/[\\/]/.test(id))
34
+ return { ok: false, error: 'id 不能含路径分隔符(/ 或 \\)' };
35
+ if (/[<>:"|?*]/.test(id))
36
+ return { ok: false, error: 'id 不能含 < > : " | ? * 这些字符' };
37
+ if (/[.\s]$/.test(id))
38
+ return { ok: false, error: 'id 不能以点或空格结尾(Windows 会静默去掉)' };
39
+ if (WINDOWS_RESERVED.test(id))
40
+ return { ok: false, error: `「${id}」是 Windows 保留设备名` };
41
+ // 控制字符(含换行/制表)会让目录名与日志不可读,直接拒绝。
42
+ if (/[\u0000-\u001f\u007f]/.test(id))
43
+ return { ok: false, error: 'id 不能含控制字符' };
44
+ return { ok: true, id };
45
+ }
46
+ /** 便捷判定(索引解析等只关心"能不能用"的地方)。 */
47
+ export function isValidPresetId(raw) {
48
+ return normalizePresetId(raw).ok;
49
+ }
@@ -1,22 +1,32 @@
1
1
  // dsh-plugin-tool-management —— AGENTS.md 预设库 + 切换 服务层。
2
2
  //
3
3
  // DSH 全局指令基线只有一个文件 ~/.dsh/AGENTS.md(USER_GLOBAL_FILE 固定),
4
- // 没有内置的「多份全局 AGENTS.md 切换」机制。本服务在插件目录内维护一个
4
+ // 没有内置的「多份全局 AGENTS.md 切换」机制。本服务在 hub 内维护一个
5
5
  // 预设库(每套一个子目录 + AGENTS.md),「应用」= 把选中预设内容写入
6
6
  // ~/.dsh/AGENTS.md,新会话生效(当前会话不变,DSH 本身如此)。
7
7
  //
8
- // 约定镜像 @deepseek-ai/dsh-agent-presets:id 即目录名,正则
9
- // /^[a-z0-9][a-z0-9-]*$/;__last-applied__ 是备份槽,不算用户预设。
8
+ // id 即目录名:字符集口径见 `./preset-id.ts`(用户裁定「什么都能写」,只留
9
+ // 文件系统安全约束);`__last-applied__` 是备份槽,不算用户预设。
10
10
  // 「当前生效」靠比对 ~/.dsh/AGENTS.md 的 sha256 与各预设 sha256 推断,
11
11
  // 无状态文件——用户手改全局文件也能如实反映。
12
- import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
12
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { createHash } from 'node:crypto';
15
- const PRESET_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
15
+ import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
16
+ import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
16
17
  const FILENAME = 'AGENTS.md';
17
- const LAST_APPLIED_ID = '__last-applied__';
18
+ const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
19
+ /** 新建预设的初始正文(空模板);用户也可以直接粘贴自己的内容。 */
20
+ const BLANK_TEMPLATE = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
21
+ /** 全局 AGENTS.md 的备份代际上限:与 mcpm patch 的 KEEP_PATCH_BACKUPS 同纪律。 */
22
+ const KEEP_GLOBAL_BACKUPS = 5;
18
23
  export function createAgentsMdService(_ctx, deps) {
19
24
  const message = (e) => String((e && e.message) || e);
25
+ /** 校验 id 并返回错误文本(合法时返回 `null`)。 */
26
+ const idError = (raw) => {
27
+ const result = normalizePresetId(raw);
28
+ return result.ok ? null : result.error;
29
+ };
20
30
  async function sha256OfFile(abs) {
21
31
  try {
22
32
  const buf = await readFile(abs, 'utf8');
@@ -36,7 +46,7 @@ export function createAgentsMdService(_ctx, deps) {
36
46
  existing = await readdir(presetsDir, { withFileTypes: true });
37
47
  }
38
48
  catch { /* 库不存在 */ }
39
- const hasPreset = existing.some((e) => e.isDirectory() && PRESET_ID_RE.test(e.name) && e.name !== LAST_APPLIED_ID);
49
+ const hasPreset = existing.some((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID);
40
50
  if (hasPreset)
41
51
  return;
42
52
  let globalContent;
@@ -67,7 +77,7 @@ export function createAgentsMdService(_ctx, deps) {
67
77
  }
68
78
  const globalHash = await sha256OfFile(globalPath);
69
79
  const ids = entries
70
- .filter((e) => e.isDirectory() && PRESET_ID_RE.test(e.name) && e.name !== LAST_APPLIED_ID)
80
+ .filter((e) => e.isDirectory() && isValidPresetId(e.name) && e.name !== LAST_APPLIED_ID)
71
81
  .map((e) => e.name);
72
82
  const presets = await Promise.all(ids.map(async (id) => {
73
83
  const contentHash = await sha256OfFile(join(presetsDir, id, FILENAME));
@@ -84,11 +94,10 @@ export function createAgentsMdService(_ctx, deps) {
84
94
  }
85
95
  }
86
96
  async function read(id) {
87
- const safeId = String(id || '');
88
- // id 即目录名,严格校验防目录穿越:仅允许小写字母/数字/连字符。
89
- // __last-applied__ 含下划线会被拒(它是备份槽,不走 read,由 apply 内部直写)。
90
- if (!PRESET_ID_RE.test(safeId))
91
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
97
+ const bad = idError(id);
98
+ if (bad)
99
+ return { ok: false, error: '非法 id:' + bad };
100
+ const safeId = String(id ?? '').trim();
92
101
  try {
93
102
  const content = await readFile(join(deps.presetsDir, safeId, FILENAME), 'utf8');
94
103
  return { ok: true, content };
@@ -97,39 +106,53 @@ export function createAgentsMdService(_ctx, deps) {
97
106
  return { ok: false, error: '预设不存在:' + safeId };
98
107
  }
99
108
  }
100
- async function create(id, from) {
101
- const safeId = String(id || '');
102
- if (!PRESET_ID_RE.test(safeId))
103
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
109
+ /** 新建预设:`content` 优先(用户在新建弹窗里直接写的内容),否则用 `from` 复制,否则空模板。 */
110
+ async function create(id, options) {
111
+ const bad = idError(id);
112
+ if (bad)
113
+ return { ok: false, error: '非法 id:' + bad };
114
+ const safeId = String(id ?? '').trim();
104
115
  const dir = join(deps.presetsDir, safeId);
105
116
  try {
106
117
  await stat(dir);
107
118
  return { ok: false, error: 'id 已存在:' + safeId };
108
119
  }
109
120
  catch { /* 不存在,继续 */ }
121
+ const explicit = options && typeof options.content === 'string' ? options.content : undefined;
110
122
  let content;
111
- const srcId = String(from || '');
112
- if (srcId) {
113
- if (!PRESET_ID_RE.test(srcId))
114
- return { ok: false, error: '非法 from id:' + srcId };
115
- try {
116
- content = await readFile(join(deps.presetsDir, srcId, FILENAME), 'utf8');
117
- }
118
- catch {
119
- return { ok: false, error: '来源预设不存在:' + srcId };
120
- }
123
+ if (explicit !== undefined) {
124
+ content = explicit;
121
125
  }
122
126
  else {
123
- content = '# AGENTS.md\n\n(DSH 全局指令基线预设,待编辑)\n';
127
+ const srcId = String(options && options.from ? options.from : '').trim();
128
+ if (srcId) {
129
+ const badFrom = idError(srcId);
130
+ if (badFrom)
131
+ return { ok: false, error: '非法来源 id:' + badFrom };
132
+ try {
133
+ content = await readFile(join(deps.presetsDir, srcId, FILENAME), 'utf8');
134
+ }
135
+ catch {
136
+ return { ok: false, error: '来源预设不存在:' + srcId };
137
+ }
138
+ }
139
+ else {
140
+ content = BLANK_TEMPLATE;
141
+ }
124
142
  }
125
143
  await mkdir(dir, { recursive: true });
126
144
  await writeFile(join(dir, FILENAME), content, 'utf8');
127
145
  return { ok: true, id: safeId };
128
146
  }
129
- async function update(id, content) {
130
- const safeId = String(id || '');
131
- if (!PRESET_ID_RE.test(safeId))
132
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
147
+ /**
148
+ * 保存预设:正文写入 + 可选**改名**(目录改名,内容随目录一起走)。
149
+ * 改名冲突(目标已存在)直接拒绝,不合并、不覆盖。
150
+ */
151
+ async function update(id, content, nextId) {
152
+ const bad = idError(id);
153
+ if (bad)
154
+ return { ok: false, error: '非法 id:' + bad };
155
+ const safeId = String(id ?? '').trim();
133
156
  const dir = join(deps.presetsDir, safeId);
134
157
  try {
135
158
  await stat(dir);
@@ -137,13 +160,64 @@ export function createAgentsMdService(_ctx, deps) {
137
160
  catch {
138
161
  return { ok: false, error: '预设不存在:' + safeId };
139
162
  }
140
- await writeFile(join(dir, FILENAME), String(content ?? ''), 'utf8');
141
- return { ok: true, id: safeId };
163
+ let targetId = safeId;
164
+ if (nextId !== undefined && String(nextId).trim() !== safeId) {
165
+ const badNext = idError(nextId);
166
+ if (badNext)
167
+ return { ok: false, error: '非法新 id:' + badNext };
168
+ targetId = String(nextId).trim();
169
+ const nextDir = join(deps.presetsDir, targetId);
170
+ try {
171
+ await stat(nextDir);
172
+ return { ok: false, error: '新 id 已存在:' + targetId };
173
+ }
174
+ catch { /* 可用 */ }
175
+ try {
176
+ await rename(dir, nextDir);
177
+ }
178
+ catch (e) {
179
+ return { ok: false, error: '改名失败:' + message(e) };
180
+ }
181
+ }
182
+ await writeFile(join(deps.presetsDir, targetId, FILENAME), String(content ?? ''), 'utf8');
183
+ return { ok: true, id: targetId, ...(targetId === safeId ? {} : { renamedFrom: safeId }) };
184
+ }
185
+ /**
186
+ * 覆盖写全局 `~/.dsh/AGENTS.md` 前的备份(多代 + `__last-applied__` 维护)。
187
+ * **必须留多代**:只有单个 `__last-applied__` 槽位时,连续写两次就会把用户原始手写内容
188
+ * 永久覆盖掉(第一次备份原始内容 → 第二次备份第一个预设),而「应用」只是点两下按钮。
189
+ * @returns 是否真的备份了(当前文件存在才备份)。
190
+ */
191
+ async function backupGlobal(current) {
192
+ if (current === null)
193
+ return false;
194
+ const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
195
+ await mkdir(backupDir, { recursive: true });
196
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
197
+ await writeFile(join(backupDir, `${FILENAME}.bak-${stamp}`), current, 'utf8');
198
+ await writeFile(join(backupDir, FILENAME), current, 'utf8');
199
+ try {
200
+ const gens = (await readdir(backupDir)).filter((n) => n.startsWith(FILENAME + '.bak-')).sort();
201
+ for (const stale of gens.slice(0, Math.max(0, gens.length - KEEP_GLOBAL_BACKUPS))) {
202
+ await rm(join(backupDir, stale), { force: true });
203
+ }
204
+ }
205
+ catch { /* 轮转失败不影响本次写入 */ }
206
+ return true;
207
+ }
208
+ async function readGlobal() {
209
+ try {
210
+ return await readFile(await deps.getGlobalAgentsMdPath(), 'utf8');
211
+ }
212
+ catch {
213
+ return null;
214
+ }
142
215
  }
143
216
  async function apply(id) {
144
- const safeId = String(id || '');
145
- if (!PRESET_ID_RE.test(safeId))
146
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
217
+ const bad = idError(id);
218
+ if (bad)
219
+ return { ok: false, error: '非法 id:' + bad };
220
+ const safeId = String(id ?? '').trim();
147
221
  let content;
148
222
  try {
149
223
  content = await readFile(join(deps.presetsDir, safeId, FILENAME), 'utf8');
@@ -151,20 +225,27 @@ export function createAgentsMdService(_ctx, deps) {
151
225
  catch {
152
226
  return { ok: false, error: '预设不存在:' + safeId };
153
227
  }
154
- const globalPath = await deps.getGlobalAgentsMdPath();
155
- // 备份当前全局内容(若存在)到 __last-applied__,再覆盖写入。
156
- let prev = null;
228
+ const prev = await readGlobal();
229
+ const backedUp = await backupGlobal(prev);
230
+ await writeFile(await deps.getGlobalAgentsMdPath(), content, 'utf8');
231
+ return { ok: true, id: safeId, backedUp };
232
+ }
233
+ /**
234
+ * 按**原文**写回全局基线(不经过任何预设)。
235
+ * 用途:场景切换的「关掉场景 → 恢复进场景之前的基线」——那份内容可能是用户手写的,
236
+ * 不一定对应任何预设。备份纪律与 `apply` 完全相同。
237
+ */
238
+ async function restore(content) {
239
+ const text = String(content ?? '');
240
+ const prev = await readGlobal();
241
+ const backedUp = await backupGlobal(prev);
157
242
  try {
158
- prev = await readFile(globalPath, 'utf8');
243
+ await writeFile(await deps.getGlobalAgentsMdPath(), text, 'utf8');
159
244
  }
160
- catch { /* 当前不存在,不备份 */ }
161
- if (prev !== null) {
162
- const backupDir = join(deps.presetsDir, LAST_APPLIED_ID);
163
- await mkdir(backupDir, { recursive: true });
164
- await writeFile(join(backupDir, FILENAME), prev, 'utf8');
245
+ catch (e) {
246
+ return { ok: false, error: '写入全局 AGENTS.md 失败:' + message(e) };
165
247
  }
166
- await writeFile(globalPath, content, 'utf8');
167
- return { ok: true, id: safeId, backedUp: prev !== null };
248
+ return { ok: true, backedUp };
168
249
  }
169
250
  async function getCurrent() {
170
251
  try {
@@ -185,7 +266,7 @@ export function createAgentsMdService(_ctx, deps) {
185
266
  }
186
267
  catch { /* 库不存在 */ }
187
268
  for (const e of entries) {
188
- if (!e.isDirectory() || !PRESET_ID_RE.test(e.name) || e.name === LAST_APPLIED_ID)
269
+ if (!e.isDirectory() || !isValidPresetId(e.name) || e.name === LAST_APPLIED_ID)
189
270
  continue;
190
271
  let c;
191
272
  try {
@@ -205,12 +286,17 @@ export function createAgentsMdService(_ctx, deps) {
205
286
  return { ok: false, error: message(e) };
206
287
  }
207
288
  }
289
+ /**
290
+ * 删除预设 = **移入回收站**(整个 `agents-md/<id>/` 目录搬走)。
291
+ * 备份槽 `__last-applied__` 不可删;正在生效的预设由调用方先拦(见 index.ts)。
292
+ */
208
293
  async function remove(id) {
209
- const safeId = String(id || '');
294
+ const safeId = String(id ?? '').trim();
210
295
  if (safeId === LAST_APPLIED_ID)
211
296
  return { ok: false, error: '备份槽不可删除' };
212
- if (!PRESET_ID_RE.test(safeId))
213
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
297
+ const bad = idError(safeId);
298
+ if (bad)
299
+ return { ok: false, error: '非法 id:' + bad };
214
300
  const dir = join(deps.presetsDir, safeId);
215
301
  try {
216
302
  await stat(dir);
@@ -218,14 +304,51 @@ export function createAgentsMdService(_ctx, deps) {
218
304
  catch {
219
305
  return { ok: false, error: '预设不存在:' + safeId };
220
306
  }
221
- await rm(dir, { recursive: true, force: true });
307
+ const moved = await moveToTrash('agents-md', safeId, [{ from: dir, dest: 'preset' }]);
308
+ if (moved.ok === false)
309
+ return { ok: false, error: '移入回收站失败:' + moved.error };
222
310
  return { ok: true, id: safeId };
223
311
  }
312
+ async function trashList() {
313
+ return { ok: true, trash: await listTrashEntries('agents-md') };
314
+ }
315
+ /** 从回收站恢复预设:同 id 已存在时**拒绝**(绝不覆盖)。 */
316
+ async function trashRestore(id) {
317
+ const entry = await readTrashEntry('agents-md', String(id ?? '').trim());
318
+ if (!entry)
319
+ return { ok: false, error: '回收站条目不存在:' + String(id ?? '').trim() };
320
+ const bad = idError(entry.name);
321
+ if (bad)
322
+ return { ok: false, error: '回收站里的 id 不合法:' + bad };
323
+ const target = join(deps.presetsDir, entry.name);
324
+ try {
325
+ await stat(target);
326
+ return { ok: false, error: '无法恢复,同 id 预设已存在:' + entry.name };
327
+ }
328
+ catch { /* 可用 */ }
329
+ try {
330
+ await mkdir(deps.presetsDir, { recursive: true });
331
+ await moveOutOfTrash('agents-md', entry.id, 'preset', target);
332
+ }
333
+ catch (e) {
334
+ return { ok: false, error: '恢复失败:' + message(e) };
335
+ }
336
+ await purgeTrashEntry('agents-md', entry.id);
337
+ return { ok: true, id: entry.name };
338
+ }
339
+ async function trashDelete(id) {
340
+ const clean = String(id ?? '').trim();
341
+ const gone = await purgeTrashEntry('agents-md', clean);
342
+ if (!gone)
343
+ return { ok: false, error: '回收站条目不存在:' + clean };
344
+ return { ok: true, id: clean };
345
+ }
224
346
  // 从外部文本内容(如导入的 .md 文件)建预设:id 校验 + 重复检查 + 写 AGENTS.md。
225
347
  async function importPreset(id, content) {
226
- const safeId = String(id || '');
227
- if (!PRESET_ID_RE.test(safeId))
228
- return { ok: false, error: '非法 id(仅允许小写字母、数字、连字符):' + safeId };
348
+ const bad = idError(id);
349
+ if (bad)
350
+ return { ok: false, error: '非法 id:' + bad };
351
+ const safeId = String(id ?? '').trim();
229
352
  const dir = join(deps.presetsDir, safeId);
230
353
  try {
231
354
  await stat(dir);
@@ -236,5 +359,5 @@ export function createAgentsMdService(_ctx, deps) {
236
359
  await writeFile(join(dir, FILENAME), String(content ?? ''), 'utf8');
237
360
  return { ok: true, id: safeId };
238
361
  }
239
- return { list, read, create, update, apply, getCurrent, remove, importPreset };
362
+ return { list, read, create, update, apply, restore, getCurrent, remove, trashList, trashRestore, trashDelete, importPreset };
240
363
  }