dsh-plugin-tool-management 0.8.5 → 0.9.1

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 (59) hide show
  1. package/CHANGELOG.md +275 -206
  2. package/README.md +71 -69
  3. package/README_EN.md +65 -65
  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 +132 -86
  21. package/lib/agents-md/preset-id.js +1 -1
  22. package/lib/agents-md/service.js +46 -9
  23. package/lib/approval-policy.js +1 -1
  24. package/lib/client.js +867 -146
  25. package/lib/compat/preset-reach.js +155 -93
  26. package/lib/context-inject.js +401 -0
  27. package/lib/hub.js +155 -13
  28. package/lib/imports/upload.js +34 -14
  29. package/lib/index.js +1072 -243
  30. package/lib/mcp/state-section.js +46 -15
  31. package/lib/rules/archive-engine.js +158 -64
  32. package/lib/rules/archive.js +41 -1
  33. package/lib/rules/service.js +424 -162
  34. package/lib/scene-prompt-sync.js +93 -12
  35. package/lib/skills/catalog.js +125 -0
  36. package/lib/skills/core.js +6 -4
  37. package/lib/skills/service.js +56 -2
  38. package/lib/subagents/catalog.js +15 -6
  39. package/lib/subagents/service.js +25 -12
  40. package/lib/subagents/tools.js +18 -13
  41. package/package.json +11 -6
  42. package/screenshots.json +8 -8
  43. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  44. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  45. package/docs/images/2MCP.png +0 -0
  46. package/docs/images/2MCP_en.png +0 -0
  47. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  48. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  49. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  50. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  51. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  52. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  53. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  54. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  55. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  56. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  57. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  58. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  59. package/lib/prompt-sections.js +0 -148
package/lib/index.js CHANGED
@@ -20,22 +20,67 @@ import { createRulesService, planMemoryExport } from './rules/service.js';
20
20
  import { createArchiveEngine } from './rules/archive-engine.js';
21
21
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
22
22
  import { createSubagentCatalog } from './subagents/catalog.js';
23
- import { SECTION_ORDER, createPromptSectionRegistrar } from './prompt-sections.js';
23
+ import { createSkillCatalog } from './skills/catalog.js';
24
+ import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, } from './context-inject.js';
24
25
  import { isApprovalNever } from './approval-policy.js';
25
26
  import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
26
- import { assessPresetReach, presetRosterOf, reachNoticeForAgent } from './compat/preset-reach.js';
27
+ import { assessPresetReach, injectionFactsOf, presetRosterOf, readCompositionFacts, reachNoticeForAgent } from './compat/preset-reach.js';
27
28
  import { fenceRejection, secretOpRejection } from './http-fence.js';
28
29
  import { planOverrideCompaction } from './mcp/override-blocks.js';
29
- import { createMcpStateCatalog, mcpStateSectionOf } from './mcp/state-section.js';
30
- import { hubPath, hubRoot, relocateEntries } from './hub.js';
30
+ import { DEFAULT_MCP_NOTE_MAX_LENGTH, createMcpStateCatalog, normalizeMcpNote } from './mcp/state-section.js';
31
+ import { hubBackupDir, hubPath, hubRoot, migrateHubLayoutSync, relocateEntries } from './hub.js';
31
32
  import { createScenePromptSync } from './scene-prompt-sync.js';
32
- import { defineSubagentListTool, defineSubagentRunTool } from './subagents/tools.js';
33
+ import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from './subagents/tools.js';
33
34
  import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
34
35
  import { homedir } from 'node:os';
35
36
  import { spawn } from 'node:child_process';
36
- import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
37
+ import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
37
38
  import { basename, dirname, join } from 'node:path';
38
39
  import { zipSync } from 'fflate';
40
+ /**
41
+ * 归一化「已知工具」侧车的一行值。
42
+ *
43
+ * 兼容两种格式:旧版 `string[]`(只存名字,v0.8.5 及以前)与新版 `Array<{name, description?}>`。
44
+ * 非法名字、重复名字一律丢掉并去重后按名字排序 —— 排序保证写盘内容稳定(不会因为枚举顺序
45
+ * 变化而反复重写侧车)。导出仅为测试接缝(与 client 的 `memDefaultPickIds` 同一用意)。
46
+ */
47
+ export function normalizeKnownTools(raw) {
48
+ if (!Array.isArray(raw))
49
+ return [];
50
+ const out = [];
51
+ const seen = new Set();
52
+ for (const item of raw) {
53
+ const name = typeof item === 'string'
54
+ ? item
55
+ : String(item?.name ?? '');
56
+ if (!name || !/^[A-Za-z0-9_-]{1,128}$/.test(name) || seen.has(name))
57
+ continue;
58
+ seen.add(name);
59
+ const description = typeof item === 'object' && item !== null && typeof item.description === 'string'
60
+ ? String(item.description)
61
+ : undefined;
62
+ out.push({ name, ...(description ? { description } : {}) });
63
+ }
64
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
65
+ }
66
+ /**
67
+ * 「当前可用」工具数:已知工具名里未被停用表扣减的个数;`*` = 整台停用 → 0。
68
+ *
69
+ * 场景档案收窄与手动逐工具开关写的是同一张停用表,所以**段、页面、`mcp_manager_list`
70
+ * 三处的「N 个工具」都必须是这个数** —— 用扣减前的总数会让模型看到自己 schema 里
71
+ * 并不存在的工具(数字与能调的工具对不上),页面也会和详情页的开关自相矛盾。
72
+ * 导出仅为测试接缝(与 `normalizeKnownTools` 同一用意)。
73
+ */
74
+ export function countEnabledTools(known, disabled) {
75
+ const off = Array.isArray(disabled) ? disabled : [];
76
+ if (off.indexOf('*') >= 0)
77
+ return 0;
78
+ let n = 0;
79
+ for (const tool of known)
80
+ if (off.indexOf(tool) < 0)
81
+ n++;
82
+ return n;
83
+ }
39
84
  // ---------------------------------------------------------------------------
40
85
  // Transcript export helpers — mirror the import parsers (src/imports/parsers.js)
41
86
  // so an exported file can be imported back losslessly. Pure Node, no ctx.
@@ -71,6 +116,10 @@ export default {
71
116
  name: 'dsh-plugin-tool-management-host',
72
117
  inject: ['timer', 'fs', 'settings', 'sandboxPolicy', 'webServer', 'tools', 'skills', 'sessions', 'agents', 'workspaceRegistry', 'sessionProjectionCache', 'sessionPersistence'],
73
118
  apply(ctx, config) {
119
+ // 布局迁移必须**先于任何读盘**:hub 内的旧名(`agents-md/` → `prompts/` 等)与
120
+ // `$DSH_HOME` 根下的插件侧车 / 日志 / patch 备份,都要在服务读它们之前搬到位。
121
+ // 同步执行:一次性的几次 stat/rename,换来「没有并发窗口」;失败只跳过该项,不抛错。
122
+ migrateHubLayoutSync();
74
123
  const fs = ctx.fs;
75
124
  const settings = ctx.settings;
76
125
  const sandboxPolicy = ctx.sandboxPolicy;
@@ -125,11 +174,11 @@ export default {
125
174
  catch (e) {
126
175
  console.error('[dsh-plugin-tool-management] skills provider setup failed:', message(e));
127
176
  }
128
- // ---------- agents-md 预设库 + 切换 ----------
177
+ // ---------- 提示词预设库(hub/prompts/)+ 切换 ----------
129
178
  // DSH 全局指令基线只有 ~/.dsh/AGENTS.md 一个文件,无内置多预设切换;
130
- // 本服务在 hub 内 agents-md/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
179
+ // 本服务在 hub 内 prompts/ 维护预设库,「应用」= 写入 ~/.dsh/AGENTS.md,
131
180
  // 下一轮对话生效:宿主 dsh-agent-instructions 每个 agent/pre-step 都会 stat 并重读该文件。
132
- // presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/agents-md。
181
+ // presetsDir 可由 config 注入(测试用),否则落到 $DSH_HOME/tool-management/prompts。
133
182
  // v0.4:旧位置(插件目录 data/agents-md-presets/)在启动时搬入 hub——
134
183
  // 插件目录在 npm 安装下会被覆盖,放用户数据在那里本身就丢数据风险。
135
184
  const PLUGIN_ROOT = (() => {
@@ -141,7 +190,7 @@ export default {
141
190
  }
142
191
  })();
143
192
  const legacyAgentsMdPresetsDir = join(PLUGIN_ROOT, 'data', 'agents-md-presets');
144
- const agentsMdPresetsDir = String(config?.presetsDir || hubPath('agents-md'));
193
+ const agentsMdPresetsDir = String(config?.presetsDir || hubPath('prompts'));
145
194
  void relocateEntries(legacyAgentsMdPresetsDir, agentsMdPresetsDir, (n) => n !== '__last-applied__')
146
195
  .catch(() => 0);
147
196
  const agentsMdService = createAgentsMdService(ctx, {
@@ -154,9 +203,9 @@ export default {
154
203
  });
155
204
  // ---------- rules(规则/记忆,v0.4)----------
156
205
  // 记忆真源 $DSH_HOME/tool-management/memories/<场景>/<name>.md(仅用户级,D1)。
157
- // **场景是显式记录**(rules-index.json 的 scenes 切片):含保留场景 `global`(界面「全局」,
206
+ // **场景是显式记录**(memories-index.json 的 scenes 切片):含保留场景 `global`(界面「全局」,
158
207
  // 其记忆注入任何对话),空场景也合法存在——不再是"恰好有这个目录名"的隐式约定。
159
- // 单投影 = 活动场景记忆 → per-agent systemPrompt 段(自动在场,模型无需调用任何工具)。
208
+ // 单投影 = 活动场景记忆 → 上下文注入(自动在场,模型无需调用任何工具;见下方注入通道)。
160
209
  // 原"始终层写 ~/.dsh/AGENTS.md"已下线(变更单 01 §4/§10):公共基线由 _shared/ 承担。
161
210
  // 旧的 $DSH_HOME/scene-memory 与 $DSH_HOME/rules 由服务在首次读盘前搬入本目录。
162
211
  // rulesRoot / rulesStateDir / rulesScenesDir 仅测试注入,生产留空由服务按 DSH_HOME 解析。
@@ -169,12 +218,7 @@ export default {
169
218
  ? { maxBytes: Number(config.rulesMaxBytes) }
170
219
  : {}),
171
220
  });
172
- try {
173
- ctx.effect(() => rulesService.registerProviders(), 'dsh-plugin-tool-management: rules providers');
174
- }
175
- catch (e) {
176
- console.error('[dsh-plugin-tool-management] rules provider setup failed:', message(e));
177
- }
221
+ // 场景记忆段的注册/生命周期由下面的注入通道统一管(rulesService.memoryText / promptText)。
178
222
  // ---------- 场景档案引擎(设计 §2)----------
179
223
  // deps 把既有写通道(MCP 单工具启停 / 技能启停)注入引擎;引擎自身串行,
180
224
  // 状态机与纯逻辑在 ./rules/archive*.ts。mcpmToolEnabled / readDisabledTools
@@ -240,12 +284,18 @@ export default {
240
284
  const states = {};
241
285
  const all = {};
242
286
  const shadowed = new Set();
287
+ const unloadable = new Set();
243
288
  for (const root of ((r && r.data && r.data.roots) || [])) {
244
289
  for (const sk of (root.skills || [])) {
245
290
  const name = String(sk.declaredName || sk.name || '');
246
291
  if (!name)
247
292
  continue;
248
293
  const key = String(root.key || '') + '/' + name;
294
+ // 结构不完整的技能在 core 里**一律拒绝启停**(error.skill.notLoadable):
295
+ // 档案里若残留这样的键,旧实现会把 `applySkills` 的"应用失败"抛出去,于是
296
+ // **整个场景进不去**。这里单独收集,与「被覆盖」同口径处理(见 applySkills)。
297
+ if (sk.loadable === false)
298
+ unloadable.add(key);
249
299
  if (sk.shadowedBy) {
250
300
  shadowed.add(key);
251
301
  all[key] = false;
@@ -256,7 +306,24 @@ export default {
256
306
  all[key] = on;
257
307
  }
258
308
  }
259
- return { states, shadowed, all };
309
+ return { states, shadowed, all, unloadable };
310
+ }
311
+ /**
312
+ * 档案里**能写、且写了有意义**的技能键:既不被同名技能覆盖,结构也完整。
313
+ *
314
+ * 为什么要单独给一个集合:档案的语义是「勾选集 = 该场景下开着的东西」,而被覆盖的副本
315
+ * 永远不可能生效(`applySkills` 直接跳过),结构不完整的更是启停都被 core 拒绝 ——
316
+ * 它们进档案只会造成「档案说开着、运行时说关着」(用户实测:启动目录后档案里多出被覆盖
317
+ * 的那个技能,而技能页显示它并未启动)。保存档案时按这个集合校验,顺带清理历史残留。
318
+ */
319
+ async function selectableSkillKeys() {
320
+ const { shadowed, unloadable } = await skillScan();
321
+ const all = new Set(Object.keys(await skillStates()));
322
+ for (const k of shadowed)
323
+ all.delete(k);
324
+ for (const k of unloadable)
325
+ all.delete(k);
326
+ return all;
260
327
  }
261
328
  async function skillStates() {
262
329
  // knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
@@ -289,6 +356,7 @@ export default {
289
356
  name,
290
357
  enabled: sk.enabled === true && !sk.shadowedBy,
291
358
  shadowed: !!sk.shadowedBy,
359
+ loadable: sk.loadable !== false,
292
360
  rootKey,
293
361
  rootLabel: String((root && root.label) || rootKey),
294
362
  ...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
@@ -321,7 +389,7 @@ export default {
321
389
  out[server] = list.filter((t) => t !== '*');
322
390
  // 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
323
391
  for (const [server, list] of Object.entries(await readKnownMcpTools())) {
324
- out[server] = [...new Set([...(out[server] || []), ...list])].sort();
392
+ out[server] = [...new Set([...(out[server] || []), ...list.map((item) => item.name)])].sort();
325
393
  }
326
394
  let schemas = [];
327
395
  try {
@@ -342,28 +410,37 @@ export default {
342
410
  applyMcpEntries: async (entries) => {
343
411
  const p = await ensurePaths();
344
412
  await withWriteLock(async () => {
345
- await writeJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE), entries);
413
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), entries);
346
414
  });
347
415
  disabledToolsCache = { at: Date.now(), value: entries };
348
416
  scheduleToolRestrictions();
349
417
  },
350
418
  knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
419
+ // 档案保存校验用:被同名覆盖的副本与结构不完整的技能都不算「可勾选」(见 selectableSkillKeys)。
420
+ selectableSkillKeys,
351
421
  // 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
352
422
  // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
353
423
  currentSkills: async () => (await skillScan()).states,
354
424
  applySkills: async (target) => {
355
- // 两道过滤,缺一不可:
425
+ // 三道过滤,缺一不可:
356
426
  // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
357
- // 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
427
+ // 2. 结构不完整的技能也跳过——core 对它们一律拒绝启停(error.skill.notLoadable),
428
+ // 不跳过就会抛成"应用失败",**整个场景进不去**(老档案里的残留键能踩到)。
429
+ // 3. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
358
430
  // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
359
431
  // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
360
- const { states, shadowed } = await skillScan();
432
+ const { states, shadowed, unloadable } = await skillScan();
361
433
  for (const [key, on] of Object.entries(target)) {
362
434
  const i = key.indexOf('/');
363
435
  if (i <= 0)
364
436
  throw new Error(`技能 key 不合法: ${key}`);
437
+ // 被同名覆盖的副本跳过(它本来就不生效,写策略只会污染用户的记录)。
365
438
  if (shadowed.has(key))
366
439
  continue;
440
+ // 结构不完整的技能跳过:core 对它们**一律拒绝启停**(error.skill.notLoadable),
441
+ // 继续往下走会抛成"应用失败",**整个场景就进不去了**(老档案里的残留键能踩到)。
442
+ if (unloadable.has(key))
443
+ continue;
367
444
  if (states[key] === on)
368
445
  continue;
369
446
  const root = key.slice(0, i);
@@ -403,7 +480,7 @@ export default {
403
480
  // 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
404
481
  // 界面在应用完成后即可用,工具集合会在数秒内补齐。
405
482
  },
406
- // 场景备注(v0.8.1):备注写在 `dsh-plugin-tool-management-notes.json`(按 loader id),
483
+ // 场景备注(v0.8.1):备注写在 hub 内的 `mcp-notes.json`(按 loader id),
407
484
  // 场景档案按 serverName 存 —— 引擎负责映射与读写,与 mcpm-note 同一条写锁。
408
485
  currentMcpNotes: async () => ({ ...(await readNotes()) }),
409
486
  applyMcpNotes: async (entries) => {
@@ -416,7 +493,7 @@ export default {
416
493
  delete map[e.id];
417
494
  }
418
495
  const p = await ensurePaths();
419
- await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'), map);
496
+ await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
420
497
  notesCache = { at: Date.now(), value: map };
421
498
  });
422
499
  },
@@ -443,12 +520,38 @@ export default {
443
520
  }
444
521
  return out;
445
522
  },
523
+ /**
524
+ * 来源级启停(进入 / 退出 / 就地重应用共用)。
525
+ *
526
+ * 容错口径(用户 2026-09-17 报「退出模式失败 → 卡在场景里」之后加的):**结构性**失败
527
+ * (来源解析不到 / 该来源本来就不允许启停)只记警告并跳过 —— 这一行与本次改动无关,
528
+ * 拿它中断整个恢复只会把用户困在场景里;**写盘**类失败仍然抛错,那说明确实没还原成功。
529
+ * 结构性失败的正解在 `skills/service.ts` 的 `requestSwitchRoot`(项目来源按 key 前缀解析)。
530
+ */
446
531
  applySkillSourceSwitches: async (switches) => {
532
+ const skipped = [];
447
533
  for (const s of switches) {
448
534
  const op = s.enabled ? 'skill-source-enable' : 'skill-source-disable';
449
- const r = await skillsService.ops[op]({ root: s.root });
450
- if (r && r.ok === false)
535
+ let r;
536
+ try {
537
+ r = await skillsService.ops[op]({ root: s.root });
538
+ }
539
+ catch (e) {
540
+ r = { ok: false, error: message(e), code: '' };
541
+ }
542
+ if (r && r.ok === false) {
543
+ const code = String((r && r.code) || '');
544
+ const structural = code === 'error.root.readonly' || code === 'error.root.unknown' ||
545
+ /来源不存在|不允许启用或停用/.test(String(r.error || ''));
546
+ if (structural) {
547
+ skipped.push(`${s.root}(${r.error})`);
548
+ continue;
549
+ }
451
550
  throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
551
+ }
552
+ }
553
+ if (skipped.length) {
554
+ ctx.logger?.warn?.(`scene: 跳过无法启停的技能来源 —— ${skipped.join('、')}`);
452
555
  }
453
556
  },
454
557
  sceneExists: async (name) => {
@@ -457,8 +560,9 @@ export default {
457
560
  },
458
561
  // 人设名全集(保存 subagents 段时校验并报 stale,与 mcp/skills 两段同口径)。
459
562
  knownPersonas: async () => new Set((await subagentService.list()).map((p) => p.name)),
460
- // 人设开关读/写(进入模式启用档案勾选的人设;退出按快照停回)。
563
+ // 人设开关读/写(进入模式:勾了的启用、未勾的停用;退出按快照两个方向还原)。
461
564
  disabledPersonas: async (names) => subagentService.enabledStore.disabledAmong(names),
565
+ enabledPersonas: async (names) => subagentService.enabledStore.enabledAmong(names),
462
566
  applySubagentSwitches: async (switches) => {
463
567
  const off = switches.filter((s) => !s.enabled).map((s) => s.name);
464
568
  const on = switches.filter((s) => s.enabled).map((s) => s.name);
@@ -475,9 +579,9 @@ export default {
475
579
  },
476
580
  });
477
581
  // ---------- 轻量子智能体(设计 §3)----------
478
- // 人设 = $DSH_HOME/tool-management/agents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
582
+ // 人设 = $DSH_HOME/tool-management/subagents/<name>.md;运行走官方 ctx.subagents.start(spawn provider)。
479
583
  // sceneLists 供场景绑定校验:启用场景(rules-list 的 active 行)档案里的 subagents 并集。
480
- // stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/agents-index.json。
584
+ // stateDir 与 rules 共用同一个覆盖键(测试注入一致);子智能体开关存 <stateDir>/subagents-index.json。
481
585
  const subagentService = createSubagentService(ctx, { stateDir: String(config?.rulesStateDir || '') });
482
586
  const subagentSceneLists = async () => {
483
587
  const slice = await rulesService.readArchiveSlice();
@@ -488,13 +592,17 @@ export default {
488
592
  .filter((s) => s.active)
489
593
  .map((s) => slice.archives[s.name]?.subagents ?? []);
490
594
  };
491
- // ---------- 注入类段(P1 人设目录 + P2 MCP 状态)----------
492
- // 两者是同一类缺陷的两端:**某个域的配置状态没有在场陈述**,模型只能靠用户口头提醒。
493
- // - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_run 不会被触发。
595
+ // ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
596
+ // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
597
+ // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
598
+ // 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
599
+ // 轨迹里各自一行),见 src/context-inject.ts 的文件头。
600
+ // - 场景和记忆:场景名 + 场景说明 + 记忆正文(同一份文本;场景与记忆是绑定的)。
601
+ // - 人设:本插件自造的概念,宿主没有对应物 → 模型不知道有哪些人设,subagent_manager_run 不会被触发。
494
602
  // - MCP:启停只改工具 schema,没有任何显式状态陈述 → 模型不知道有哪些 server,
495
603
  // 更拿不到用户写的**备注**(那是写给「未来的模型」的决策提示,工具 schema 永远传达不到)。
496
- // 两个段都只列「名字 + 摘要」,正文/工具清单绝不进上下文。
497
- // 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts 的文件头。
604
+ // 各域只列「名字 + 摘要」,正文/工具清单绝不进上下文。
605
+ // 口径与理由分别见 src/subagents/catalog.ts、src/mcp/state-section.ts。
498
606
  const subagentCatalog = createSubagentCatalog({
499
607
  list: () => subagentService.list(),
500
608
  sceneLists: subagentSceneLists,
@@ -503,24 +611,140 @@ export default {
503
611
  const mcpStateCatalog = createMcpStateCatalog({
504
612
  rows: () => mcpmRowsWithNotes(false),
505
613
  });
614
+ // 技能目录与提示词两个域是**官方载体的兜底**:预设挂得到官方
615
+ // dsh-tool-skill / dsh-agent-instructions 时注入侧会跳过它们(见 context-inject.ts 的
616
+ // CARRIER_FACT_OF),挂不到(极简)时才由这里送 —— 所以极简下也能按开关决定要不要。
617
+ const skillCatalog = createSkillCatalog({
618
+ state: () => skillsService.ops['skill-state']({}),
619
+ });
620
+ // 注入设置(侧车 `inject-settings.json`,界面在「兼容」页):
621
+ // - underSuppressingPresets:压制型预设(persona complete / 关运行时上下文,如极简)
622
+ // 下是否仍然注入。默认 false = 跟随预设。
623
+ // - domains:各域开关(任何预设下都生效;界面五个勾选)。
624
+ // 注入器每个 step 都要同步读一次设置 → 走 TTL 缓存;未加载时先给默认值并异步预热。
625
+ const INJECT_SETTINGS_FILE = 'inject-settings.json';
626
+ const INJECT_SETTINGS_TTL_MS = 3000;
627
+ let injectSettingsCache = null;
628
+ async function readInjectSettings(force = false) {
629
+ if (injectSettingsCache && !force && Date.now() - injectSettingsCache.at < INJECT_SETTINGS_TTL_MS)
630
+ return injectSettingsCache.value;
631
+ await ensurePaths();
632
+ const raw = await readJsonFile(hubPath(INJECT_SETTINGS_FILE));
633
+ injectSettingsCache = { at: Date.now(), value: normalizeInjectSettings(raw) };
634
+ return injectSettingsCache.value;
635
+ }
636
+ /** 同步快照(注入器取用);还没加载时先返回默认值,并顺手预热一次。 */
637
+ function injectSettingsSync() {
638
+ if (injectSettingsCache)
639
+ return injectSettingsCache.value;
640
+ void readInjectSettings().catch(() => { });
641
+ return DEFAULT_INJECT_SETTINGS;
642
+ }
643
+ /** 边界提示要的注入设置快照(总开关 + 各域开关)。 */
644
+ const injectNoticeOptions = () => {
645
+ const s = injectSettingsSync();
646
+ return { underSuppressingPresets: s.underSuppressingPresets, domains: s.domains };
647
+ };
648
+ async function injectSettingsOp(args) {
649
+ const current = await readInjectSettings();
650
+ if (!args || args.set !== true)
651
+ return { ok: true, settings: current };
652
+ const next = normalizeInjectSettings({
653
+ underSuppressingPresets: typeof args.underSuppressingPresets === 'boolean'
654
+ ? args.underSuppressingPresets
655
+ : current.underSuppressingPresets,
656
+ domains: { ...current.domains, ...(args.domains && typeof args.domains === 'object' ? args.domains : {}) },
657
+ });
658
+ await ensurePaths();
659
+ return withWriteLock(async () => {
660
+ try {
661
+ await writeJsonFile(hubPath(INJECT_SETTINGS_FILE), next);
662
+ }
663
+ catch (e) {
664
+ return { ok: false, error: '设置保存失败: ' + message(e) };
665
+ }
666
+ injectSettingsCache = { at: Date.now(), value: next };
667
+ return { ok: true, settings: next };
668
+ });
669
+ }
670
+ // 注入判定要的两条预设事实(压制型?挂得到 AGENTS.md 通道?)。读取走 TTL 缓存:
671
+ // 组合文件可以随时被编辑(patchReload: live),但每个 step 读一次盘没有必要。
672
+ const PRESET_FACTS_TTL_MS = 15_000;
673
+ const presetFactsCache = new Map();
674
+ async function presetFactsForAgent(agent) {
675
+ const roster = presetRoster();
676
+ if (!roster || typeof roster.composedPreset !== 'function' || typeof roster.read !== 'function')
677
+ return undefined;
678
+ let presetId = '';
679
+ try {
680
+ presetId = String(roster.composedPreset(agent?.ctx) ?? '');
681
+ }
682
+ catch {
683
+ return undefined;
684
+ }
685
+ if (presetId === '')
686
+ return undefined;
687
+ const hit = presetFactsCache.get(presetId);
688
+ if (hit && Date.now() - hit.at < PRESET_FACTS_TTL_MS)
689
+ return hit.value;
690
+ try {
691
+ const text = String((await roster.read(presetId)) ?? '');
692
+ const value = injectionFactsOf(readCompositionFacts(text));
693
+ presetFactsCache.set(presetId, { at: Date.now(), value });
694
+ return value;
695
+ }
696
+ catch {
697
+ return undefined;
698
+ }
699
+ }
700
+ // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
701
+ // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
702
+ let contextInjectorLive = null;
506
703
  try {
507
704
  ctx.effect(() => {
508
- const registrar = createPromptSectionRegistrar(ctx, [
509
- { name: 'tool-management:subagents', order: SECTION_ORDER.subagents, text: () => subagentCatalog.text() },
510
- mcpStateSectionOf(mcpStateCatalog, SECTION_ORDER.mcpState),
511
- ]);
512
- return () => registrar.dispose();
513
- }, 'dsh-plugin-tool-management: prompt sections');
705
+ const injector = createContextInjector({
706
+ ctx,
707
+ // 顺序即界面勾选与消息顺序(用户裁定 2026-09-16):场景和记忆 → MCP → 技能 →
708
+ // 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
709
+ // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
710
+ domains: () => [
711
+ { key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText() },
712
+ { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcpStateCatalog.text() },
713
+ { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
714
+ { key: 'subagents', name: 'tool-management:subagents', label: '子智能体', form: 'catalog', text: () => subagentCatalog.text() },
715
+ { key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => rulesService.promptText() },
716
+ ],
717
+ settings: () => injectSettingsSync(),
718
+ factsFor: (agent) => presetFactsForAgent(agent),
719
+ });
720
+ contextInjectorLive = () => injector.live();
721
+ return () => { contextInjectorLive = null; injector.dispose(); };
722
+ }, 'dsh-plugin-tool-management: context injection');
514
723
  }
515
724
  catch (e) {
516
- console.error('[dsh-plugin-tool-management] prompt section setup failed:', message(e));
725
+ console.error('[dsh-plugin-tool-management] context injection setup failed:', message(e));
517
726
  }
727
+ void readInjectSettings().catch(() => { });
518
728
  // 预热放到下一轮事件循环:此时 apply 的同步初始化(补丁路径、tools 服务…)已全部完成,
519
729
  // 避免在初始化中途就去读 MCP 补丁与工具 schema。
520
730
  setTimeout(() => {
521
731
  void subagentCatalog.warm();
522
732
  void mcpStateCatalog.warm();
733
+ void skillCatalog.warm();
523
734
  }, 0);
735
+ // 技能写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
736
+ // 与下面人设目录同一手法:写入口只有 service 的 writeOps,不需要在界面层逐个补。
737
+ for (const opName of skillsService.writeOps) {
738
+ const original = skillsService.ops[opName];
739
+ if (typeof original !== 'function')
740
+ continue;
741
+ skillsService.ops[opName] = async (args) => {
742
+ const result = await original(args);
743
+ if (result && result.ok !== false)
744
+ void skillCatalog.refresh();
745
+ return result;
746
+ };
747
+ }
524
748
  // 人设写操作成功后立即重算目录(SWR:text() 同步返回缓存值,写后主动 refresh)。
525
749
  // 包装在 service 的 ops 上:写入口只有这一处,不需要在界面层逐个补。
526
750
  for (const opName of subagentService.writeOps) {
@@ -537,7 +761,7 @@ export default {
537
761
  /**
538
762
  * 人设改名后同步场景绑定:场景档案的 `subagents` 名单存的是**人设名**,
539
763
  * 不跟着改就会留一个悬空引用(界面把它报成 stale,用户看到「人设不存在」却找不到地方改)。
540
- * 绑定存在 rules-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
764
+ * 绑定存在 memories-index.json 里,人设服务看不到它,所以在这一层补一次(同提示词改名的做法)。
541
765
  */
542
766
  async function rebindSubagentInArchives(from, to) {
543
767
  try {
@@ -601,9 +825,11 @@ export default {
601
825
  };
602
826
  }
603
827
  /**
604
- * 模式进行中保存当前场景的档案时,联动人设开关:subagents 段新勾进来的名字**立即启用**,
605
- * 并追加进快照的「模式期间自动启用」名单(退出时停回)。只启用、不停用 —— 用户手动
606
- * 关掉的开关不该被一次档案保存悄悄改回去。
828
+ * 模式进行中保存当前场景的档案时,联动人设开关:**档案 = 这个场景开着的人设**
829
+ * (与进入场景时同一口径,见 archive-engine)——
830
+ * 新勾进来的立即启用,并追加进快照的「退出时停回」名单;
831
+ * 取消勾选的立即停用;其中「进场景前就开着」的那些追加进快照的「退出时开回」名单
832
+ * (在停回名单里的说明是本次进场景才打开的,退出本来就该关,不进开回名单)。
607
833
  */
608
834
  const baseSceneArchiveSave = archiveService.ops['scene-archive-save'];
609
835
  if (typeof baseSceneArchiveSave === 'function') {
@@ -611,23 +837,33 @@ export default {
611
837
  const res = await baseSceneArchiveSave(args);
612
838
  try {
613
839
  const bound = res && res.ok !== false && res.archive && Array.isArray(res.archive.subagents) ? res.archive.subagents : [];
614
- if (bound.length) {
615
- const slice = await rulesService.readArchiveSlice();
616
- const mode = slice.mode;
617
- if (mode && mode.scene === res.scene) {
618
- const disabled = await subagentService.enabledStore.disabledAmong(bound);
619
- if (disabled.length) {
620
- await subagentService.enabledStore.setEnabled(disabled, true);
621
- void subagentCatalog.refresh();
622
- const snapshot = mode.snapshot;
623
- if (snapshot) {
624
- const merged = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
625
- for (const n of disabled)
626
- if (merged.indexOf(n) < 0)
627
- merged.push(n);
628
- await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: merged } } });
629
- }
630
- }
840
+ const slice = await rulesService.readArchiveSlice();
841
+ const mode = slice.mode;
842
+ if (mode && mode.scene === res.scene) {
843
+ const every = (await subagentService.list()).map((p) => p.name);
844
+ const unbound = every.filter((n) => bound.indexOf(n) < 0);
845
+ const toOn = await subagentService.enabledStore.disabledAmong(bound);
846
+ const toOff = await subagentService.enabledStore.enabledAmong(unbound);
847
+ if (toOn.length)
848
+ await subagentService.enabledStore.setEnabled(toOn, true);
849
+ if (toOff.length)
850
+ await subagentService.enabledStore.setEnabled(toOff, false);
851
+ if (toOn.length || toOff.length)
852
+ void subagentCatalog.refresh();
853
+ const snapshot = mode.snapshot;
854
+ // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
855
+ // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
856
+ // archive-engine 的 restoreSnapshot 兼容分支)。
857
+ if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
858
+ const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
859
+ const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
860
+ for (const n of toOn)
861
+ if (backOn.indexOf(n) < 0)
862
+ backOn.push(n);
863
+ for (const n of toOff)
864
+ if (backOn.indexOf(n) < 0 && offList.indexOf(n) < 0)
865
+ offList.push(n);
866
+ await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...snapshot, subagents: backOn, subagentsOn: offList } } });
631
867
  }
632
868
  }
633
869
  }
@@ -652,7 +888,7 @@ export default {
652
888
  // last line of defense for plaintext credentials too, not just writes.
653
889
  'mcpm-reveal',
654
890
  'skill-open',
655
- // agents-md 写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
891
+ // 提示词写操作(create/update/remove 改预设库;apply 写全局 AGENTS.md;import 从外部内容建预设)
656
892
  'agentsmd-create', 'agentsmd-update', 'agentsmd-apply', 'agentsmd-remove', 'agentsmd-import',
657
893
  // 提示词预设的回收站(恢复 / 永久删除都是写)
658
894
  'agentsmd-trash-restore', 'agentsmd-trash-delete',
@@ -663,6 +899,8 @@ export default {
663
899
  // 通用导出:往用户指定的目录写文件,按写操作门禁(token)。
664
900
  'bundle-export',
665
901
  'history-archive-batch', 'history-workspace-register',
902
+ // 注入设置(五个域开关 / 压制型预设口径)写侧车,按写操作门禁。
903
+ 'inject-settings',
666
904
  ]);
667
905
  // 会泄露明文凭据 / 完整配置的 op:**必须**带对的访问令牌,没配令牌就一律拒绝
668
906
  // (判定在 http-fence.ts 的 secretOpRejection,含两种情况的区分与理由)。
@@ -677,7 +915,7 @@ export default {
677
915
  // 历史侧车三件套(归档时刻账本 / 保留期设置 / 工作区登记快照)统一放 hub 根
678
916
  // (`$DSH_HOME/tool-management/`),**不放插件目录**:npm 安装下插件目录会被
679
917
  // `dsh plugin update` 整体替换,放那里等于"升级即丢账本"——账本一丢,保留期基线
680
- // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把 agents-md 预设
918
+ // 就从 archivedAt 退回 createdAt,归档会话会被提前清掉(v0.4 把提示词预设
681
919
  // 搬进 hub 是同一条理由,见上方注释;这三个是当时漏掉的)。
682
920
  // 旧位置(插件目录 data/)在启动时一次性搬入:只搬不删、绝不覆盖、失败下次再试。
683
921
  const historyArchivedAtFile = String(config?.archivedAtFile || hubPath('history-archived-at.json'));
@@ -738,31 +976,44 @@ export default {
738
976
  }
739
977
  }
740
978
  /**
741
- * **当前生效的提示词预设 id**(界面「生效中」的那一份),供写操作做门禁:
742
- * ① 启用的场景绑定了它 → 就是它(场景优先级最高,与 agentsmd-list 的标记同一口径);
743
- * ② 否则回退到文件比对(`~/.dsh/AGENTS.md` 内容 == 某份预设)。
744
- * 探测失败返回 `null`(调用方决定放行还是拒绝;当前只在删除上使用,放行 + warn)。
979
+ * **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
980
+ *
981
+ * 场景接管期间(启用场景 / 全局绑定在驱动基线):
982
+ * - 应用**同一份** = 重新应用(把被手改的基线写回去),走场景同步;
983
+ * - 应用**别的份** = 换掉这个场景的绑定(用户裁定 2026-09-17:未锁定时切换要可用,
984
+ * 并且同步写进场景档案)。绑定改完再走一次场景同步,于是「场景页显示什么 / 实际注入
985
+ * 什么 / 谁是引用方」三处立刻重新对齐(此前的做法是直接拒绝,用户得先退出场景)。
986
+ * 锁定仍由 `guardLockedOps` 挡住(agentsmd-apply 在冻结清单里)。
745
987
  */
746
- async function effectivePresetId() {
747
- try {
748
- const r = await rulesService.ops['rules-list']({});
749
- const sp = r && r.ok ? r.scenePrompt : null;
750
- if (sp && sp.scene && !sp.missing && sp.presetId)
751
- return String(sp.presetId);
752
- }
753
- catch (e) {
754
- ctx.logger?.warn?.(`agents-md: scene prompt probe failed: ${message(e)}`);
755
- }
756
- try {
757
- const cur = await agentsMdService.getCurrent();
758
- if (cur && cur.ok && cur.presetId)
759
- return String(cur.presetId);
760
- }
761
- catch (e) {
762
- ctx.logger?.warn?.(`agents-md: global baseline probe failed: ${message(e)}`);
988
+ async function applyPresetGuarded(id) {
989
+ const driver = await scenePromptSync.driver();
990
+ if (driver) {
991
+ const target = String(id ?? '').trim();
992
+ if (driver.presetId !== target) {
993
+ const rebound = await rulesService.ops['rules-update-scene']({ name: driver.scene, prompt: target });
994
+ if (!rebound || rebound.ok === false) {
995
+ return {
996
+ ok: false,
997
+ code: 'error.agentsMd.sceneRebindFailed',
998
+ error: `无法把场景「${driver.label}」的提示词绑定改成「${target}」:${(rebound && rebound.error) || '未知原因'}`,
999
+ };
1000
+ }
1001
+ }
1002
+ const r = await scenePromptSync.sync();
1003
+ if (r.error)
1004
+ return { ok: false, error: r.error };
1005
+ return { ok: true, id, viaScene: true };
763
1006
  }
764
- return null;
1007
+ return agentsMdService.apply(id);
765
1008
  }
1009
+ /** 删除拒绝里的引用说明(人话;与界面标签同一套结构化事实)。 */
1010
+ const promptRefReason = (ref) => {
1011
+ if (ref && ref.kind === 'scene')
1012
+ return `场景「${ref.label || ''}」${ref.active ? '(已启用)' : ''}绑定了它`;
1013
+ if (ref && ref.kind === 'restore')
1014
+ return '它就是退出场景后要恢复的全局提示词';
1015
+ return '~/.dsh/AGENTS.md 当前内容就是它';
1016
+ };
766
1017
  async function readHistoryRetention() {
767
1018
  try {
768
1019
  await historyStandby;
@@ -867,7 +1118,6 @@ export default {
867
1118
  catch (e) { /* ignore */ }
868
1119
  if (!home)
869
1120
  throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
870
- await migrateRenamedData(home);
871
1121
  const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
872
1122
  let profileDir = null;
873
1123
  let profileName = 'web';
@@ -914,33 +1164,9 @@ export default {
914
1164
  return false;
915
1165
  }
916
1166
  }
917
- // ---------- one-time data migration (pre-rename sidecars & state) ----------
918
- // The plugin used to ship under the name dsh-skill-mcp-manager: its sidecar
919
- // JSON files and the skills state directory lived under the old prefix. On
920
- // first boot pull them to the new names so an upgraded install keeps its
921
- // data. Best effort — any failure just starts the new layout from scratch.
922
- async function migrateRenamedData(home) {
923
- const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
924
- const pairs = [
925
- // 状态目录经历过两次改名:skill-mcp-manager → dsh-plugin-tool-management → tool-management
926
- ['skill-mcp-manager', 'tool-management'],
927
- ['dsh-plugin-tool-management', 'tool-management'],
928
- ['skill-mcp-manager-notes.json', 'dsh-plugin-tool-management-notes.json'],
929
- ['skill-mcp-manager-settings.json', 'dsh-plugin-tool-management-settings.json'],
930
- ['skill-mcp-manager-disabled-tools.json', 'dsh-plugin-tool-management-disabled-tools.json'],
931
- ['skill-mcp-manager-export.json', 'dsh-plugin-tool-management-export.json'],
932
- ];
933
- for (const [oldName, newName] of pairs) {
934
- try {
935
- const oldAbs = home + sep + oldName;
936
- const newAbs = home + sep + newName;
937
- if (!(await exists(oldAbs)) || (await exists(newAbs)))
938
- continue;
939
- await rename(oldAbs, newAbs);
940
- }
941
- catch (e) { /* best effort */ }
942
- }
943
- }
1167
+ // 一次性数据迁移(旧插件名 / 旧侧车名 / 旧 hub 目录名)已统一收进 hub.ts 的
1168
+ // `migrateHubLayoutSync()`(在 apply() 开头、任何读盘之前同步跑一次)——
1169
+ // 这里不再维护第二份迁移表,避免两处口径漂移。
944
1170
  async function readPatch(abs) {
945
1171
  try {
946
1172
  const t = await fs.resolve(abs);
@@ -961,17 +1187,27 @@ export default {
961
1187
  const p = (n) => String(n).padStart(2, '0');
962
1188
  return '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
963
1189
  }
1190
+ // 改 patch 前的自动备份:写在 `hub/backups/`(不再堆在 `$DSH_HOME` 根下)。
1191
+ // 两个 patch 都叫 `cordis.patch.yml`(全局 + `profiles/<名字>/`),所以备份名里带上层级,
1192
+ // 否则两处在同一个目录里互相撞名、互相挤掉。最坏情况(配置写坏导致 DSH 起不来)
1193
+ // 按文档里的路径找回:`~/.dsh/tool-management/backups/`。
964
1194
  async function backupPatchFile(abs, previous) {
965
1195
  const i = Math.max(abs.lastIndexOf('\\'), abs.lastIndexOf('/'));
966
1196
  if (i <= 0)
967
1197
  return;
968
- const dir = abs.slice(0, i);
969
1198
  const base = abs.slice(i + 1);
970
- await writeFile(abs + '.bak-' + backupStamp(), previous, 'utf8');
1199
+ const profile = /(?:^|[\\/])profiles[\\/]([^\\/]+)$/.exec(abs.slice(0, i));
1200
+ const tag = profile ? 'profile-' + profile[1].replace(/[^A-Za-z0-9._-]/g, '_') : 'global';
1201
+ const stem = base + '.' + tag;
1202
+ const dir = hubBackupDir();
1203
+ await mkdir(dir, { recursive: true });
1204
+ await writeFile(join(dir, stem + '.bak-' + backupStamp()), previous, 'utf8');
971
1205
  try {
972
- const names = (await readdir(dir)).filter((name) => name.startsWith(base + '.bak-')).sort();
1206
+ // 旧位置的备份没有层级后缀(迁移搬进来的),只统计不删错别人的。
1207
+ const mine = (name) => name.startsWith(stem + '.bak-') || (tag === 'global' && name.startsWith(base + '.bak-'));
1208
+ const names = (await readdir(dir)).filter(mine).sort();
973
1209
  for (const stale of names.slice(0, Math.max(0, names.length - KEEP_PATCH_BACKUPS))) {
974
- await unlink(dir + abs[i] + stale);
1210
+ await unlink(join(dir, stale));
975
1211
  }
976
1212
  }
977
1213
  catch (e) { /* pruning is best-effort */ }
@@ -1439,12 +1675,7 @@ export default {
1439
1675
  // for display. Only the direct `properties` of the parameters object are
1440
1676
  // summarized — nested object/array children are omitted (one level is enough
1441
1677
  // for a preview, keeps the dialog readable).
1442
- // ---------- plugin-owned side-car data (notes / settings) ----------
1443
- // The patch file carries loader config only, so user-facing extras live in
1444
- // small JSON files under the DSH home, next to the patch files they describe.
1445
- function sidecarPath(home, name) {
1446
- return home + (home.indexOf('\\') >= 0 ? '\\' : '/') + name;
1447
- }
1678
+ // ---------- plugin-owned side-car data (notes / settings / disabled tools) ----------
1448
1679
  async function readJsonFile(abs) {
1449
1680
  try {
1450
1681
  return JSON.parse(await readFile(abs, 'utf8'));
@@ -1463,13 +1694,24 @@ export default {
1463
1694
  // hit the cache, but every WRITE re-reads the file (force=true) and merges
1464
1695
  // on top of the fresh on-disk state. Without the forced re-read, a manual
1465
1696
  // edit of the sidecar file would be silently overwritten by a stale cache.
1697
+ //
1698
+ // 落点:**hub 内**(`~/.dsh/tool-management/`),文件名按域取(`mcp-*`)。
1699
+ // 它们曾经躺在 `$DSH_HOME` 根下、还带着插件名前缀(`dsh-plugin-tool-management-notes.json`),
1700
+ // 2026-09-16 用户要求「插件产生的文件全部收进 hub、名字与当前代码一致」——
1701
+ // 旧文件由 hub.ts 的 `migrateHubLayoutSync()` 在启动时搬进来(见 apply())。
1466
1702
  const SIDECAR_TTL_MS = 3000;
1703
+ const MCP_NOTES_FILE = 'mcp-notes.json';
1704
+ const MCP_SETTINGS_FILE = 'mcp-settings.json';
1705
+ const MCP_DISABLED_TOOLS_FILE = 'mcp-disabled-tools.json';
1706
+ const MCP_KNOWN_TOOLS_FILE = 'mcp-known-tools.json';
1707
+ const MCP_EXPORT_FILE = 'mcp-export.json';
1708
+ /** MCP 侧车绝对路径(都在 hub 内)。 */
1709
+ const mcpSidecar = (name) => hubPath(name);
1467
1710
  let notesCache = null;
1468
1711
  async function readNotes(force = false) {
1469
1712
  if (notesCache && !force && Date.now() - notesCache.at < SIDECAR_TTL_MS)
1470
1713
  return notesCache.value;
1471
- const p = await ensurePaths();
1472
- const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'));
1714
+ const raw = await readJsonFile(mcpSidecar(MCP_NOTES_FILE));
1473
1715
  const out = {};
1474
1716
  if (raw && typeof raw === 'object') {
1475
1717
  for (const key of Object.keys(raw)) {
@@ -1495,7 +1737,7 @@ export default {
1495
1737
  else
1496
1738
  delete map[id];
1497
1739
  try {
1498
- await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'), map);
1740
+ await writeJsonFile(mcpSidecar(MCP_NOTES_FILE), map);
1499
1741
  }
1500
1742
  catch (e) {
1501
1743
  return { ok: false, error: '备注保存失败: ' + message(e) };
@@ -1519,7 +1761,7 @@ export default {
1519
1761
  if (pluginSettingsCache && !force && Date.now() - pluginSettingsCache.at < SIDECAR_TTL_MS)
1520
1762
  return pluginSettingsCache.value;
1521
1763
  const p = await ensurePaths();
1522
- const raw = await readJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-settings.json'));
1764
+ const raw = await readJsonFile(mcpSidecar(MCP_SETTINGS_FILE));
1523
1765
  pluginSettingsCache = {
1524
1766
  at: Date.now(),
1525
1767
  value: {
@@ -1555,7 +1797,7 @@ export default {
1555
1797
  const p = await ensurePaths();
1556
1798
  return withWriteLock(async () => {
1557
1799
  try {
1558
- await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-settings.json'), next);
1800
+ await writeJsonFile(mcpSidecar(MCP_SETTINGS_FILE), next);
1559
1801
  }
1560
1802
  catch (e) {
1561
1803
  return { ok: false, error: '设置保存失败: ' + message(e) };
@@ -1570,13 +1812,12 @@ export default {
1570
1812
  // every scope's model-visible schema list, and `tools.guard` denies the call
1571
1813
  // before the body runs. Disabled tools therefore become invisible AND
1572
1814
  // uncallable — no patch rewrite, no DSH restart.
1573
- const DISABLED_TOOLS_FILE = 'dsh-plugin-tool-management-disabled-tools.json';
1574
1815
  let disabledToolsCache = null;
1575
1816
  async function readDisabledTools(force = false) {
1576
1817
  if (disabledToolsCache && !force && Date.now() - disabledToolsCache.at < SIDECAR_TTL_MS)
1577
1818
  return disabledToolsCache.value;
1578
1819
  const p = await ensurePaths();
1579
- const raw = await readJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE));
1820
+ const raw = await readJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE));
1580
1821
  const out = {};
1581
1822
  if (raw && typeof raw === 'object') {
1582
1823
  for (const serverName of Object.keys(raw)) {
@@ -1607,19 +1848,18 @@ export default {
1607
1848
  // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
1608
1849
  // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
1609
1850
  // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
1610
- const KNOWN_MCP_TOOLS_FILE = 'dsh-plugin-tool-management-mcp-tools.json';
1611
1851
  let knownMcpToolsCache = null;
1612
1852
  async function readKnownMcpTools(force = false) {
1613
1853
  if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
1614
1854
  return knownMcpToolsCache.value;
1615
1855
  const p = await ensurePaths();
1616
- const raw = await readJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE));
1856
+ const raw = await readJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE));
1617
1857
  const out = {};
1618
1858
  if (raw && typeof raw === 'object') {
1619
1859
  for (const serverName of Object.keys(raw)) {
1620
- const list = raw[serverName];
1621
- if (Array.isArray(list))
1622
- out[serverName] = list.map((name) => String(name)).filter((name) => /^[A-Za-z0-9_-]{1,128}$/.test(name)).sort();
1860
+ const list = normalizeKnownTools(raw[serverName]);
1861
+ if (list.length)
1862
+ out[serverName] = list;
1623
1863
  }
1624
1864
  }
1625
1865
  knownMcpToolsCache = { at: Date.now(), value: out };
@@ -1627,7 +1867,7 @@ export default {
1627
1867
  }
1628
1868
  async function writeKnownMcpTools(value) {
1629
1869
  const p = await ensurePaths();
1630
- await writeJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE), value);
1870
+ await writeJsonFile(mcpSidecar(MCP_KNOWN_TOOLS_FILE), value);
1631
1871
  knownMcpToolsCache = { at: Date.now(), value: value };
1632
1872
  }
1633
1873
  /** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
@@ -1667,7 +1907,7 @@ export default {
1667
1907
  else
1668
1908
  delete map[serverName];
1669
1909
  try {
1670
- await writeJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE), map);
1910
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), map);
1671
1911
  }
1672
1912
  catch (e) {
1673
1913
  return { ok: false, error: '保存失败: ' + message(e) };
@@ -1861,12 +2101,19 @@ export default {
1861
2101
  }
1862
2102
  }
1863
2103
  // 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
1864
- // 想给未运行服务器挑工具就全靠这份最后见过的名单(描述/参数不可用,按名字勾选)。
1865
- for (const name of (await readKnownMcpTools())[serverName] || []) {
1866
- if (listed.has(name))
2104
+ // 想给未运行服务器挑工具就全靠这份最后见过的名单。描述也一并带上(v0.9.0 起缓存),
2105
+ // 并标 `stale: true`:界面据此说明「上次运行时」,不假装是实时数据。
2106
+ for (const known of (await readKnownMcpTools())[serverName] || []) {
2107
+ if (listed.has(known.name))
1867
2108
  continue;
1868
- toolsList.push({ name, description: '(服务器未运行;描述暂不可用)', enabled: !disabledHere.has(name), parameters: [] });
1869
- listed.add(name);
2109
+ toolsList.push({
2110
+ name: known.name,
2111
+ description: known.description || '(服务器未运行;这个名字来自上次运行记录)',
2112
+ enabled: !disabledHere.has(known.name),
2113
+ parameters: [],
2114
+ stale: true,
2115
+ });
2116
+ listed.add(known.name);
1870
2117
  }
1871
2118
  return { ok: true, tools: toolsList };
1872
2119
  }
@@ -1903,13 +2150,19 @@ export default {
1903
2150
  for (;;) {
1904
2151
  const t = await mcpmTools({ serverName });
1905
2152
  if (t && t.ok && (t.tools || []).length) {
1906
- // 记进「已知工具」缓存:以后未运行时场景档案也能列出。
1907
- const names = t.tools.map((x) => String(x.name)).filter(Boolean);
1908
- if (names.length) {
2153
+ // 记进「已知工具」缓存:以后未运行时场景档案也能列出(名字 + 这次的描述)。
2154
+ const known = t.tools
2155
+ .filter((x) => String(x && x.name))
2156
+ .map((x) => ({ name: String(x.name), ...(typeof x.description === 'string' && x.description ? { description: x.description } : {}) }));
2157
+ if (known.length) {
1909
2158
  try {
1910
2159
  const cache = await readKnownMcpTools();
1911
- const merged = [...new Set([...(cache[serverName] || []), ...names])].sort();
1912
- cache[serverName] = merged;
2160
+ const byName = new Map((cache[serverName] || []).map((item) => [item.name, item]));
2161
+ for (const item of known) {
2162
+ const old = byName.get(item.name);
2163
+ byName.set(item.name, old && old.description && !item.description ? old : { ...old, ...item });
2164
+ }
2165
+ cache[serverName] = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1913
2166
  await writeKnownMcpTools(cache);
1914
2167
  }
1915
2168
  catch { /* 缓存写失败不影响本次返回 */ }
@@ -1950,10 +2203,13 @@ export default {
1950
2203
  rows.push(normalizeRow(r, level, abs));
1951
2204
  }
1952
2205
  const toolCounts = {};
2206
+ const enabledToolCounts = {};
1953
2207
  try {
1954
2208
  const schemas = await tools.schemas();
1955
2209
  const knownCache = await readKnownMcpTools();
2210
+ const disabledMap = await readDisabledTools();
1956
2211
  const liveNames = {};
2212
+ const liveTools = {};
1957
2213
  const seen = new Set();
1958
2214
  for (const s of schemas) {
1959
2215
  const fullName = String(s && s.name || '');
@@ -1966,6 +2222,11 @@ export default {
1966
2222
  const list = liveNames[m[1]] || (liveNames[m[1]] = []);
1967
2223
  if (list.indexOf(m[2]) < 0)
1968
2224
  list.push(m[2]);
2225
+ // 顺手把描述记下来:未运行时详情页就有东西可显示,不必再重复「描述暂不可用」。
2226
+ const bucket = liveTools[m[1]] || (liveTools[m[1]] = []);
2227
+ const desc = typeof s.description === 'string' ? s.description : '';
2228
+ if (!bucket.some((t) => t.name === m[2]))
2229
+ bucket.push({ name: m[2], ...(desc ? { description: desc } : {}) });
1969
2230
  }
1970
2231
  // 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
1971
2232
  // 也能显示最后一次见过的工具数,而不是永远 0。
@@ -1979,24 +2240,42 @@ export default {
1979
2240
  for (const [server, list] of Object.entries(liveNames))
1980
2241
  for (const t of list)
1981
2242
  addTool(server, t);
1982
- for (const [server, list] of Object.entries(await readDisabledTools()))
2243
+ for (const [server, list] of Object.entries(disabledMap))
1983
2244
  for (const t of list)
1984
2245
  if (t !== '*')
1985
2246
  addTool(server, t);
1986
2247
  for (const [server, list] of Object.entries(knownCache))
1987
2248
  for (const t of list)
1988
- addTool(server, t);
1989
- for (const [server, set] of Object.entries(union))
2249
+ addTool(server, t.name);
2250
+ for (const [server, set] of Object.entries(union)) {
1990
2251
  toolCounts[server] = set.size;
1991
- // 回写「已知工具」:live 见到的名字并入缓存(有变化才写盘,读路径上的 best-effort)。
2252
+ // 可用数 = 已知工具里未被停用表扣减的(`*` = 整台停用 → 0)。场景档案收窄与
2253
+ // 手动逐工具开关写的是同一张停用表,段与页面的「N 个工具」都必须按它扣减,
2254
+ // 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
2255
+ enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
2256
+ }
2257
+ // 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
2258
+ // 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
1992
2259
  let cacheChanged = false;
1993
- for (const [server, list] of Object.entries(liveNames)) {
2260
+ for (const [server, list] of Object.entries(liveTools)) {
1994
2261
  const prev = knownCache[server] || [];
1995
- const next = [...new Set([...prev, ...list])].sort();
1996
- if (next.join('\u0000') !== prev.join('\u0000')) {
1997
- knownCache[server] = next;
1998
- cacheChanged = true;
2262
+ const byName = new Map(prev.map((t) => [t.name, t]));
2263
+ for (const tool of list) {
2264
+ const old = byName.get(tool.name);
2265
+ if (!old) {
2266
+ byName.set(tool.name, tool);
2267
+ cacheChanged = true;
2268
+ continue;
2269
+ }
2270
+ if (tool.description && old.description !== tool.description) {
2271
+ byName.set(tool.name, { ...old, description: tool.description });
2272
+ cacheChanged = true;
2273
+ }
1999
2274
  }
2275
+ const next = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
2276
+ if (next.length !== prev.length)
2277
+ cacheChanged = true;
2278
+ knownCache[server] = next;
2000
2279
  }
2001
2280
  if (cacheChanged)
2002
2281
  void writeKnownMcpTools(knownCache).catch(() => { });
@@ -2013,14 +2292,23 @@ export default {
2013
2292
  for (const e of live) {
2014
2293
  const bid = bareEntryId(e.entryId);
2015
2294
  const found = rows.find((r) => r.id === bid);
2016
- if (found)
2017
- found.live = { enabled: e.enabled, phase: e.fiberPhase };
2295
+ if (found) {
2296
+ // **补丁说停用 → 它不可能在运行。** loader 的清单偶尔停在上一帧(用户实测:退出场景后
2297
+ // 开关已关、进程已停、AI 也调不到,页面却仍显示「已运行」)——同一个响应里两处矛盾时
2298
+ // 以**补丁**为准:开关是真正生效的控制面,显示跟着它走,四者(实际 / 显示 / 开关 / AI)
2299
+ // 才一致。反方向的偏差(刚停用、进程还在收尾的几秒)同样由补丁给出的答案兜住。
2300
+ found.live = found.disabled
2301
+ ? { enabled: false, phase: e.fiberPhase }
2302
+ : { enabled: e.enabled, phase: e.fiberPhase };
2303
+ }
2018
2304
  else
2019
2305
  rows.push({ id: e.entryId, serverName: e.entryId, transport: null, url: null, command: null, args: null, env: null, headers: null, level: 'loader', disabled: !e.enabled, managed: false, live: { enabled: e.enabled, phase: e.fiberPhase } });
2020
2306
  }
2021
2307
  for (const row of rows) {
2022
2308
  if (row.toolCount === undefined)
2023
2309
  row.toolCount = toolCounts[row.serverName] || 0;
2310
+ if (row.enabledToolCount === undefined)
2311
+ row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
2024
2312
  }
2025
2313
  // A loader id that appears twice (same id in both patch files, or twice in
2026
2314
  // one) makes the composition fail to boot. Surface it instead of hiding it.
@@ -2146,7 +2434,7 @@ export default {
2146
2434
  toolMap[serverName] = toolMap[cur.serverName];
2147
2435
  delete toolMap[cur.serverName];
2148
2436
  try {
2149
- await writeJsonFile(sidecarPath(p.home, DISABLED_TOOLS_FILE), toolMap);
2437
+ await writeJsonFile(mcpSidecar(MCP_DISABLED_TOOLS_FILE), toolMap);
2150
2438
  }
2151
2439
  catch (e) { /* non-fatal */ }
2152
2440
  disabledToolsCache = { at: Date.now(), value: toolMap };
@@ -2433,7 +2721,7 @@ export default {
2433
2721
  const json = JSON.stringify({ exportedAt: new Date().toISOString(), rows }, null, 2);
2434
2722
  let savedTo = null;
2435
2723
  try {
2436
- const abs = p.home + (p.home.indexOf('\\') >= 0 ? '\\' : '/') + 'dsh-plugin-tool-management-export.json';
2724
+ const abs = mcpSidecar(MCP_EXPORT_FILE);
2437
2725
  await writePatch(abs, json);
2438
2726
  savedTo = abs;
2439
2727
  }
@@ -2834,7 +3122,7 @@ export default {
2834
3122
  * 单个预设的工具名(列表里含 MCP 与宿主平面的工具,因为子代理的可见集合是
2835
3123
  * "宿主平面 ∪ 该预设"的并集)。枚举会为该预设建立 standing mount —— 官方语义:
2836
3124
  * 一个预设每进程只挂一次,正常创建会话时同样会挂,所以这里按 id 缓存 60 秒;
2837
- * `subagent_run` 每次委派都要用它校验名单,不能每次都重新枚举。
3125
+ * `subagent_manager_run` 每次委派都要用它校验名单,不能每次都重新枚举。
2838
3126
  */
2839
3127
  const presetNamesCache = new Map();
2840
3128
  async function presetToolNames(id) {
@@ -3077,11 +3365,20 @@ export default {
3077
3365
  // 场景驱动时**只有场景绑的那份**算生效中(用户裁定);文件里那份降级为「文件里是它」,
3078
3366
  // 因为场景的提示词已经接管基线。没有场景驱动时才按文件比对定「生效中」。
3079
3367
  const sceneDrives = sceneId !== null;
3368
+ // 引用清单与删除保护同源(scene-prompt-sync):界面据此禁用删除并说明「谁在用」,
3369
+ // 不必自己再拼一遍判定(那正是这次四处状态各说各话的根源)。探测失败 = 空清单。
3370
+ const refs = await scenePromptSync.refs();
3080
3371
  const presets = base.presets.map((p) => {
3081
3372
  const fileApplied = p.active === true;
3082
3373
  const byScene = sceneDrives && sceneId === p.id;
3083
3374
  const active = sceneDrives ? byScene : fileApplied;
3084
- return { ...p, active, fileApplied, activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null) };
3375
+ return {
3376
+ ...p,
3377
+ active,
3378
+ fileApplied,
3379
+ activeVia: byScene ? 'scene' : (active && fileApplied ? 'file' : null),
3380
+ refs: refs ? refs.get(p.id) || [] : [],
3381
+ };
3085
3382
  });
3086
3383
  return { ...base, presets, ...(scenePrompt ? { scenePrompt } : {}) };
3087
3384
  },
@@ -3093,7 +3390,7 @@ export default {
3093
3390
  ...(args && typeof args.description === 'string' ? { description: String(args.description) } : {}),
3094
3391
  }),
3095
3392
  // 保存:改正文 + 可选改名(nextId)。改名成功顺带把场景绑定一起改名 ——
3096
- // 绑定存在 rules-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
3393
+ // 绑定存在 memories-index.json 里,预设库自己看不到,不叫这一声就会留悬空绑定。
3097
3394
  // 保存后同步一次全局基线:若改的正是「正在驱动基线的那份」,正文要跟着写进
3098
3395
  // `~/.dsh/AGENTS.md`(否则页面标着「生效中」而文件里还是旧内容)。
3099
3396
  'agentsmd-update': async (args) => {
@@ -3112,24 +3409,37 @@ export default {
3112
3409
  }
3113
3410
  return withAgentsMdSync({ ...res, ...renamed });
3114
3411
  },
3115
- 'agentsmd-apply': (args) => agentsMdService.apply(String((args && args.id) || '')),
3412
+ // 应用 = 写全局基线文件;场景接管期间只放行「场景绑定的那一份」(见 applyPresetGuarded)。
3413
+ 'agentsmd-apply': (args) => applyPresetGuarded(String((args && args.id) || '')),
3116
3414
  'agentsmd-get-current': () => agentsMdService.getCurrent(),
3117
- // 删除:**正在生效的那份拒绝删除**(用户裁定)。生效 = 启用的场景绑定了它,
3118
- // 否则 = 文件内容就是它。禁用的按钮只是提示,真正的门在这里(模型与旧页面也会走这条路)。
3119
- // 探测失败时放行并记一条 warn:删预设不动 ~/.dsh/AGENTS.md,最坏是少一份副本,可重建。
3415
+ // 删除:**被引用的那份一律拒绝**(用户裁定,2026-09-16 扩到引用清单)。三类引用:
3416
+ // 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
3417
+ // 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
3418
+ // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
3419
+ // 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
3420
+ // 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
3120
3421
  'agentsmd-remove': async (args) => {
3121
3422
  const id = String((args && args.id) || '');
3122
- const effective = await effectivePresetId();
3123
- if (effective !== null && effective === id) {
3124
- return { ok: false, error: `「${id}」正在生效,不能删除:先启用别的场景或换绑提示词,或先把别的预设应用上去。` };
3423
+ const refs = await scenePromptSync.refs();
3424
+ if (refs === null)
3425
+ ctx.logger?.warn?.('agents-md: reference probe failed; remove allowed');
3426
+ const why = refs ? refs.get(id) || [] : [];
3427
+ if (why.length) {
3428
+ return {
3429
+ ok: false,
3430
+ code: 'error.agentsMd.referenced',
3431
+ error: `「${id}」仍被引用,不能删除:${why.map(promptRefReason).join(';')}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
3432
+ };
3125
3433
  }
3126
3434
  return agentsMdService.remove(id);
3127
3435
  },
3128
3436
  'agentsmd-import': (args) => agentsMdService.importPreset(String((args && args.id) || ''), String((args && args.content) ?? ''), args && typeof args.description === 'string' ? String(args.description) : undefined),
3129
- // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/agents-md-trash/<id>/`,
3437
+ // 提示词预设的回收站:删预设 = 把整个预设目录移入 `hub/trash/prompts-trash/<id>/`,
3130
3438
  // 误删可从「提示词」页的回收站里恢复(同 id 已存在时拒绝恢复,绝不覆盖)。
3131
3439
  'agentsmd-trash-list': () => agentsMdService.trashList(),
3132
- 'agentsmd-trash-restore': (args) => agentsMdService.trashRestore(String((args && args.id) || '')),
3440
+ // 恢复也要同步:把一个「场景绑定着、但曾被删掉」的预设恢复回来 → 绑定重新变活,
3441
+ // 基线要跟着写回它(否则场景页说「生效中:它」而文件里并不是)。
3442
+ 'agentsmd-trash-restore': async (args) => withAgentsMdSync(await agentsMdService.trashRestore(String((args && args.id) || ''))),
3133
3443
  'agentsmd-trash-delete': (args) => agentsMdService.trashDelete(String((args && args.id) || '')),
3134
3444
  // History(归档会话管理)ops。只读:history-list / history-retention-get;
3135
3445
  // 写:history-archive / history-unarchive / history-delete / history-retention-set。
@@ -3344,7 +3654,8 @@ export default {
3344
3654
  },
3345
3655
  // 预设可达性矩阵:每个 Agent 预设下,本插件的注入类能力到不到得了模型。
3346
3656
  // 只读预设组合文本,不挂载任何预设、不改任何数据。回答的是"面板上说注入了,
3347
- // 模型真的看得到吗" —— minimal 这类 persona complete 的预设会压制全部提示词段。
3657
+ // 模型真的看得到吗" —— 压制型预设(persona complete / includeRuntimeContext:false,
3658
+ // 如极简)默认不注入,可在「注入」设置里改成强制注入。
3348
3659
  'preset-reach': async () => {
3349
3660
  try {
3350
3661
  // 宿主平面的 MCP 工具数:四个官方预设都不在组合里挂 MCP,MCP 由
@@ -3359,13 +3670,31 @@ export default {
3359
3670
  catch {
3360
3671
  mcpTools = undefined;
3361
3672
  }
3362
- const report = await assessPresetReach(presetRoster(), { mcpTools });
3673
+ const settings = await readInjectSettings();
3674
+ const report = await assessPresetReach(presetRoster(), {
3675
+ mcpTools,
3676
+ inject: { underSuppressingPresets: settings.underSuppressingPresets, domains: settings.domains },
3677
+ });
3363
3678
  return { ok: true, ...report };
3364
3679
  }
3365
3680
  catch (e) {
3366
3681
  return { ok: false, error: message(e) };
3367
3682
  }
3368
3683
  },
3684
+ // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
3685
+ 'inject-settings': (args) => injectSettingsOp(args),
3686
+ // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 本次运行的投递统计。
3687
+ // 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
3688
+ 'injection-live': async () => {
3689
+ if (contextInjectorLive === null)
3690
+ return { ok: false, error: '注入通道未装配' };
3691
+ try {
3692
+ return { ok: true, ...contextInjectorLive() };
3693
+ }
3694
+ catch (e) {
3695
+ return { ok: false, error: message(e) };
3696
+ }
3697
+ },
3369
3698
  'history-unarchive': async (args) => {
3370
3699
  const registry = getHistoryRegistry();
3371
3700
  if (!registry)
@@ -3548,7 +3877,7 @@ export default {
3548
3877
  }
3549
3878
  }
3550
3879
  else if (kind === 'memories') {
3551
- // 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/SKILL.md`)两种形态,
3880
+ // 记忆有 flat(`<场景>/<name>.md`)与 bundle(`<场景>/<name>/<name>.md`)两种形态,
3552
3881
  // 且场景名本身可含 '/' —— 按 id 拼路径会把 bundle 读成 `<场景>/<name>.md`,读不到
3553
3882
  // 就静默丢项(详见 planMemoryExport)。所以走索引拿那条规则自己的真实路径。
3554
3883
  const r = await rulesService.ops['rules-list']({});
@@ -3753,6 +4082,340 @@ export default {
3753
4082
  const r = await rulesService.ops['rules-list']({});
3754
4083
  return ((r && r.scenes) || []).filter((s) => s.locked === true).map((s) => String(s.name));
3755
4084
  }
4085
+ /** 当前启用(= 已进入)的那个场景名;没有则 null。 */
4086
+ async function activeSceneName() {
4087
+ try {
4088
+ const r = await rulesService.ops['rules-list']({});
4089
+ return r && r.ok && r.activeScene ? String(r.activeScene) : null;
4090
+ }
4091
+ catch {
4092
+ return null;
4093
+ }
4094
+ }
4095
+ /** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
4096
+ async function lockedSceneGuard() {
4097
+ const scene = await activeSceneName();
4098
+ if (!scene)
4099
+ return null;
4100
+ const locked = await lockedSceneNames();
4101
+ return locked.includes(scene) ? `场景「${scene}」已锁定:先到场景页解锁再改。` : null;
4102
+ }
4103
+ // ---------- 场景内改开关 = 同步改档案(v0.9.2)----------
4104
+ /**
4105
+ * 用户裁定(2026-09-17):场景**未锁定**时,页面上的开关(MCP / 技能 / 子智能体 / 提示词)
4106
+ * 照常可用,改动**同步写进当前场景的档案**;只有「锁定」才冻结这些开关。
4107
+ * 记忆不在此列 —— 它的开关是 `rules[*].enabled` 单一真相源,本来就不经过场景档案。
4108
+ *
4109
+ * 为什么必须同步:档案的语义是「勾选集 = 该场景下开着的东西,段未定义 = 全关」(见
4110
+ * archive-engine 的四域同口径)。只改运行时、不改档案,退出场景会被快照整体回滚 ——
4111
+ * 用户看到的「改了」是假的;写进档案才既当下生效、下次进这个场景又照样生效。
4112
+ * 新增 / 改名 / 删除 / 备注 / 导入导出这些**内容**操作不进档案(它们不是场景维度)。
4113
+ */
4114
+ /**
4115
+ * 技能档案 key 映射:`<来源 key>\u0000<条目名>` → `{ 档案 key, selectable }`。
4116
+ *
4117
+ * `selectable=false` = 被同名技能覆盖的副本,或结构不完整(core 拒绝启停)——
4118
+ * 这两种都**不可能生效**,勾进档案只会让「档案说开着、技能页说没启动」(用户实测)。
4119
+ * `skill-disable` 不受此限:它只是把键从档案里删掉,任何键都可以删。
4120
+ */
4121
+ async function skillArchiveKeys() {
4122
+ const map = new Map();
4123
+ try {
4124
+ const r = await skillsService.ops['skill-state']({});
4125
+ for (const root of ((r && r.data && r.data.roots) || [])) {
4126
+ const rootKey = String((root && root.key) || '');
4127
+ if (!rootKey)
4128
+ continue;
4129
+ for (const sk of ((root && root.skills) || [])) {
4130
+ const entry = String((sk && sk.name) || '');
4131
+ if (!entry)
4132
+ continue;
4133
+ // 与 skillRows / skillScan 同口径:档案里存**声明名**(frontmatter name)。
4134
+ // 用条目名拼 key 会被保存时的 stale 校验当成未知键丢掉。
4135
+ map.set(rootKey + '\u0000' + entry, {
4136
+ key: rootKey + '/' + String(sk.declaredName || entry),
4137
+ selectable: !sk.shadowedBy && sk.loadable !== false,
4138
+ });
4139
+ }
4140
+ }
4141
+ }
4142
+ catch { /* 读不到状态 → 调用方退化为原样拼 key */ }
4143
+ return map;
4144
+ }
4145
+ /**
4146
+ * 某个来源下的技能档案 key(来源级开关用):
4147
+ * - `all`:该来源下**全部**键 —— 停用来源时按它清理(连历史残留一起清掉);
4148
+ * - `selectable`:真正**生效得了**的那些 —— 启用来源时只并入这一批。
4149
+ */
4150
+ async function skillArchiveKeysOfRoot(rootKey) {
4151
+ const all = [];
4152
+ const selectable = [];
4153
+ try {
4154
+ const r = await skillsService.ops['skill-state']({});
4155
+ for (const root of ((r && r.data && r.data.roots) || [])) {
4156
+ if (String((root && root.key) || '') !== rootKey)
4157
+ continue;
4158
+ for (const sk of ((root && root.skills) || [])) {
4159
+ const name = String((sk && sk.declaredName) || (sk && sk.name) || '');
4160
+ if (!name)
4161
+ continue;
4162
+ const key = rootKey + '/' + name;
4163
+ all.push(key);
4164
+ if (!sk.shadowedBy && sk.loadable !== false)
4165
+ selectable.push(key);
4166
+ }
4167
+ }
4168
+ }
4169
+ catch { /* 读不到 → 空列表 */ }
4170
+ return { all, selectable };
4171
+ }
4172
+ /** 字符串数组的「勾上 / 取消」(已处于目标状态时原样返回,避免无谓的档案写入)。 */
4173
+ function toggleInList(list, key, on) {
4174
+ if (!key)
4175
+ return list;
4176
+ const has = list.indexOf(key) >= 0;
4177
+ if (on === has)
4178
+ return list;
4179
+ return on ? [...list, key] : list.filter((x) => x !== key);
4180
+ }
4181
+ /** loader 条目 id → 服务器名(档案的 mcp 段按 serverName 存)。 */
4182
+ async function serverNameOfId(id) {
4183
+ try {
4184
+ const r = await mcpmListView();
4185
+ for (const row of ((r && r.rows) || [])) {
4186
+ if (String((row && row.id) || '') === id)
4187
+ return String(row.serverName || row.id);
4188
+ }
4189
+ }
4190
+ catch { /* 读不到 → null(这一笔不同步,如实跳过) */ }
4191
+ return null;
4192
+ }
4193
+ /**
4194
+ * 某台服务器当前**启用**的工具名(把档案里的 `'*'` 具化成具体名单时用)。
4195
+ *
4196
+ * live schema 优先(就是此刻真的存在的工具);服务器没跑起来时退回「最后见过的工具名单 −
4197
+ * 停用表」——与 `computeMcpPlan` 的 `knownTools` 同源,别把「不知道」当成「一个都没有」。
4198
+ */
4199
+ async function enabledToolNames(server) {
4200
+ const live = await (async () => {
4201
+ try {
4202
+ const r = await mcpmTools({ serverName: server });
4203
+ return ((r && r.tools) || []).filter((t) => t.enabled !== false).map((t) => String(t.name));
4204
+ }
4205
+ catch {
4206
+ return [];
4207
+ }
4208
+ })();
4209
+ if (live.length)
4210
+ return live;
4211
+ try {
4212
+ const known = (await readKnownMcpTools())[server] || [];
4213
+ const off = new Set(await readDisabledTools().then((m) => m[server] || []));
4214
+ if (off.has('*'))
4215
+ return [];
4216
+ return known.map((item) => String(item.name)).filter((n) => !off.has(n));
4217
+ }
4218
+ catch {
4219
+ return [];
4220
+ }
4221
+ }
4222
+ /**
4223
+ * 把一次开关落到当前场景的档案上。
4224
+ * 返回 null = 无需改动(没有活动场景 / 已锁定 / 这一笔不影响档案);
4225
+ * 返回字符串 = 档案没跟上(运行时已经生效,如实告诉用户,而不是假装成功)。
4226
+ */
4227
+ async function syncSwitchToScene(opName, args) {
4228
+ const scene = await activeSceneName();
4229
+ if (!scene)
4230
+ return null;
4231
+ if ((await lockedSceneNames()).includes(scene))
4232
+ return null;
4233
+ // 开关方向。**先按 op 名判定「动词即方向」的那几个**,再看 `args.enabled`:
4234
+ // `skill-enable` / `skill-disable` / `skill-source-enable` / `skill-source-disable`
4235
+ // 的参数里**根本没有 `enabled` 字段**(只有 root / name),早先统一读 `args.enabled`
4236
+ // 会把它们一律读成 false,于是「在场景里打开某个技能」被同步成「关闭」——目标集合
4237
+ // 一次变化都没有 → 直接早退不写盘,用户看到的就是「开关了但档案不跟着变」
4238
+ // (2026-09-17 用真实产物复现)。反向更糟:启用一个来源会被同步成「把该来源下的
4239
+ // 技能全部从档案里删掉」,档案与运行时当场对不上。
4240
+ // 其余 op 仍按宿主逐字对齐:`mcpm-tool-enabled` 的语义是「缺省 = 启用」(`enabled !== false`)。
4241
+ const enabled = opName === 'skill-enable' || opName === 'skill-source-enable'
4242
+ ? true
4243
+ : opName === 'skill-disable' || opName === 'skill-source-disable'
4244
+ ? false
4245
+ : args && typeof args.enabled === 'boolean'
4246
+ ? args.enabled === true
4247
+ : opName === 'mcpm-tool-enabled';
4248
+ let next;
4249
+ let cur = {};
4250
+ try {
4251
+ const slice = await rulesService.readArchiveSlice();
4252
+ cur = (slice.archives && slice.archives[scene]) || {};
4253
+ next = { ...cur };
4254
+ switch (opName) {
4255
+ case 'skill-enable':
4256
+ case 'skill-disable': {
4257
+ const root = String((args && args.root) || 'dsh');
4258
+ const entry = String((args && args.name) || '');
4259
+ const hit = (await skillArchiveKeys()).get(root + '\u0000' + entry);
4260
+ const key = (hit && hit.key) || root + '/' + entry;
4261
+ // 勾进来这件事只对**生效得了**的技能做:被同名覆盖的副本、结构不完整的技能
4262
+ // 写进去也永远不生效(运行时那一步本来就跳过了它们),档案就会开始说谎。
4263
+ // 取消勾选(enabled=false)不受限——它只是把键删掉,历史残留正好顺手清掉。
4264
+ if (enabled && hit && !hit.selectable)
4265
+ return null;
4266
+ next.skills = toggleInList(Array.isArray(next.skills) ? next.skills.slice() : [], key, enabled);
4267
+ break;
4268
+ }
4269
+ case 'skill-set-all': {
4270
+ const items = Array.isArray(args && args.items) ? args.items : [];
4271
+ const map = await skillArchiveKeys();
4272
+ let list = Array.isArray(next.skills) ? next.skills.slice() : [];
4273
+ for (const item of items) {
4274
+ const root = String((item && item.root) || '');
4275
+ const entry = String((item && item.name) || '');
4276
+ if (!root || !entry)
4277
+ continue;
4278
+ const hit = map.get(root + '\u0000' + entry);
4279
+ if (enabled && hit && !hit.selectable)
4280
+ continue;
4281
+ list = toggleInList(list, (hit && hit.key) || root + '/' + entry, enabled);
4282
+ }
4283
+ next.skills = list;
4284
+ break;
4285
+ }
4286
+ case 'skill-source-enable':
4287
+ case 'skill-source-disable': {
4288
+ // 来源级没有独立字段:档案靠「来源下有没有被勾的技能」反推(computeSkillsPlan)。
4289
+ // 启用时只并入**生效得了**的技能(被覆盖的副本/结构不完整的整批不进档案);
4290
+ // 停用时按**全部**键清(连历史残留一起清掉)。
4291
+ const keys = await skillArchiveKeysOfRoot(String((args && args.root) || ''));
4292
+ const pick = enabled ? keys.selectable : keys.all;
4293
+ let list = Array.isArray(next.skills) ? next.skills.slice() : [];
4294
+ if (enabled) {
4295
+ for (const k of pick)
4296
+ if (list.indexOf(k) < 0)
4297
+ list.push(k);
4298
+ }
4299
+ else {
4300
+ const drop = new Set(pick);
4301
+ list = list.filter((k) => !drop.has(k));
4302
+ }
4303
+ next.skills = list;
4304
+ break;
4305
+ }
4306
+ case 'mcpm-set-enabled': {
4307
+ const server = await serverNameOfId(String((args && args.id) || ''));
4308
+ if (!server)
4309
+ return null;
4310
+ next.mcp = { ...(next.mcp || {}) };
4311
+ if (enabled)
4312
+ next.mcp[server] = '*';
4313
+ else
4314
+ delete next.mcp[server];
4315
+ break;
4316
+ }
4317
+ case 'mcpm-set-all': {
4318
+ if (!enabled) {
4319
+ next.mcp = {};
4320
+ break;
4321
+ }
4322
+ const all = {};
4323
+ try {
4324
+ const r = await mcpmListView();
4325
+ for (const row of ((r && r.rows) || [])) {
4326
+ const n = String((row && row.serverName) || '');
4327
+ if (n)
4328
+ all[n] = '*';
4329
+ }
4330
+ }
4331
+ catch {
4332
+ return null;
4333
+ }
4334
+ next.mcp = all;
4335
+ break;
4336
+ }
4337
+ case 'mcpm-tool-enabled': {
4338
+ const server = String((args && args.serverName) || '');
4339
+ const tool = String((args && args.tool) || '');
4340
+ if (!server || !tool)
4341
+ return null;
4342
+ const has = next.mcp && Object.prototype.hasOwnProperty.call(next.mcp, server);
4343
+ // 服务器没勾 = 该场景下这台服务器关着,工具级开关在运行时本来就不生效(工具不存在)。
4344
+ if (!has)
4345
+ return null;
4346
+ const spec = next.mcp[server];
4347
+ if (spec === '*') {
4348
+ // '*' = 全部工具。只有「关掉某个工具」才需要具化;否则 '*' 已经涵盖了启用这一笔。
4349
+ if (enabled)
4350
+ return null;
4351
+ const live = await enabledToolNames(server);
4352
+ next.mcp = { ...next.mcp, [server]: live.filter((t) => t !== tool) };
4353
+ }
4354
+ else {
4355
+ const list = Array.isArray(spec) ? spec.slice() : [];
4356
+ next.mcp = {
4357
+ ...next.mcp,
4358
+ [server]: enabled
4359
+ ? (list.indexOf(tool) < 0 ? [...list, tool] : list)
4360
+ : list.filter((t) => t !== tool),
4361
+ };
4362
+ }
4363
+ break;
4364
+ }
4365
+ case 'subagent-toggle': {
4366
+ const name = String((args && args.name) || '');
4367
+ next.subagents = toggleInList(Array.isArray(next.subagents) ? next.subagents.slice() : [], name, enabled);
4368
+ break;
4369
+ }
4370
+ default: return null;
4371
+ }
4372
+ }
4373
+ catch (e) {
4374
+ return message(e);
4375
+ }
4376
+ // 空段 = 未定义(「一个都没勾 = 全关」),删掉以免两种语义并存(与 scene-archive-save 同口径)。
4377
+ for (const seg of ['skills', 'subagents']) {
4378
+ const list = next[seg];
4379
+ if (Array.isArray(list) && list.length === 0)
4380
+ delete next[seg];
4381
+ }
4382
+ if (next.mcp && Object.keys(next.mcp).length === 0)
4383
+ delete next.mcp;
4384
+ if (JSON.stringify(next) === JSON.stringify(cur))
4385
+ return null; // 档案本来就是这样 → 不写盘
4386
+ // 与「在档案弹窗里点保存」走同一条路:落盘 + 就地重应用 + 快照并入(退出仍按进场景前还原)。
4387
+ try {
4388
+ const save = archiveService.ops['scene-archive-save'];
4389
+ const res = await save({ scene, archive: next });
4390
+ if (res && res.ok === false)
4391
+ return String(res.error || '场景档案保存失败');
4392
+ if (res && res.applyError)
4393
+ return String(res.applyError);
4394
+ }
4395
+ catch (e) {
4396
+ return message(e);
4397
+ }
4398
+ return null;
4399
+ }
4400
+ /**
4401
+ * 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
4402
+ * 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
4403
+ * 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
4404
+ */
4405
+ function syncSceneArchiveOnSwitch(opNames) {
4406
+ for (const opName of opNames) {
4407
+ const original = handlers[opName];
4408
+ if (typeof original !== 'function')
4409
+ continue;
4410
+ handlers[opName] = async (args) => {
4411
+ const res = await original(args);
4412
+ if (!res || res.ok === false)
4413
+ return res;
4414
+ const err = await syncSwitchToScene(opName, args);
4415
+ return err ? { ...res, sceneSyncError: err } : res;
4416
+ };
4417
+ }
4418
+ }
3756
4419
  function guardLockedOps(opNames, what) {
3757
4420
  for (const opName of opNames) {
3758
4421
  const original = handlers[opName];
@@ -3769,9 +4432,9 @@ export default {
3769
4432
  guardLockedOps([
3770
4433
  // MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
3771
4434
  'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
3772
- // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
4435
+ // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站 / 批量启停。
3773
4436
  'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
3774
- 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload',
4437
+ 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload', 'skill-set-all',
3775
4438
  'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
3776
4439
  // 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
3777
4440
  'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
@@ -3780,6 +4443,13 @@ export default {
3780
4443
  // 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
3781
4444
  'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
3782
4445
  ], '修改');
4446
+ // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
4447
+ // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
4448
+ syncSceneArchiveOnSwitch([
4449
+ 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled',
4450
+ 'skill-enable', 'skill-disable', 'skill-set-all', 'skill-source-enable', 'skill-source-disable',
4451
+ 'subagent-toggle',
4452
+ ]);
3783
4453
  // 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
3784
4454
  for (const [opName, pickScene, what] of [
3785
4455
  ['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
@@ -3796,10 +4466,13 @@ export default {
3796
4466
  return original(args);
3797
4467
  };
3798
4468
  }
3799
- // 各页列表响应带上 anyLocked:界面据此禁用写控件(读 op,附加字段不影响既有消费方)。
4469
+ // 各页列表响应带上 anyLocked + activeScene:界面据此禁用写控件、并说明
4470
+ // 「当前处于场景 X,开关请到档案里改」(读 op,附加字段不影响既有消费方)。
3800
4471
  async function annotateLocked(res) {
3801
- if (res && res.ok !== false)
4472
+ if (res && res.ok !== false) {
3802
4473
  res.anyLocked = (await lockedSceneNames()).length > 0;
4474
+ res.activeScene = await activeSceneName();
4475
+ }
3803
4476
  return res;
3804
4477
  }
3805
4478
  for (const opName of ['mcpm-list', 'mcpm-reveal', 'skill-state', 'subagent-list', 'agentsmd-list']) {
@@ -3811,22 +4484,49 @@ export default {
3811
4484
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
3812
4485
  const text = (value) => [{ type: 'text', text: value }];
3813
4486
  tools.register(defineTool({
3814
- name: 'skill_mcp_manager_list',
3815
- description: 'List all configured MCP servers (level, enabled state, live loader status, registered tool count).',
3816
- parameters: {},
4487
+ name: 'mcp_manager_list',
4488
+ description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. Defaults to enabled servers; pass all=true for every configured server.',
4489
+ parameters: {
4490
+ all: { type: 'boolean', description: 'Include disabled servers (default false).' },
4491
+ },
3817
4492
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3818
- async execute() {
3819
- const r = await mcpmList();
4493
+ async execute(args, exec) {
4494
+ const showAll = Boolean(args && args.all === true);
4495
+ // 必须走 mcpmListView()(= mcpmRowsWithNotes(false)):备注在这里合入,且未打码的
4496
+ // url/headers/env 已被打码。备注是本插件 MCP 这一项的真正价值(用户写给未来模型的
4497
+ // 决策提示,如「A 挂了改用 B」),而它只走注入通道 —— 压制型预设下不注入时,
4498
+ // 这里就是唯一的读取路径。
4499
+ const r = await mcpmListView();
3820
4500
  if (!r.ok)
3821
4501
  throw new Error(r.error);
3822
- const summary = (r.rows || []).map((x) => (x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
3823
- (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
3824
- (typeof x.toolCount === 'number' ? ' | tools:' + x.toolCount : '')));
3825
- return 'MCP servers:\n' + (summary.join('\n') || '(none)');
4502
+ const all = r.rows || [];
4503
+ // 默认只列「已启用」的(用户裁定 2026-09-16,与技能 / 记忆列表同口径):停用的服务器
4504
+ // 模型调不到,列出来只是噪音。表头给出全量口径,所以不会变成盲区。
4505
+ const shown = showAll ? all : all.filter((x) => !x.disabled);
4506
+ const summary = shown.map((x) => {
4507
+ const note = normalizeMcpNote(x.notes, DEFAULT_MCP_NOTE_MAX_LENGTH);
4508
+ // 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
4509
+ // 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
4510
+ const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
4511
+ return x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
4512
+ (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
4513
+ (typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
4514
+ (note ? ' | user-hint:' + note : '');
4515
+ });
4516
+ const header = 'MCP servers: ' + (showAll
4517
+ ? all.length + ' configured'
4518
+ : shown.length + ' enabled of ' + all.length + ' configured' +
4519
+ (shown.length === all.length ? '' : ' (pass all=true for every configured server)')) + '\n';
4520
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文,如极简)下本插件
4521
+ // 默认不注入 —— 模型只有 mcp__* 的工具名与参数,没有服务级信息。不说明的话,
4522
+ // 模型会把「能调用这些工具」当成「已经知道有哪些 server、用户给它们写了什么备注」。
4523
+ // 提示本身不点名任何工具,所以挂在哪个发现型工具上都不会出现循环指引。
4524
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
4525
+ return header + (summary.join('\n') || '(none)') + notice;
3826
4526
  },
3827
4527
  }));
3828
4528
  tools.register(defineTool({
3829
- name: 'skill_mcp_manager_set_enabled',
4529
+ name: 'mcp_manager_set_enabled',
3830
4530
  description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
3831
4531
  parameters: {
3832
4532
  id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
@@ -3835,14 +4535,20 @@ export default {
3835
4535
  },
3836
4536
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3837
4537
  async execute(args) {
4538
+ const blocked = await lockedSceneGuard();
4539
+ if (blocked)
4540
+ throw new Error(blocked);
3838
4541
  const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
3839
4542
  if (!r.ok)
3840
4543
  throw new Error(r.error);
3841
- return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled');
4544
+ // 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
4545
+ const syncErr = await syncSwitchToScene('mcpm-set-enabled', args);
4546
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
4547
+ (syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
3842
4548
  },
3843
4549
  }));
3844
4550
  tools.register(defineTool({
3845
- name: 'skill_mcp_manager_restart',
4551
+ name: 'mcp_manager_restart',
3846
4552
  description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
3847
4553
  parameters: {
3848
4554
  id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
@@ -3857,7 +4563,7 @@ export default {
3857
4563
  },
3858
4564
  }));
3859
4565
  tools.register(defineTool({
3860
- name: 'skill_mcp_manager_add',
4566
+ name: 'mcp_manager_add',
3861
4567
  description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
3862
4568
  parameters: {
3863
4569
  serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
@@ -3881,26 +4587,69 @@ export default {
3881
4587
  // tools/pre-execute hook below (the model must ask before writing files).
3882
4588
  tools.register(defineTool({
3883
4589
  name: 'skill_manager_list',
3884
- description: 'List DSH skills across all sources (dsh/agents/codex/claude user roots and project roots) with their enabled state.',
3885
- parameters: {},
4590
+ description: 'List DSH skills with enabled state, effective/shadowed status and source file path. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
4591
+ parameters: {
4592
+ all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
4593
+ },
3886
4594
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3887
- async execute() {
4595
+ async execute(args, exec) {
4596
+ const showAll = Boolean(args && args.all === true);
3888
4597
  const r = await skillsService.ops['skill-state']({});
3889
4598
  if (!r || r.ok === false)
3890
4599
  throw new Error((r && r.error) || 'skill-state failed');
3891
4600
  const data = r.data || {};
3892
- const lines = [];
4601
+ // 同名(canonical = declaredName || name)跨根重复时只有**一份**生效,排序规则是
4602
+ // 「同名首选 > 来源优先级 rank」,**与启用状态无关**(core 的 groupLoadableSkillsByName)。
4603
+ // 于是「启用被覆盖的那份」是静默无效的:工具会回 OK,技能却依然不可用 —— 用户实测
4604
+ // 踩到的正是这个坑(同一技能在 dsh/agents/claude/custom 各有一份,启错根等于没启)。
4605
+ // 这里把生效/被覆盖如实标出来,让模型(和人)不必猜。
4606
+ //
4607
+ // 默认只列启用的(2026-09-16 用户裁定):本机 36 条里只有 1 条启用,全列一次约 4.3k
4608
+ // 字符且永久留在 transcript。表头始终给出全量口径(多少条 / 多少同名组),所以
4609
+ // 「还有没列出来的」不会变成盲区 —— 要看就传 all=true。
4610
+ const rows = [];
4611
+ const counts = new Map();
3893
4612
  for (const root of data.roots || []) {
3894
4613
  for (const skill of root.skills || []) {
3895
- lines.push((skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (skill.enabled ? 'enabled' : 'disabled'));
4614
+ const key = String(skill.declaredName || skill.name || '');
4615
+ counts.set(key, (counts.get(key) || 0) + 1);
4616
+ // `enabled` 只写在「胜出者」上(core 的 markWinners);影子副本与不可加载的技能
4617
+ // 没有这个字段。原先直接读 skill.enabled,这些技能一律被报成 disabled ——
4618
+ // 这里改用与界面同口径的兜底推导(client 的 isSkillEnabled)。
4619
+ const enabled = skill.enabled !== undefined
4620
+ ? skill.enabled === true
4621
+ : (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false);
4622
+ const marks = [];
4623
+ if (skill.shadowedBy && skill.shadowedBy.root)
4624
+ marks.push('shadowed by ' + skill.shadowedBy.root);
4625
+ if (skill.preferred === true)
4626
+ marks.push('preferred');
4627
+ rows.push({
4628
+ root: String(root.key || ''),
4629
+ enabled,
4630
+ text: (skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (enabled ? 'enabled' : 'disabled') +
4631
+ (marks.length ? ' | ' + marks.join(' ') : '') +
4632
+ (skill.path ? ' | ' + skill.path : ''),
4633
+ });
3896
4634
  }
3897
4635
  }
3898
- return 'Skills:\n' + (lines.join('\n') || '(none)');
4636
+ const dupGroups = [...counts.values()].filter((n) => n > 1).length;
4637
+ const shown = showAll ? rows : rows.filter((row) => row.enabled);
4638
+ const header = 'Skills: ' + (showAll
4639
+ ? rows.length + ' entries'
4640
+ : shown.length + ' enabled of ' + rows.length + ' entries') +
4641
+ (dupGroups ? ' · ' + dupGroups + ' duplicated names' : '') +
4642
+ (showAll || shown.length === rows.length ? '' : ' (pass all=true to include disabled and shadowed entries)') + '\n';
4643
+ // 注入边界:预设没挂 dsh-tool-skill(如极简)时官方技能目录不在,本插件的
4644
+ // 「技能目录」注入域会在开关打开时兜底(见上面的注入通道);两者都没有时模型看到的
4645
+ // 只是这份名单 —— 说清边界,别让它以为上下文里已经有技能正文(正文用路径读)。
4646
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
4647
+ return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
3899
4648
  },
3900
4649
  }));
3901
4650
  tools.register(defineTool({
3902
4651
  name: 'skill_manager_set_enabled',
3903
- description: 'Enable or disable one DSH skill (manager policy only; skill source files are never modified).',
4652
+ description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect.',
3904
4653
  parameters: {
3905
4654
  name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
3906
4655
  enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
@@ -3908,11 +4657,40 @@ export default {
3908
4657
  },
3909
4658
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3910
4659
  async execute(args) {
4660
+ const blocked = await lockedSceneGuard();
4661
+ if (blocked)
4662
+ throw new Error(blocked);
4663
+ const root = String(args.root || 'dsh');
3911
4664
  const op = args.enabled ? 'skill-enable' : 'skill-disable';
3912
- const r = await skillsService.ops[op]({ name: args.name, root: args.root || 'dsh' });
4665
+ const r = await skillsService.ops[op]({ name: args.name, root });
3913
4666
  if (!r || r.ok === false)
3914
4667
  throw new Error((r && r.error) || 'skill toggle failed');
3915
- return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled');
4668
+ // 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
4669
+ const syncErr = await syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
4670
+ // 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
4671
+ // 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
4672
+ let shadowedBy = '';
4673
+ try {
4674
+ const state = await skillsService.ops['skill-state']({});
4675
+ for (const rt of (state && state.data && state.data.roots) || []) {
4676
+ for (const skill of rt.skills || []) {
4677
+ if (String(skill.declaredName || skill.name || '') !== String(args.name))
4678
+ continue;
4679
+ if (String(rt.key || '') !== root)
4680
+ continue;
4681
+ if (skill.shadowedBy && skill.shadowedBy.root)
4682
+ shadowedBy = String(skill.shadowedBy.root);
4683
+ }
4684
+ }
4685
+ }
4686
+ catch {
4687
+ shadowedBy = '';
4688
+ }
4689
+ const warn = shadowedBy
4690
+ ? ' — BUT this copy is shadowed by the same-name skill in "' + shadowedBy + '", so it stays inactive: enable that copy instead, or make this root the preferred copy in the panel'
4691
+ : '';
4692
+ const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
4693
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
3916
4694
  },
3917
4695
  }));
3918
4696
  tools.register(defineTool({
@@ -3920,7 +4698,7 @@ export default {
3920
4698
  // 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
3921
4699
  // tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
3922
4700
  // root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
3923
- description: 'Create a new local DSH skill under the tool-management skills root (DSH_HOME/tool-management/skills). Use only when the user explicitly asks to create or save a reusable skill.',
4701
+ description: 'Create a new DSH skill under DSH_HOME/tool-management/skills. Use only when the user explicitly asks to create or save a reusable skill.',
3924
4702
  parameters: {
3925
4703
  name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
3926
4704
  description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
@@ -3937,63 +4715,108 @@ export default {
3937
4715
  }));
3938
4716
  // AGENTS.md 预设库:模型可查/切,不能造/删(避免模型乱删用户预设)。
3939
4717
  tools.register(defineTool({
3940
- name: 'agentsmd_list',
3941
- description: 'List AGENTS.md presets in the plugin preset library (id, active state).',
3942
- parameters: {},
4718
+ name: 'prompt_manager_list',
4719
+ description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. Defaults to the preset currently in effect; pass all=true for the whole library.',
4720
+ parameters: {
4721
+ all: { type: 'boolean', description: 'Include inactive presets (default false).' },
4722
+ },
3943
4723
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3944
- async execute(_args, exec) {
4724
+ async execute(args, exec) {
4725
+ const showAll = Boolean(args && args.all === true);
3945
4726
  const r = await agentsMdService.list();
3946
4727
  if (!r.ok)
3947
4728
  throw new Error(r.error);
3948
- const summary = r.presets.map((p) => p.id + (p.active ? ' [active]' : ''));
3949
- // 同上:AGENTS.md 由 dsh-agent-instructions 行承载,预设没挂这一行(或 persona
3950
- // 是 complete)时文件内容不会进提示词。
3951
- const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
3952
- return 'AGENTS.md presets:\n' + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
4729
+ const all = r.presets || [];
4730
+ // 默认只列「当前这份 ~/.dsh/AGENTS.md 是从哪个预设来的」(用户裁定 2026-09-16)。
4731
+ // 判定分两级:内容逐字节相同 = 生效;否则看服务层记下的「最近一次应用」——
4732
+ // 用户手改过全局文件时,第二级仍能给出答案,不必退化成全列(那会白烧上下文)。
4733
+ const active = all.filter((p) => p.active === true);
4734
+ const fallback = active.length ? active : all.filter((p) => p.lastApplied === true);
4735
+ const shown = showAll ? all : fallback;
4736
+ // 路径这一列是「名单 → 全文」的闭环:预设正文既不进提示词、也没有读取工具,
4737
+ // 给出文件路径让模型用宿主的文件工具读,比再造一个 read 工具省一条常驻 schema。
4738
+ const summary = shown.map((p) => {
4739
+ const mark = p.active ? ' [active]'
4740
+ : p.lastApplied ? ' [last applied — ~/.dsh/AGENTS.md has changed since]'
4741
+ : '';
4742
+ return p.id + mark + ' | ' + join(agentsMdPresetsDir, p.id, 'AGENTS.md');
4743
+ });
4744
+ const header = 'AGENTS.md presets: ' + (showAll
4745
+ ? all.length + ' in the library'
4746
+ : fallback.length
4747
+ ? fallback.length + (active.length ? ' active' : ' last applied') + ' of ' + all.length +
4748
+ (fallback.length === all.length ? '' : ' (pass all=true for the whole library)')
4749
+ : '0 active of ' + all.length + ' (pass all=true for the whole library)') + '\n';
4750
+ // 同上:AGENTS.md 由官方 dsh-agent-instructions 行承载(极简没挂这一行),
4751
+ // 这一行不在时文件内容不会进上下文。
4752
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
4753
+ return header + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
3953
4754
  },
3954
4755
  }));
3955
4756
  tools.register(defineTool({
3956
- name: 'agentsmd_apply',
3957
- description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md. DSH re-reads that file every turn, so it takes effect on the next turn.',
4757
+ name: 'prompt_manager_apply',
4758
+ description: 'Apply one AGENTS.md preset by writing it to ~/.dsh/AGENTS.md; effective on the next turn. Refused while a scene drives the baseline, unless it is that scene\'s bound preset (change the scene binding instead).',
3958
4759
  parameters: {
3959
4760
  id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
3960
4761
  },
3961
4762
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3962
4763
  async execute(args) {
3963
- const r = await agentsMdService.apply(args.id);
4764
+ const r = await applyPresetGuarded(args.id);
3964
4765
  if (!r.ok)
3965
4766
  throw new Error(r.error);
3966
4767
  return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md (next session; current session unchanged' + (r.backedUp ? '; previous backed up to __last-applied__' : '') + ')';
3967
4768
  },
3968
4769
  }));
3969
4770
  // ---------- rules model tools(v0.4)----------
3970
- // 活动场景的记忆正文会自动进入系统提示词(无需调用工具读取);这里的工具用于
3971
- // 查询/编辑规则本身。rule_manager_write 受 tools/pre-execute 审批门禁(D2)。
4771
+ // 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于
4772
+ // 查询/编辑规则本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
3972
4773
  // 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
3973
4774
  tools.register(defineTool({
3974
- name: 'rule_manager_list',
3975
- description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description).',
4775
+ name: 'memory_manager_list',
4776
+ description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). Defaults to the memories that will actually be injected; pass all=true for every entry.',
3976
4777
  parameters: {
3977
4778
  group: { type: 'string', description: 'Optional scene filter.' },
4779
+ all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
3978
4780
  },
3979
4781
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
3980
4782
  async execute(args, exec) {
4783
+ const showAll = Boolean(args && args.all === true);
3981
4784
  const r = await rulesService.ops['rules-list'](args);
3982
4785
  if (!r || r.ok === false)
3983
4786
  throw new Error((r && r.error) || '读取规则失败');
3984
- const lines = (r.rules || []).map((x) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + (x.enabled ? '已启用' : '已停用') +
4787
+ // 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
4788
+ // 在 index.active 里,再叠加单条 enabled 与同名 bundle 的 shadowed。默认只列这些,
4789
+ // 全列一次会把没启用的条目也算进上下文(用户裁定 2026-09-16,与技能列表同口径)。
4790
+ const sceneOn = (group) => group === 'global' ||
4791
+ (group !== '' && (r.activeMode === 'all' || String(r.activeScene || '') === group));
4792
+ const stateOf = (x) => {
4793
+ if (x.shadowed === true)
4794
+ return { injects: false, label: '被同名覆盖' };
4795
+ if (x.enabled === false)
4796
+ return { injects: false, label: '已停用' };
4797
+ if (!sceneOn(String(x.group || '')))
4798
+ return { injects: false, label: '场景未启用' };
4799
+ return { injects: true, label: '已启用' };
4800
+ };
4801
+ const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
4802
+ const shown = showAll ? rows : rows.filter((row) => row.injects);
4803
+ const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
3985
4804
  (x.description ? ' — ' + x.description : '')));
3986
4805
  const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
3987
- // 注入边界:预设可能压制提示词段(complete persona,或未挂 agent-instructions),
4806
+ const header = '记忆:' + (showAll
4807
+ ? rows.length + ' 条'
4808
+ : shown.length + ' 条会注入 / 共 ' + rows.length + ' 条' +
4809
+ (shown.length === rows.length ? '' : '(传 all=true 看全部)')) + '\n';
4810
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文)下本插件默认不注入,
3988
4811
  // 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
3989
- const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx);
3990
- return '记忆(' + (r.rules || []).length + '):\n' + (lines.join('\n') || '(无记忆)') +
4812
+ const notice = await reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions());
4813
+ return header + (lines.join('\n') || '(无记忆)') +
3991
4814
  '\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
3992
4815
  },
3993
4816
  }));
3994
4817
  tools.register(defineTool({
3995
- name: 'rule_manager_read',
3996
- description: 'Read the full body of one memory under ~/.dsh/tool-management/memories.',
4818
+ name: 'memory_manager_read',
4819
+ description: 'Read the full body of one memory under ~/.dsh/tool-management/memories. Call it only for memories that are not already in your context (disabled, unassigned to a scene, or dropped by the injection budget).',
3997
4820
  parameters: {
3998
4821
  id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
3999
4822
  },
@@ -4006,11 +4829,11 @@ export default {
4006
4829
  },
4007
4830
  }));
4008
4831
  tools.register(defineTool({
4009
- name: 'rule_manager_write',
4010
- description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene). It becomes active automatically once its scene is enabled. Requires user confirmation (configurable).',
4832
+ name: 'memory_manager_write',
4833
+ description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).',
4011
4834
  parameters: {
4012
- group: { type: 'string', required: true, description: 'Scene name under ~/.dsh/tool-management/memories; `global` = the always-on scene (any Unicode except path separators and < > : " | ? *).' },
4013
- name: { type: 'string', required: true, description: 'Memory name = the .md file name without the extension; any Unicode is fine (Chinese included), <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
4835
+ group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
4836
+ name: { type: 'string', required: true, description: 'Memory name = .md file name without extension; <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
4014
4837
  description: { type: 'string', required: true, description: 'One-sentence description (<=500 chars).' },
4015
4838
  body: { type: 'string', required: true, description: 'Markdown body (<=256 KiB).' },
4016
4839
  },
@@ -4022,15 +4845,21 @@ export default {
4022
4845
  return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
4023
4846
  },
4024
4847
  }));
4025
- // ---------- 子智能体工具(subagent_list / subagent_run)----------
4848
+ // ---------- 子智能体工具(subagent_manager_list / subagent_manager_run)----------
4026
4849
  // exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
4027
4850
  try {
4028
4851
  // 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
4029
4852
  // 裸 register 会把未编译的参数声明直接发给模型 API。
4030
- tools.register(defineTool(defineSubagentListTool({ list: () => subagentService.list(), sceneLists: subagentSceneLists })));
4853
+ tools.register(defineTool(defineSubagentManagerListTool({
4854
+ list: () => subagentService.list(),
4855
+ sceneLists: subagentSceneLists,
4856
+ // 与其余发现型工具同口径:压制型预设下不注入时,人设目录不在模型上下文里,
4857
+ // 只有工具可用。挂上边界提示,模型才不会把「看不到人设」当成「没有人设」。
4858
+ noticeFor: (exec) => reachNoticeForAgent(presetRoster(), exec && exec.agent && exec.agent.ctx, injectNoticeOptions()),
4859
+ })));
4031
4860
  // 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
4032
4861
  // 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
4033
- tools.register(defineTool(defineSubagentRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
4862
+ tools.register(defineTool(defineSubagentManagerRunTool({ ...subagentService, sceneLists: subagentSceneLists, toolFilterFor: subagentToolFilterFor })));
4034
4863
  }
4035
4864
  catch (e) {
4036
4865
  console.error('[dsh-plugin-tool-management] subagent tool registration failed:', message(e));
@@ -4044,8 +4873,8 @@ export default {
4044
4873
  // 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
4045
4874
  const CONFIRM_LABELS = {
4046
4875
  skill_manager_create: '「新建技能」',
4047
- rule_manager_write: '「写入记忆」',
4048
- subagent_run: '「运行子代理」',
4876
+ memory_manager_write: '「写入记忆」',
4877
+ subagent_manager_run: '「运行子代理」',
4049
4878
  };
4050
4879
  const bypassedByFullAccess = (exec) => {
4051
4880
  if (!isApprovalNever(ctx, exec))
@@ -4054,11 +4883,11 @@ export default {
4054
4883
  pluginLog()('confirm-bypass', `完全权限(approval=never):跳过${CONFIRM_LABELS[String(exec && exec.name)]}的确认,直接放行`).catch(() => { });
4055
4884
  return true;
4056
4885
  };
4057
- // subagent_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
4886
+ // subagent_manager_run 的目标人设是否真实存在。确认门是「问用户要不要做」,如果这个请求
4058
4887
  // 本来就做不成(人设不存在),弹卡 / 写 bypass 日志只会产生一次无效审批:
4059
4888
  // 用户批准之后模型收到的是「人设不可用」。所以先校验,再决定要不要问。
4060
4889
  // 校验本身不可用时返回 true(保持原行为,绝不因为探测失败而少问一次)。
4061
- const subagentRunTargetExists = async (exec) => {
4890
+ const subagentManagerRunTargetExists = async (exec) => {
4062
4891
  try {
4063
4892
  const name = String((exec && exec.arguments && exec.arguments.agent) || '').trim();
4064
4893
  if (!name)
@@ -4073,14 +4902,14 @@ export default {
4073
4902
  ctx.on('tools/pre-execute', async (exec, next) => {
4074
4903
  if (!exec || !CONFIRM_LABELS[String(exec.name)])
4075
4904
  return next();
4076
- if (exec.name === 'subagent_run' && !(await subagentRunTargetExists(exec)))
4905
+ if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
4077
4906
  return next();
4078
4907
  if (bypassedByFullAccess(exec))
4079
4908
  return next();
4080
4909
  if (exec.name === 'skill_manager_create') {
4081
4910
  return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
4082
4911
  }
4083
- if (exec.name === 'rule_manager_write') {
4912
+ if (exec.name === 'memory_manager_write') {
4084
4913
  // D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
4085
4914
  // 不在此处做任何兜底放行。
4086
4915
  return readPluginSettings()
@@ -4089,7 +4918,7 @@ export default {
4089
4918
  : next()))
4090
4919
  .catch(() => ({ kind: 'ask', reason: 'Write a memory under ~/.dsh/tool-management/memories' }));
4091
4920
  }
4092
- // subagent_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
4921
+ // subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
4093
4922
  return readPluginSettings()
4094
4923
  .then((s) => (s.requireConfirmForModelSubagentRun !== false
4095
4924
  ? { kind: 'ask', reason: 'Run a subagent (consumes tokens)' }
@@ -4119,8 +4948,8 @@ export default {
4119
4948
  try {
4120
4949
  // MCP servers (de)register their tools as instances come and go; the
4121
4950
  // visible-set restriction must track that — and so must the MCP state
4122
- // section: 工具加载完成后若不重算,段会一直停留在「空/旧集合」的缓存值上
4123
- //(用户切 MCP 后立即看系统提示词就是空段 —— 2026-09-16 实测)。
4951
+ // section: 工具加载完成后若不重算,注入文本会一直停留在「空/旧集合」的缓存值上
4952
+ //(用户切 MCP 后立即看上下文注入就是空段 —— 2026-09-16 实测)。
4124
4953
  ctx.effect(() => {
4125
4954
  const stop = ctx.on('tools/change', () => {
4126
4955
  scheduleToolRestrictions();