dsh-plugin-tool-management 0.6.0 → 0.7.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 (35) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +106 -221
  3. package/README_EN.md +105 -220
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +39 -38
  21. package/lib/agents-md/service.js +43 -2
  22. package/lib/client.js +708 -140
  23. package/lib/compat/preset-reach.js +425 -0
  24. package/lib/http-fence.js +21 -0
  25. package/lib/hub.js +160 -2
  26. package/lib/index.js +256 -39
  27. package/lib/mcp/override-blocks.js +195 -0
  28. package/lib/rules/service.js +78 -1
  29. package/lib/skills/core.js +11 -0
  30. package/lib/skills/service.js +3 -0
  31. package/lib/subagents/service.js +197 -23
  32. package/lib/subagents/tools.js +8 -2
  33. package/package.json +3 -2
  34. package/screenshots.json +10 -10
  35. package/docs/Changelog.md +0 -144
@@ -34,6 +34,7 @@ import { expandUploads, planMemoryImport } from '../imports/upload.js';
34
34
  import { createRuleProviderRegistrar } from './provider.js';
35
35
  import { normalizePresetId } from '../agents-md/preset-id.js';
36
36
  import { normalizeArchive, memoryAllowed } from './archive.js';
37
+ import { listTrashEntries, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
37
38
  // ── 常量 ───────────────────────────────────────────────────────────────────
38
39
  const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
39
40
  const MAX_DIRECTORIES = 2000; // 目录预算
@@ -2047,9 +2048,16 @@ export function createRulesService(ctx, deps) {
2047
2048
  const parts = parseId(id);
2048
2049
  if (!parts)
2049
2050
  return fail('error.rules.notFound', `规则不存在:${id}`);
2051
+ // `enabled` 必须显式给。缺省时旧实现回落到"沿用当前值",却照样刷新
2052
+ // updatedAt 并返回 ok:true —— 调用方按 toggle(翻转)理解时会以为自己改了
2053
+ // 状态,实际什么都没改(界面不受影响:它一直显式传值)。与 rulesSetActive
2054
+ // 同一条口径:参数缺失就明确拒绝,不猜。
2055
+ if (typeof (args && args.enabled) !== 'boolean') {
2056
+ return fail('error.rules.invalidArgs', '缺少参数:enabled 必须是布尔值(toggle 只按传入值写入,不做"翻转"推断)');
2057
+ }
2050
2058
  const index = await readIndex(stateDir);
2051
2059
  const idxEntry = index.rules[id] || {};
2052
- const enabled = args && args.enabled !== undefined ? args.enabled === true : (idxEntry.enabled ?? true);
2060
+ const enabled = args.enabled === true;
2053
2061
  index.rules[id] = { ...idxEntry, enabled, updatedAt: new Date().toISOString() };
2054
2062
  await writeIndex(stateDir, index);
2055
2063
  invalidateSnapshot();
@@ -2252,6 +2260,15 @@ export function createRulesService(ctx, deps) {
2252
2260
  if (index.mode?.scene === name) {
2253
2261
  return fail('error.rules.sceneInMode', `场景「${name}」正处在当前模式,请先退出模式再删除`);
2254
2262
  }
2263
+ // 场景本体(记录 + 档案)**先移入回收站**,再从索引里摘掉——这样「删除场景」
2264
+ // 是可恢复的。记忆目录必须是空的(上面已拦),所以没有正文需要搬。
2265
+ const trashed = await moveToTrash('scenes', name, [], {
2266
+ record: (index.scenes && index.scenes[name]) || null,
2267
+ archive: (index.archives && index.archives[name]) || null,
2268
+ });
2269
+ if (trashed.ok === false) {
2270
+ return fail('error.rules.ioFailed', `移入回收站失败:${trashed.error}`);
2271
+ }
2255
2272
  try {
2256
2273
  // fs.rm 删目录必须 recursive(即使已确认它是空的),否则报 EISDIR。
2257
2274
  await rm(join(rulesRoot, name), { recursive: true, force: true });
@@ -2277,8 +2294,63 @@ export function createRulesService(ctx, deps) {
2277
2294
  await writeIndex(stateDir, index);
2278
2295
  invalidateSnapshot();
2279
2296
  invalidateProviders();
2297
+ return { ok: true, name, trashId: trashed.id };
2298
+ }
2299
+ /** 场景回收站列表(只读):`<hub>/trash/scenes-trash/<id>/manifest.json`。 */
2300
+ async function sceneTrashList() {
2301
+ return { ok: true, trash: await listTrashEntries('scenes') };
2302
+ }
2303
+ /**
2304
+ * 从回收站恢复一个场景:重建记忆目录 + 写回记录与档案。
2305
+ * 同名场景已存在时**拒绝**(绝不覆盖既有场景)。
2306
+ */
2307
+ async function sceneTrashRestore(args) {
2308
+ const id = String((args && args.id) || '').trim();
2309
+ const entry = await readTrashEntry('scenes', id);
2310
+ if (!entry)
2311
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2312
+ const name = String(entry.name || '').trim();
2313
+ if (!isValidGroupSegment(name))
2314
+ return fail('error.rules.invalidGroup', `回收站里的场景名非法:${name || '(空)'}`);
2315
+ const index = await readIndex(stateDir);
2316
+ if (index.scenes?.[name])
2317
+ return fail('error.rules.invalidGroup', `无法恢复,同名场景已存在:${name}`);
2318
+ const dir = join(rulesRoot, name);
2319
+ let entries = [];
2320
+ try {
2321
+ entries = await readdir(dir);
2322
+ }
2323
+ catch {
2324
+ entries = [];
2325
+ }
2326
+ if (entries.filter((n) => !n.startsWith('.')).length > 0) {
2327
+ return fail('error.rules.sceneNotEmpty', `无法恢复,记忆目录「${name}」里已有内容,请先处理`);
2328
+ }
2329
+ const data = (entry.data || {});
2330
+ try {
2331
+ await mkdir(dir, { recursive: true });
2332
+ }
2333
+ catch (e) {
2334
+ return fail('error.rules.ioFailed', `重建场景目录失败:${message(e)}`);
2335
+ }
2336
+ if (data.record && typeof data.record === 'object')
2337
+ index.scenes = { ...(index.scenes || {}), [name]: data.record };
2338
+ if (data.archive && typeof data.archive === 'object')
2339
+ index.archives = { ...(index.archives || {}), [name]: data.archive };
2340
+ await writeIndex(stateDir, index);
2341
+ await purgeTrashEntry('scenes', id);
2342
+ invalidateSnapshot();
2343
+ invalidateProviders();
2280
2344
  return { ok: true, name };
2281
2345
  }
2346
+ /** 永久删除一条场景回收站条目。 */
2347
+ async function sceneTrashDelete(args) {
2348
+ const id = String((args && args.id) || '').trim();
2349
+ const gone = await purgeTrashEntry('scenes', id);
2350
+ if (!gone)
2351
+ return fail('error.rules.notFound', `回收站条目不存在:${id}`);
2352
+ return { ok: true, id };
2353
+ }
2282
2354
  /**
2283
2355
  * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2284
2356
  * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
@@ -2365,6 +2437,8 @@ export function createRulesService(ctx, deps) {
2365
2437
  'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
2366
2438
  'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
2367
2439
  'rules-attach', 'rules-detach', 'rules-trash-remove',
2440
+ // 场景回收站(与记忆回收站 rules-trash-* 分开:那套管记忆正文,这套管场景记录与档案)
2441
+ 'scene-trash-restore', 'scene-trash-delete',
2368
2442
  ]);
2369
2443
  const ops = {
2370
2444
  'rules-list': (args) => rulesList(args || {}),
@@ -2386,6 +2460,9 @@ export function createRulesService(ctx, deps) {
2386
2460
  'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
2387
2461
  'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
2388
2462
  'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
2463
+ 'scene-trash-list': () => sceneTrashList(),
2464
+ 'scene-trash-restore': (args) => runWrite(() => sceneTrashRestore(args || {})),
2465
+ 'scene-trash-delete': (args) => runWrite(() => sceneTrashDelete(args || {})),
2389
2466
  'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
2390
2467
  };
2391
2468
  const service = {
@@ -178,6 +178,9 @@ export function customRootsFromState(stateValue) {
178
178
  label: typeof item.label === "string" && item.label.trim()
179
179
  ? item.label.trim().slice(0, 64)
180
180
  : "自定义目录",
181
+ // 用户加进来的来源(相对「按约定发现」的 dsh / hub / agents / codex / claude):
182
+ // 界面据此决定要不要给「永久删除」——只有自定义来源能真正从插件里删掉记录。
183
+ custom: true,
181
184
  mutable: false,
182
185
  toggleable: true,
183
186
  native: false,
@@ -280,6 +283,10 @@ export async function removeCustomRoot(key, log) {
280
283
  delete current.state.sources[clean];
281
284
  delete current.state.disabledSkills[clean];
282
285
  delete current.state.enabledSkills[clean];
286
+ // 已移除记录一起清掉:否则「永久删除」之后状态文件里还留着一条指向不存在来源的脏键
287
+ // (读取时的 normalize 会丢弃它,但没理由写进去)。
288
+ if (Array.isArray(current.state.removedSources))
289
+ current.state.removedSources = current.state.removedSources.filter((key) => key !== clean);
283
290
  await writeManagerState(current.state);
284
291
  if (log)
285
292
  log("custom-remove", `移除自定义技能目录 ${target.path} (${clean})`);
@@ -2904,6 +2911,8 @@ export async function state(options = {}) {
2904
2911
  mutable: root.mutable,
2905
2912
  deletable: root.deletable === true,
2906
2913
  removable: !isDefaultSkillSource(root) && root.scope !== "project",
2914
+ // 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
2915
+ custom: root.custom === true,
2907
2916
  // 默认来源(dsh / hub):界面据此隐藏来源开关、只显示「可管理」标记。
2908
2917
  defaultSource: isDefaultSkillSource(root),
2909
2918
  toggleable: root.toggleable,
@@ -2983,6 +2992,8 @@ export async function state(options = {}) {
2983
2992
  truncated: truncated === true,
2984
2993
  // 界面用:能否从管理器「移除」(不再读取)。dsh / hub / 项目级不可移除。
2985
2994
  removable: !isDefaultSkillSource(root) && root.scope !== "project",
2995
+ // 自定义来源(用户手加的目录):只有它能「永久删除」——按约定发现的来源删不掉。
2996
+ custom: root.custom === true,
2986
2997
  // 默认来源(dsh / hub):界面据此隐藏来源开关(它们不能停用),换成「可管理」标记。
2987
2998
  defaultSource: isDefaultSkillSource(root),
2988
2999
  removed: false,
@@ -397,6 +397,9 @@ export function createSkillsService(ctx) {
397
397
  // 写操作清单:与下方 ops 表同文件同源维护;HTTP 端门禁由 index.ts 从本集合派生,勿在宿主端另抄一份。
398
398
  const writeOps = new Set([
399
399
  'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
400
+ // 移除/恢复来源会改写本地来源状态(listProviderCandidates 随之变化,模型侧的技能目录
401
+ // 也变),是写操作;漏在这份清单里 = 配了访问令牌也不会被要求带令牌。
402
+ 'skill-source-remove', 'skill-source-restore',
400
403
  'skill-prefer', 'skill-unprefer',
401
404
  'skill-create', 'skill-import', 'skill-upload', 'skill-delete',
402
405
  'skill-trash-restore', 'skill-trash-delete', 'skill-custom-add', 'skill-custom-remove',
@@ -5,9 +5,10 @@
5
5
  // v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
6
6
  // (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
7
7
  import { createRequire } from 'node:module';
8
- import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { resolveDshHome } from '../skills/core.js';
11
+ import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
11
12
  import { expandUploads, planPersonaImport } from '../imports/upload.js';
12
13
  const message = (e) => String((e && e.message) || e);
13
14
  /** 人设默认目录:`$DSH_HOME/tool-management/agents/`。 */
@@ -50,25 +51,66 @@ async function relocateLegacyPersonas(dir) {
50
51
  }
51
52
  }
52
53
  }
53
- /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny 五个键。 */
54
+ /** 逗号/顿号分隔的名字清单(空串 → undefined)。 */
55
+ function listOf(value) {
56
+ const text = String(value ?? '').trim();
57
+ if (!text)
58
+ return undefined;
59
+ const list = text.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
60
+ return list.length ? list : undefined;
61
+ }
62
+ /**
63
+ * 解析 `toolsByPreset:` 块 —— 每个 Agent 预设一行:
64
+ *
65
+ * ```yaml
66
+ * toolsByPreset:
67
+ * standard: allow: read, write
68
+ * ptc: deny: workflow
69
+ * ```
70
+ *
71
+ * 缩进即"在块内",回到顶格即块结束(frontmatter 只有这一处嵌套,所以不需要完整
72
+ * YAML 解析器)。模式 id 只做形状校验,认不认得由运行时按当前预设名单判断。
73
+ */
74
+ export function parsePresetToolRules(frontmatter) {
75
+ const rules = {};
76
+ let inBlock = false;
77
+ for (const raw of frontmatter.split(/\r?\n/)) {
78
+ const line = raw.replace(/\s+#.*$/, '');
79
+ if (!inBlock) {
80
+ if (/^toolsbypreset\s*:\s*$/i.test(line.trim()))
81
+ inBlock = true;
82
+ continue;
83
+ }
84
+ if (line.trim() === '')
85
+ continue;
86
+ if (!/^\s/.test(line))
87
+ break;
88
+ const m = /^\s*([A-Za-z0-9._-]+)\s*:\s*(allow|deny)\s*:\s*(.*)$/i.exec(line);
89
+ if (!m)
90
+ continue;
91
+ const names = listOf(m[3]);
92
+ // 空名单 = 不限制 = 不落盘,读到时也直接忽略,免得把"没开"读成"开了但空"。
93
+ if (!names)
94
+ continue;
95
+ rules[m[1]] = { mode: m[2].toLowerCase() === 'deny' ? 'deny' : 'allow', names };
96
+ }
97
+ return Object.keys(rules).length ? rules : undefined;
98
+ }
99
+ /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset。 */
54
100
  export function parsePersona(raw, fallbackName) {
55
101
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
56
102
  const data = {};
57
103
  let body = raw;
104
+ let toolsByPreset;
58
105
  if (m) {
59
106
  for (const line of m[1].split(/\r?\n/)) {
60
107
  const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
61
108
  if (kv)
62
109
  data[kv[1].toLowerCase()] = kv[2].trim();
63
110
  }
111
+ toolsByPreset = parsePresetToolRules(m[1]);
64
112
  body = raw.slice(m[0].length);
65
113
  }
66
- const listOf = (value) => {
67
- if (!value)
68
- return undefined;
69
- const list = value.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
70
- return list.length ? list : undefined;
71
- };
72
114
  const tools = listOf(data.tools);
73
115
  // 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
74
116
  const toolsDeny = listOf(data.toolsdeny);
@@ -80,6 +122,7 @@ export function parsePersona(raw, fallbackName) {
80
122
  model: data.model || undefined,
81
123
  tools,
82
124
  toolsDeny,
125
+ ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
83
126
  body: body.trim(),
84
127
  path: '',
85
128
  };
@@ -155,8 +198,14 @@ export function createSubagentService(ctx, opts) {
155
198
  throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
156
199
  return runtime;
157
200
  }
158
- async function runOnce(parentAgent, p, task, signal) {
201
+ async function runOnce(parentAgent, p, task, signal, toolFilter) {
159
202
  const runtime = await ensureSpawnProvider();
203
+ // 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
204
+ // undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
205
+ const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
206
+ ? { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) }
207
+ : null;
208
+ const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
160
209
  // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
161
210
  const run = await runtime.start('spawn', {
162
211
  label: p.name,
@@ -166,10 +215,8 @@ export function createSubagentService(ctx, opts) {
166
215
  prompt: [{ type: 'text', text: task }],
167
216
  persona: p.body,
168
217
  // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
169
- // 所以选择器给的是「全体预设工具并集」,见 index.ts presetToolCandidates)。
170
- ...((p.tools?.length || p.toolsDeny?.length)
171
- ? { toolFilter: { ...(p.tools?.length ? { allow: p.tools } : {}), ...(p.toolsDeny?.length ? { deny: p.toolsDeny } : {}) } }
172
- : {}),
218
+ // 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
219
+ ...(resolved === null ? {} : { toolFilter: resolved }),
173
220
  maxDepth: 1,
174
221
  // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
175
222
  // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
@@ -197,8 +244,8 @@ export function createSubagentService(ctx, opts) {
197
244
  }
198
245
  // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
199
246
  let chain = Promise.resolve();
200
- const runSerial = (parentAgent, p, task, signal) => {
201
- const queued = chain.then(() => runOnce(parentAgent, p, task, signal), () => runOnce(parentAgent, p, task, signal));
247
+ const runSerial = (parentAgent, p, task, signal, toolFilter) => {
248
+ const queued = chain.then(() => runOnce(parentAgent, p, task, signal, toolFilter), () => runOnce(parentAgent, p, task, signal, toolFilter));
202
249
  chain = queued.catch(() => undefined);
203
250
  return queued;
204
251
  };
@@ -207,7 +254,7 @@ export function createSubagentService(ctx, opts) {
207
254
  const docs = await list();
208
255
  return {
209
256
  ok: true,
210
- subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null })),
257
+ subagents: docs.map((p) => ({ name: p.name, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null })),
211
258
  };
212
259
  },
213
260
  'subagent-get': async (args) => {
@@ -216,7 +263,7 @@ export function createSubagentService(ctx, opts) {
216
263
  const p = docs.find((d) => d.name === name);
217
264
  if (!p)
218
265
  return { ok: false, error: `人设不存在: ${name}` };
219
- return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], body: p.body } };
266
+ return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, body: p.body } };
220
267
  },
221
268
  'subagent-create': async (args) => {
222
269
  const name = String((args && args.name) || '').trim();
@@ -250,13 +297,55 @@ export function createSubagentService(ctx, opts) {
250
297
  cache = null;
251
298
  return { ok: true, name };
252
299
  },
300
+ /**
301
+ * 删除人设 = **移入回收站**(`hub/trash/agents-trash/<id>/persona.md`)。
302
+ * 早先这里是 `rm`,而界面按钮写着「移入回收站」——文案与行为不一致;现在行为追上文案,
303
+ * 误删可以从子智能体页的回收站里恢复。
304
+ */
253
305
  'subagent-delete': async (args) => {
254
306
  const name = String((args && args.name) || '').trim();
255
307
  if (!validPersonaName(name))
256
308
  return { ok: false, error: `人设名不合法: ${name || '(空)'}` };
257
- await rm(join(dir, name + '.md')).catch(() => { });
309
+ const target = join(dir, name + '.md');
310
+ const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
311
+ if (!exists)
312
+ return { ok: false, error: `人设不存在: ${name}` };
313
+ const moved = await moveToTrash('agents', name, [{ from: target, dest: 'persona.md' }]);
314
+ if (moved.ok === false)
315
+ return { ok: false, error: `移入回收站失败: ${moved.error}` };
258
316
  cache = null;
259
- return { ok: true, name };
317
+ return { ok: true, name, trashId: moved.id };
318
+ },
319
+ 'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('agents') }),
320
+ 'subagent-trash-restore': async (args) => {
321
+ const id = String((args && args.id) || '').trim();
322
+ const entry = await readTrashEntry('agents', id);
323
+ if (!entry)
324
+ return { ok: false, error: `回收站条目不存在: ${id}` };
325
+ if (!validPersonaName(entry.name))
326
+ return { ok: false, error: `回收站里的人设名不合法: ${entry.name}` };
327
+ const target = join(dir, entry.name + '.md');
328
+ const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
329
+ // 绝不覆盖:同名人设已经存在时如实拒绝,让人自己决定怎么办。
330
+ if (exists)
331
+ return { ok: false, error: `无法恢复,同名人设已存在: ${entry.name}` };
332
+ try {
333
+ await mkdir(dir, { recursive: true });
334
+ await moveOutOfTrash('agents', id, 'persona.md', target);
335
+ }
336
+ catch (e) {
337
+ return { ok: false, error: `恢复失败: ${message(e)}` };
338
+ }
339
+ await purgeTrashEntry('agents', id);
340
+ cache = null;
341
+ return { ok: true, name: entry.name };
342
+ },
343
+ 'subagent-trash-delete': async (args) => {
344
+ const id = String((args && args.id) || '').trim();
345
+ const gone = await purgeTrashEntry('agents', id);
346
+ if (!gone)
347
+ return { ok: false, error: `回收站条目不存在: ${id}` };
348
+ return { ok: true, id };
260
349
  },
261
350
  /**
262
351
  * 导入人设(.md / .zip):一个 .md 一个人设,文件名(去扩展名)= 人设名;
@@ -302,20 +391,46 @@ export function createSubagentService(ctx, opts) {
302
391
  return { ok: true, imported, skipped };
303
392
  },
304
393
  };
305
- return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import']) };
394
+ return { list, runSerial, ops, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete']) };
306
395
  }
307
396
  function validPersonaName(name) {
308
397
  return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
309
398
  }
399
+ /** 字符串数组规范化(非数组/空项都丢掉)。 */
400
+ function toStringList(value) {
401
+ return Array.isArray(value) ? value.map((x) => String(x).trim()).filter(Boolean) : [];
402
+ }
403
+ /**
404
+ * 规范化前端传来的 `toolsByPreset`:丢掉空名单(空 = 不限制 = 不落盘),
405
+ * 模式 id 只收形状合法的(预设 id 允许字母数字点横线)。
406
+ */
407
+ export function presetRulesOf(value) {
408
+ if (value === null || typeof value !== 'object')
409
+ return [];
410
+ const out = [];
411
+ for (const [rawId, rawRule] of Object.entries(value)) {
412
+ const id = String(rawId).trim();
413
+ if (!id || !/^[A-Za-z0-9._-]+$/.test(id))
414
+ continue;
415
+ if (rawRule === null || typeof rawRule !== 'object')
416
+ continue;
417
+ const rec = rawRule;
418
+ const names = toStringList(rec.names);
419
+ if (!names.length)
420
+ continue;
421
+ out.push([id, { mode: String(rec.mode) === 'deny' ? 'deny' : 'allow', names }]);
422
+ }
423
+ return out;
424
+ }
310
425
  /** 人设文件序列化:frontmatter 只写用户填过的键;正文 = 人设提示词。 */
311
426
  export function serializePersona(args) {
312
427
  const description = String((args && args.description) || '').replace(/\r?\n/g, ' ').trim();
313
428
  const provider = String((args && args.provider) || '').trim();
314
429
  const model = String((args && args.model) || '').trim();
315
- const toList = (v) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : []);
316
- const tools = toList(args?.tools);
430
+ const tools = toStringList(args?.tools);
317
431
  // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
318
- const toolsDeny = toList(args?.toolsDeny ?? args?.tools_deny);
432
+ const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
433
+ const byPreset = presetRulesOf(args?.toolsByPreset);
319
434
  const body = String((args && args.body) ?? '').trim();
320
435
  const lines = ['---'];
321
436
  if (description)
@@ -328,6 +443,65 @@ export function serializePersona(args) {
328
443
  lines.push('tools: ' + tools.join(', '));
329
444
  if (toolsDeny.length)
330
445
  lines.push('toolsDeny: ' + toolsDeny.join(', '));
446
+ if (byPreset.length) {
447
+ lines.push('toolsByPreset:');
448
+ for (const [id, rule] of byPreset)
449
+ lines.push(' ' + id + ': ' + rule.mode + ': ' + rule.names.join(', '));
450
+ }
331
451
  lines.push('---', '', body, '');
332
452
  return lines.join('\n');
333
453
  }
454
+ /**
455
+ * 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
456
+ *
457
+ * 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
458
+ * 而各预设的工具集合差别极大(极简模式只有持久 shell),官方 `tools.restrict()` 遇到
459
+ * 名单里不存在的工具名会直接抛错、子代理根本起不来。所以:
460
+ *
461
+ * - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
462
+ * (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
463
+ * 用当前启动的 MCP);
464
+ * - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
465
+ * - 名单里有已经消失的工具名 → **丢掉并在 note 里如实说明**(不接受静默失效);
466
+ * - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
467
+ *
468
+ * 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
469
+ */
470
+ export function decideToolFilter(persona, presetId, known) {
471
+ const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
472
+ if (rule && rule.names.length) {
473
+ const usable = rule.names.filter((name) => known.names.has(name));
474
+ const dropped = rule.names.filter((name) => !known.names.has(name));
475
+ const droppedNote = dropped.length ? `名单里这些工具当前不存在,已忽略:${dropped.join('、')}` : undefined;
476
+ if (!usable.length) {
477
+ return { filter: null, note: `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
478
+ }
479
+ if (rule.mode === 'allow')
480
+ return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
481
+ return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
482
+ }
483
+ // 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
484
+ const legacyAllow = (persona.tools || []).filter((name) => known.names.has(name));
485
+ const legacyDeny = (persona.toolsDeny || []).filter((name) => known.names.has(name));
486
+ const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
487
+ const filter = {
488
+ ...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
489
+ ...(legacyDeny.length ? { deny: legacyDeny } : {}),
490
+ };
491
+ const notes = [];
492
+ const hasLegacy = Boolean((persona.tools || []).length || (persona.toolsDeny || []).length);
493
+ if (presetId === null) {
494
+ // 说清这一刻到底靠什么在跑:有旧格式就照旧格式,没有就明说"按模式配的限制这次不生效"。
495
+ notes.push(hasLegacy
496
+ ? '没能判断当前会话的 Agent 预设,按旧格式的全局名单执行'
497
+ : '没能判断当前会话的 Agent 预设,按模式配的限制这次不生效');
498
+ }
499
+ if (legacyDropped.length)
500
+ notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
501
+ if (!Object.keys(filter).length) {
502
+ if (presetId === null && !notes.length)
503
+ notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');
504
+ return { filter: null, ...(notes.length ? { note: notes.join(';') } : {}) };
505
+ }
506
+ return { filter, ...(notes.length ? { note: notes.join(';') } : {}) };
507
+ }
@@ -54,9 +54,15 @@ export function defineSubagentRunTool(subagents) {
54
54
  const available = allowed.map((p) => p.name).join('、') || '(无)';
55
55
  throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
56
56
  }
57
- const r = await subagents.runSerial(exec.agent, persona, task, exec.signal);
57
+ // 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
58
+ // 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
59
+ const decision = typeof subagents.toolFilterFor === 'function'
60
+ ? await subagents.toolFilterFor(persona, exec.agent && exec.agent.ctx)
61
+ : undefined;
62
+ const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter);
58
63
  const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
59
- return prefix + r.text;
64
+ const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
65
+ return note + prefix + r.text;
60
66
  },
61
67
  };
62
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-tool-management",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DSH plugin: one settings panel for MCP servers, Skills, AGENTS.md presets, archived sessions and scene memories — plus scene memory injected into the system prompt and one-shot persona subagents. Settings UI + HTTP API + 14 model tools (skill_mcp_manager_*, skill_manager_*, agentsmd_*, rule_manager_*, subagent_*). Install with one command: dsh plugin --profile web add dsh-plugin-tool-management@latest",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,7 @@
20
20
  "cordis.patch.yml",
21
21
  "README.md",
22
22
  "README_EN.md",
23
+ "CHANGELOG.md",
23
24
  "LICENSE",
24
25
  "screenshots.json",
25
26
  "docs"
@@ -40,7 +41,7 @@
40
41
  "check:host": "node scripts/host-deps.mjs && node scripts/doctor.mjs",
41
42
  "host-deps": "node scripts/host-deps.mjs --fix",
42
43
  "doctor": "node scripts/doctor.mjs",
43
- "test": "npm run build && npm run check:i18n && node --test test/archive.test.mjs test/import.test.mjs test/approval-policy.test.mjs test/subagent-scene.test.mjs test/subagent-persona.test.mjs test/hub-layout.test.mjs test/skills-state.test.mjs test/skills-delete.test.mjs test/skills-source-remove.test.mjs test/client-exports.test.mjs test/client-render.test.mjs test/compat-probe.test.mjs test/compat-fallback.test.mjs"
44
+ "test": "npm run build && npm run check:i18n && node --test test/archive.test.mjs test/import.test.mjs test/approval-policy.test.mjs test/subagent-scene.test.mjs test/subagent-persona.test.mjs test/hub-layout.test.mjs test/skills-state.test.mjs test/skills-delete.test.mjs test/skills-source-remove.test.mjs test/client-exports.test.mjs test/client-render.test.mjs test/compat-probe.test.mjs test/compat-fallback.test.mjs test/preset-reach.test.mjs test/rules-toggle-args.test.mjs"
44
45
  },
45
46
  "keywords": [
46
47
  "dsh",
package/screenshots.json CHANGED
@@ -1,10 +1,10 @@
1
- [
2
- "docs/images/1场景.png",
3
- "docs/images/2MCP.png",
4
- "docs/images/3技能.png",
5
- "docs/images/4子智能体.png",
6
- "docs/images/5提示词.png",
7
- "docs/images/6记忆.png",
8
- "docs/images/7会话.png",
9
- "docs/images/8兼容.png"
10
- ]
1
+ [
2
+ "docs/images/1场景.png",
3
+ "docs/images/2MCP.png",
4
+ "docs/images/3技能.png",
5
+ "docs/images/4子智能体.png",
6
+ "docs/images/5提示词.png",
7
+ "docs/images/6记忆.png",
8
+ "docs/images/7会话.png",
9
+ "docs/images/8兼容.png"
10
+ ]