dsh-plugin-tool-management 0.16.6 → 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 +76 -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 +43 -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 +74 -4
  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) {
@@ -302,6 +302,13 @@ function officialSkillRoot() {
302
302
  deletable: false,
303
303
  // 来源层无开关(同默认来源),单个技能可启停;overlay 以 599 压过官方
304
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 的宿主适配矩阵,官方更新时人工核对这一处。
305
312
  toggleable: true,
306
313
  native: false,
307
314
  scope: "user",
@@ -2019,7 +2026,28 @@ export async function setPreferredSkill(root, name, preferred, log) {
2019
2026
  log(preferred === false ? "skill-unprefer" : "skill-prefer", preferred === false
2020
2027
  ? `取消同名首选 ${canonicalName}`
2021
2028
  : `同名首选 ${canonicalName} → ${definition.key}`);
2022
- 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
+ };
2023
2051
  }
2024
2052
  async function safeExistingEntryPaths(root, name) {
2025
2053
  const paths = [];
@@ -2702,6 +2730,28 @@ export async function importSkill(source, log, options = {}) {
2702
2730
  return { kind: analysis.kind, imported, skipped, failed };
2703
2731
  }
2704
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
+ }
2705
2755
  /**
2706
2756
  * 校验浏览器或 ZIP 提供的相对路径。上传内容始终写成普通文件,不解释 ZIP 的链接元数据。
2707
2757
  * 这样既不依赖浏览器泄露本机绝对路径,也不会让归档跨出管理器暂存目录。
@@ -2727,6 +2777,7 @@ function normalizeUploadPath(input) {
2727
2777
  parts.some((part) => !part ||
2728
2778
  part === "." ||
2729
2779
  part === ".." ||
2780
+ pathSegmentRewritten(part) ||
2730
2781
  part.length > MAX_ENTRY_NAME_LENGTH ||
2731
2782
  WINDOWS_DEVICE_NAME_RE.test(part))) {
2732
2783
  throw codedError(`上传条目路径非法: ${raw}`, "error.upload.path", {
@@ -3110,7 +3161,17 @@ export async function skillDetail(keyOrRoot, name, options = {}) {
3110
3161
  sourceReadOnly: !root.mutable,
3111
3162
  };
3112
3163
  }
3113
- /** 生成 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
+ */
3114
3175
  export async function listProviderCandidates(options = {}) {
3115
3176
  const policyResult = await readManagerState();
3116
3177
  const candidates = [];
@@ -3129,7 +3190,9 @@ export async function listProviderCandidates(options = {}) {
3129
3190
  items.push({ root, entry });
3130
3191
  }
3131
3192
  }
3132
- 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()) {
3133
3196
  const { root, entry } = group[0];
3134
3197
  const project = root.scope === "project";
3135
3198
  const policyOnly = root.mutable;
@@ -3288,14 +3351,21 @@ function markWinners(items, options = {}) {
3288
3351
  const preferred = options.preferred;
3289
3352
  for (const [canonicalName, [winner, ...shadowed],] of groupLoadableSkillsByName(items, preferred)) {
3290
3353
  winners.set(canonicalName, winner);
3291
- // 只有「首选命中且确实赢了」才算首选;来源被停用/移除时不谎报。
3354
+ // `preferred` = 「这份显式选择**记录在案**且它确实赢了同名之争」,**不等于它正在生效**:
3355
+ // 首选来源自己停用时它照样赢(停用不参与选赢家),此时 `view.enabled === false`,
3356
+ // 而其余副本拿到 `shadowedBy.enabled === false` —— 界面据此说「两份都不生效」。
3357
+ // 这里仍要打上 preferred,否则界面拿不到「取消首选」那颗按钮,用户就没法撤销这次选择。
3292
3358
  if (preferred && preferred[canonicalName] === winner.root.key)
3293
3359
  winner.view.preferred = true;
3294
3360
  if (options.markShadowed !== false) {
3361
+ // `enabled` 传赢家的启用状态:界面靠它区分「另一份在跑」与「两份都不生效」。
3362
+ // 少了这个字段,被覆盖的一行只能说「同名技能 X 正在生效」—— 而 X 停用时那是假话。
3363
+ const winnerEnabled = winner.policy.enabled === true;
3295
3364
  for (const item of shadowed)
3296
3365
  item.view.shadowedBy = {
3297
3366
  root: winner.root.key,
3298
3367
  name: winner.entry.name,
3368
+ enabled: winnerEnabled,
3299
3369
  };
3300
3370
  }
3301
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.6",
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",