dsh-plugin-tool-management 0.9.1 → 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 (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  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 +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
@@ -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
  /**
@@ -564,13 +571,14 @@ function notDeletableError(definition) {
564
571
  */
565
572
  function reservedSourceError(root) {
566
573
  const key = root && root.key ? root.key : "";
574
+ const origin = key === "dsh"
575
+ ? "DSH 技能目录是默认来源,必须读取"
576
+ : "导入技能目录是默认来源、插件新建/导入的落点,必须读取";
567
577
  return {
568
578
  ok: false,
569
579
  code: "error.source.reserved",
570
- params: { root: key },
571
- error: key === "dsh"
572
- ? "DSH 技能目录是默认来源,必须读取:不能停用或移除(里面的技能可以删除)"
573
- : "导入技能目录是默认来源、插件新建/导入的落点,必须读取:不能停用或移除(里面的技能可以删除)",
580
+ params: { root: key, origin },
581
+ error: `${origin}:不能停用或移除(里面的技能可以删除)`,
574
582
  };
575
583
  }
576
584
  function rootDefinition(root) {
@@ -622,7 +630,11 @@ async function checkedWritableRootDefinition(root) {
622
630
  };
623
631
  }
624
632
  // 项目仓库内容不可信;拒绝通过 .dsh 或 skills 链接把写入重定向到项目之外。
625
- 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
+ ]) {
626
638
  const st = await lstatOrNull(path);
627
639
  if (st && (!st.isDirectory() || st.isSymbolicLink())) {
628
640
  return {
@@ -635,12 +647,6 @@ async function checkedWritableRootDefinition(root) {
635
647
  }
636
648
  return definition;
637
649
  }
638
- /** 判断 child 是否与 parent 相同或位于其内部。跨盘符时 relative 会返回绝对路径。 */
639
- function isSameOrDescendant(parent, child) {
640
- const rel = relative(resolve(parent), resolve(child));
641
- return (rel === "" ||
642
- (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)));
643
- }
644
650
  /** 解析真实路径;中间若有目录链接,按落地目录比较重叠。 */
645
651
  async function resolvedPath(path) {
646
652
  try {
@@ -701,16 +707,9 @@ async function lstatOrNull(path) {
701
707
  }
702
708
  /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
703
709
  export function entryPath(root, name) {
704
- if (typeof name !== "string" ||
705
- name === "" ||
706
- name === "." ||
707
- name === ".." ||
708
- name.startsWith(".") ||
709
- name.length > MAX_ENTRY_NAME_LENGTH ||
710
- /[\\/:*?"<>|\0]/.test(name) ||
711
- /[. ]$/.test(name) ||
712
- WINDOWS_DEVICE_NAME_RE.test(name) ||
713
- basename(name) !== name)
710
+ // 段名谓词收敛到 paths.js(此前与 rules / subagents / imports 各写一套,口径不一)。
711
+ // 原实现里 `basename(name) !== name` 那条已被谓词的「不含路径分隔符」覆盖。
712
+ if (!isValidSegment(name, MAX_ENTRY_NAME_LENGTH))
714
713
  return null;
715
714
  const rootPath = resolve(root);
716
715
  const path = resolve(rootPath, name);
@@ -819,6 +818,7 @@ export function unquote(v) {
819
818
  /** Windows 上杀毒软件或索引器可能短暂占用目录;只重试明确可恢复的 rename 错误。 */
820
819
  export async function renameWithRetry(source, destination, options = {}) {
821
820
  const rename = typeof options.rename === "function" ? options.rename : fs.rename;
821
+ // Number.isInteger/isFinite 不收窄类型,故断言成 number 以保持既有比较表达式。
822
822
  const maxAttempts = Number.isInteger(options.maxAttempts) && options.maxAttempts > 0
823
823
  ? options.maxAttempts
824
824
  : 6;
@@ -830,6 +830,7 @@ export async function renameWithRetry(source, destination, options = {}) {
830
830
  return await rename(source, destination);
831
831
  }
832
832
  catch (error) {
833
+ // 系统 rename 异常按可选 code 读取,保持既有重试判定。
833
834
  if (!TRANSIENT_RENAME_CODES.has(error && error.code) ||
834
835
  attempt >= maxAttempts)
835
836
  throw error;
@@ -853,6 +854,7 @@ async function movePathWithFallback(source, destination, options = {}) {
853
854
  return { copied: false, cleanupError: null };
854
855
  }
855
856
  catch (error) {
857
+ // 系统异常按可选 code 读取,保持既有 EXDEV 判定。
856
858
  if (!error || error.code !== "EXDEV")
857
859
  throw error;
858
860
  }
@@ -877,6 +879,7 @@ async function movePathWithFallback(source, destination, options = {}) {
877
879
  await removeMovedPath(quarantine);
878
880
  }
879
881
  catch (error) {
882
+ // 调用方按可选 message 读取该清理异常,保持既有告警文案。
880
883
  cleanupError = error;
881
884
  }
882
885
  return { copied: true, cleanupError, quarantine };
@@ -913,6 +916,7 @@ export async function resolveEntry(root, name) {
913
916
  return null;
914
917
  return ((await discoverReadonlyEntries(definition.path)).entries.find((entry) => entry.name === name) || null);
915
918
  }
919
+ // 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
916
920
  root = definition ? definition.path : root;
917
921
  try {
918
922
  const bundlePath = entryPath(root, name);
@@ -1019,6 +1023,7 @@ export async function scanEntries(root, options = {}) {
1019
1023
  const definition = rootDefinition(root);
1020
1024
  if (definition && !definition.mutable) {
1021
1025
  const discovered = await discoverReadonlyEntries(definition.path);
1026
+ // metadataOnly 时条目的摘要字段由调用方按需读取(见 visibleEntryForRoot)。
1022
1027
  if (options.metadataOnly)
1023
1028
  return discovered;
1024
1029
  const entries = [];
@@ -1035,6 +1040,7 @@ export async function scanEntries(root, options = {}) {
1035
1040
  }
1036
1041
  return { ...discovered, entries };
1037
1042
  }
1043
+ // 传入 definition 对象时取它的 path;其余情况按 key 字符串处理。
1038
1044
  root = definition ? definition.path : root;
1039
1045
  const rootStat = await lstatOrNull(resolve(root));
1040
1046
  if (!rootStat || !rootStat.isDirectory() || rootStat.isSymbolicLink())
@@ -1103,7 +1109,10 @@ export async function scanEntries(root, options = {}) {
1103
1109
  }
1104
1110
  /** 同一真实技能优先归属直接 SSOT;同类条目再按来源 rank 决胜。 */
1105
1111
  async function scanDeduplicatedRoots(roots = userRoots(), options = {}) {
1106
- 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
+ ]));
1107
1116
  const scans = new Map();
1108
1117
  const groups = new Map();
1109
1118
  for (const [root, scan] of results) {
@@ -1342,9 +1351,8 @@ function normalizeManagerState(value) {
1342
1351
  * 也不参与 provider 候选。源文件不动,清掉这个标记即恢复。
1343
1352
  */
1344
1353
  export function activeUserRoots(stateValue) {
1345
- const removed = new Set(stateValue && Array.isArray(stateValue.removedSources)
1346
- ? stateValue.removedSources
1347
- : []);
1354
+ const state = stateValue;
1355
+ const removed = new Set(state && Array.isArray(state.removedSources) ? state.removedSources : []);
1348
1356
  return userRoots().filter((root) => !removed.has(root.key));
1349
1357
  }
1350
1358
  /** 裁剪自定义来源列表:非法条目丢弃,key/path 绑定校验,label 规整。 */
@@ -1419,6 +1427,7 @@ export async function readManagerState() {
1419
1427
  };
1420
1428
  }
1421
1429
  catch (error) {
1430
+ // 系统异常按可选 code 读取,保持既有 ENOENT 判定。
1422
1431
  if (error && error.code === "ENOENT")
1423
1432
  return { state: defaultManagerState(), warning: null, writable: true };
1424
1433
  return {
@@ -1578,6 +1587,7 @@ async function checkedPolicyRootDefinition(root) {
1578
1587
  resolve(join(definition.projectRoot, definition.kind === "project-dsh" ? ".dsh" : ".agents", "skills"));
1579
1588
  if (!validProjectRoot)
1580
1589
  return null;
1590
+ // validProjectRoot 已校验 projectRoot 为字符串,断言只为让类型收敛。
1581
1591
  if (!(await projectSourceSafe(definition))) {
1582
1592
  return {
1583
1593
  ok: false,
@@ -1778,12 +1788,13 @@ async function readTrashMetadata(id) {
1778
1788
  * 在短重试窗口后仍返回 EPERM。此时保留 stage 作为唯一可回滚副本,逐项复制到最终目录,
1779
1789
  * 并最后写 metadata:listTrash() 在复制完整前不会暴露半成品。
1780
1790
  */
1781
- async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
1791
+ async function publishTrashStage(stage, finalPath, metadata, renameOptions = {}) {
1782
1792
  try {
1783
1793
  await renameWithRetry(stage, finalPath, renameOptions);
1784
1794
  return { fallback: false, cleanupError: null };
1785
1795
  }
1786
1796
  catch (error) {
1797
+ // 系统异常按可选 code 读取,保持既有可恢复码判定。
1787
1798
  if (!TRANSIENT_RENAME_CODES.has(error && error.code))
1788
1799
  throw error;
1789
1800
  }
@@ -1810,6 +1821,7 @@ async function publishTrashStage(stage, finalPath, metadata, renameOptions) {
1810
1821
  await fs.rm(stage, { recursive: true, force: true });
1811
1822
  }
1812
1823
  catch (error) {
1824
+ // 调用方按可选 message 读取该清理异常,保持既有告警文案。
1813
1825
  cleanupError = error;
1814
1826
  }
1815
1827
  return { fallback: true, cleanupError };
@@ -1918,12 +1930,14 @@ export async function deleteSkill(root, name, log, options = {}) {
1918
1930
  return { id, name, deletedAt: metadata.deletedAt, root: metadata.root };
1919
1931
  }
1920
1932
  catch (error) {
1933
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
1921
1934
  const rollbackFailures = [];
1922
1935
  for (const item of moved.reverse()) {
1923
1936
  try {
1924
1937
  await movePathWithFallback(item.destination, item.path, options.renameOptions);
1925
1938
  }
1926
1939
  catch (rollbackError) {
1940
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
1927
1941
  rollbackFailures.push({
1928
1942
  path: item.destination,
1929
1943
  error: String(rollbackError && rollbackError.message
@@ -2127,8 +2141,11 @@ async function collectCandidates(dir) {
2127
2141
  }
2128
2142
  /** 导入内容不接受符号链接,避免把目标目录外的内容带入技能目录。 */
2129
2143
  async function assertNoSymbolicLinks(source) {
2130
- const pending = [{ path: source, depth: 0 }];
2144
+ const pending = [
2145
+ { path: source, depth: 0 },
2146
+ ];
2131
2147
  while (pending.length) {
2148
+ // while 条件保证栈非空,断言只为让类型收敛。
2132
2149
  const current = pending.pop();
2133
2150
  if (current.depth > MAX_SOURCE_DEPTH)
2134
2151
  throw codedError(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`, "error.source.tooDeep", { depth: MAX_SOURCE_DEPTH, path: source });
@@ -2160,6 +2177,7 @@ async function preflightCandidates(pending, conflicts, failed) {
2160
2177
  await assertNoSymbolicLinks(candidate.source);
2161
2178
  }
2162
2179
  catch (error) {
2180
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2163
2181
  failed.push(attachCode({
2164
2182
  source: candidate.source,
2165
2183
  error: String(error && error.message ? error.message : error),
@@ -2190,20 +2208,26 @@ async function copyToTemporary(source, target, isDir) {
2190
2208
  async function replaceWithCopy(source, dest, isDir, existing = []) {
2191
2209
  const stage = await copyToTemporary(source, dest, isDir);
2192
2210
  const backups = [];
2211
+ // 三处 rename 一律走 `renameWithRetry`:瞬时占用(Windows 杀软 / 索引器 / 资源管理器)返回的
2212
+ // EACCES / EBUSY / EPERM 正是它重试的那些码。此前只有 `:959` 的回滚用了带重试的版本,
2213
+ // 而**发布与它的补偿回滚用的是裸 rename** —— 发布因瞬时锁抛错时,紧随其后的回滚在同一刻、
2214
+ // 同一目录上大概率撞同一个句柄,于是留下"目标已空、真值还叫 backup"的半态(无自动恢复路径)。
2215
+ // 保护覆盖不均不是取舍,是漏。
2193
2216
  try {
2194
2217
  for (const path of existing) {
2195
2218
  const backup = temporaryPath(path, "backup");
2196
- await fs.rename(path, backup);
2219
+ await renameWithRetry(path, backup);
2197
2220
  backups.push({ path, backup });
2198
2221
  }
2199
- await fs.rename(stage, dest);
2222
+ await renameWithRetry(stage, dest);
2200
2223
  }
2201
2224
  catch (error) {
2225
+ // 系统异常按可选 message 读取,保持既有回滚失败明细。
2202
2226
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
2203
2227
  const rollbackFailures = [];
2204
2228
  for (const item of backups.reverse()) {
2205
2229
  try {
2206
- await fs.rename(item.backup, item.path);
2230
+ await renameWithRetry(item.backup, item.path);
2207
2231
  }
2208
2232
  catch (rollbackError) {
2209
2233
  rollbackFailures.push(item.backup);
@@ -2221,6 +2245,7 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
2221
2245
  await fs.rm(item.backup, { recursive: true, force: true });
2222
2246
  }
2223
2247
  catch (error) {
2248
+ // 系统异常按可选 message 读取,保持既有告警文案。
2224
2249
  warnings.push({
2225
2250
  code: "warning.backupUncleaned",
2226
2251
  params: {
@@ -2272,6 +2297,7 @@ export async function importSkill(source, log, options = {}) {
2272
2297
  candidates = await collectCandidates(source);
2273
2298
  }
2274
2299
  catch (error) {
2300
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2275
2301
  return attachCode({
2276
2302
  ok: false,
2277
2303
  error: String(error && error.message ? error.message : error),
@@ -2321,6 +2347,7 @@ export async function importSkill(source, log, options = {}) {
2321
2347
  });
2322
2348
  continue;
2323
2349
  }
2350
+ // 上方计数循环已为该 kebab 记数(合法名称且目标可写),断言只为让类型收敛。
2324
2351
  if (nameCount.get(c.kebab) > 1) {
2325
2352
  failed.push({
2326
2353
  source: c.source,
@@ -2377,6 +2404,7 @@ export async function importSkill(source, log, options = {}) {
2377
2404
  log("import", `导入 ${p.source} -> ${p.dest}`);
2378
2405
  }
2379
2406
  catch (e) {
2407
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2380
2408
  failed.push(attachCode({ source: p.source, error: String(e && e.message ? e.message : e) }, e));
2381
2409
  }
2382
2410
  }
@@ -2392,6 +2420,7 @@ export async function importSkill(source, log, options = {}) {
2392
2420
  log("import-overwrite", `覆盖导入 ${c.source} -> ${dest}`);
2393
2421
  }
2394
2422
  catch (e) {
2423
+ // 系统异常按可选 message 读取,保持既有失败明细文案。
2395
2424
  failed.push(attachCode({ source: c.source, error: String(e && e.message ? e.message : e) }, e));
2396
2425
  }
2397
2426
  }
@@ -2467,9 +2496,11 @@ function decodeUploadBase64(value, maxBytes, code = "error.upload.tooLarge") {
2467
2496
  return bytes;
2468
2497
  }
2469
2498
  function uploadError(error) {
2499
+ // 按可选 message 读取抛出值,保持既有兜底文案。
2500
+ const cause = error;
2470
2501
  return attachCode({
2471
2502
  ok: false,
2472
- error: String(error && error.message ? error.message : error),
2503
+ error: String(cause && cause.message ? cause.message : error),
2473
2504
  }, error);
2474
2505
  }
2475
2506
  async function writeUploadedEntries(contentRoot, entries) {
@@ -2520,6 +2551,7 @@ async function writeUploadedZip(contentRoot, encoded) {
2520
2551
  });
2521
2552
  }
2522
2553
  catch (error) {
2554
+ // 业务错误(我们抛的 error.* 前缀)透传;其余按 ZIP 解压失败处理。
2523
2555
  if (error && /^error\./.test(String(error.code || "")))
2524
2556
  throw error;
2525
2557
  throw codedError(`ZIP 无法解压: ${String(error && error.message ? error.message : error)}`, "error.upload.zipInvalid");
@@ -2554,7 +2586,7 @@ async function prepareUploadedSource(sessionRoot, input) {
2554
2586
  const batchRoot = join(sessionRoot, "batch");
2555
2587
  const wrappedRoot = join(batchRoot, skillName);
2556
2588
  await fs.mkdir(batchRoot, { recursive: true });
2557
- await fs.rename(contentRoot, wrappedRoot);
2589
+ await renameWithRetry(contentRoot, wrappedRoot);
2558
2590
  return batchRoot;
2559
2591
  }
2560
2592
  /**
@@ -2648,12 +2680,13 @@ export async function createSkill(input, log, options = {}) {
2648
2680
  log("create", `创建 ${flatPath}`);
2649
2681
  return { name, path: flatPath, root: definition.key };
2650
2682
  }
2683
+ // 上方 entryPath(root, name) === null 的守卫已保证目标非空,断言只为让类型收敛。
2651
2684
  const target = entryPath(root, name);
2652
2685
  const stage = temporaryPath(target, "create");
2653
2686
  try {
2654
2687
  await fs.mkdir(stage);
2655
2688
  await fs.writeFile(join(stage, "SKILL.md"), content, "utf8");
2656
- await fs.rename(stage, target);
2689
+ await renameWithRetry(stage, target);
2657
2690
  }
2658
2691
  catch (error) {
2659
2692
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
@@ -2722,7 +2755,10 @@ export async function listProviderCandidates(options = {}) {
2722
2755
  const candidates = [];
2723
2756
  const user = activeUserRoots(policyResult.state).concat(customRootsFromState(policyResult.state).filter((root) => !policyResult.state.removedSources.includes(root.key)));
2724
2757
  const userScans = await scanDeduplicatedRoots(user);
2725
- 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
+ })));
2726
2762
  const cwd = options && typeof options.cwd === "string" ? options.cwd : undefined;
2727
2763
  if (cwd) {
2728
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
  }