dsh-plugin-tool-management 0.16.5 → 0.17.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 (52) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +2 -1
  3. package/README_EN.md +2 -1
  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 +53 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +44 -17
  23. package/lib/compat/probe.js +75 -24
  24. package/lib/context-inject.js +73 -7
  25. package/lib/host-names.js +150 -0
  26. package/lib/hub.js +82 -11
  27. package/lib/imports/parsers.js +40 -15
  28. package/lib/index.js +168 -74
  29. package/lib/mcp/loader-token.js +26 -0
  30. package/lib/mcp/manager.js +314 -87
  31. package/lib/mcp/patch-yaml.js +207 -40
  32. package/lib/mcp/secret-guard.js +122 -16
  33. package/lib/memories/archive-engine.js +21 -11
  34. package/lib/memories/index-io.js +125 -12
  35. package/lib/memories/service.js +163 -40
  36. package/lib/memories/snapshot.js +32 -9
  37. package/lib/op-registry.js +51 -8
  38. package/lib/ops/compat.js +6 -2
  39. package/lib/ops/prompts.js +18 -5
  40. package/lib/ops/scene-records.js +117 -10
  41. package/lib/ops/sessions.js +50 -4
  42. package/lib/ops/snapshot.js +105 -12
  43. package/lib/ops/trash.js +45 -9
  44. package/lib/prompts/service.js +7 -2
  45. package/lib/request-gate.js +115 -56
  46. package/lib/scene-prompt-sync.js +46 -7
  47. package/lib/scenes/candidates.js +21 -3
  48. package/lib/sessions/workspace.js +40 -5
  49. package/lib/skills/core.js +206 -43
  50. package/lib/skills/service.js +9 -4
  51. package/lib/subagents/service.js +6 -1
  52. package/package.json +1 -1
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
15
15
  // 放进本文件会让两个域互相引用。
16
+ import { MCP_TOOL_PREFIX } from '../host-names.js';
17
+ import { serverNameCandidates } from '../host-names.js';
16
18
  import { presetRosterOf } from '../compat/preset-reach.js';
17
19
  import { injectionFactsOf, readCompositionFacts, readCompositionText } from '../compat/preset-reach.js';
18
20
  /**
@@ -39,9 +41,25 @@ export function createCandidates(deps) {
39
41
  schemas = await tools.schemas();
40
42
  }
41
43
  catch { /* 无 live 工具 → 仅启停表 */ }
44
+ // 切分的候选 serverName 集合必须与 `serverKnownTools`(index.ts)同源 —— 同一批来源、
45
+ // 同一个 builder,否则「服务器名含 `__`」时两处会把同一个全名切成不同的 (server, tool)
46
+ // (2026-09-30 审查 N1)。三个来源里补丁那份是唯一与切分结果无关的(另两份是插件自己
47
+ // 写出来的:停用表键来自界面勾选、缓存键来自上一次切分),缺它会在首次遇到含 `__` 的名字时
48
+ // 切错并锁死。任一份读不到就退化为其余两份(少候选 = 少命中,不会多命中)。
49
+ let known = {};
50
+ try {
51
+ known = await mcp.readKnownMcpTools();
52
+ }
53
+ catch { /* 缓存不可读 → 仅停用表 */ }
54
+ let configured = [];
55
+ try {
56
+ configured = await mcp.configuredServerNames();
57
+ }
58
+ catch { /* 补丁读不到 → 仅另两份 */ }
59
+ const serverNames = serverNameCandidates(configured, disabled, known);
42
60
  const liveByServer = {};
43
61
  for (const s of schemas) {
44
- const p = toolKeyParts(String((s && s.name) || ''));
62
+ const p = toolKeyParts(String((s && s.name) || ''), serverNames);
45
63
  if (!p)
46
64
  continue;
47
65
  const list = liveByServer[p.server] || (liveByServer[p.server] = []);
@@ -214,7 +232,7 @@ export function createCandidates(deps) {
214
232
  continue;
215
233
  // MCP 工具名形如 `mcp__<server>__<tool>`:面向上百个条目,噪声大于价值 → 不进候选。
216
234
  // 子代理照样能用当前在跑的 MCP —— 那份名单在 decideToolFilter 里运行时并进白名单。
217
- const names = (await presetToolNames(id)).filter((name) => !name.startsWith('mcp__'));
235
+ const names = (await presetToolNames(id)).filter((name) => !name.startsWith(MCP_TOOL_PREFIX));
218
236
  presets.push({
219
237
  id,
220
238
  name: String((p && (p.name || p.id)) || id),
@@ -236,7 +254,7 @@ export function createCandidates(deps) {
236
254
  }
237
255
  // 当前会话的工具即便没有任何预设可枚举,也要出现在候选里(否则选择器是空的)。
238
256
  for (const name of currentNames) {
239
- if (name.startsWith('mcp__'))
257
+ if (name.startsWith(MCP_TOOL_PREFIX))
240
258
  continue;
241
259
  const rec = byName.get(name) || { name, presets: new Set(), current: false };
242
260
  byName.set(name, rec);
@@ -1056,8 +1056,11 @@ var ArchiveWorkspaceRegistry = class {
1056
1056
  /**
1057
1057
  * 永久删除一个会话及其全部痕迹(转录目录、工作区记账、归档标记、
1058
1058
  * 投影缓存行)。
1059
+ *
1060
+ * 级联只删**已归档**的子代理会话(审查 F10);被保留的未归档子会话 id 通过
1061
+ * `keptUnarchivedDescendants` 回给调用方,界面据此如实呈现,不假装整支都没了。
1059
1062
  * @param sessionId - 要删除的会话。
1060
- * @returns 持久化完成后的 `{ deleted: true }`。
1063
+ * @returns `{ deleted: true }`,有被保留的未归档子会话时附带其 id。
1061
1064
  * @throws {@link ArchiveUnknownSessionError} 会话未知时抛出。
1062
1065
  */
1063
1066
  async deleteSession(sessionId) {
@@ -1144,7 +1147,9 @@ var ArchiveWorkspaceRegistry = class {
1144
1147
  await projCache?.whenIdle?.();
1145
1148
  if (projCache !== void 0)
1146
1149
  await projCache.delete(sessionId);
1147
- await this.deleteDescendants(sessionId, storedIndex);
1150
+ // 级联只吃已归档的子会话;被保留(未归档)的 id 一路带回 op 层,让界面/模型
1151
+ // 知道「父会话已删,但这一支没有全没」(审查 F10)。
1152
+ const keptDescendants = await this.deleteDescendants(sessionId, storedIndex);
1148
1153
  await this.cleanSpill(sessionId);
1149
1154
  await this.removeTranscriptDirectory(sessionId);
1150
1155
  // 只有物理工件删除成功后才能提交记账清理;否则批量目标会因归档标记
@@ -1163,7 +1168,12 @@ var ArchiveWorkspaceRegistry = class {
1163
1168
  if (deletedHeader !== void 0)
1164
1169
  this.deletedIdentities.set(sessionId, headerIdentity(deletedHeader));
1165
1170
  this.publishDeletedSession(sessionId);
1166
- return { deleted: true };
1171
+ return {
1172
+ deleted: true,
1173
+ ...(keptDescendants.length === 0
1174
+ ? {}
1175
+ : { keptUnarchivedDescendants: keptDescendants }),
1176
+ };
1167
1177
  }
1168
1178
  /** 删除完成后通知全部客户端;新版不再通过伪造冷会话生命周期触发通知。 */
1169
1179
  publishDeletedSession(sessionId) {
@@ -1394,8 +1404,21 @@ var ArchiveWorkspaceRegistry = class {
1394
1404
  /** 尽力而为的级联删除:删除 `sessionId` 的 SUBAGENT 子会话。
1395
1405
  * 仅头部标记 `origin: "subagent"` 的会话参与:单凭 `parentSession` 有歧义
1396
1406
  *(fork 分支也携带它),而 fork 分支是独立的用户会话,绝不能被级联删除。
1397
- * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。 */
1407
+ *
1408
+ * **只级联「已归档」的子会话**(2026-09-30 审查 F10)。此前这里只按
1409
+ * `parentSession + origin: subagent` 收集就逐个 `deleteSessionCore`,**完全不查归档集合**,
1410
+ * 于是「只能删已归档」在**注册表层不成立** —— 删一个已归档父会话,会把它**未归档**的子代理
1411
+ * 会话一起永久删掉。op 入口那层的收敛(`history-delete` / `deleteArchivedSessions`)拦不住
1412
+ * 这条路:级联发生在删除主体内部,不经过任何 op 门禁。现在把不变量落到这里,而不是只写在
1413
+ * 注释里 —— 未归档的一律**保留**(之后可单独归档再删),并记一条日志说明保留了哪些。
1414
+ *
1415
+ * 归档集合取 `requireState().archivedSessionIds`(权威),不取 `archivedSessionMetadata()`
1416
+ * 那份有损投影;`requireState()` 抛错时**一个都不删**(fail-closed:宁可不级联,也不凭未知
1417
+ * 状态做不可逆操作)。
1418
+ * @param storedIndex - 可选的批首 `id → header` 快照,见 {@link storedHeaderIndex}。
1419
+ * @returns 被保留(未归档)的子会话 id(含后代递归上来的),全部删净时为空数组。 */
1398
1420
  async deleteDescendants(sessionId, storedIndex) {
1421
+ const kept = [];
1399
1422
  try {
1400
1423
  const descendants = [];
1401
1424
  const sessions = this.ctx.get("sessions");
@@ -1414,11 +1437,20 @@ var ArchiveWorkspaceRegistry = class {
1414
1437
  !descendants.includes(header.id))
1415
1438
  descendants.push(header.id);
1416
1439
  }
1440
+ // 先取归档集合再动手:它抛错时一个子会话都不会被删(fail-closed)。
1441
+ const archived = new Set(this.requireState().archivedSessionIds);
1417
1442
  for (const childId of descendants) {
1443
+ if (!archived.has(childId)) {
1444
+ kept.push(childId);
1445
+ continue;
1446
+ }
1418
1447
  try {
1419
1448
  if (!(await this.sessionKnown(childId, storedIndex)))
1420
1449
  continue;
1421
- await this.deleteSessionCore(childId, storedIndex);
1450
+ const result = await this.deleteSessionCore(childId, storedIndex);
1451
+ // 孙辈里被保留的同样要往上带,否则界面只看到最外一层。
1452
+ if (Array.isArray(result?.keptUnarchivedDescendants))
1453
+ kept.push(...result.keptUnarchivedDescendants);
1422
1454
  }
1423
1455
  catch (error) {
1424
1456
  if (error instanceof ArchiveUnknownSessionError)
@@ -1426,10 +1458,13 @@ var ArchiveWorkspaceRegistry = class {
1426
1458
  this.ctx.logger.warn(`archive-manager: cascade delete of subagent session "${childId}" (child of "${sessionId}") failed: ${String(error)}`);
1427
1459
  }
1428
1460
  }
1461
+ if (kept.length)
1462
+ this.ctx.logger.warn(`archive-manager: cascade delete of "${sessionId}" kept ${kept.length} unarchived subagent session(s) — only archived sessions may be deleted: ${kept.join(", ")}`);
1429
1463
  }
1430
1464
  catch (error) {
1431
1465
  this.ctx.logger.warn(`archive-manager: descendant enumeration for deleted session "${sessionId}" failed: ${String(error)}`);
1432
1466
  }
1467
+ return kept;
1433
1468
  }
1434
1469
  /** 尽力而为的 spill 清理:移除该会话作用域的 spill 目录。 */
1435
1470
  async cleanSpill(sessionId) {
@@ -13,7 +13,7 @@
13
13
  // 文件写入错误由路由返回给调用方。
14
14
  import { homedir } from "node:os";
15
15
  import { join, basename, dirname, resolve, isAbsolute, } from "node:path";
16
- import { promises as fs, existsSync, mkdirSync, readdirSync, copyFileSync, rmSync, } from "node:fs";
16
+ import { promises as fs, existsSync, mkdirSync, readdirSync, copyFileSync, rmSync, statSync, } from "node:fs";
17
17
  import { createRequire } from "node:module";
18
18
  import { createHash, randomUUID } from "node:crypto";
19
19
  import { unzipSync } from "fflate";
@@ -60,25 +60,27 @@ export function resolveAgentsHome() {
60
60
  }
61
61
  // ── 官方内置技能来源(随宿主注入会话上下文的内置技能)──────────────────────
62
62
  //
63
- // 桌面版把 4 个官方技能注入每个会话(用户 2026-09-30 实测目录):office-docx /
64
- // office-pptx / office-xlsx(dsh-skill-office,桌面宿主把 assetRoot 指到
65
- // `<resources>/runtime/office-skills`)与 diagnose-windows-sandbox-acl
66
- // (dsh-sandbox-windows-acl,source 'bundled',rank 600)。web 宿主一个都不注入
67
- // (用户裁定:web 用不上这个功能)。它们或住在官方 asar 里、或由官方 provider
68
- // 复制进临时目录自管,插件直接扫描/接管有两个坑:宿主子进程里对 asar 的 promises
69
- // 读法与拦截器解析都出现过时灵时不灵(同机两次启动一组 4/1、一组全 0,实测);
70
- // 沙盒技能的脚本要交给 PowerShell 子进程,asar 路径读不了。
63
+ // 宿主按形态注入不同的官方技能(官方源码逐一核对,2026-09-30):
64
+ // - 桌面版 4 个:office-docx / office-pptx / office-xlsx(dsh-skill-office 只在桌面
65
+ // 宿主装配,assetRoot 指到 `<resources>/runtime/office-skills`)+ diagnose-windows-
66
+ // sandbox-acl(dsh-sandbox-local 的门控是 `process.platform === "win32"`,rank 600)。
67
+ // - web 版:沙盒诊断技能由宿主注入(门控只看 win32 —— 用户 2026-09-30 截图实锤,
68
+ // 0.16.5 按「web 不注入」的误判把 web 挡在门外,导致页面不显示、宿主照注);office
69
+ // 插件 web 宿主不装配、不注入,但用户裁定(2026-09-30)技能页照样把这三个暴露出来
70
+ // 统一管理(npm 包自带完整 assets,接管后可启停)。
71
71
  //
72
- // 因此这里把来源**物化**成插件自有的一份真实目录(`$DSH_HOME/tool-management/
73
- // official-skills/`):每次宿主启动从官方源头整目录覆盖拷贝(与官方 provider 的
74
- // 「私有资源副本」同一做法),扫描、启停、overlay、脚本执行全部落在真实文件上。
72
+ // 它们或住在官方 asar 里、或由官方 provider 复制进临时目录自管,插件直接扫描/接管有两个
73
+ // 坑:宿主子进程里对 asar 的 promises 读法与拦截器解析都出现过时灵时不灵(同机两次启动
74
+ // 一组 4/1、一组全 0,实测);沙盒技能的脚本要交给 PowerShell 子进程,asar 路径读不了。
75
+ //
76
+ // 因此这里把来源**物化**成插件自有的一份真实目录,**每种宿主形态各一份**
77
+ // (`$DSH_HOME/tool-management/official-skills/<desktop|web>/`):每次宿主启动从官方源头
78
+ // 整目录覆盖拷贝(与官方 provider 的「私有资源副本」同一做法),扫描、启停、overlay、
79
+ // 脚本执行全部落在真实文件上。599 压过宿主 provider 的 600,停用即不注入。分形态是竞态
80
+ // 教训:0.16.5 之前共用一份目录,web 启动把它解析到的沙盒技能盖进去,桌面物化好的
81
+ // 4 个只剩 1 个(实测);改成各写各的子目录,互不清对方的账。共享布局的残留(旧条目
82
+ // 直接躺在 official-skills/ 下)按已知名单清理。
75
83
  //
76
- // 两个边界(都出过事):
77
- // - 只有桌面版(有 process.resourcesPath)才允许进来。物化目录在 $DSH_HOME 下
78
- // 与各宿主**共用**,而 web 宿主进程里的官方模块拦截器能把 @deepseek-ai 裸名
79
- // 解析到宿主副本 —— web 若也进来重建,会把桌面物化好的整目录清成它解析到的
80
- // 那几个(2026-09-30 实测:桌面物化好 4 个,web 一启动,两份 UI 同时只剩沙盒
81
- // 1 个)。所以非桌面在碰目录**之前**就退出,一个技能都不列。
82
84
  // - 预设参考技能(dsh-agent-preset/skills)不注入会话上下文,不列入(用户裁定)。
83
85
  //
84
86
  // 单个来源、随 DSH 技能之后排列,来源层语义同默认来源(不可移除、无来源开关)。
@@ -133,29 +135,90 @@ function resolvedPackageDir(packageName, subpath) {
133
135
  }
134
136
  }
135
137
  /**
136
- * office 技能源头。office 只在桌面版由宿主挂载(web 的补丁层没有它),所以只在
137
- * 桌面环境里找:运行时真实目录优先(官方 assetRoot),其次 asar 包内 assets。
138
- * 非桌面环境一律 null —— 不把没被注入的技能凭空列出来。
138
+ * office 技能源头。桌面版由宿主挂载:运行时真实目录优先(官方 assetRoot),其次 asar
139
+ * 包内 assets。web 宿主不装配 office 插件(不注入),但 npm 包自带完整 assets —— 用户
140
+ * 裁定(2026-09-30)技能页照样暴露这三个技能,走宿主进程解析链取包内一份。解析不到
141
+ * 一律 null —— 不把拿不到真实文件的技能凭空列出来。
139
142
  */
140
143
  function officialOfficeSource() {
141
144
  const resources = resourcesDir();
142
- if (resources === null)
143
- return null;
144
- const runtimeDir = join(resources, "runtime", "office-skills");
145
- if (existsSync(join(runtimeDir, "office-docx")))
146
- return runtimeDir;
147
- const asarDir = desktopPackageDir("@deepseek-ai/dsh-skill-office", "assets");
148
- if (asarDir !== null && existsSync(join(asarDir, "office-docx")))
149
- return asarDir;
150
- return null;
151
- }
152
- /** 沙盒诊断技能源头(桌面版 asar 直连优先,其次本机解析链;仅 Windows 注入)。 */
145
+ if (resources !== null) {
146
+ const runtimeDir = join(resources, "runtime", "office-skills");
147
+ if (existsSync(join(runtimeDir, "office-docx")))
148
+ return runtimeDir;
149
+ const asarDir = desktopPackageDir("@deepseek-ai/dsh-skill-office", "assets");
150
+ if (asarDir !== null && existsSync(join(asarDir, "office-docx")))
151
+ return asarDir;
152
+ }
153
+ return resolvedPackageDir("@deepseek-ai/dsh-skill-office", "assets");
154
+ }
155
+ /** 沙盒诊断技能源头(桌面 asar 直连优先,其次宿主进程解析链;宿主门控仅看 win32,web/桌面皆注入)。 */
153
156
  function officialSandboxSource() {
154
157
  if (process.platform !== "win32")
155
158
  return null;
156
159
  return (desktopPackageDir("@deepseek-ai/dsh-sandbox-windows-acl", "assets/diagnose-windows-sandbox-acl") ??
157
160
  resolvedPackageDir("@deepseek-ai/dsh-sandbox-windows-acl", "assets/diagnose-windows-sandbox-acl"));
158
161
  }
162
+ // ── office 技能的「Installed LibreOffice Kit」段 ────────────────────────────
163
+ //
164
+ // 官方 dsh-office provider 在 get 时给三个 office 技能的正文**追加**一段
165
+ // 「Installed LibreOffice Kit」:SKILL.md 明文要求「使用这段提供的 libreofficeKit.node
166
+ // 与 .cli 绝对路径,禁止自行搜索/猜测」。我们以 599 接管后官方 provider 不再被问到,
167
+ // 这段必须由我们补上 —— 否则模型拿到的 office 技能缺执行 LibreOffice 命令所需的全部
168
+ // 路径(这个缺口 0.16.5 在桌面上就已存在)。文案与 JSON 形状逐字取自官方
169
+ // dsh-skill-office lib/index.js 的 officeRuntime();node/cli 的落点按宿主形态各自核实:
170
+ // - 桌面:官方明确要求 packaged 应用必须给**独立 node**(electron 本体不行)。
171
+ // 实测桌面把运行时装在 `<resources>/runtime/primary-runtime/`(DSH_PRIMARY_RUNTIME
172
+ // 指向它,SDK 部署可被同名环境变量改写),node 在其 dependencies/node/bin 下;cli
173
+ // 必须用 `app.asar.unpacked` 里的真实文件 —— 独立 node 读不了 asar 内部。
174
+ // - web:官方默认 process.execPath(宿主是普通 node 进程),cli 从宿主进程解析链取。
175
+ // 任一路径不是真实文件 → 用官方口径的「已停用」文案(SKILL.md 自有降级分支,模型不会
176
+ // 去猜路径),绝不给一个跑不起来的路径。
177
+ const OFFICE_SKILL_NAMES = new Set([
178
+ "office-docx",
179
+ "office-pptx",
180
+ "office-xlsx",
181
+ ]);
182
+ const OFFICE_RUNTIME_DISABLED = "\n\nLibreOffice Kit is disabled in this deployment.";
183
+ let officeRuntimeSectionCache;
184
+ /** 官方 provider 在 get 时追加的 LibreOffice Kit 段(每进程算一次,路径不随会话变)。 */
185
+ function officialOfficeRuntimeSection() {
186
+ if (officeRuntimeSectionCache === undefined)
187
+ officeRuntimeSectionCache = computeOfficeRuntimeSection();
188
+ return officeRuntimeSectionCache;
189
+ }
190
+ function computeOfficeRuntimeSection() {
191
+ const resources = resourcesDir();
192
+ let node;
193
+ let cli;
194
+ const runtimeRoot = process.env.DSH_PRIMARY_RUNTIME || process.env.DSH_BUNDLED_PRIMARY_RUNTIME;
195
+ if (runtimeRoot) {
196
+ // SDK / 容器部署:官方配置从运行时根推导(cordis 装配逐字核对)。
197
+ node = join(resolve(runtimeRoot), "dependencies", "node", "bin", process.platform === "win32" ? "node.exe" : "node");
198
+ cli = resolvedPackageDir("@deepseek-ai/libreoffice-kit", "lib/cli.js");
199
+ }
200
+ else if (resources !== null) {
201
+ node = join(resources, "runtime", "primary-runtime", "dependencies", "node", "bin", process.platform === "win32" ? "node.exe" : "node");
202
+ // 桌面的 cli 只认 unpacked 真实文件(独立 node 读不了 asar;electron 的 fs 补丁
203
+ // 会让 existsSync 对 asar 内部也返回真,所以这里不用解析链兜底)。
204
+ cli = join(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "libreoffice-kit", "lib", "cli.js");
205
+ }
206
+ else {
207
+ node = process.execPath;
208
+ cli = resolvedPackageDir("@deepseek-ai/libreoffice-kit", "lib/cli.js");
209
+ }
210
+ const usable = (path) => {
211
+ try {
212
+ return isAbsolute(path) && statSync(path).isFile();
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ };
218
+ if (!usable(node) || cli === null || !usable(cli))
219
+ return OFFICE_RUNTIME_DISABLED;
220
+ return `\n\n## Installed LibreOffice Kit\n\nUse these absolute paths for every LibreOffice Kit command. Pass the CLI entry as the first argument to Node.\n\n${JSON.stringify({ libreofficeKit: { node, cli } }, null, 2)}`;
221
+ }
159
222
  function copyDirectorySync(from, to) {
160
223
  mkdirSync(to, { recursive: true });
161
224
  for (const entry of readdirSync(from, { withFileTypes: true })) {
@@ -172,17 +235,40 @@ function copyDirectorySync(from, to) {
172
235
  * 每次宿主启动整目录重建(官方包只随宿主更新变化,宿主更新必然重启宿主)。
173
236
  */
174
237
  let officialRootCache;
238
+ /** 宿主形态子目录名:桌面(有 process.resourcesPath)与 web 等普通 node 进程各物化一份。 */
239
+ function officialFormName() {
240
+ return resourcesDir() === null ? "web" : "desktop";
241
+ }
242
+ /**
243
+ * 0.16.5 之前各形态共用一份目录(旧条目直接躺在 official-skills/ 下);分形态目录后
244
+ * 这些残留只会变成幽灵条目,按这份已知名单清掉。只清名单内的名字,绝不碰旁边的
245
+ * `<form>/` 子目录 —— 那是另一种形态物化的现役目录。
246
+ */
247
+ const LEGACY_OFFICIAL_ENTRY_NAMES = Object.freeze([
248
+ "office-docx",
249
+ "office-pptx",
250
+ "office-xlsx",
251
+ "scripts",
252
+ "diagnose-windows-sandbox-acl",
253
+ ]);
254
+ function cleanupLegacyOfficialLayout(root) {
255
+ for (const name of LEGACY_OFFICIAL_ENTRY_NAMES) {
256
+ try {
257
+ rmSync(join(root, name), { recursive: true, force: true });
258
+ }
259
+ catch {
260
+ /* 残留清理失败不影响物化,下轮启动再试 */
261
+ }
262
+ }
263
+ }
175
264
  function materializeOfficialSkills() {
176
- // 非桌面宿主(web 等普通 node 进程)一个官方技能都不注入,也不许碰共享的物化
177
- // 目录 —— 否则 web 启动会把它解析到的那几个盖进目录,桌面版物化好的就没了
178
- // (见上方块注释)。必须在任何 fs 访问之前退出。
179
- if (resourcesDir() === null)
180
- return null;
181
265
  const office = officialOfficeSource();
182
266
  const sandbox = officialSandboxSource();
183
267
  if (office === null && sandbox === null)
184
268
  return null;
185
- const root = join(resolveDshHome(), "tool-management", "official-skills");
269
+ // 每种宿主形态只整目录重建**自己**这份(竞态教训见上方块注释);对方形态的子目录
270
+ // 与共享层残留清理互不干涉。
271
+ const root = join(resolveDshHome(), "tool-management", "official-skills", officialFormName());
186
272
  try {
187
273
  rmSync(root, { recursive: true, force: true });
188
274
  // office:整个 assets 根拷进来(office-*/ 与 scripts/ 的相对结构必须保留 ——
@@ -192,6 +278,7 @@ function materializeOfficialSkills() {
192
278
  if (sandbox !== null) {
193
279
  copyDirectorySync(sandbox, join(root, basename(sandbox)));
194
280
  }
281
+ cleanupLegacyOfficialLayout(dirname(root));
195
282
  return root;
196
283
  }
197
284
  catch (error) {
@@ -215,6 +302,13 @@ function officialSkillRoot() {
215
302
  deletable: false,
216
303
  // 来源层无开关(同默认来源),单个技能可启停;overlay 以 599 压过官方
217
304
  // provider 的 600,脚本从物化副本的真实路径执行。
305
+ //
306
+ // 残余风险(2026-09-30 审查 §5 F12,如实标注、不修):`599` 是个**数字契约** ——
307
+ // 它只在"官方那个 provider 的 rank 恰好是 600"时成立。官方调档位(把内置技能提到
308
+ // 599 以上、或降到 599 以下)会让接管失效:要么官方内置技能重新注入、与 599 层并存
309
+ // (同名两份),要么本插件反向遮蔽掉本不该遮蔽的东西。rank 是纯数字、没有能力探测
310
+ // 能提前发现,doctor 也查不了(要枚举官方 provider 的注册参数)。所以记为**已知
311
+ // 假设**,列进 CHANGELOG 的宿主适配矩阵,官方更新时人工核对这一处。
218
312
  toggleable: true,
219
313
  native: false,
220
314
  scope: "user",
@@ -1932,7 +2026,28 @@ export async function setPreferredSkill(root, name, preferred, log) {
1932
2026
  log(preferred === false ? "skill-unprefer" : "skill-prefer", preferred === false
1933
2027
  ? `取消同名首选 ${canonicalName}`
1934
2028
  : `同名首选 ${canonicalName} → ${definition.key}`);
1935
- return { name: canonicalName, root: preferred === false ? null : definition.key };
2029
+ // 首选**可以**指向停用来源:这个写操作只记录一次选择,不启停任何东西,所以照旧写成功。
2030
+ // 但「记录成功」不等于「已经生效」—— 停用不参与选赢家、且停用赢家仍要挡住低优先级副本
2031
+ // (见 listProviderCandidates),于是首选来源自己停用时,同名两份**都不生效**。
2032
+ // 这里如实回话:调用方(含模型,`skill-prefer` 是模型可调的)不能以为「已改为生效」。
2033
+ // 不返回 ok:false —— 那会让界面的「启用这个」(prefer 之后紧跟 enable)在 enable 之前就中断。
2034
+ const effective = effectiveSkillPolicy(current, definition, {
2035
+ ...summary,
2036
+ ...resolved,
2037
+ });
2038
+ const base = {
2039
+ name: canonicalName,
2040
+ root: preferred === false ? null : definition.key,
2041
+ };
2042
+ if (preferred === false || effective.enabled)
2043
+ return base;
2044
+ return {
2045
+ ...base,
2046
+ effective: false,
2047
+ warning: `已记录同名首选,但它不会生效:${canonicalName}(${definition.label || definition.key})` +
2048
+ `当前处于停用状态,而它仍然挡住同名的其他副本 —— 两份都不生效。` +
2049
+ `启用其中一份即可(启用这份,或改用另一份)。`,
2050
+ };
1936
2051
  }
1937
2052
  async function safeExistingEntryPaths(root, name) {
1938
2053
  const paths = [];
@@ -2615,6 +2730,28 @@ export async function importSkill(source, log, options = {}) {
2615
2730
  return { kind: analysis.kind, imported, skipped, failed };
2616
2731
  }
2617
2732
  // ── 浏览器上传导入 ──────────────────────────────────────────────────────────
2733
+ /**
2734
+ * 一个上传路径段会不会被文件系统**改写**成别的东西。
2735
+ *
2736
+ * 为什么必须单判这一条(2026-09-30 审查 P2-17,实为路径逃逸而非口径分叉):
2737
+ * 下面的 `part === ".."` 只挡**恰好**是 `..` 的段,而 Windows 的路径归一化会把段尾的
2738
+ * **点与空格**静默裁掉 —— `.. `(`..` 加一个空格)在 `CreateFileW` / `CreateDirectoryW`
2739
+ * 眼里就是 `..`。于是 `join(contentRoot, ".. ", "x")` 看着在根内(`isSameOrDescendant`
2740
+ * 也判在根内:它按字符串比,`".. "` 不等于 `".."`,`relative` 得到的 `".. /x"` 也不以
2741
+ * `"../"` 开头),实际会写到**暂存目录之外**。同一条判据顺带覆盖 `...` / `. ` /
2742
+ * `foo.`(最后一个不逃逸,但会让磁盘上的名字与界面显示的名字不一致 —— `paths.ts` 的
2743
+ * `trailing-dot-or-space` 就是为这个存在的)。
2744
+ *
2745
+ * 只挡"会被改写"的形态,**不挡** `.gitignore` / `.DS_Store` 这类正常点文件:技能包带它们
2746
+ * 是常态(macOS 解压出来的包必带 `.DS_Store`),一律拒绝会让正常技能导不进来。
2747
+ * 注意 `../imports/upload.ts` 的 `normalizeEntryPath` 走的是更宽的一条(拒绝**所有**
2748
+ * 点开头的段)—— 那里是历史口径,方向更保守,两边都安全,不必强行统一。
2749
+ */
2750
+ function pathSegmentRewritten(part) {
2751
+ if (/[\u0000-\u001f\u007f]/.test(part))
2752
+ return true;
2753
+ return part !== part.replace(/[.\s]+$/, "");
2754
+ }
2618
2755
  /**
2619
2756
  * 校验浏览器或 ZIP 提供的相对路径。上传内容始终写成普通文件,不解释 ZIP 的链接元数据。
2620
2757
  * 这样既不依赖浏览器泄露本机绝对路径,也不会让归档跨出管理器暂存目录。
@@ -2640,6 +2777,7 @@ function normalizeUploadPath(input) {
2640
2777
  parts.some((part) => !part ||
2641
2778
  part === "." ||
2642
2779
  part === ".." ||
2780
+ pathSegmentRewritten(part) ||
2643
2781
  part.length > MAX_ENTRY_NAME_LENGTH ||
2644
2782
  WINDOWS_DEVICE_NAME_RE.test(part))) {
2645
2783
  throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
@@ -3023,7 +3161,17 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
3023
3161
  sourceReadOnly: !root.mutable,
3024
3162
  };
3025
3163
  }
3026
- /** 生成 manager provider 候选:保留禁用候选以阻止低优先级重名副本意外激活。 */
3164
+ /**
3165
+ * 生成 manager provider 候选:保留禁用候选以阻止低优先级重名副本意外激活。
3166
+ *
3167
+ * **同名首选必须传进分组**(2026-09-30 审查 F8)。此前这里调
3168
+ * `groupLoadableSkillsByName(items)` 时没带 `preferred`,而管理页(`markWinners`)带了 ——
3169
+ * 于是「同名首选」只在界面上生效:用户把 A 设为首选,界面把 A 标成「同名首选」、把 B 标成
3170
+ * 「被覆盖」,模型侧却仍按 rank 拿到 B。**界面说的和模型用的不是同一份**,且没有任何一处报错。
3171
+ *
3172
+ * 分组函数本身是共用的(同一份排序规则),缺的只是这个参数;传进去两侧即同源。
3173
+ * 未命中(键已失效 / 来源被移除)时分组函数自己退化为纯 rank 顺序,与界面一致。
3174
+ */
3027
3175
  export async function listProviderCandidates(options = {}) {
3028
3176
  const policyResult = await readManagerState();
3029
3177
  const candidates = [];
@@ -3042,7 +3190,9 @@ export async function listProviderCandidates(options = {}) {
3042
3190
  items.push({ root, entry });
3043
3191
  }
3044
3192
  }
3045
- for (const group of groupLoadableSkillsByName(items).values()) {
3193
+ // 与管理页 markWinners 同一个 preferred 表、同一个分组函数 —— 界面与模型必须挑同一个赢家。
3194
+ const preferredSkills = policyResult.state.preferredSkills || null;
3195
+ for (const group of groupLoadableSkillsByName(items, preferredSkills).values()) {
3046
3196
  const { root, entry } = group[0];
3047
3197
  const project = root.scope === "project";
3048
3198
  const policyOnly = root.mutable;
@@ -3139,6 +3289,12 @@ export async function getProviderSkill(candidate, options = {}) {
3139
3289
  const summary = entryOf(locator.entryName, entry.kind, entry.docPath, doc);
3140
3290
  if (!summary.loadable || summary.declaredName !== candidate.name)
3141
3291
  return undefined;
3292
+ // 官方 office 技能:补上官方 provider 在 get 时追加的 LibreOffice Kit 段
3293
+ // (SKILL.md 明文引用这段的 node/cli 路径,见上方块注释)。
3294
+ const officeRuntime = locator.rootKey === OFFICIAL_SOURCE_KEY &&
3295
+ OFFICE_SKILL_NAMES.has(locator.entryName)
3296
+ ? officialOfficeRuntimeSection()
3297
+ : "";
3142
3298
  return {
3143
3299
  name: candidate.name,
3144
3300
  description: candidate.description,
@@ -3148,7 +3304,7 @@ export async function getProviderSkill(candidate, options = {}) {
3148
3304
  resourceBase: candidate.resourceBase,
3149
3305
  path: candidate.path,
3150
3306
  metadata: candidate.metadata,
3151
- content: doc.body.trim(),
3307
+ content: doc.body.trim() + officeRuntime,
3152
3308
  };
3153
3309
  }
3154
3310
  catch {
@@ -3195,14 +3351,21 @@ function markWinners(items, options = {}) {
3195
3351
  const preferred = options.preferred;
3196
3352
  for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
3197
3353
  winners.set(canonicalName, winner);
3198
- // 只有「首选命中且确实赢了」才算首选;来源被停用/移除时不谎报。
3354
+ // `preferred` = 「这份显式选择**记录在案**且它确实赢了同名之争」,**不等于它正在生效**:
3355
+ // 首选来源自己停用时它照样赢(停用不参与选赢家),此时 `view.enabled === false`,
3356
+ // 而其余副本拿到 `shadowedBy.enabled === false` —— 界面据此说「两份都不生效」。
3357
+ // 这里仍要打上 preferred,否则界面拿不到「取消首选」那颗按钮,用户就没法撤销这次选择。
3199
3358
  if (preferred && preferred[canonicalName] === winner.root.key)
3200
3359
  winner.view.preferred = true;
3201
3360
  if (options.markShadowed !== false) {
3361
+ // `enabled` 传赢家的启用状态:界面靠它区分「另一份在跑」与「两份都不生效」。
3362
+ // 少了这个字段,被覆盖的一行只能说「同名技能 X 正在生效」—— 而 X 停用时那是假话。
3363
+ const winnerEnabled = winner.policy.enabled === true;
3202
3364
  for (const item of shadowed)
3203
3365
  item.view.shadowedBy = {
3204
3366
  root: winner.root.key,
3205
3367
  name: winner.entry.name,
3368
+ enabled: winnerEnabled,
3206
3369
  };
3207
3370
  }
3208
3371
  if (options.markWinner !== false)
@@ -9,7 +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
+ import { isInsideRootResolved, isSameOrDescendant, isValidSegment } from '../paths.js';
13
13
  import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, updateSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
14
14
  const MAX_LOG_BYTES = 1 << 20;
15
15
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
@@ -106,9 +106,14 @@ async function moveToSystemTrash(abs) {
106
106
  async function permanentlyDeleteTrashSafely(id, log) {
107
107
  const clean = String(id || '').trim();
108
108
  const root = trashRootPath();
109
- const sep = root.indexOf('\\') >= 0 ? '\\' : '/';
110
- const target = /^[A-Za-z0-9._-]{1,128}$/.test(clean) ? root + sep + clean : null;
111
- if (target && target.indexOf(root + sep) === 0 && existsSync(target)) {
109
+ // 走 `paths.ts` 的单段名校验,而不是自写白名单(审查 F1,0.17.0):
110
+ // 原判据 `/^[A-Za-z0-9._-]{1,128}$/` 放行 `.` 与 `..`,而 `root + sep + '..'` 解析后
111
+ // 正是**回收站根目录**,末尾那句字符串前缀比对拦不住它 —— 于是「删掉某一条」变成
112
+ // 「把整个 `trash/`(技能/场景/子智能体/提示词四类)送进系统回收站」,且返回 `ok:true`。
113
+ // 换成 `isValidSegment` 还让两条路同源:能通过这里的 id,必定也能通过
114
+ // `permanentlyDeleteTrash` 内部的 `entryPath`(同一个谓词),不会一个放行、一个拒绝。
115
+ const target = isValidSegment(clean) ? join(root, clean) : null;
116
+ if (target && isSameOrDescendant(root, target) && existsSync(target)) {
112
117
  if (await moveToSystemTrash(target)) {
113
118
  await log('trash-delete-system', { id: clean, path: target });
114
119
  return { id: clean, method: 'system-trash' };
@@ -517,7 +517,12 @@ export function createSubagentService(ctx, opts) {
517
517
  const fresh = [];
518
518
  try {
519
519
  const entries = await readdir(dir);
520
- for (const fileName of entries.filter((e) => e.endsWith('.md')).sort()) {
520
+ // 只列**能管得动**的人设(2026-09-30 审查 P2-18):`validPersonaName` 走
521
+ // `paths.ts` 的 `isValidSegment`,隐藏名(`.foo`)被判 `hidden` —— 于是此前列表里
522
+ // 会出现一个「看得见、改不了、删不掉」的人设(任何 op 都拒它)。同一条谓词在这里
523
+ // 收口;大小写也按 `relocateLegacyPersonas` 的 `toLowerCase()` 口径统一(`A.MD` 在
524
+ // 那边算人设、在这边不算,是同一个分叉的另一半)。
525
+ for (const fileName of entries.filter((e) => !e.startsWith('.') && e.toLowerCase().endsWith('.md')).sort()) {
521
526
  const raw = await readFile(join(dir, fileName), 'utf8').catch(() => '');
522
527
  if (!raw.trim())
523
528
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.16.5",
3
+ "version": "0.17.0",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, skills, scenes, memories, subagent personas and AGENTS.md presets — a scene switches the whole environment, and the model can drive it from chat too.",
5
5
  "repository": {
6
6
  "type": "git",