dsh-plugin-tool-management 0.10.0 → 0.11.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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  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 +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -12,11 +12,12 @@
12
12
  // 系统异常(fs ENOENT 等)透传 String(e.message),不加 code。
13
13
  // 文件写入错误由路由返回给调用方。
14
14
  import { homedir } from "node:os";
15
- import { join, basename, dirname, resolve, relative, isAbsolute, sep, } from "node:path";
15
+ import { join, basename, dirname, resolve, isAbsolute, } from "node:path";
16
16
  import { promises as fs } from "node:fs";
17
17
  import { createHash, randomUUID } from "node:crypto";
18
18
  import { unzipSync } from "fflate";
19
19
  import { discoverReadonlyEntries, validDiscoveryName, } from "./readonly-discovery.js";
20
+ import { isSameOrDescendant, isValidSegment } from "../paths.js";
20
21
  const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
22
  const PROJECT_ROOT_KEY_RE = /^project-(?:dsh|agents):[a-f0-9]{16}$/;
22
23
  const CUSTOM_ROOT_KEY_RE = /^custom-[a-f0-9]{16}$/;
@@ -41,10 +42,12 @@ function codedError(message, code, params) {
41
42
  }
42
43
  /** 把业务 Error 的 code/params 附加到失败明细;系统异常(ENOENT 等,非 error.* 前缀)保持原文。 */
43
44
  function attachCode(item, error) {
44
- if (error && typeof error.code === "string" && /^error\./.test(error.code))
45
- item.code = error.code;
46
- if (error && error.params)
47
- item.params = error.params;
45
+ // 透传并回写任意失败明细对象(形状由各调用点决定),故按 any 处理。
46
+ const coded = error;
47
+ if (coded && typeof coded.code === "string" && /^error\./.test(coded.code))
48
+ item.code = coded.code;
49
+ if (coded && coded.params)
50
+ item.params = coded.params;
48
51
  return item;
49
52
  }
50
53
  // ── 路径解析 ────────────────────────────────────────────────────────────────
@@ -139,7 +142,9 @@ export function userRoots() {
139
142
  const DEFAULT_SOURCE_KEYS = Object.freeze(["dsh", "hub"]);
140
143
  /** 是否为默认来源。接受 root 对象或 key 字符串;`dsh` / `hub` 的所有特判都走这里。 */
141
144
  export function isDefaultSkillSource(rootOrKey) {
142
- const key = rootOrKey && typeof rootOrKey === "object" ? rootOrKey.key : rootOrKey;
145
+ const key = rootOrKey && typeof rootOrKey === "object"
146
+ ? rootOrKey.key
147
+ : rootOrKey;
143
148
  return DEFAULT_SOURCE_KEYS.includes(key);
144
149
  }
145
150
  function projectIdentity(path) {
@@ -159,9 +164,8 @@ export function customRootKey(path) {
159
164
  }
160
165
  /** 从状态值解析自定义来源定义;非法条目静默丢弃,key 与 path 不匹配的不信任。 */
161
166
  export function customRootsFromState(stateValue) {
162
- const list = stateValue && Array.isArray(stateValue.customRoots)
163
- ? stateValue.customRoots
164
- : [];
167
+ const state = stateValue;
168
+ const list = state && Array.isArray(state.customRoots) ? state.customRoots : [];
165
169
  const out = [];
166
170
  for (const item of list) {
167
171
  if (!item || typeof item !== "object")
@@ -443,6 +447,7 @@ export async function browseDirectories(inputPath) {
443
447
  directory = await fs.stat(canonical);
444
448
  }
445
449
  catch (error) {
450
+ // 系统异常按可选 message 读取,保持既有判定分支不变。
446
451
  return {
447
452
  ok: false,
448
453
  error: `无法读取目录: ${target}`,
@@ -487,6 +492,7 @@ export async function browseDirectories(inputPath) {
487
492
  }
488
493
  }
489
494
  catch (error) {
495
+ // 系统异常按可选 message 读取,保持既有判定分支不变。
490
496
  return {
491
497
  ok: false,
492
498
  error: `无法读取目录: ${canonical}`,
@@ -517,6 +523,7 @@ export async function browseDirectories(inputPath) {
517
523
  return { path: canonical, home: homedir(), crumbs, entries, truncated };
518
524
  }
519
525
  function dshRootPath() {
526
+ // userRoots() 恒含 dsh 来源(见上方列表),断言只为让类型收敛。
520
527
  return userRoots().find((root) => root.key === "dsh").path;
521
528
  }
522
529
  /**
@@ -623,7 +630,11 @@ async function checkedWritableRootDefinition(root) {
623
630
  };
624
631
  }
625
632
  // 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
626
- for (const path of [join(definition.projectRoot, ".dsh"), definition.path]) {
633
+ // definition.scope === "project" 时 projectRoot 必有值(见 ProjectSource),断言只为让类型收敛。
634
+ for (const path of [
635
+ join(definition.projectRoot, ".dsh"),
636
+ definition.path,
637
+ ]) {
627
638
  const st = await lstatOrNull(path);
628
639
  if (st && (!st.isDirectory() || st.isSymbolicLink())) {
629
640
  return {
@@ -636,12 +647,6 @@ async function checkedWritableRootDefinition(root) {
636
647
  }
637
648
  return definition;
638
649
  }
639
- /** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
640
- function isSameOrDescendant(parent, child) {
641
- const rel = relative(resolve(parent), resolve(child));
642
- return (rel === "" ||
643
- (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)));
644
- }
645
650
  /** 解析真实路径;中间若有目录链接,按落地目录比较重叠。 */
646
651
  async function resolvedPath(path) {
647
652
  try {
@@ -702,16 +707,9 @@ async function lstatOrNull(path) {
702
707
  }
703
708
  /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
704
709
  export function entryPath(root, name) {
705
- if (typeof name !== "string" ||
706
- name === "" ||
707
- name === "." ||
708
- name === ".." ||
709
- name.startsWith(".") ||
710
- name.length > MAX_ENTRY_NAME_LENGTH ||
711
- /[\\/:*?"<>|\0]/.test(name) ||
712
- /[. ]$/.test(name) ||
713
- WINDOWS_DEVICE_NAME_RE.test(name) ||
714
- basename(name) !== name)
710
+ // 段名谓词收敛到 paths.js(此前与 rules / subagents / imports 各写一套,口径不一)。
711
+ // 原实现里 `basename(name) !== name` 那条已被谓词的「不含路径分隔符」覆盖。
712
+ if (!isValidSegment(name, MAX_ENTRY_NAME_LENGTH))
715
713
  return null;
716
714
  const rootPath = resolve(root);
717
715
  const path = resolve(rootPath, name);
@@ -820,6 +818,7 @@ export function unquote(v) {
820
818
  /** Windows 上杀毒软件或索引器可能短暂占用目录;只重试明确可恢复的 rename 错误。 */
821
819
  export async function renameWithRetry(source, destination, options = {}) {
822
820
  const rename = typeof options.rename === "function" ? options.rename : fs.rename;
821
+ // Number.isInteger/isFinite 不收窄类型,故断言成 number 以保持既有比较表达式。
823
822
  const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0
824
823
  ? options.maxAttempts
825
824
  : 6;
@@ -831,6 +830,7 @@ export async function renameWithRetry(source, destination, options = {}) {
831
830
  return await rename(source, destination);
832
831
  }
833
832
  catch (error) {
833
+ // 系统 rename 异常按可选 code 读取,保持既有重试判定。
834
834
  if (!TRANSIENT_RENAME_CODES.has(error && error.code) ||
835
835
  attempt >= maxAttempts)
836
836
  throw error;
@@ -854,6 +854,7 @@ async function movePathWithFallback(source, destination, options = {}) {
854
854
  return { copied: false, cleanupError: null };
855
855
  }
856
856
  catch (error) {
857
+ // 系统异常按可选 code 读取,保持既有 EXDEV 判定。
857
858
  if (!error || error.code !== "EXDEV")
858
859
  throw error;
859
860
  }
@@ -878,6 +879,7 @@ async function movePathWithFallback(source, destination, options = {}) {
878
879
  await removeMovedPath(quarantine);
879
880
  }
880
881
  catch (error) {
882
+ // 调用方按可选 message 读取该清理异常,保持既有告警文案。
881
883
  cleanupError = error;
882
884
  }
883
885
  return { copied: true, cleanupError, quarantine };
@@ -914,6 +916,7 @@ export async function resolveEntry(root, name) {
914
916
  return null;
915
917
  return ((await discoverReadonlyEntries(definition.path)).entries.find((entry) => entry.name === name) || null);
916
918
  }
919
+ // 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
917
920
  root = definition ? definition.path : root;
918
921
  try {
919
922
  const bundlePath = entryPath(root, name);
@@ -1020,6 +1023,7 @@ export async function scanEntries(root, options = {}) {
1020
1023
  const definition = rootDefinition(root);
1021
1024
  if (definition && !definition.mutable) {
1022
1025
  const discovered = await discoverReadonlyEntries(definition.path);
1026
+ // metadataOnly 时条目的摘要字段由调用方按需读取(见 visibleEntryForRoot)。
1023
1027
  if (options.metadataOnly)
1024
1028
  return discovered;
1025
1029
  const entries = [];
@@ -1036,6 +1040,7 @@ export async function scanEntries(root, options = {}) {
1036
1040
  }
1037
1041
  return { ...discovered, entries };
1038
1042
  }
1043
+ // 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
1039
1044
  root = definition ? definition.path : root;
1040
1045
  const rootStat = await lstatOrNull(resolve(root));
1041
1046
  if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
@@ -1104,7 +1109,10 @@ export async function scanEntries(root, options = {}) {
1104
1109
  }
1105
1110
  /** 同一真实技能优先归属直接 SSOT;同类条目再按来源 rank 决胜。 */
1106
1111
  async function scanDeduplicatedRoots(roots = userRoots(), options = {}) {
1107
- const results = await Promise.all(roots.map(async (root) => [root, await scanEntries(root, options)]));
1112
+ const results = await Promise.all(roots.map(async (root) => [
1113
+ root,
1114
+ await scanEntries(root, options),
1115
+ ]));
1108
1116
  const scans = new Map();
1109
1117
  const groups = new Map();
1110
1118
  for (const [root, scan] of results) {
@@ -1343,9 +1351,8 @@ function normalizeManagerState(value) {
1343
1351
  * 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
1344
1352
  */
1345
1353
  export function activeUserRoots(stateValue) {
1346
- const removed = new Set(stateValue && Array.isArray(stateValue.removedSources)
1347
- ? stateValue.removedSources
1348
- : []);
1354
+ const state = stateValue;
1355
+ const removed = new Set(state && Array.isArray(state.removedSources) ? state.removedSources : []);
1349
1356
  return userRoots().filter((root) => !removed.has(root.key));
1350
1357
  }
1351
1358
  /** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
@@ -1420,6 +1427,7 @@ export async function readManagerState() {
1420
1427
  };
1421
1428
  }
1422
1429
  catch (error) {
1430
+ // 系统异常按可选 code 读取,保持既有 ENOENT 判定。
1423
1431
  if (error && error.code === "ENOENT")
1424
1432
  return { state: defaultManagerState(), warning: null, writable: true };
1425
1433
  return {
@@ -1579,6 +1587,7 @@ async function checkedPolicyRootDefinition(root) {
1579
1587
  resolve(join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents", "skills"));
1580
1588
  if (!validProjectRoot)
1581
1589
  return null;
1590
+ // validProjectRoot 已校验 projectRoot 为字符串,断言只为让类型收敛。
1582
1591
  if (!(await projectSourceSafe(definition))) {
1583
1592
  return {
1584
1593
  ok: false,
@@ -1779,12 +1788,13 @@ async function readTrashMetadata(id) {
1779
1788
  * 在短重试窗口后仍返回 EPERM。此时保留 stage 作为唯一可回滚副本,逐项复制到最终目录,
1780
1789
  * 并最后写 metadata:listTrash() 在复制完整前不会暴露半成品。
1781
1790
  */
1782
- async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
1791
+ async function publishTrashStage(stage, finalPath, metadata, renameOptions = {}) {
1783
1792
  try {
1784
1793
  await renameWithRetry(stage, finalPath, renameOptions);
1785
1794
  return { fallback: false, cleanupError: null };
1786
1795
  }
1787
1796
  catch (error) {
1797
+ // 系统异常按可选 code 读取,保持既有可恢复码判定。
1788
1798
  if (!TRANSIENT_RENAME_CODES.has(error && error.code))
1789
1799
  throw error;
1790
1800
  }
@@ -1811,6 +1821,7 @@ async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
1811
1821
  await fs.rm(stage, { recursive: true, force: true });
1812
1822
  }
1813
1823
  catch (error) {
1824
+ // 调用方按可选 message 读取该清理异常,保持既有告警文案。
1814
1825
  cleanupError = error;
1815
1826
  }
1816
1827
  return { fallback: true, cleanupError };
@@ -1919,12 +1930,14 @@ export async function deleteSkill(root, name, log, options = {}) {
1919
1930
  return { id, name, deletedAt: metadata.deletedAt, root: metadata.root };
1920
1931
  }
1921
1932
  catch (error) {
1933
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
1922
1934
  const rollbackFailures = [];
1923
1935
  for (const item of moved.reverse()) {
1924
1936
  try {
1925
1937
  await movePathWithFallback(item.destination, item.path, options.renameOptions);
1926
1938
  }
1927
1939
  catch (rollbackError) {
1940
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
1928
1941
  rollbackFailures.push({
1929
1942
  path: item.destination,
1930
1943
  error: String(rollbackError && rollbackError.message
@@ -2128,8 +2141,11 @@ async function collectCandidates(dir) {
2128
2141
  }
2129
2142
  /** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
2130
2143
  async function assertNoSymbolicLinks(source) {
2131
- const pending = [{ path: source, depth: 0 }];
2144
+ const pending = [
2145
+ { path: source, depth: 0 },
2146
+ ];
2132
2147
  while (pending.length) {
2148
+ // while 条件保证栈非空,断言只为让类型收敛。
2133
2149
  const current = pending.pop();
2134
2150
  if (current.depth > MAX_SOURCE_DEPTH)
2135
2151
  throw codedError(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`, "error.source.tooDeep", { depth: MAX_SOURCE_DEPTH, path: source });
@@ -2161,6 +2177,7 @@ async function preflightCandidates(pending, conflicts, failed) {
2161
2177
  await assertNoSymbolicLinks(candidate.source);
2162
2178
  }
2163
2179
  catch (error) {
2180
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2164
2181
  failed.push(attachCode({
2165
2182
  source: candidate.source,
2166
2183
  error: String(error && error.message ? error.message : error),
@@ -2191,20 +2208,26 @@ async function copyToTemporary(source, target, isDir) {
2191
2208
  async function replaceWithCopy(source, dest, isDir, existing = []) {
2192
2209
  const stage = await copyToTemporary(source, dest, isDir);
2193
2210
  const backups = [];
2211
+ // 三处 rename 一律走 `renameWithRetry`:瞬时占用(Windows 杀软 / 索引器 / 资源管理器)返回的
2212
+ // EACCES / EBUSY / EPERM 正是它重试的那些码。此前只有 `:959` 的回滚用了带重试的版本,
2213
+ // 而**发布与它的补偿回滚用的是裸 rename** —— 发布因瞬时锁抛错时,紧随其后的回滚在同一刻、
2214
+ // 同一目录上大概率撞同一个句柄,于是留下"目标已空、真值还叫 backup"的半态(无自动恢复路径)。
2215
+ // 保护覆盖不均不是取舍,是漏。
2194
2216
  try {
2195
2217
  for (const path of existing) {
2196
2218
  const backup = temporaryPath(path, "backup");
2197
- await fs.rename(path, backup);
2219
+ await renameWithRetry(path, backup);
2198
2220
  backups.push({ path, backup });
2199
2221
  }
2200
- await fs.rename(stage, dest);
2222
+ await renameWithRetry(stage, dest);
2201
2223
  }
2202
2224
  catch (error) {
2225
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
2203
2226
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
2204
2227
  const rollbackFailures = [];
2205
2228
  for (const item of backups.reverse()) {
2206
2229
  try {
2207
- await fs.rename(item.backup, item.path);
2230
+ await renameWithRetry(item.backup, item.path);
2208
2231
  }
2209
2232
  catch (rollbackError) {
2210
2233
  rollbackFailures.push(item.backup);
@@ -2222,6 +2245,7 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
2222
2245
  await fs.rm(item.backup, { recursive: true, force: true });
2223
2246
  }
2224
2247
  catch (error) {
2248
+ // 系统异常按可选 message 读取,保持既有告警文案。
2225
2249
  warnings.push({
2226
2250
  code: "warning.backupUncleaned",
2227
2251
  params: {
@@ -2273,6 +2297,7 @@ export async function importSkill(source, log, options = {}) {
2273
2297
  candidates = await collectCandidates(source);
2274
2298
  }
2275
2299
  catch (error) {
2300
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2276
2301
  return attachCode({
2277
2302
  ok: false,
2278
2303
  error: String(error && error.message ? error.message : error),
@@ -2322,6 +2347,7 @@ export async function importSkill(source, log, options = {}) {
2322
2347
  });
2323
2348
  continue;
2324
2349
  }
2350
+ // 上方计数循环已为该 kebab 记数(合法名称且目标可写),断言只为让类型收敛。
2325
2351
  if (nameCount.get(c.kebab) > 1) {
2326
2352
  failed.push({
2327
2353
  source: c.source,
@@ -2378,6 +2404,7 @@ export async function importSkill(source, log, options = {}) {
2378
2404
  log("import", `导入 ${p.source} -> ${p.dest}`);
2379
2405
  }
2380
2406
  catch (e) {
2407
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2381
2408
  failed.push(attachCode({ source: p.source, error: String(e && e.message ? e.message : e) }, e));
2382
2409
  }
2383
2410
  }
@@ -2393,6 +2420,7 @@ export async function importSkill(source, log, options = {}) {
2393
2420
  log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
2394
2421
  }
2395
2422
  catch (e) {
2423
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2396
2424
  failed.push(attachCode({ source: c.source, error: String(e && e.message ? e.message : e) }, e));
2397
2425
  }
2398
2426
  }
@@ -2468,9 +2496,11 @@ function decodeUploadBase64(value, maxBytes, code = "error.upload.tooLarge") {
2468
2496
  return bytes;
2469
2497
  }
2470
2498
  function uploadError(error) {
2499
+ // 按可选 message 读取抛出值,保持既有兜底文案。
2500
+ const cause = error;
2471
2501
  return attachCode({
2472
2502
  ok: false,
2473
- error: String(error && error.message ? error.message : error),
2503
+ error: String(cause && cause.message ? cause.message : error),
2474
2504
  }, error);
2475
2505
  }
2476
2506
  async function writeUploadedEntries(contentRoot, entries) {
@@ -2521,6 +2551,7 @@ async function writeUploadedZip(contentRoot, encoded) {
2521
2551
  });
2522
2552
  }
2523
2553
  catch (error) {
2554
+ // 业务错误(我们抛的 error.* 前缀)透传;其余按 ZIP 解压失败处理。
2524
2555
  if (error && /^error\./.test(String(error.code || "")))
2525
2556
  throw error;
2526
2557
  throw codedError(`ZIP 无法解压: ${String(error && error.message ? error.message : error)}`, "error.upload.zipInvalid");
@@ -2555,7 +2586,7 @@ async function prepareUploadedSource(sessionRoot, input) {
2555
2586
  const batchRoot = join(sessionRoot, "batch");
2556
2587
  const wrappedRoot = join(batchRoot, skillName);
2557
2588
  await fs.mkdir(batchRoot, { recursive: true });
2558
- await fs.rename(contentRoot, wrappedRoot);
2589
+ await renameWithRetry(contentRoot, wrappedRoot);
2559
2590
  return batchRoot;
2560
2591
  }
2561
2592
  /**
@@ -2649,12 +2680,13 @@ export async function createSkill(input, log, options = {}) {
2649
2680
  log("create", `创建 ${flatPath}`);
2650
2681
  return { name, path: flatPath, root: definition.key };
2651
2682
  }
2683
+ // 上方 entryPath(root, name) === null 的守卫已保证目标非空,断言只为让类型收敛。
2652
2684
  const target = entryPath(root, name);
2653
2685
  const stage = temporaryPath(target, "create");
2654
2686
  try {
2655
2687
  await fs.mkdir(stage);
2656
2688
  await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
2657
- await fs.rename(stage, target);
2689
+ await renameWithRetry(stage, target);
2658
2690
  }
2659
2691
  catch (error) {
2660
2692
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
@@ -2723,7 +2755,10 @@ export async function listProviderCandidates(options = {}) {
2723
2755
  const candidates = [];
2724
2756
  const user = activeUserRoots(policyResult.state).concat(customRootsFromState(policyResult.state).filter((root) => !policyResult.state.removedSources.includes(root.key)));
2725
2757
  const userScans = await scanDeduplicatedRoots(user);
2726
- const items = user.flatMap((root) => userScans.get(root.key).entries.map((entry) => ({ root, entry })));
2758
+ const items = user.flatMap((root) => userScans.get(root.key).entries.map((entry) => ({
2759
+ root,
2760
+ entry,
2761
+ })));
2727
2762
  const cwd = options && typeof options.cwd === "string" ? options.cwd : undefined;
2728
2763
  if (cwd) {
2729
2764
  const roots = await projectRoots([cwd]);
@@ -22,7 +22,9 @@ export function validDiscoveryName(name) {
22
22
  /** 只返回文件定位信息,不读取正文,供列表、详情及策略复用同一发现与去重规则。 */
23
23
  export async function discoverReadonlyEntries(root) {
24
24
  const rootPath = resolve(root);
25
- const queue = [{ path: rootPath, depth: 0, ancestors: new Set() }];
25
+ const queue = [
26
+ { path: rootPath, depth: 0, ancestors: new Set() },
27
+ ];
26
28
  const byName = new Map();
27
29
  let directories = 0;
28
30
  let entries = 0;
@@ -50,6 +52,7 @@ export async function discoverReadonlyEntries(root) {
50
52
  docStat = await fs.lstat(docPath);
51
53
  }
52
54
  catch (error) {
55
+ // fs 抛出的错误按 Node 错误形状读取 code(此处只关心 ENOENT)
53
56
  if (error.code !== "ENOENT")
54
57
  throw error;
55
58
  }
@@ -9,6 +9,7 @@ import { Worker } from 'node:worker_threads';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
+ import { isInsideRootResolved } from '../paths.js';
12
13
  import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
14
  const MAX_LOG_BYTES = 1 << 20;
14
15
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
@@ -35,18 +36,44 @@ function makeLog() {
35
36
  };
36
37
  }
37
38
  // ── 系统回收站(永久删除的最后一道保险) ──────────────────────────────────
39
+ /**
40
+ * 外部进程(PowerShell / gio)的超时上限。这些调用在技能的**写队列**里被 await
41
+ * (skill-trash-delete),进程一旦挂起,该域全部写操作会永久停摆且无任何提示 ——
42
+ * 回收站 API 在文件被占用、系统弹窗等情况下确实会挂住。超时后杀掉进程并返回 false,
43
+ * 让调用方按既有的「回收站失败 → 硬删除」兜底走下去,队列得以继续。
44
+ */
45
+ const EXTERNAL_CMD_TIMEOUT_MS = 30_000;
38
46
  function runQuietly(command, args) {
39
47
  return new Promise((resolve) => {
48
+ let settled = false;
49
+ let timer = null;
50
+ const finish = (ok) => {
51
+ if (settled)
52
+ return;
53
+ settled = true;
54
+ if (timer) {
55
+ clearTimeout(timer);
56
+ timer = null;
57
+ }
58
+ resolve(ok);
59
+ };
40
60
  let child;
41
61
  try {
42
62
  child = spawn(command, args, { stdio: 'ignore', windowsHide: true });
43
63
  }
44
64
  catch {
45
- resolve(false);
65
+ finish(false);
46
66
  return;
47
67
  }
48
- child.on('error', () => resolve(false));
49
- child.on('close', (code) => resolve(code === 0));
68
+ timer = setTimeout(() => {
69
+ try {
70
+ child.kill('SIGKILL');
71
+ }
72
+ catch { /* 进程可能已自行退出 */ }
73
+ finish(false);
74
+ }, EXTERNAL_CMD_TIMEOUT_MS);
75
+ child.on('error', () => finish(false));
76
+ child.on('close', (code) => finish(code === 0));
50
77
  });
51
78
  }
52
79
  function psQuote(value) {
@@ -92,7 +119,7 @@ async function permanentlyDeleteTrashSafely(id, log) {
92
119
  // ── 文件监听:外部改动自动失效 ────────────────────────────────────────────
93
120
  /**
94
121
  * 监听目录,200ms 防抖后回调:在编辑器或其他工具里新增/修改/删除文件后,无需手动
95
- * 刷新即可看到变化。技能来源与规则根目录共用(见 src/rules/service.ts 的场景记忆段缓存)。
122
+ * 刷新即可看到变化。技能来源与规则根目录共用(见 src/memories/service.ts 的场景记忆段缓存)。
96
123
  *
97
124
  * 为什么用 worker_threads:Windows 上 fs.watch(recursive) 的句柄在「被监听
98
125
  * 目录被删除」时会静默卡死事件循环(不触发 error、unref 也无效)——宿主
@@ -433,6 +460,36 @@ export function createSkillsService(ctx) {
433
460
  'skill-set-all',
434
461
  'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
435
462
  ]);
463
+ /**
464
+ * 新建 / 导入 / 恢复技能后统一「默认停用」。**失败不能吞。**
465
+ *
466
+ * 这里原先写作 `.catch(() => undefined)`,两处都漏:一是抛出的写失败被吞,二是
467
+ * `markSkillsDisabled` 在状态文件不可读时**不抛**、而是返回 `{ ok: false }` —— 那个
468
+ * 返回值此前根本没被看过。两种情况下技能都停在启用态、下一轮就进模型上下文,而用户
469
+ * 看到的是「创建成功」。
470
+ *
471
+ * 沿用 `sceneSyncError` 的形状:这里只给**原因原文**,句子由界面按当前语言拼
472
+ * (服务端拼中文会让英文界面露出中文)。
473
+ */
474
+ async function disableNewSkills(targets, res) {
475
+ if (!targets.length)
476
+ return res;
477
+ let reason = null;
478
+ try {
479
+ const out = await markSkillsDisabled(targets, log);
480
+ if (out && out.ok === false)
481
+ reason = String(out.error || '状态文件不可读');
482
+ }
483
+ catch (e) {
484
+ reason = message(e);
485
+ }
486
+ if (reason === null)
487
+ return res;
488
+ // 本来就已经失败的回执:把原因并进 error,不要用 warning 把失败说成「成功但有提示」。
489
+ return res && res.ok === false
490
+ ? { ...res, error: `${res.error};另外,「默认停用」也没能写入(${reason})` }
491
+ : { ...res, stateSyncError: reason };
492
+ }
436
493
  const ops = {
437
494
  // 读操作(state 走短 TTL 缓存,见上方 readState)
438
495
  'skill-state': wrap(() => readState()),
@@ -488,7 +545,7 @@ export function createSkillsService(ctx) {
488
545
  const res = await createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
489
546
  // v0.8.5:新建技能默认不启动(用户裁定)——与子智能体 / MCP 同口径。
490
547
  if (res && res.ok !== false && res.name) {
491
- await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
548
+ return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
492
549
  }
493
550
  return res;
494
551
  }), afterWrite),
@@ -499,9 +556,7 @@ export function createSkillsService(ctx) {
499
556
  });
500
557
  // 导入的技能默认停用;部分成功也对 imported 逐条停用。
501
558
  const names = res && Array.isArray(res.imported) ? res.imported : [];
502
- if (names.length)
503
- await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
504
- return res;
559
+ return disableNewSkills(names.map((n) => ({ root: 'hub', name: String(n) })), res);
505
560
  }), afterWrite),
506
561
  'skill-upload': wrap((args) => write(async () => {
507
562
  const res = await importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
@@ -509,16 +564,14 @@ export function createSkillsService(ctx) {
509
564
  });
510
565
  // 上传/导入同口径:默认停用。
511
566
  const names = res && Array.isArray(res.imported) ? res.imported : (res && res.name ? [res.name] : []);
512
- if (names.length)
513
- await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
514
- return res;
567
+ return disableNewSkills(names.map((n) => ({ root: 'hub', name: String(n) })), res);
515
568
  }), afterWrite),
516
569
  'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
517
570
  'skill-trash-restore': wrap((args) => write(async () => {
518
571
  const res = await restoreTrash(String(args.id || ''), log, projectOptions());
519
572
  // 恢复的技能默认停用(v0.8.5 用户裁定:新建/导入/恢复一律不启动,手动开启)。
520
573
  if (res && res.ok !== false && res.name) {
521
- await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
574
+ return disableNewSkills([{ root: String(res.root || 'hub'), name: String(res.name) }], res);
522
575
  }
523
576
  return res;
524
577
  }), afterWrite),
@@ -527,5 +580,37 @@ export function createSkillsService(ctx) {
527
580
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
528
581
  'skill-custom-remove': wrap((args) => write(() => removeCustomRoot(args && args.key, log)), afterWrite),
529
582
  };
530
- return { ops, writeOps, registerProviders };
583
+ /**
584
+ * 该绝对路径是否落在「本插件认识的技能来源」内。
585
+ *
586
+ * 为什么需要它:`skill-open` 会把路径交给**系统默认编辑器**,不设边界的话它就是一个
587
+ * 「任意绝对路径 → 启动外部程序」的入口。三类来源都要收进来,否则会把合法技能误挡在外:
588
+ * 静态根(`userRoots`)+ 状态文件里的自定义根 + 当前活动工作区的项目来源。
589
+ */
590
+ async function isInsideKnownSkillRoot(abs) {
591
+ const dirs = roots.map((r) => String((r && r.path) || '')).filter(Boolean);
592
+ try {
593
+ for (const r of customRootsFromState((await readManagerState()).state)) {
594
+ if (r && r.path)
595
+ dirs.push(String(r.path));
596
+ }
597
+ }
598
+ catch { /* 状态不可读 → 只用静态根 */ }
599
+ try {
600
+ for (const r of await projectRoots(projectOptions().projectCwds)) {
601
+ if (r && r.path)
602
+ dirs.push(String(r.path));
603
+ }
604
+ }
605
+ catch { /* 项目来源解析失败 → 跳过 */ }
606
+ for (const dir of dirs) {
607
+ try {
608
+ if (await isInsideRootResolved(dir, abs))
609
+ return true;
610
+ }
611
+ catch { /* 单个根失败不影响其他 */ }
612
+ }
613
+ return false;
614
+ }
615
+ return { ops, writeOps, registerProviders, isInsideKnownSkillRoot };
531
616
  }