dsh-plugin-tool-management 0.17.0 → 0.18.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 (83) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -3
  3. package/README_EN.md +18 -3
  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/update.md +16 -0
  21. package/lib/client.js +1573 -133
  22. package/lib/compat/capability-specs.js +321 -0
  23. package/lib/compat/host-probe.js +261 -0
  24. package/lib/compat/host-roots.js +168 -0
  25. package/lib/compat/probe-types.js +6 -0
  26. package/lib/compat/probe.js +9 -720
  27. package/lib/context-inject-contract.js +114 -0
  28. package/lib/context-inject-frame.js +147 -0
  29. package/lib/context-inject-select.js +127 -0
  30. package/lib/context-inject-surface.js +97 -0
  31. package/lib/context-inject.js +10 -478
  32. package/lib/http-api.js +229 -0
  33. package/lib/hub.js +2 -0
  34. package/lib/index.js +134 -836
  35. package/lib/mcp/inspect-probe.js +199 -0
  36. package/lib/mcp/manager.js +30 -548
  37. package/lib/mcp/tool-visibility.js +388 -0
  38. package/lib/memories/archive-engine.js +142 -5
  39. package/lib/memories/archive.js +10 -1
  40. package/lib/memories/discovery.js +261 -0
  41. package/lib/memories/export-plan.js +42 -0
  42. package/lib/memories/memories-types.js +1 -0
  43. package/lib/memories/service.js +4 -38
  44. package/lib/memories/snapshot.js +10 -252
  45. package/lib/op-registry.js +21 -0
  46. package/lib/ops/compat.js +11 -0
  47. package/lib/ops/quick-prompts.js +26 -0
  48. package/lib/ops/scene-records.js +40 -0
  49. package/lib/ops/scene-sync.js +71 -1
  50. package/lib/ops/sessions.js +13 -9
  51. package/lib/ops/snapshot.js +18 -12
  52. package/lib/prompts/quick-service.js +211 -0
  53. package/lib/scene-linkage.js +184 -0
  54. package/lib/sessions/archive-schemas.js +121 -0
  55. package/lib/sessions/path-key.js +46 -0
  56. package/lib/sessions/workspace.js +5 -163
  57. package/lib/settings-sidecars.js +143 -0
  58. package/lib/skills/core-types.js +8 -0
  59. package/lib/skills/core-util.js +81 -0
  60. package/lib/skills/core.js +56 -2326
  61. package/lib/skills/dir-watch.js +71 -0
  62. package/lib/skills/frontmatter.js +96 -0
  63. package/lib/skills/homes.js +14 -0
  64. package/lib/skills/import-pipeline.js +231 -0
  65. package/lib/skills/manager-state.js +800 -0
  66. package/lib/skills/official.js +269 -0
  67. package/lib/skills/provider-registration.js +89 -0
  68. package/lib/skills/service.js +5 -240
  69. package/lib/skills/skill-roots.js +287 -0
  70. package/lib/skills/skill-trash.js +265 -0
  71. package/lib/skills/system-trash.js +97 -0
  72. package/lib/skills/upload-pipeline.js +374 -0
  73. package/lib/slash-settings.js +25 -0
  74. package/lib/subagents/persona-parse.js +312 -0
  75. package/lib/subagents/persona-types.js +5 -0
  76. package/lib/subagents/persona-write.js +93 -0
  77. package/lib/subagents/run-provider.js +159 -0
  78. package/lib/subagents/service.js +14 -638
  79. package/lib/subagents/tool-filter.js +98 -0
  80. package/lib/token-config.js +240 -0
  81. package/lib/tool-table-switch.js +163 -0
  82. package/lib/tools/scene.js +40 -9
  83. package/package.json +3 -2
@@ -0,0 +1,287 @@
1
+ // 技能来源与路径底座:四个用户级来源、项目级来源、各类 root/state/trash 路径的推导,
2
+ // 以及「这条路径算不算在那个来源里」的比较口径。
3
+ //
4
+ // 声明从 skills/core.ts 按行号区间搬来,正文一字未改(唯一的容差是 `--export-top-level`
5
+ // 加的 `export ` 前缀,moved-verify 逐字对拍)。
6
+ //
7
+ // 为什么先剥这一层:core.ts 里往上数的每一个域(manager 状态、启停策略、导入链路、上传、
8
+ // 回收站)都落在这组路径函数上,而它们自己只向下引 ./official.js / ./homes.js / node:*。
9
+ // 于是这一层搬出去是**单向**的:core.js 引它,它不引 core.js,加载图无环。
10
+ import { homedir } from "node:os";
11
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
+ import { promises as fs } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { lstatOrNull, pathIdentity } from "./core-util.js";
15
+ import { OFFICIAL_SOURCE_KEY, officialSkillRoot } from "./official.js";
16
+ import { resolveAgentsHome, resolveDshHome } from "./homes.js";
17
+ import { isSameOrDescendant } from "../paths.js";
18
+ export function userRoots() {
19
+ const official = officialSkillRoot();
20
+ return [
21
+ {
22
+ key: "dsh",
23
+ path: join(resolveDshHome(), "skills"),
24
+ label: "DSH 技能",
25
+ mutable: true,
26
+ // 默认来源:路径不可移除、不可停用(必须读取),但里面的技能可删(见 isDefaultSkillSource)。
27
+ deletable: true,
28
+ // 注意:`toggleable` 指的是「这个来源里的**单个技能**能否启停」,与来源开关无关 ——
29
+ // 来源层的启停/移除由 isDefaultSkillSource 判定,两者别混。
30
+ toggleable: true,
31
+ native: true,
32
+ rank: 400,
33
+ },
34
+ // 官方内置紧随 DSH 技能之后(用户裁定):同为一眼要看的来源,排在导入技能之前。
35
+ ...(official === null ? [] : [official]),
36
+ {
37
+ // v0.4:插件**新建/导入**的技能落到 hub 内(用户要求:插件产生的文件收在
38
+ // $DSH_HOME/tool-management/)。rank 高于 DSH 技能,同名时 hub 版本遮蔽官方目录里的;
39
+ // 官方 ~/.dsh/skills/ 仍作为可切换来源列出(不搬走、不删)。
40
+ //
41
+ // 界面名「导入技能」:它是插件导入/新建技能的落点,不是"管理器自己的一类技能"。
42
+ // 与 dsh 同为默认来源:路径必须读取(不可移除、不可停用),里面的技能可删。
43
+ key: "hub",
44
+ path: join(resolveDshHome(), "tool-management", "skills"),
45
+ label: "导入技能",
46
+ localeKey: "hub",
47
+ mutable: true,
48
+ deletable: true,
49
+ toggleable: true,
50
+ native: false,
51
+ rank: 350,
52
+ },
53
+ {
54
+ key: "agents",
55
+ path: join(resolveAgentsHome(), "skills"),
56
+ label: "公共 Agent",
57
+ mutable: false,
58
+ toggleable: true,
59
+ native: true,
60
+ rank: 450,
61
+ },
62
+ {
63
+ key: "codex",
64
+ path: join(process.env.DSH_CODEX_HOME || join(homedir(), ".codex"), "skills"),
65
+ label: "Codex",
66
+ mutable: false,
67
+ toggleable: true,
68
+ native: false,
69
+ rank: 520,
70
+ },
71
+ {
72
+ key: "claude",
73
+ path: join(process.env.DSH_CLAUDE_HOME || join(homedir(), ".claude"), "skills"),
74
+ label: "Claude",
75
+ mutable: false,
76
+ toggleable: true,
77
+ native: false,
78
+ rank: 530,
79
+ },
80
+ ];
81
+ }
82
+ export const DEFAULT_SOURCE_KEYS = Object.freeze(["dsh", "hub", OFFICIAL_SOURCE_KEY]);
83
+ export function isDefaultSkillSource(rootOrKey) {
84
+ const key = rootOrKey && typeof rootOrKey === "object"
85
+ ? rootOrKey.key
86
+ : rootOrKey;
87
+ return DEFAULT_SOURCE_KEYS.includes(key);
88
+ }
89
+ export function projectIdentity(path) {
90
+ const canonical = pathIdentity(path);
91
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
92
+ }
93
+ export function customRootKey(path) {
94
+ return "custom-" + projectIdentity(path);
95
+ }
96
+ export async function nearestProjectRoot(cwd) {
97
+ if (typeof cwd !== "string" || !isAbsolute(cwd))
98
+ return null;
99
+ let start;
100
+ try {
101
+ start = await fs.realpath(resolve(cwd));
102
+ if (!(await fs.stat(start)).isDirectory())
103
+ return null;
104
+ }
105
+ catch (error) {
106
+ // 目录不存在(会话还开着、项目已被删)是最常见的一类:带上系统错误码,
107
+ // 调用方对 ENOENT 静默跳过 —— 报警留给真正读不了的目录。
108
+ const code = error && typeof error === "object" && "code" in error
109
+ ? String(error.code)
110
+ : undefined;
111
+ return { unavailable: true, cwd, ...(code ? { code } : {}) };
112
+ }
113
+ let current = start;
114
+ for (;;) {
115
+ try {
116
+ await fs.lstat(join(current, ".git"));
117
+ return { root: current, cwd: start };
118
+ }
119
+ catch {
120
+ /* 继续向上查找最近的项目根 */
121
+ }
122
+ const parent = dirname(current);
123
+ if (parent === current)
124
+ return null;
125
+ current = parent;
126
+ }
127
+ }
128
+ export async function projectSourceSafe(definition) {
129
+ // 只读来源允许目录链接,但重叠来源会绕过用户根的停用策略。
130
+ if (definition.kind === "project-agents")
131
+ return !(await overlapsUserSkillRoot(definition.path));
132
+ const container = join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents");
133
+ for (const path of [container, definition.path]) {
134
+ const st = await lstatOrNull(path);
135
+ if (st && (!st.isDirectory() || st.isSymbolicLink()))
136
+ return false;
137
+ }
138
+ return !(await overlapsUserSkillRoot(definition.path));
139
+ }
140
+ export async function projectRoots(projectCwds = [], diagnostics) {
141
+ const projects = new Map();
142
+ for (const cwd of Array.isArray(projectCwds) ? projectCwds : []) {
143
+ const found = await nearestProjectRoot(cwd);
144
+ if (!found || !found.root) {
145
+ // 目录不存在(ENOENT)= 会话开着、项目已删:这是常态而不是故障,静默跳过。
146
+ // 报警只留给真实读不了的目录(权限、损坏的链接等)。
147
+ if (found && found.unavailable && found.code === "ENOENT")
148
+ continue;
149
+ if (found &&
150
+ found.unavailable &&
151
+ Array.isArray(diagnostics) &&
152
+ typeof cwd === "string" &&
153
+ isAbsolute(cwd)) {
154
+ diagnostics.push({
155
+ code: "warning.project.unavailable",
156
+ params: { path: cwd },
157
+ error: `活动会话的项目目录不可访问,项目技能未显示: ${cwd}`,
158
+ });
159
+ }
160
+ continue;
161
+ }
162
+ const identity = pathIdentity(found.root);
163
+ const existing = projects.get(identity) || {
164
+ root: found.root,
165
+ cwds: new Set(),
166
+ };
167
+ existing.cwds.add(found.cwd);
168
+ projects.set(identity, existing);
169
+ }
170
+ const roots = [];
171
+ for (const project of [...projects.values()].sort((a, b) => a.root.localeCompare(b.root))) {
172
+ const id = projectIdentity(project.root);
173
+ const common = {
174
+ mutable: false,
175
+ // 未知/只读来源(外部 Agent 与自定义绝对路径)一律不可删;用户级 dsh / hub
176
+ // 与本仓库的项目级 .dsh/skills 都是可删的(见上方两处 `deletable: true`)。
177
+ deletable: false,
178
+ toggleable: false,
179
+ native: true,
180
+ scope: "project",
181
+ projectRoot: project.root,
182
+ projectName: basename(project.root) || project.root,
183
+ workspaceCwds: [...project.cwds].sort(),
184
+ };
185
+ const candidates = [
186
+ {
187
+ ...common,
188
+ key: `project-dsh:${id}`,
189
+ kind: "project-dsh",
190
+ localeKey: "projectDsh",
191
+ path: join(project.root, ".dsh", "skills"),
192
+ label: "Project DSH",
193
+ rank: 100,
194
+ mutable: true,
195
+ deletable: true,
196
+ toggleable: true,
197
+ },
198
+ {
199
+ ...common,
200
+ key: `project-agents:${id}`,
201
+ kind: "project-agents",
202
+ localeKey: "projectAgents",
203
+ path: join(project.root, ".agents", "skills"),
204
+ label: "Project Agent",
205
+ rank: 200,
206
+ toggleable: true,
207
+ },
208
+ ];
209
+ for (const candidate of candidates) {
210
+ if (await projectSourceSafe(candidate))
211
+ roots.push(candidate);
212
+ }
213
+ }
214
+ return roots;
215
+ }
216
+ export function managerHomePath() {
217
+ return join(resolveDshHome(), "tool-management");
218
+ }
219
+ export function managerStatePath() {
220
+ return join(managerHomePath(), "skills-state.json");
221
+ }
222
+ export function trashRootPath() {
223
+ return join(managerHomePath(), "trash", "skills-trash");
224
+ }
225
+ export function dshRootPath() {
226
+ // userRoots() 恒含 dsh 来源(见上方列表),断言只为让类型收敛。
227
+ return userRoots().find((root) => root.key === "dsh").path;
228
+ }
229
+ export function skillCreateRootPath() {
230
+ const hub = userRoots().find((root) => root.key === "hub");
231
+ return hub && hub.path ? hub.path : dshRootPath();
232
+ }
233
+ export function readonlyError(action) {
234
+ return {
235
+ ok: false,
236
+ code: "error.root.readonly",
237
+ params: { action },
238
+ error: action === "delete"
239
+ ? "该技能来源不允许删除"
240
+ : "该技能来源不允许启用或停用",
241
+ };
242
+ }
243
+ export function rootDefinition(root) {
244
+ if (root && typeof root === "object" && typeof root.key === "string")
245
+ return root;
246
+ if (typeof root !== "string")
247
+ return null;
248
+ const resolved = resolve(root);
249
+ return userRoots().find((item) => resolve(item.path) === resolved) || null;
250
+ }
251
+ export function rootByKey(key) {
252
+ return userRoots().find((item) => item.key === key) || null;
253
+ }
254
+ export async function resolvedPath(path) {
255
+ try {
256
+ return await fs.realpath(path);
257
+ }
258
+ catch {
259
+ return resolve(path);
260
+ }
261
+ }
262
+ export async function comparisonPath(path) {
263
+ let current = resolve(path);
264
+ const missing = [];
265
+ for (;;) {
266
+ try {
267
+ return resolve(await fs.realpath(current), ...missing);
268
+ }
269
+ catch {
270
+ const parent = dirname(current);
271
+ if (parent === current)
272
+ return resolve(path);
273
+ missing.unshift(basename(current));
274
+ current = parent;
275
+ }
276
+ }
277
+ }
278
+ export async function overlapsUserSkillRoot(path) {
279
+ const candidate = await comparisonPath(path);
280
+ for (const root of userRoots()) {
281
+ const userPath = await comparisonPath(root.path);
282
+ if (isSameOrDescendant(candidate, userPath) ||
283
+ isSameOrDescendant(userPath, candidate))
284
+ return true;
285
+ }
286
+ return false;
287
+ }
@@ -0,0 +1,265 @@
1
+ // 技能回收站:可写根目录的判定、把条目移进/移出回收站的落盘动作,与回收站清单的读写。
2
+ //
3
+ // 声明从 skills/core.ts 按行号区间搬来,正文一字未改(唯一的容差是 `--export-top-level`
4
+ // 加的 `export ` 前缀,moved-verify 逐字对拍)。
5
+ //
6
+ // 与 ./system-trash.ts 不是一回事:那一层管的是**操作系统**回收站(把删掉的东西交给
7
+ // explorer / Finder / gio),这一层管的是本插件自己的 `~/.dsh/tool-management/trash/`。
8
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
9
+ import { promises as fs } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
+ import { isSameOrDescendant } from "../paths.js";
12
+ import { codedError, entryPath, lstatOrNull, pathIdentity, renameWithRetry, } from "./core-util.js";
13
+ import { dshRootPath, overlapsUserSkillRoot, projectIdentity, projectRoots, readonlyError, rootByKey, rootDefinition, skillCreateRootPath, trashRootPath, } from "./skill-roots.js";
14
+ export function writableRootDefinition(root) {
15
+ const definition = rootDefinition(root);
16
+ if (!definition || definition.mutable !== true)
17
+ return null;
18
+ if (definition.key === "dsh")
19
+ return resolve(definition.path) === resolve(dshRootPath())
20
+ ? rootByKey("dsh")
21
+ : null;
22
+ // v0.4:hub 内技能目录同为用户级可写根(插件新建/导入的落点)。
23
+ if (definition.key === "hub")
24
+ return resolve(definition.path) === resolve(skillCreateRootPath())
25
+ ? rootByKey("hub")
26
+ : null;
27
+ if (definition.scope !== "project" ||
28
+ definition.kind !== "project-dsh" ||
29
+ typeof definition.projectRoot !== "string" ||
30
+ !isAbsolute(definition.projectRoot) ||
31
+ definition.key !==
32
+ `project-dsh:${projectIdentity(definition.projectRoot)}` ||
33
+ resolve(definition.path) !==
34
+ resolve(join(definition.projectRoot, ".dsh", "skills")))
35
+ return null;
36
+ return definition;
37
+ }
38
+ export async function checkedWritableRootDefinition(root) {
39
+ const definition = writableRootDefinition(root);
40
+ if (!definition || definition.scope !== "project")
41
+ return definition;
42
+ if (await overlapsUserSkillRoot(definition.path)) {
43
+ return {
44
+ ok: false,
45
+ error: `项目技能目录与用户技能目录重叠,拒绝写入: ${definition.path}`,
46
+ code: "error.root.unsafe",
47
+ params: { path: definition.path },
48
+ };
49
+ }
50
+ // 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
51
+ // definition.scope === "project" 时 projectRoot 必有值(见 ProjectSource),断言只为让类型收敛。
52
+ for (const path of [
53
+ join(definition.projectRoot, ".dsh"),
54
+ definition.path,
55
+ ]) {
56
+ const st = await lstatOrNull(path);
57
+ if (st && (!st.isDirectory() || st.isSymbolicLink())) {
58
+ return {
59
+ ok: false,
60
+ error: `项目技能目录不安全,拒绝写入: ${path}`,
61
+ code: "error.root.unsafe",
62
+ params: { path },
63
+ };
64
+ }
65
+ }
66
+ return definition;
67
+ }
68
+ export async function removeMovedPath(path) {
69
+ const st = await lstatOrNull(path);
70
+ if (!st)
71
+ return;
72
+ if (st.isDirectory() && !st.isSymbolicLink())
73
+ await fs.rm(path, { recursive: true, force: false });
74
+ else
75
+ await fs.unlink(path);
76
+ }
77
+ export async function movePathWithFallback(source, destination, options = {}) {
78
+ try {
79
+ await renameWithRetry(source, destination, options);
80
+ return { copied: false, cleanupError: null };
81
+ }
82
+ catch (error) {
83
+ // 系统异常按可选 code 读取,保持既有 EXDEV 判定。
84
+ if (!error || error.code !== "EXDEV")
85
+ throw error;
86
+ }
87
+ const quarantine = join(dirname(source), `.${basename(source)}.dssm-move-${randomUUID()}`);
88
+ try {
89
+ await fs.cp(source, destination, {
90
+ recursive: true,
91
+ dereference: false,
92
+ errorOnExist: true,
93
+ force: false,
94
+ verbatimSymlinks: true,
95
+ });
96
+ // 源与 quarantine 位于同一目录;成功后原技能名立即消失,避免递归删除留下半份可见条目。
97
+ await renameWithRetry(source, quarantine, options);
98
+ }
99
+ catch (error) {
100
+ await removeMovedPath(destination).catch(() => undefined);
101
+ throw error;
102
+ }
103
+ let cleanupError = null;
104
+ try {
105
+ await removeMovedPath(quarantine);
106
+ }
107
+ catch (error) {
108
+ // 调用方按可选 message 读取该清理异常,保持既有告警文案。
109
+ cleanupError = error;
110
+ }
111
+ return { copied: true, cleanupError, quarantine };
112
+ }
113
+ export async function safeExistingEntryPaths(root, name) {
114
+ const paths = [];
115
+ const bundle = entryPath(root, name);
116
+ if (bundle === null)
117
+ return paths;
118
+ const flat = resolve(root, `${name}.md`);
119
+ const bundleStat = await lstatOrNull(bundle);
120
+ if (bundleStat && (bundleStat.isDirectory() || bundleStat.isSymbolicLink()))
121
+ paths.push({ path: bundle, fileName: name, recursive: true });
122
+ const flatStat = await lstatOrNull(flat);
123
+ if (flatStat && (flatStat.isFile() || flatStat.isSymbolicLink()))
124
+ paths.push({ path: flat, fileName: `${name}.md`, recursive: false });
125
+ return paths;
126
+ }
127
+ export async function readTrashMetadata(id) {
128
+ if (entryPath(trashRootPath(), id) === null)
129
+ return null;
130
+ try {
131
+ const value = JSON.parse(await fs.readFile(join(trashRootPath(), id, "metadata.json"), "utf8"));
132
+ if (!value ||
133
+ value.id !== id ||
134
+ typeof value.name !== "string" ||
135
+ !Array.isArray(value.entries))
136
+ return null;
137
+ return value;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ export function trashRootMetadata(definition) {
144
+ if (definition.scope !== "project")
145
+ return { key: definition.key, scope: "user", label: definition.label };
146
+ return {
147
+ key: definition.key,
148
+ scope: "project",
149
+ kind: "project-dsh",
150
+ projectRoot: definition.projectRoot,
151
+ projectName: definition.projectName,
152
+ label: definition.label,
153
+ };
154
+ }
155
+ export async function restoreRootDefinition(metadata, options = {}) {
156
+ // version 1 entries predate scoped Trash and always belong to $DSH_HOME/skills.
157
+ if (!metadata.root)
158
+ return rootByKey("dsh");
159
+ // 用户级来源按 key 重新解析:路径来自 userRoots(),不信元数据里记住的 path
160
+ // (DSH_HOME 换过之后,旧路径可能已经不在读取范围内了)。
161
+ if (metadata.root.scope === "user")
162
+ return rootByKey(metadata.root.key);
163
+ if (metadata.root.scope !== "project" ||
164
+ metadata.root.kind !== "project-dsh" ||
165
+ typeof metadata.root.key !== "string" ||
166
+ typeof metadata.root.projectRoot !== "string" ||
167
+ !isAbsolute(metadata.root.projectRoot))
168
+ return {
169
+ ok: false,
170
+ error: `回收站条目来源非法: ${metadata.id}`,
171
+ code: "error.trash.invalid",
172
+ params: { id: metadata.id },
173
+ };
174
+ const roots = await projectRoots(options.projectCwds);
175
+ const normalizedProjectRoot = pathIdentity(metadata.root.projectRoot);
176
+ const root = roots.find((item) => item.key === metadata.root.key &&
177
+ item.kind === "project-dsh" &&
178
+ pathIdentity(item.projectRoot) === normalizedProjectRoot);
179
+ if (!root) {
180
+ return {
181
+ ok: false,
182
+ error: `原项目当前不在活动工作区中,无法恢复: ${metadata.root.projectRoot}`,
183
+ code: "error.trash.projectUnavailable",
184
+ params: { path: metadata.root.projectRoot },
185
+ };
186
+ }
187
+ return checkedWritableRootDefinition(root);
188
+ }
189
+ export async function listTrash() {
190
+ let items;
191
+ try {
192
+ items = await fs.readdir(trashRootPath(), { withFileTypes: true });
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ const result = [];
198
+ for (const item of items) {
199
+ if (!item.isDirectory() || item.name.startsWith("."))
200
+ continue;
201
+ const metadata = await readTrashMetadata(item.name);
202
+ if (metadata)
203
+ result.push(metadata);
204
+ }
205
+ return result.sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)));
206
+ }
207
+ export async function restoreTrash(id, log, options = {}) {
208
+ const metadata = await readTrashMetadata(id);
209
+ if (!metadata)
210
+ return {
211
+ ok: false,
212
+ error: `回收站条目不存在: ${id}`,
213
+ code: "error.trash.notFound",
214
+ params: { id },
215
+ };
216
+ const definition = await restoreRootDefinition(metadata, options);
217
+ if (!definition || definition.ok === false)
218
+ return definition || readonlyError("restore");
219
+ const root = definition.path;
220
+ const conflicts = await safeExistingEntryPaths(root, metadata.name);
221
+ if (conflicts.length)
222
+ return {
223
+ ok: false,
224
+ error: `无法恢复,同名技能已存在: ${metadata.name}`,
225
+ code: "error.trash.conflict",
226
+ params: { name: metadata.name },
227
+ };
228
+ await fs.mkdir(root, { recursive: true });
229
+ const itemRoot = join(trashRootPath(), id);
230
+ const moved = [];
231
+ try {
232
+ for (const fileName of metadata.entries) {
233
+ const source = join(itemRoot, fileName);
234
+ const destination = join(root, fileName);
235
+ if (!isSameOrDescendant(itemRoot, source) ||
236
+ !isSameOrDescendant(root, destination))
237
+ throw codedError("回收站条目路径非法", "error.trash.invalid", { id });
238
+ await movePathWithFallback(source, destination, options.renameOptions);
239
+ moved.push({ source, destination });
240
+ }
241
+ await fs.rm(itemRoot, { recursive: true, force: true });
242
+ if (log)
243
+ log("restore", `从回收站恢复 ${metadata.name} -> ${root}`);
244
+ return { id, name: metadata.name, root: trashRootMetadata(definition) };
245
+ }
246
+ catch (error) {
247
+ for (const item of moved.reverse())
248
+ await movePathWithFallback(item.destination, item.source, options.renameOptions).catch(() => undefined);
249
+ throw error;
250
+ }
251
+ }
252
+ export async function permanentlyDeleteTrash(id, log) {
253
+ const metadata = await readTrashMetadata(id);
254
+ if (!metadata)
255
+ return {
256
+ ok: false,
257
+ error: `回收站条目不存在: ${id}`,
258
+ code: "error.trash.notFound",
259
+ params: { id },
260
+ };
261
+ await fs.rm(join(trashRootPath(), id), { recursive: true, force: true });
262
+ if (log)
263
+ log("trash-delete", `永久删除回收站条目 ${metadata.name} (${id})`);
264
+ return { id, name: metadata.name };
265
+ }
@@ -0,0 +1,97 @@
1
+ // 系统回收站:永久删除前的最后一道保险(Win 走 PowerShell、macOS 移进 ~/.Trash、Linux 用 gio)。
2
+ //
3
+ // 从 skills/service.ts 整段搬来,一行未改。这一层只跟外部进程打交道,不认识技能也不碰状态;
4
+ // 外部命令一律带超时上限,失败返回 false 而不是抛出 —— 调用方据此决定要不要继续真删。
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { rename } from 'node:fs/promises';
8
+ import { homedir } from 'node:os';
9
+ import { basename, join } from 'node:path';
10
+ import { isSameOrDescendant, isValidSegment } from '../paths.js';
11
+ import { permanentlyDeleteTrash, trashRootPath } from './core.js';
12
+ // ── 系统回收站(永久删除的最后一道保险) ──────────────────────────────────
13
+ /**
14
+ * 外部进程(PowerShell / gio)的超时上限。这些调用在技能的**写队列**里被 await
15
+ * (skill-trash-delete),进程一旦挂起,该域全部写操作会永久停摆且无任何提示 ——
16
+ * 回收站 API 在文件被占用、系统弹窗等情况下确实会挂住。超时后杀掉进程并返回 false,
17
+ * 让调用方按既有的「回收站失败 → 硬删除」兜底走下去,队列得以继续。
18
+ */
19
+ export const EXTERNAL_CMD_TIMEOUT_MS = 30_000;
20
+ export function runQuietly(command, args) {
21
+ return new Promise((resolve) => {
22
+ let settled = false;
23
+ let timer = null;
24
+ const finish = (ok) => {
25
+ if (settled)
26
+ return;
27
+ settled = true;
28
+ if (timer) {
29
+ clearTimeout(timer);
30
+ timer = null;
31
+ }
32
+ resolve(ok);
33
+ };
34
+ let child;
35
+ try {
36
+ child = spawn(command, args, { stdio: 'ignore', windowsHide: true });
37
+ }
38
+ catch {
39
+ finish(false);
40
+ return;
41
+ }
42
+ timer = setTimeout(() => {
43
+ try {
44
+ child.kill('SIGKILL');
45
+ }
46
+ catch { /* 进程可能已自行退出 */ }
47
+ finish(false);
48
+ }, EXTERNAL_CMD_TIMEOUT_MS);
49
+ child.on('error', () => finish(false));
50
+ child.on('close', (code) => finish(code === 0));
51
+ });
52
+ }
53
+ export function psQuote(value) {
54
+ return "'" + String(value).replace(/'/g, "''") + "'";
55
+ }
56
+ /** Win → 回收站;macOS → ~/.Trash;Linux → gio trash。失败返回 false。 */
57
+ export async function moveToSystemTrash(abs) {
58
+ try {
59
+ if (process.platform === 'win32') {
60
+ const script = [
61
+ 'Add-Type -AssemblyName Microsoft.VisualBasic',
62
+ "[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(" + psQuote(abs) + ", 'OnlyErrorDialogs', 'SendToRecycleBin')",
63
+ ].join('; ');
64
+ return await runQuietly('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
65
+ }
66
+ if (process.platform === 'darwin') {
67
+ await rename(abs, join(homedir(), '.Trash', basename(abs) + '-' + Date.now()));
68
+ return true;
69
+ }
70
+ return await runQuietly('gio', ['trash', abs]);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * 永久删除回收站条目:先尝试系统回收站(用户仍可在系统里找回),
78
+ * 失败才落到 core 的硬删除 —— 让「永久删除」也不会静默丢数据。
79
+ */
80
+ export async function permanentlyDeleteTrashSafely(id, log) {
81
+ const clean = String(id || '').trim();
82
+ const root = trashRootPath();
83
+ // 走 `paths.ts` 的单段名校验,而不是自写白名单(审查 F1,0.17.0):
84
+ // 原判据 `/^[A-Za-z0-9._-]{1,128}$/` 放行 `.` 与 `..`,而 `root + sep + '..'` 解析后
85
+ // 正是**回收站根目录**,末尾那句字符串前缀比对拦不住它 —— 于是「删掉某一条」变成
86
+ // 「把整个 `trash/`(技能/场景/子智能体/提示词四类)送进系统回收站」,且返回 `ok:true`。
87
+ // 换成 `isValidSegment` 还让两条路同源:能通过这里的 id,必定也能通过
88
+ // `permanentlyDeleteTrash` 内部的 `entryPath`(同一个谓词),不会一个放行、一个拒绝。
89
+ const target = isValidSegment(clean) ? join(root, clean) : null;
90
+ if (target && isSameOrDescendant(root, target) && existsSync(target)) {
91
+ if (await moveToSystemTrash(target)) {
92
+ await log('trash-delete-system', { id: clean, path: target });
93
+ return { id: clean, method: 'system-trash' };
94
+ }
95
+ }
96
+ return permanentlyDeleteTrash(clean, log);
97
+ }