dsh-plugin-tool-management 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -10,18 +10,19 @@
10
10
  //
11
11
  // Build: `tsc -p tsconfig.json` compiles this to lib/index.js (the shipped
12
12
  // artifact — same convention as DSH's own packages, which ship compiled JS).
13
- import { defineTool } from '@deepseek-ai/dsh-tools';
13
+ import { defineTool as hostDefineTool } from '@deepseek-ai/dsh-tools';
14
14
  import { createRequire } from 'node:module';
15
- import { randomUUID } from 'node:crypto';
15
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
16
16
  import { ArchiveWorkspaceRegistry as HistoryService, workspaceBaseName, workspacePathKey } from './history/workspace.js';
17
17
  import { createSkillsService, pluginLog } from './skills/service.js';
18
+ import { renameWithRetry } from './skills/core.js';
18
19
  import { createAgentsMdService } from './agents-md/service.js';
19
20
  import { createRulesService, planMemoryExport } from './rules/service.js';
20
21
  import { createArchiveEngine } from './rules/archive-engine.js';
21
22
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
22
23
  import { createSubagentCatalog } from './subagents/catalog.js';
23
24
  import { createSkillCatalog } from './skills/catalog.js';
24
- import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, } from './context-inject.js';
25
+ import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
25
26
  import { isApprovalNever } from './approval-policy.js';
26
27
  import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, summarize } from './compat/probe.js';
27
28
  import { assessPresetReach, injectionFactsOf, presetRosterOf, readCompositionFacts, reachNoticeForAgent } from './compat/preset-reach.js';
@@ -34,7 +35,7 @@ import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from './s
34
35
  import { detectFormat, extractText, parseGenericText, parseJsonlTranscript, parseMarkdownTranscript } from './imports/parsers.js';
35
36
  import { homedir } from 'node:os';
36
37
  import { spawn } from 'node:child_process';
37
- import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
38
+ import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
38
39
  import { basename, dirname, join } from 'node:path';
39
40
  import { zipSync } from 'fflate';
40
41
  /**
@@ -154,6 +155,10 @@ export default {
154
155
  // is not an injected service and throws "cannot get property without
155
156
  // inject" at boot.
156
157
  const TOKEN = String(config?.token || process.env.DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN || '').trim();
158
+ // 令牌比较走「双侧 sha256 → timingSafeEqual」:摘要定长 32 字节,无需长度分支
159
+ // (直接比较不等长 Buffer 会抛),逐字节的耗时不随匹配前缀长度变化 —— JS 的 ===
160
+ // 逐字符短路,理论上可被计时侧信道逐位猜测。
161
+ const tokenMatches = (presented) => timingSafeEqual(createHash('sha256').update(presented).digest(), createHash('sha256').update(TOKEN).digest());
157
162
  const wait = (ms) => ctx.timeout(ms);
158
163
  const message = (e) => String((e && e.message) || e);
159
164
  let writeChain = Promise.resolve();
@@ -284,12 +289,18 @@ export default {
284
289
  const states = {};
285
290
  const all = {};
286
291
  const shadowed = new Set();
292
+ const unloadable = new Set();
287
293
  for (const root of ((r && r.data && r.data.roots) || [])) {
288
294
  for (const sk of (root.skills || [])) {
289
295
  const name = String(sk.declaredName || sk.name || '');
290
296
  if (!name)
291
297
  continue;
292
298
  const key = String(root.key || '') + '/' + name;
299
+ // 结构不完整的技能在 core 里**一律拒绝启停**(error.skill.notLoadable):
300
+ // 档案里若残留这样的键,旧实现会把 `applySkills` 的"应用失败"抛出去,于是
301
+ // **整个场景进不去**。这里单独收集,与「被覆盖」同口径处理(见 applySkills)。
302
+ if (sk.loadable === false)
303
+ unloadable.add(key);
293
304
  if (sk.shadowedBy) {
294
305
  shadowed.add(key);
295
306
  all[key] = false;
@@ -300,7 +311,24 @@ export default {
300
311
  all[key] = on;
301
312
  }
302
313
  }
303
- return { states, shadowed, all };
314
+ return { states, shadowed, all, unloadable };
315
+ }
316
+ /**
317
+ * 档案里**能写、且写了有意义**的技能键:既不被同名技能覆盖,结构也完整。
318
+ *
319
+ * 为什么要单独给一个集合:档案的语义是「勾选集 = 该场景下开着的东西」,而被覆盖的副本
320
+ * 永远不可能生效(`applySkills` 直接跳过),结构不完整的更是启停都被 core 拒绝 ——
321
+ * 它们进档案只会造成「档案说开着、运行时说关着」(用户实测:启动目录后档案里多出被覆盖
322
+ * 的那个技能,而技能页显示它并未启动)。保存档案时按这个集合校验,顺带清理历史残留。
323
+ */
324
+ async function selectableSkillKeys() {
325
+ const { shadowed, unloadable } = await skillScan();
326
+ const all = new Set(Object.keys(await skillStates()));
327
+ for (const k of shadowed)
328
+ all.delete(k);
329
+ for (const k of unloadable)
330
+ all.delete(k);
331
+ return all;
304
332
  }
305
333
  async function skillStates() {
306
334
  // knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
@@ -333,6 +361,7 @@ export default {
333
361
  name,
334
362
  enabled: sk.enabled === true && !sk.shadowedBy,
335
363
  shadowed: !!sk.shadowedBy,
364
+ loadable: sk.loadable !== false,
336
365
  rootKey,
337
366
  rootLabel: String((root && root.label) || rootKey),
338
367
  ...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
@@ -392,22 +421,31 @@ export default {
392
421
  scheduleToolRestrictions();
393
422
  },
394
423
  knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
424
+ // 档案保存校验用:被同名覆盖的副本与结构不完整的技能都不算「可勾选」(见 selectableSkillKeys)。
425
+ selectableSkillKeys,
395
426
  // 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
396
427
  // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
397
428
  currentSkills: async () => (await skillScan()).states,
398
429
  applySkills: async (target) => {
399
- // 两道过滤,缺一不可:
430
+ // 三道过滤,缺一不可:
400
431
  // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
401
- // 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
432
+ // 2. 结构不完整的技能也跳过——core 对它们一律拒绝启停(error.skill.notLoadable),
433
+ // 不跳过就会抛成"应用失败",**整个场景进不去**(老档案里的残留键能踩到)。
434
+ // 3. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
402
435
  // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
403
436
  // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
404
- const { states, shadowed } = await skillScan();
437
+ const { states, shadowed, unloadable } = await skillScan();
405
438
  for (const [key, on] of Object.entries(target)) {
406
439
  const i = key.indexOf('/');
407
440
  if (i <= 0)
408
441
  throw new Error(`技能 key 不合法: ${key}`);
442
+ // 被同名覆盖的副本跳过(它本来就不生效,写策略只会污染用户的记录)。
409
443
  if (shadowed.has(key))
410
444
  continue;
445
+ // 结构不完整的技能跳过:core 对它们**一律拒绝启停**(error.skill.notLoadable),
446
+ // 继续往下走会抛成"应用失败",**整个场景就进不去了**(老档案里的残留键能踩到)。
447
+ if (unloadable.has(key))
448
+ continue;
411
449
  if (states[key] === on)
412
450
  continue;
413
451
  const root = key.slice(0, i);
@@ -487,12 +525,38 @@ export default {
487
525
  }
488
526
  return out;
489
527
  },
528
+ /**
529
+ * 来源级启停(进入 / 退出 / 就地重应用共用)。
530
+ *
531
+ * 容错口径(用户 2026-09-17 报「退出模式失败 → 卡在场景里」之后加的):**结构性**失败
532
+ * (来源解析不到 / 该来源本来就不允许启停)只记警告并跳过 —— 这一行与本次改动无关,
533
+ * 拿它中断整个恢复只会把用户困在场景里;**写盘**类失败仍然抛错,那说明确实没还原成功。
534
+ * 结构性失败的正解在 `skills/service.ts` 的 `requestSwitchRoot`(项目来源按 key 前缀解析)。
535
+ */
490
536
  applySkillSourceSwitches: async (switches) => {
537
+ const skipped = [];
491
538
  for (const s of switches) {
492
539
  const op = s.enabled ? 'skill-source-enable' : 'skill-source-disable';
493
- const r = await skillsService.ops[op]({ root: s.root });
494
- if (r && r.ok === false)
540
+ let r;
541
+ try {
542
+ r = await skillsService.ops[op]({ root: s.root });
543
+ }
544
+ catch (e) {
545
+ r = { ok: false, error: message(e), code: '' };
546
+ }
547
+ if (r && r.ok === false) {
548
+ const code = String((r && r.code) || '');
549
+ const structural = code === 'error.root.readonly' || code === 'error.root.unknown' ||
550
+ /来源不存在|不允许启用或停用/.test(String(r.error || ''));
551
+ if (structural) {
552
+ skipped.push(`${s.root}(${r.error})`);
553
+ continue;
554
+ }
495
555
  throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
556
+ }
557
+ }
558
+ if (skipped.length) {
559
+ ctx.logger?.warn?.(`scene: 跳过无法启停的技能来源 —— ${skipped.join('、')}`);
496
560
  }
497
561
  },
498
562
  sceneExists: async (name) => {
@@ -513,7 +577,7 @@ export default {
513
577
  await subagentService.enabledStore.setEnabled(on, true);
514
578
  void subagentCatalog.refresh();
515
579
  },
516
- // 记忆 id 全集(保存 memories 段时校验并报 stale)。
580
+ // 记忆 id 全集。引擎已不消费(memories 段 P5 起废弃),实现保留以维持接口形状。
517
581
  knownMemoryIds: async () => {
518
582
  const r = await rulesService.ops['rules-list']({});
519
583
  return new Set(((r && r.rules) || []).filter((x) => !x.shadowed).map((x) => String(x.id)));
@@ -638,6 +702,12 @@ export default {
638
702
  return undefined;
639
703
  }
640
704
  }
705
+ // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
706
+ // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
707
+ let contextInjectorLive = null;
708
+ // 采纳遥测的入口(同样是 apply 作用域的中转):工具注册在 effect 之外,
709
+ // 拿不到 effect 内部的 `injector`;经这个 `let` 中转,热更新重建注入器也能跟上。
710
+ let contextInjectorNote = null;
641
711
  try {
642
712
  ctx.effect(() => {
643
713
  const injector = createContextInjector({
@@ -645,17 +715,43 @@ export default {
645
715
  // 顺序即界面勾选与消息顺序(用户裁定 2026-09-16):场景和记忆 → MCP → 技能 →
646
716
  // 子智能体 → 提示词。form 是宿主语义轴:记忆是「当前状态」(snapshot,后发取代先发),
647
717
  // 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
718
+ //
719
+ // `applicableTo`(2026-09-17 方案 A):子智能体域按"**目录该不该注入到这么深的
720
+ // 会话**"(人设的 `catalogDepth`,默认 1 = 只在顶层注入)判断,**不是**"能不能
721
+ // 委派"。委派可行性由官方决定:`dsh-tool-subagent` 默认 `maxDepth: 3`,provider
722
+ // 只在传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫
723
+ // "还有没有委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里
724
+ // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
725
+ // 所以不会分叉。
726
+ //
727
+ // 记忆域(2026-09-17 用户裁定):**只在顶层注入**。记忆是"父会话的现场",不是子代理
728
+ // 完成任务所需的事实 —— 而且它带着「一律照办,覆盖你的默认做法」这种强主张,塞进
729
+ // 一次性子会话只会与角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与
730
+ // 整份场景记忆)。子代理手里有 `memory_manager_list/read`,需要什么自己取;父代理
731
+ // 上下文里也有记忆,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
732
+ // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
733
+ // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
734
+ // 工具,不知道清单就只能瞎调)。
648
735
  domains: () => [
649
- { key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText() },
736
+ { key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => rulesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
650
737
  { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcpStateCatalog.text() },
651
738
  { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
652
- { key: 'subagents', name: 'tool-management:subagents', label: '子智能体', form: 'catalog', text: () => subagentCatalog.text() },
653
- { key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => rulesService.promptText() },
739
+ {
740
+ key: 'subagents',
741
+ name: 'tool-management:subagents',
742
+ label: '子智能体',
743
+ form: 'catalog',
744
+ text: (agent) => subagentCatalog.text(agent),
745
+ applicableTo: (agent) => subagentCatalog.catalogVisibleAt(subagentDepthOf(agent)),
746
+ },
747
+ { key: 'prompt', name: 'tool-management:prompt', label: '提示词', form: 'instructions', text: () => rulesService.promptText(), files: () => rulesService.promptFiles() },
654
748
  ],
655
749
  settings: () => injectSettingsSync(),
656
750
  factsFor: (agent) => presetFactsForAgent(agent),
657
751
  });
658
- return () => injector.dispose();
752
+ contextInjectorLive = () => injector.live();
753
+ contextInjectorNote = (toolName, agent) => injector.noteToolUse(toolName, agent);
754
+ return () => { contextInjectorLive = null; contextInjectorNote = null; injector.dispose(); };
659
755
  }, 'dsh-plugin-tool-management: context injection');
660
756
  }
661
757
  catch (e) {
@@ -720,12 +816,51 @@ export default {
720
816
  return 0;
721
817
  }
722
818
  }
819
+ /**
820
+ * 人设改名后同步运行时快照(F-025):退出还原按 `subagentsAll` 逐名走,快照里
821
+ * 还留着旧名的话,退出会「旧名静默跳过、新名不还原」—— 被改名的人设停留在
822
+ * 场景期间状态。快照在进入场景时拍、改名发生在进入后,只能在改名时跟着改。
823
+ */
824
+ async function renameSubagentInSnapshot(from, to) {
825
+ try {
826
+ const slice = await rulesService.readArchiveSlice();
827
+ const mode = slice && slice.mode;
828
+ const snapshot = mode && mode.snapshot;
829
+ if (!snapshot)
830
+ return;
831
+ let changed = false;
832
+ const next = { ...snapshot };
833
+ if (snapshot.subagentsAll && Object.prototype.hasOwnProperty.call(snapshot.subagentsAll, from)) {
834
+ const all = {};
835
+ for (const [k, v] of Object.entries(snapshot.subagentsAll)) {
836
+ all[k === from ? to : k] = v;
837
+ if (k === from)
838
+ changed = true;
839
+ }
840
+ next.subagentsAll = all;
841
+ }
842
+ for (const field of ['subagents', 'subagentsOn']) {
843
+ const list = snapshot[field];
844
+ if (Array.isArray(list) && list.includes(from)) {
845
+ next[field] = list.map((n) => (n === from ? to : n));
846
+ changed = true;
847
+ }
848
+ }
849
+ if (changed)
850
+ await rulesService.patchIndex({ mode: { ...mode, snapshot: next } });
851
+ }
852
+ catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
853
+ }
723
854
  const baseSubagentUpdate = subagentService.ops['subagent-update'];
724
855
  if (typeof baseSubagentUpdate === 'function') {
725
856
  subagentService.ops['subagent-update'] = async (args) => {
726
857
  const res = await baseSubagentUpdate(args);
727
- if (res && res.ok !== false && res.renamedFrom)
728
- void rebindSubagentInArchives(String(res.renamedFrom), String(res.name));
858
+ if (res && res.ok !== false && res.renamedFrom) {
859
+ const from = String(res.renamedFrom);
860
+ const to = String(res.name);
861
+ void rebindSubagentInArchives(from, to);
862
+ void renameSubagentInSnapshot(from, to);
863
+ }
729
864
  return res;
730
865
  };
731
866
  }
@@ -788,7 +923,10 @@ export default {
788
923
  if (toOn.length || toOff.length)
789
924
  void subagentCatalog.refresh();
790
925
  const snapshot = mode.snapshot;
791
- if (snapshot && (toOn.length || toOff.length)) {
926
+ // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
927
+ // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
928
+ // archive-engine 的 restoreSnapshot 兼容分支)。
929
+ if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
792
930
  const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
793
931
  const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
794
932
  for (const n of toOn)
@@ -817,6 +955,10 @@ export default {
817
955
  'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-restart',
818
956
  'mcpm-compact',
819
957
  'mcpm-export', 'mcpm-import', 'mcpm-note', 'mcpm-settings', 'mcpm-tool-enabled',
958
+ // mcpm-tools-refresh 为了拿实时工具表会临时启用目标服务器、结束后恢复原状(两次
959
+ // writePatch),恢复失败还会停留在启用态 —— 是写不是读,按写门禁(与 mcpm-restart
960
+ // 已在清单同理;0.6.0/0.7.0 已有两次写 op 漏列前科)。
961
+ 'mcpm-tools-refresh',
820
962
  // mcpm-reveal returns UNMASKED secrets; even though it is a read, it is
821
963
  // token-gated like a write — on a LAN-exposed port the token must be the
822
964
  // last line of defense for plaintext credentials too, not just writes.
@@ -912,22 +1054,29 @@ export default {
912
1054
  /**
913
1055
  * **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
914
1056
  *
915
- * 场景接管期间(启用场景 / 全局绑定在驱动基线)只允许「重新应用场景绑定的那一份」:
916
- * 应用别的预设会绕过场景绑定,于是「场景页显示 A、实际注入 B、A 不可删而 B 可删」
917
- * 四套状态各说各话(用户实测)。同一份 = 修复(把被手改的基线写回去),走场景同步
918
- * 而不是裸写 —— 同步会在写之前补上「进场景前的基线」快照,退出场景才恢复得回来。
919
- * 换提示词的唯一正路在场景设置里改绑定(`rules-update-scene`,成功后同样同步基线)。
1057
+ * 场景接管期间(启用场景 / 全局绑定在驱动基线):
1058
+ * - 应用**同一份** = 重新应用(把被手改的基线写回去),走场景同步;
1059
+ * - 应用**别的份** = 换掉这个场景的绑定(用户裁定 2026-09-17:未锁定时切换要可用,
1060
+ * 并且同步写进场景档案)。绑定改完再走一次场景同步,于是「场景页显示什么 / 实际注入
1061
+ * 什么 / 谁是引用方」三处立刻重新对齐(此前的做法是直接拒绝,用户得先退出场景)。
1062
+ * 锁定由 `guardLockedOps` 挡住(agentsmd-apply 在冻结清单里);模型工具
1063
+ * `prompt_manager_apply` 不经过 handlers,execute 里自带 `lockedSceneGuard`(F-001)。
920
1064
  */
921
1065
  async function applyPresetGuarded(id) {
922
- const blocked = await scenePromptSync.applyGuard(id);
923
- if (blocked) {
924
- return {
925
- ok: false,
926
- code: 'error.agentsMd.scenePromptLocked',
927
- error: `提示词基线当前由场景「${blocked.label}」绑定的预设「${blocked.presetId}」接管,不能直接应用其他预设:要换提示词,请到场景页修改该场景的绑定,或先退出场景。`,
928
- };
929
- }
930
- if (await scenePromptSync.driver()) {
1066
+ const driver = await scenePromptSync.driver();
1067
+ if (driver) {
1068
+ const target = String(id ?? '').trim();
1069
+ if (driver.presetId !== target) {
1070
+ const rebound = await rulesService.ops['rules-update-scene']({ name: driver.scene, prompt: target });
1071
+ if (!rebound || rebound.ok === false) {
1072
+ return {
1073
+ ok: false,
1074
+ code: 'error.agentsMd.sceneRebindFailed',
1075
+ error: `无法把场景「${driver.label}」的提示词绑定改成「${target}」:${(rebound && rebound.error) || '未知原因'}`,
1076
+ params: { scene: driver.label, target, reason: String((rebound && rebound.error) || '未知原因') },
1077
+ };
1078
+ }
1079
+ }
931
1080
  const r = await scenePromptSync.sync();
932
1081
  if (r.error)
933
1082
  return { ok: false, error: r.error };
@@ -1142,7 +1291,6 @@ export default {
1142
1291
  catch (e) { /* pruning is best-effort */ }
1143
1292
  }
1144
1293
  async function writePatch(abs, content) {
1145
- const t = await fs.resolve(abs);
1146
1294
  const policy = await sandboxPolicy.resolve({ mode: 'danger-full-access' });
1147
1295
  try {
1148
1296
  const previous = await readPatch(abs);
@@ -1150,7 +1298,20 @@ export default {
1150
1298
  await backupPatchFile(abs, previous);
1151
1299
  }
1152
1300
  catch (e) { /* a failed backup must never block the write */ }
1153
- await fs.writeText(t, content, undefined, undefined, policy);
1301
+ // 同目录临时文件 + rename 原子替换:进程中断/磁盘满最坏留下一个 .tmp 兄弟,
1302
+ // 不会截断补丁本体。官方 app-boot 写这两个补丁文件用的是同一套模式
1303
+ // (`filename + '.tmp'` → rename + 瞬时错误重试,lib/index.js `_writeFile`),
1304
+ // 所以 HMR 对 rename 的容忍不需要额外验证。内容写仍走宿主 fs 服务
1305
+ // (沙箱策略与审批语义不变),rename 只是元数据动作。
1306
+ const temp = abs + '.' + randomUUID().slice(0, 8) + '.dsh-tmp';
1307
+ try {
1308
+ await fs.writeText(await fs.resolve(temp), content, undefined, undefined, policy);
1309
+ await renameWithRetry(temp, abs);
1310
+ }
1311
+ catch (e) {
1312
+ await rm(temp, { force: true }).catch(() => undefined);
1313
+ throw e;
1314
+ }
1154
1315
  }
1155
1316
  // ---------- duplicate loader-id guard ----------
1156
1317
  // Two rows with the same loader id make the plugin composition fail to
@@ -1330,7 +1491,6 @@ export default {
1330
1491
  managedIds.add(m[1].trim());
1331
1492
  }
1332
1493
  const rows = [];
1333
- const overrides = [];
1334
1494
  const blocks = [];
1335
1495
  let current = null;
1336
1496
  for (const line of lines) {
@@ -1369,16 +1529,18 @@ export default {
1369
1529
  }
1370
1530
  }
1371
1531
  else {
1532
+ // 覆盖块按**文件顺序**生效,不是"收集后统一回填":官方
1533
+ // `@deepseek-ai/dsh-app-boot` 的 applyEntryPatches 只给此刻已入索引的 id 打补丁
1534
+ // (insert 是插入时立即入索引),命中不到就 warn + skip。所以写在 insert 之前的
1535
+ // 覆盖块在宿主侧是 no-op —— 这里同样丢弃,界面才和生效值一致。
1372
1536
  const entry = parseEntry(block.text.split('\n'));
1373
- if (entry && entry.name === '@deepseek-ai/dsh-mcp-client')
1374
- overrides.push({ id: entry.id, disabled: entry.disabled });
1537
+ if (entry && entry.name === '@deepseek-ai/dsh-mcp-client' && entry.disabled !== undefined) {
1538
+ const row = rows.find((r) => r.id === entry.id);
1539
+ if (row)
1540
+ row.disabled = entry.disabled;
1541
+ }
1375
1542
  }
1376
1543
  }
1377
- for (const o of overrides) {
1378
- const row = rows.find((r) => r.id === o.id);
1379
- if (row && o.disabled !== undefined)
1380
- row.disabled = o.disabled;
1381
- }
1382
1544
  return { rows };
1383
1545
  }
1384
1546
  // ---------- line-based block editing ----------
@@ -2038,7 +2200,9 @@ export default {
2038
2200
  toolsList.push({
2039
2201
  name: known.name,
2040
2202
  description: known.description || '(服务器未运行;这个名字来自上次运行记录)',
2041
- enabled: !disabledHere.has(known.name),
2203
+ // 与上方 live 行同一口径:整台停用(`*`)时 stale 行同样报停用,
2204
+ // 否则同页「live 行停用、stale 行启用」自相矛盾。
2205
+ enabled: wildcardHere ? false : !disabledHere.has(known.name),
2042
2206
  parameters: [],
2043
2207
  stale: true,
2044
2208
  });
@@ -2133,6 +2297,24 @@ export default {
2133
2297
  }
2134
2298
  const toolCounts = {};
2135
2299
  const enabledToolCounts = {};
2300
+ // 只来自**真实 schema** 的两个数,以及只来自**「已知工具」缓存**的一个数。
2301
+ //
2302
+ // 为什么必须与上面那两个分开(2026-09-17 用户实测发现):`toolCounts` 是三处并集
2303
+ // (live ∪ 停用表 ∪ 缓存),把"这台 server 曾经有什么工具"与"它现在有什么工具"混成了
2304
+ // 一个数。界面状态胶囊与注入段此前读的就是它,于是**一台已经连不上的 server 会被报成
2305
+ // 「已运行」并注入上下文**,模型照着去调 `mcp__X__*`,而那个工具根本没注册 —— 白烧一轮。
2306
+ //
2307
+ // 分开之后三个数各有明确含义:
2308
+ // liveToolCounts 当前真实注册的工具数(0 = 一个都没注册)
2309
+ // liveEnabledToolCounts 上面这个数里未被停用表扣减的(真正能调到的)
2310
+ // knownToolCounts 缓存里的工具数 —— 它是**曾经真的连上过**的证据(缓存只在真实
2311
+ // schema 里见到工具时才写入),0 = 从未连上过
2312
+ //
2313
+ // 前两个给状态胶囊与注入段用;`toolCounts` / `enabledToolCounts` 语义不变,继续给场景
2314
+ // 档案挑工具、详情页清单用(那里"上次见过哪些工具"正是需要的,且已标 `stale`)。
2315
+ const liveToolCounts = {};
2316
+ const liveEnabledToolCounts = {};
2317
+ const knownToolCounts = {};
2136
2318
  try {
2137
2319
  const schemas = await tools.schemas();
2138
2320
  const knownCache = await readKnownMcpTools();
@@ -2183,6 +2365,17 @@ export default {
2183
2365
  // 否则收窄后模型看到的数字比它能调的工具多,页面与详情页也会互相矛盾。
2184
2366
  enabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
2185
2367
  }
2368
+ // 真实来源的两个数:只数 `liveNames`(schema 里真正注册了的),再按停用表扣减。
2369
+ for (const [server, list] of Object.entries(liveNames)) {
2370
+ const set = new Set(list);
2371
+ liveToolCounts[server] = set.size;
2372
+ liveEnabledToolCounts[server] = countEnabledTools(set, disabledMap[server] || []);
2373
+ }
2374
+ // 「曾经连上过」的证据:缓存里这台 server 见过几个工具。注意读的是**回写之前**的
2375
+ // `knownCache` —— 回写会把这次 live 见到的并进去,而这次 live 见到的本来就已经算在
2376
+ // liveToolCounts 里了,不需要它来充当"曾经"。
2377
+ for (const [server, list] of Object.entries(knownCache))
2378
+ knownToolCounts[server] = list.length;
2186
2379
  // 回写「已知工具」:live 见到的名字与描述并入缓存(有变化才写盘,读路径上的 best-effort)。
2187
2380
  // 名字集合变化,或某个已知工具这次拿到了描述而缓存里没有 → 都算变化。
2188
2381
  let cacheChanged = false;
@@ -2238,7 +2431,21 @@ export default {
2238
2431
  row.toolCount = toolCounts[row.serverName] || 0;
2239
2432
  if (row.enabledToolCount === undefined)
2240
2433
  row.enabledToolCount = enabledToolCounts[row.serverName] || 0;
2241
- }
2434
+ // 这三个**总是**覆盖:它们描述的是"此刻的真实状态",而缓存合并出来的那两个数
2435
+ // 描述的是"这台 server 有过哪些工具"。两者混淆过一次,代价是注入段谎报可用。
2436
+ row.liveToolCount = liveToolCounts[row.serverName] || 0;
2437
+ row.liveEnabledToolCount = liveEnabledToolCounts[row.serverName] || 0;
2438
+ row.knownToolCount = knownToolCounts[row.serverName] || 0;
2439
+ }
2440
+ // 列表顺序(用户要求 2026-09-17):**全局在前、应用级在后**,各级按服务器名排,
2441
+ // 当前在跑的 loader 行垫底。放在服务端而不是页面里:同一个顺序也是场景档案勾选器
2442
+ // (`scene-inventory`)与模型侧 `mcp_manager_list` 的顺序 —— 三处说同一件事。
2443
+ // 名字比较与技能页同一套(localeCompare),大小写混排时不会把大写全顶到前面。
2444
+ const levelRank = { global: 0, project: 1, loader: 2 };
2445
+ const serverNameOf = (row) => String(row.serverName || row.id || '');
2446
+ rows.sort((a, b) => (levelRank[a.level] ?? 3) - (levelRank[b.level] ?? 3) ||
2447
+ serverNameOf(a).localeCompare(serverNameOf(b)) ||
2448
+ String(a.id).localeCompare(String(b.id)));
2242
2449
  // A loader id that appears twice (same id in both patch files, or twice in
2243
2450
  // one) makes the composition fail to boot. Surface it instead of hiding it.
2244
2451
  const idCounts = new Map();
@@ -2353,6 +2560,17 @@ export default {
2353
2560
  }
2354
2561
  const oldAbs = cur.level === 'global' ? p.globalPatch : p.projectPatch;
2355
2562
  const newAbs = level === 'global' ? p.globalPatch : p.projectPatch;
2563
+ // 编辑表单不建模 config 里的全部键:手写补丁可能带 toolCallTimeoutMs 这类字段,
2564
+ // 重建前从旧条目原样带回,否则一次「编辑」就把它静默删掉。只透传 buildInsertBlock
2565
+ // 已有发射路径的键 —— 其余未知键的保真需要值保持式序列化(裸布尔/数字经
2566
+ // unquote/yq 往返会漂成字符串),不在此处理,宁可如实丢弃也不写错类型。
2567
+ try {
2568
+ const curRaw = parseRows(await readPatch(oldAbs)).rows.find((r) => r.id === id);
2569
+ if (curRaw && curRaw.config && curRaw.config.toolCallTimeoutMs != null) {
2570
+ row.toolCallTimeoutMs = curRaw.config.toolCallTimeoutMs;
2571
+ }
2572
+ }
2573
+ catch { /* 旧文件读不了时按建模字段重建,与原行为一致 */ }
2356
2574
  const block = buildInsertBlock(row);
2357
2575
  return withWriteLock(async () => {
2358
2576
  // Per-tool disable state is keyed by the serverName namespace: migrate
@@ -2368,6 +2586,20 @@ export default {
2368
2586
  catch (e) { /* non-fatal */ }
2369
2587
  disabledToolsCache = { at: Date.now(), value: toolMap };
2370
2588
  }
2589
+ // F-025:运行时快照的 mcp 停用表同样按 serverName 键 —— 改名不跟着改,
2590
+ // 退出场景整体回写时旧键成死键、新名工具的停用状态丢失(回到全启用)。
2591
+ try {
2592
+ const slice = await rulesService.readArchiveSlice();
2593
+ const mode = slice && slice.mode;
2594
+ const mcpMap = mode && mode.snapshot && mode.snapshot.mcp;
2595
+ if (mcpMap && Object.prototype.hasOwnProperty.call(mcpMap, cur.serverName)) {
2596
+ const next = {};
2597
+ for (const [k, v] of Object.entries(mcpMap))
2598
+ next[k === cur.serverName ? serverName : k] = v;
2599
+ await rulesService.patchIndex({ mode: { ...mode, snapshot: { ...mode.snapshot, mcp: next } } });
2600
+ }
2601
+ }
2602
+ catch { /* 快照同步失败不阻断改名本身;残留与修复前一致 */ }
2371
2603
  }
2372
2604
  if (oldAbs !== newAbs) {
2373
2605
  // Level migration: remove from the old file, insert into the new one.
@@ -2550,6 +2782,16 @@ export default {
2550
2782
  // waits deliberately run OUTSIDE the write lock — holding the global
2551
2783
  // write lock for up to ~11s stalled every other write op.
2552
2784
  await withWriteLock(async () => {
2785
+ // 并发护栏:阶段 1 的强制写自身必然把 effective 状态置为停用,所以走到这里时
2786
+ // 若读到「已启用」,只可能是等待窗口内别的写操作重新启用了它 —— 以现状为准并
2787
+ // 警告,绝不按重启前快照把用户的显式选择改回去。其余情形按 wasDisabled 原样恢复。
2788
+ // (已知残留:窗口内被「停用」与阶段 1 的写不可区分,仍按快照恢复 —— 重启语义
2789
+ // 本就是「状态与重启前一致」,方向性无害。)
2790
+ const currentDisabled = await isRowDisabled(id, level);
2791
+ if (!currentDisabled && wasDisabled) {
2792
+ warnings.push('重启等待窗口内该服务被重新启用:已保留启用状态,未按重启前状态恢复');
2793
+ return;
2794
+ }
2553
2795
  let c = await readPatch(abs);
2554
2796
  c = removeMarked(c, id, 'disable');
2555
2797
  if (wasDisabled)
@@ -2693,9 +2935,13 @@ export default {
2693
2935
  return { skipped: true, reason: 'serverName 已存在' };
2694
2936
  if (idTaken && !overwrite)
2695
2937
  return { skipped: true, reason: 'id 已存在' };
2938
+ // Overwrite: purge every trace of the id from BOTH patch files first,
2939
+ // then insert the imported row at its own level. The purge is not
2940
+ // atomic with the insert, so keep the pre-purge content of both files
2941
+ // and restore it when the insert fails — same trade-off as mcpmEdit's
2942
+ // level migration: losing the old entries is worse than a transient dup.
2943
+ const origs = [];
2696
2944
  if (idTaken && overwrite) {
2697
- // Purge every trace of the id from BOTH patch files first, then
2698
- // insert the imported row at its own level.
2699
2945
  for (const lvl of ['project', 'global']) {
2700
2946
  const lAbs = lvl === 'global' ? p.globalPatch : p.projectPatch;
2701
2947
  let lContent = '';
@@ -2705,24 +2951,50 @@ export default {
2705
2951
  catch (e) {
2706
2952
  continue;
2707
2953
  }
2708
- lContent = removeEntryAll(lContent, row.id);
2954
+ origs.push({ abs: lAbs, content: lContent });
2709
2955
  try {
2710
- await writePatch(lAbs, lContent);
2956
+ await writePatch(lAbs, removeEntryAll(lContent, row.id));
2711
2957
  }
2712
2958
  catch (e) {
2713
- return { skipped: true, reason: '覆盖旧条目失败: ' + message(e) };
2959
+ // 前面已清掉的文件也要恢复:purge 中途失败同样不能留下「旧条目没了」的状态。
2960
+ for (const o of origs) {
2961
+ try {
2962
+ await writePatch(o.abs, o.content);
2963
+ }
2964
+ catch { /* best effort */ }
2965
+ }
2966
+ return { skipped: true, reason: '覆盖旧条目失败(已回滚): ' + message(e) };
2714
2967
  }
2715
2968
  }
2716
2969
  }
2717
2970
  const abs = row.level === 'global' ? p.globalPatch : p.projectPatch;
2718
- let c = await readPatch(abs);
2719
- const before = c;
2720
- c = appendBlock(c, buildInsertBlock(row));
2721
- if (row.disabled)
2722
- c = appendBlock(c, buildDisableBlock(row.id, true));
2723
- if (duplicateGuard(before, c))
2724
- return { skipped: true, reason: '会产生重复的 loader id' };
2725
- await writePatch(abs, c);
2971
+ try {
2972
+ let c = await readPatch(abs);
2973
+ const before = c;
2974
+ c = appendBlock(c, buildInsertBlock(row));
2975
+ if (row.disabled)
2976
+ c = appendBlock(c, buildDisableBlock(row.id, true));
2977
+ const guard = duplicateGuard(before, c);
2978
+ if (guard) {
2979
+ for (const o of origs) {
2980
+ try {
2981
+ await writePatch(o.abs, o.content);
2982
+ }
2983
+ catch { /* best effort */ }
2984
+ }
2985
+ return { skipped: true, reason: guard.error };
2986
+ }
2987
+ await writePatch(abs, c);
2988
+ }
2989
+ catch (e) {
2990
+ for (const o of origs) {
2991
+ try {
2992
+ await writePatch(o.abs, o.content);
2993
+ }
2994
+ catch { /* best effort */ }
2995
+ }
2996
+ return { skipped: true, reason: '写入失败(已回滚): ' + message(e) };
2997
+ }
2726
2998
  return { added: true, wasOverwrite: idTaken && overwrite };
2727
2999
  });
2728
3000
  if (res.added) {
@@ -3354,10 +3626,12 @@ export default {
3354
3626
  ctx.logger?.warn?.('agents-md: reference probe failed; remove allowed');
3355
3627
  const why = refs ? refs.get(id) || [] : [];
3356
3628
  if (why.length) {
3629
+ const refsText = why.map(promptRefReason).join(';');
3357
3630
  return {
3358
3631
  ok: false,
3359
3632
  code: 'error.agentsMd.referenced',
3360
- error: `「${id}」仍被引用,不能删除:${why.map(promptRefReason).join(';')}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
3633
+ error: `「${id}」仍被引用,不能删除:${refsText}。先改掉引用(换绑提示词 / 退出场景 / 应用别的预设)再删除。`,
3634
+ params: { id, refs: refsText },
3361
3635
  };
3362
3636
  }
3363
3637
  return agentsMdService.remove(id);
@@ -3612,6 +3886,18 @@ export default {
3612
3886
  },
3613
3887
  // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
3614
3888
  'inject-settings': (args) => injectSettingsOp(args),
3889
+ // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 本次运行的投递统计。
3890
+ // 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
3891
+ 'injection-live': async () => {
3892
+ if (contextInjectorLive === null)
3893
+ return { ok: false, error: '注入通道未装配' };
3894
+ try {
3895
+ return { ok: true, ...contextInjectorLive() };
3896
+ }
3897
+ catch (e) {
3898
+ return { ok: false, error: message(e) };
3899
+ }
3900
+ },
3615
3901
  'history-unarchive': async (args) => {
3616
3902
  const registry = getHistoryRegistry();
3617
3903
  if (!registry)
@@ -4009,33 +4295,327 @@ export default {
4009
4295
  return null;
4010
4296
  }
4011
4297
  }
4012
- /** 「场景内开关一律禁用」的拒绝文案(界面 / 模型工具共用;不在场景里返回 null)。 */
4013
- async function sceneSwitchBlock() {
4298
+ /** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
4299
+ async function lockedSceneGuard() {
4014
4300
  const scene = await activeSceneName();
4015
4301
  if (!scene)
4016
4302
  return null;
4017
- return `当前处于场景「${scene}」:开关由场景档案定义,不能在页面上直接改。请到场景页的档案编辑器里改(改完立即生效;退出场景仍按进场景前的状态还原),或先退出场景。`;
4303
+ const locked = await lockedSceneNames();
4304
+ return locked.includes(scene) ? `场景「${scene}」已锁定:先到场景页解锁再改。` : null;
4305
+ }
4306
+ // ---------- 场景内改开关 = 同步改档案(v0.9.2)----------
4307
+ /**
4308
+ * 用户裁定(2026-09-17):场景**未锁定**时,页面上的开关(MCP / 技能 / 子智能体 / 提示词)
4309
+ * 照常可用,改动**同步写进当前场景的档案**;只有「锁定」才冻结这些开关。
4310
+ * 记忆不在此列 —— 它的开关是 `rules[*].enabled` 单一真相源,本来就不经过场景档案。
4311
+ *
4312
+ * 为什么必须同步:档案的语义是「勾选集 = 该场景下开着的东西,段未定义 = 全关」(见
4313
+ * archive-engine 的四域同口径)。只改运行时、不改档案,退出场景会被快照整体回滚 ——
4314
+ * 用户看到的「改了」是假的;写进档案才既当下生效、下次进这个场景又照样生效。
4315
+ * 新增 / 改名 / 删除 / 备注 / 导入导出这些**内容**操作不进档案(它们不是场景维度)。
4316
+ */
4317
+ /**
4318
+ * 技能档案 key 映射:`<来源 key>\u0000<条目名>` → `{ 档案 key, selectable }`。
4319
+ *
4320
+ * `selectable=false` = 被同名技能覆盖的副本,或结构不完整(core 拒绝启停)——
4321
+ * 这两种都**不可能生效**,勾进档案只会让「档案说开着、技能页说没启动」(用户实测)。
4322
+ * `skill-disable` 不受此限:它只是把键从档案里删掉,任何键都可以删。
4323
+ */
4324
+ async function skillArchiveKeys() {
4325
+ const map = new Map();
4326
+ try {
4327
+ const r = await skillsService.ops['skill-state']({});
4328
+ for (const root of ((r && r.data && r.data.roots) || [])) {
4329
+ const rootKey = String((root && root.key) || '');
4330
+ if (!rootKey)
4331
+ continue;
4332
+ for (const sk of ((root && root.skills) || [])) {
4333
+ const entry = String((sk && sk.name) || '');
4334
+ if (!entry)
4335
+ continue;
4336
+ // 与 skillRows / skillScan 同口径:档案里存**声明名**(frontmatter name)。
4337
+ // 用条目名拼 key 会被保存时的 stale 校验当成未知键丢掉。
4338
+ map.set(rootKey + '\u0000' + entry, {
4339
+ key: rootKey + '/' + String(sk.declaredName || entry),
4340
+ selectable: !sk.shadowedBy && sk.loadable !== false,
4341
+ });
4342
+ }
4343
+ }
4344
+ }
4345
+ catch { /* 读不到状态 → 调用方退化为原样拼 key */ }
4346
+ return map;
4347
+ }
4348
+ /**
4349
+ * 某个来源下的技能档案 key(来源级开关用):
4350
+ * - `all`:该来源下**全部**键 —— 停用来源时按它清理(连历史残留一起清掉);
4351
+ * - `selectable`:真正**生效得了**的那些 —— 启用来源时只并入这一批。
4352
+ */
4353
+ async function skillArchiveKeysOfRoot(rootKey) {
4354
+ const all = [];
4355
+ const selectable = [];
4356
+ try {
4357
+ const r = await skillsService.ops['skill-state']({});
4358
+ for (const root of ((r && r.data && r.data.roots) || [])) {
4359
+ if (String((root && root.key) || '') !== rootKey)
4360
+ continue;
4361
+ for (const sk of ((root && root.skills) || [])) {
4362
+ const name = String((sk && sk.declaredName) || (sk && sk.name) || '');
4363
+ if (!name)
4364
+ continue;
4365
+ const key = rootKey + '/' + name;
4366
+ all.push(key);
4367
+ if (!sk.shadowedBy && sk.loadable !== false)
4368
+ selectable.push(key);
4369
+ }
4370
+ }
4371
+ }
4372
+ catch { /* 读不到 → 空列表 */ }
4373
+ return { all, selectable };
4374
+ }
4375
+ /** 字符串数组的「勾上 / 取消」(已处于目标状态时原样返回,避免无谓的档案写入)。 */
4376
+ function toggleInList(list, key, on) {
4377
+ if (!key)
4378
+ return list;
4379
+ const has = list.indexOf(key) >= 0;
4380
+ if (on === has)
4381
+ return list;
4382
+ return on ? [...list, key] : list.filter((x) => x !== key);
4383
+ }
4384
+ /** loader 条目 id → 服务器名(档案的 mcp 段按 serverName 存)。 */
4385
+ async function serverNameOfId(id) {
4386
+ try {
4387
+ const r = await mcpmListView();
4388
+ for (const row of ((r && r.rows) || [])) {
4389
+ if (String((row && row.id) || '') === id)
4390
+ return String(row.serverName || row.id);
4391
+ }
4392
+ }
4393
+ catch { /* 读不到 → null(这一笔不同步,如实跳过) */ }
4394
+ return null;
4018
4395
  }
4019
4396
  /**
4020
- * 场景内「开关」类操作一律禁用(用户裁定 2026-09-16)。
4397
+ * 某台服务器当前**启用**的工具名(把档案里的 `'*'` 具化成具体名单时用)。
4021
4398
  *
4022
- * 场景里的环境由**档案**定义,页面开关在场景里本来就是"白改":退出场景时 MCP 停用表、
4023
- * 技能表按快照整体写回,那些改动会被静默丢弃,而档案又不会跟着变 —— 于是「页面开关 /
4024
- * 实际生效 / 档案勾选」三处各说各话(用户实测数次)。要改就改档案:那里改完立即生效、
4025
- * 且档案持久化(下次进这个场景照旧生效);退出场景时运行时仍按进场景前的快照还原。
4026
- * 只拦**启停**:新增 / 改名 / 删除 / 备注 / 重启 / 导入导出这些内容操作照旧可用
4027
- * (要整体冻结用场景「锁定」)。
4399
+ * live schema 优先(就是此刻真的存在的工具);服务器没跑起来时退回「最后见过的工具名单 −
4400
+ * 停用表」——与 `computeMcpPlan` 的 `knownTools` 同源,别把「不知道」当成「一个都没有」。
4401
+ */
4402
+ async function enabledToolNames(server) {
4403
+ const live = await (async () => {
4404
+ try {
4405
+ const r = await mcpmTools({ serverName: server });
4406
+ return ((r && r.tools) || []).filter((t) => t.enabled !== false).map((t) => String(t.name));
4407
+ }
4408
+ catch {
4409
+ return [];
4410
+ }
4411
+ })();
4412
+ if (live.length)
4413
+ return live;
4414
+ try {
4415
+ const known = (await readKnownMcpTools())[server] || [];
4416
+ const off = new Set(await readDisabledTools().then((m) => m[server] || []));
4417
+ if (off.has('*'))
4418
+ return [];
4419
+ return known.map((item) => String(item.name)).filter((n) => !off.has(n));
4420
+ }
4421
+ catch {
4422
+ return [];
4423
+ }
4424
+ }
4425
+ /**
4426
+ * 把一次开关落到当前场景的档案上。
4427
+ * 返回 null = 无需改动(没有活动场景 / 已锁定 / 这一笔不影响档案);
4428
+ * 返回字符串 = 档案没跟上(运行时已经生效,如实告诉用户,而不是假装成功)。
4429
+ */
4430
+ async function syncSwitchToScene(opName, args) {
4431
+ const scene = await activeSceneName();
4432
+ if (!scene)
4433
+ return null;
4434
+ if ((await lockedSceneNames()).includes(scene))
4435
+ return null;
4436
+ // 开关方向。**先按 op 名判定「动词即方向」的那几个**,再看 `args.enabled`:
4437
+ // `skill-enable` / `skill-disable` / `skill-source-enable` / `skill-source-disable`
4438
+ // 的参数里**根本没有 `enabled` 字段**(只有 root / name),早先统一读 `args.enabled`
4439
+ // 会把它们一律读成 false,于是「在场景里打开某个技能」被同步成「关闭」——目标集合
4440
+ // 一次变化都没有 → 直接早退不写盘,用户看到的就是「开关了但档案不跟着变」
4441
+ // (2026-09-17 用真实产物复现)。反向更糟:启用一个来源会被同步成「把该来源下的
4442
+ // 技能全部从档案里删掉」,档案与运行时当场对不上。
4443
+ // 其余 op 仍按宿主逐字对齐:`mcpm-tool-enabled` 的语义是「缺省 = 启用」(`enabled !== false`)。
4444
+ const enabled = opName === 'skill-enable' || opName === 'skill-source-enable'
4445
+ ? true
4446
+ : opName === 'skill-disable' || opName === 'skill-source-disable'
4447
+ ? false
4448
+ : args && typeof args.enabled === 'boolean'
4449
+ ? args.enabled === true
4450
+ : opName === 'mcpm-tool-enabled';
4451
+ let next;
4452
+ let cur = {};
4453
+ try {
4454
+ const slice = await rulesService.readArchiveSlice();
4455
+ cur = (slice.archives && slice.archives[scene]) || {};
4456
+ next = { ...cur };
4457
+ switch (opName) {
4458
+ case 'skill-enable':
4459
+ case 'skill-disable': {
4460
+ const root = String((args && args.root) || 'dsh');
4461
+ const entry = String((args && args.name) || '');
4462
+ const hit = (await skillArchiveKeys()).get(root + '\u0000' + entry);
4463
+ const key = (hit && hit.key) || root + '/' + entry;
4464
+ // 勾进来这件事只对**生效得了**的技能做:被同名覆盖的副本、结构不完整的技能
4465
+ // 写进去也永远不生效(运行时那一步本来就跳过了它们),档案就会开始说谎。
4466
+ // 取消勾选(enabled=false)不受限——它只是把键删掉,历史残留正好顺手清掉。
4467
+ if (enabled && hit && !hit.selectable)
4468
+ return null;
4469
+ next.skills = toggleInList(Array.isArray(next.skills) ? next.skills.slice() : [], key, enabled);
4470
+ break;
4471
+ }
4472
+ case 'skill-set-all': {
4473
+ const items = Array.isArray(args && args.items) ? args.items : [];
4474
+ const map = await skillArchiveKeys();
4475
+ let list = Array.isArray(next.skills) ? next.skills.slice() : [];
4476
+ for (const item of items) {
4477
+ const root = String((item && item.root) || '');
4478
+ const entry = String((item && item.name) || '');
4479
+ if (!root || !entry)
4480
+ continue;
4481
+ const hit = map.get(root + '\u0000' + entry);
4482
+ if (enabled && hit && !hit.selectable)
4483
+ continue;
4484
+ list = toggleInList(list, (hit && hit.key) || root + '/' + entry, enabled);
4485
+ }
4486
+ next.skills = list;
4487
+ break;
4488
+ }
4489
+ case 'skill-source-enable':
4490
+ case 'skill-source-disable': {
4491
+ // 来源级没有独立字段:档案靠「来源下有没有被勾的技能」反推(computeSkillsPlan)。
4492
+ // 启用时只并入**生效得了**的技能(被覆盖的副本/结构不完整的整批不进档案);
4493
+ // 停用时按**全部**键清(连历史残留一起清掉)。
4494
+ const keys = await skillArchiveKeysOfRoot(String((args && args.root) || ''));
4495
+ const pick = enabled ? keys.selectable : keys.all;
4496
+ let list = Array.isArray(next.skills) ? next.skills.slice() : [];
4497
+ if (enabled) {
4498
+ for (const k of pick)
4499
+ if (list.indexOf(k) < 0)
4500
+ list.push(k);
4501
+ }
4502
+ else {
4503
+ const drop = new Set(pick);
4504
+ list = list.filter((k) => !drop.has(k));
4505
+ }
4506
+ next.skills = list;
4507
+ break;
4508
+ }
4509
+ case 'mcpm-set-enabled': {
4510
+ const server = await serverNameOfId(String((args && args.id) || ''));
4511
+ if (!server)
4512
+ return null;
4513
+ next.mcp = { ...(next.mcp || {}) };
4514
+ if (enabled)
4515
+ next.mcp[server] = '*';
4516
+ else
4517
+ delete next.mcp[server];
4518
+ break;
4519
+ }
4520
+ case 'mcpm-set-all': {
4521
+ if (!enabled) {
4522
+ next.mcp = {};
4523
+ break;
4524
+ }
4525
+ const all = {};
4526
+ try {
4527
+ const r = await mcpmListView();
4528
+ for (const row of ((r && r.rows) || [])) {
4529
+ const n = String((row && row.serverName) || '');
4530
+ if (n)
4531
+ all[n] = '*';
4532
+ }
4533
+ }
4534
+ catch {
4535
+ return null;
4536
+ }
4537
+ next.mcp = all;
4538
+ break;
4539
+ }
4540
+ case 'mcpm-tool-enabled': {
4541
+ const server = String((args && args.serverName) || '');
4542
+ const tool = String((args && args.tool) || '');
4543
+ if (!server || !tool)
4544
+ return null;
4545
+ const has = next.mcp && Object.prototype.hasOwnProperty.call(next.mcp, server);
4546
+ // 服务器没勾 = 该场景下这台服务器关着,工具级开关在运行时本来就不生效(工具不存在)。
4547
+ if (!has)
4548
+ return null;
4549
+ const spec = next.mcp[server];
4550
+ if (spec === '*') {
4551
+ // '*' = 全部工具。只有「关掉某个工具」才需要具化;否则 '*' 已经涵盖了启用这一笔。
4552
+ if (enabled)
4553
+ return null;
4554
+ const live = await enabledToolNames(server);
4555
+ next.mcp = { ...next.mcp, [server]: live.filter((t) => t !== tool) };
4556
+ }
4557
+ else {
4558
+ const list = Array.isArray(spec) ? spec.slice() : [];
4559
+ next.mcp = {
4560
+ ...next.mcp,
4561
+ [server]: enabled
4562
+ ? (list.indexOf(tool) < 0 ? [...list, tool] : list)
4563
+ : list.filter((t) => t !== tool),
4564
+ };
4565
+ }
4566
+ break;
4567
+ }
4568
+ case 'subagent-toggle': {
4569
+ const name = String((args && args.name) || '');
4570
+ next.subagents = toggleInList(Array.isArray(next.subagents) ? next.subagents.slice() : [], name, enabled);
4571
+ break;
4572
+ }
4573
+ default: return null;
4574
+ }
4575
+ }
4576
+ catch (e) {
4577
+ return message(e);
4578
+ }
4579
+ // 空段 = 未定义(「一个都没勾 = 全关」),删掉以免两种语义并存(与 scene-archive-save 同口径)。
4580
+ for (const seg of ['skills', 'subagents']) {
4581
+ const list = next[seg];
4582
+ if (Array.isArray(list) && list.length === 0)
4583
+ delete next[seg];
4584
+ }
4585
+ if (next.mcp && Object.keys(next.mcp).length === 0)
4586
+ delete next.mcp;
4587
+ if (JSON.stringify(next) === JSON.stringify(cur))
4588
+ return null; // 档案本来就是这样 → 不写盘
4589
+ // 与「在档案弹窗里点保存」走同一条路:落盘 + 就地重应用 + 快照并入(退出仍按进场景前还原)。
4590
+ try {
4591
+ const save = archiveService.ops['scene-archive-save'];
4592
+ const res = await save({ scene, archive: next });
4593
+ if (res && res.ok === false)
4594
+ return String(res.error || '场景档案保存失败');
4595
+ if (res && res.applyError)
4596
+ return String(res.applyError);
4597
+ }
4598
+ catch (e) {
4599
+ return message(e);
4600
+ }
4601
+ return null;
4602
+ }
4603
+ /**
4604
+ * 包一层:开关成功后把改动同步进当前场景档案(见 syncSwitchToScene)。
4605
+ * 失败只挂 `sceneSyncError`,不改写原操作的成功结论 —— 运行时确实改了,档案没跟上要说得清。
4606
+ * 锁定仍由 `guardLockedOps` 挡住(更靠内的那层包装先执行)。
4028
4607
  */
4029
- function guardSceneSwitches(opNames, what) {
4608
+ function syncSceneArchiveOnSwitch(opNames) {
4030
4609
  for (const opName of opNames) {
4031
4610
  const original = handlers[opName];
4032
4611
  if (typeof original !== 'function')
4033
4612
  continue;
4034
4613
  handlers[opName] = async (args) => {
4035
- const blocked = await sceneSwitchBlock();
4036
- if (blocked)
4037
- return { ok: false, code: 'error.sceneSwitchesLocked', error: blocked };
4038
- return original(args);
4614
+ const res = await original(args);
4615
+ if (!res || res.ok === false)
4616
+ return res;
4617
+ const err = await syncSwitchToScene(opName, args);
4618
+ return err ? { ...res, sceneSyncError: err } : res;
4039
4619
  };
4040
4620
  }
4041
4621
  }
@@ -4055,9 +4635,9 @@ export default {
4055
4635
  guardLockedOps([
4056
4636
  // MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
4057
4637
  'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
4058
- // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
4638
+ // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站 / 批量启停。
4059
4639
  'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable', 'skill-source-remove', 'skill-source-restore',
4060
- 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload',
4640
+ 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload', 'skill-set-all',
4061
4641
  'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
4062
4642
  // 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
4063
4643
  'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
@@ -4066,14 +4646,13 @@ export default {
4066
4646
  // 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
4067
4647
  'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
4068
4648
  ], '修改');
4069
- // 场景内「开关」类操作一律禁用(用户裁定):改开关请去场景档案编辑器。
4070
- // 与「锁定」正交:锁定冻结全部写操作,这里只拦启停,内容编辑照旧。
4071
- guardSceneSwitches([
4649
+ // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
4650
+ // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
4651
+ syncSceneArchiveOnSwitch([
4072
4652
  'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled',
4073
- 'skill-enable', 'skill-disable', 'skill-source-enable', 'skill-source-disable',
4074
- 'skill-prefer', 'skill-unprefer',
4653
+ 'skill-enable', 'skill-disable', 'skill-set-all', 'skill-source-enable', 'skill-source-disable',
4075
4654
  'subagent-toggle',
4076
- ], '开关');
4655
+ ]);
4077
4656
  // 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
4078
4657
  for (const [opName, pickScene, what] of [
4079
4658
  ['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
@@ -4107,9 +4686,50 @@ export default {
4107
4686
  }
4108
4687
  // ---------- agent-facing tools (standard ctx.tools.register + defineTool) ----------
4109
4688
  const text = (value) => [{ type: 'text', text: value }];
4689
+ /**
4690
+ * 采纳遥测:把每个工具包一层,模型一调就记一笔"哪个域被伸手了"(见 context-inject 的
4691
+ * `noteToolUse`)。计数在宿主工具对象之外,所以**参数校验失败也算**——我们要测的是
4692
+ * "模型知不知道有这个域、会不会去用",不是"调用写得对不对"。
4693
+ *
4694
+ * 只统计本插件自己的工具,不走 `tools/result` 这类宿主事件:事件的作用域语义一旦变化,
4695
+ * 遥测会静默变成 0,而 0 恰好会被读成"模型从来不用"——一个假结论比没有结论更糟。
4696
+ * 包自己的工具是确定性的:工具在,观测就在。
4697
+ */
4698
+ const trackAdoption = (def) => {
4699
+ const name = String(def.name ?? '');
4700
+ const run = def.execute;
4701
+ if (name === '' || typeof run !== 'function')
4702
+ return def;
4703
+ return {
4704
+ ...def,
4705
+ async execute(args, exec) {
4706
+ try {
4707
+ if (contextInjectorNote !== null)
4708
+ contextInjectorNote(name, exec && exec.agent);
4709
+ }
4710
+ catch { /* 遥测绝不能影响工具 */ }
4711
+ return run(args, exec);
4712
+ },
4713
+ };
4714
+ };
4715
+ /**
4716
+ * 与宿主 `defineTool` **同签名的**本地包装。
4717
+ *
4718
+ * 为什么用同名遮蔽而不是逐个改 14 个注册点:采纳遥测必须覆盖**每一个**域工具,
4719
+ * 漏一个就得到"这个域从来没用过"的假结论。遮蔽 import 让"新加工具自动被统计"成为
4720
+ * 默认,而不是靠后来者记得手动加。
4721
+ *
4722
+ * 类型上刻意**不重写签名**,而是把箭头函数断言成 `typeof hostDefineTool`:宿主签名带
4723
+ * `const S/O` 类型参数,照着写一遍会让 TS 在每个调用点上多展开一层 `InferObject<S, ?>`,
4724
+ * 直接撞 "Excessive stack depth comparing types"(实测)。断言成宿主自己的类型之后,
4725
+ * 14 个调用点的参数表推断与改造前**完全一致** —— 宿主改签名时该报错的地方照旧报错;
4726
+ * 包装体本身只是转发(参数收 `unknown`、原样交给宿主),不参与推断,也就没有可失效的类型。
4727
+ */
4728
+ const hostDefineToolAny = hostDefineTool;
4729
+ const defineTool = ((options) => trackAdoption(hostDefineToolAny(options)));
4110
4730
  tools.register(defineTool({
4111
4731
  name: 'mcp_manager_list',
4112
- 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.',
4732
+ 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. The same list is injected into your context each turn (the「本机 MCP 服务器的当前状态」system-reminder); this tool is the raw view — all=true includes disabled servers. Defaults to enabled servers; pass all=true for every configured server.',
4113
4733
  parameters: {
4114
4734
  all: { type: 'boolean', description: 'Include disabled servers (default false).' },
4115
4735
  },
@@ -4159,13 +4779,16 @@ export default {
4159
4779
  },
4160
4780
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4161
4781
  async execute(args) {
4162
- const blocked = await sceneSwitchBlock();
4782
+ const blocked = await lockedSceneGuard();
4163
4783
  if (blocked)
4164
4784
  throw new Error(blocked);
4165
4785
  const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
4166
4786
  if (!r.ok)
4167
4787
  throw new Error(r.error);
4168
- return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled');
4788
+ // 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
4789
+ const syncErr = await syncSwitchToScene('mcpm-set-enabled', args);
4790
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
4791
+ (syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
4169
4792
  },
4170
4793
  }));
4171
4794
  tools.register(defineTool({
@@ -4198,6 +4821,9 @@ export default {
4198
4821
  },
4199
4822
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4200
4823
  async execute(args) {
4824
+ const blocked = await lockedSceneGuard();
4825
+ if (blocked)
4826
+ throw new Error(blocked);
4201
4827
  const r = await mcpmAdd(args);
4202
4828
  if (!r.ok)
4203
4829
  throw new Error(r.error);
@@ -4208,7 +4834,7 @@ export default {
4208
4834
  // tools/pre-execute hook below (the model must ask before writing files).
4209
4835
  tools.register(defineTool({
4210
4836
  name: 'skill_manager_list',
4211
- 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.',
4837
+ description: 'List DSH skills with enabled state, effective/shadowed status and source file path. The injected「本机技能目录」system-reminder carries callable skills and summaries only; use this tool to get a source file path (read the file for the full body) and to see entries that are off. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
4212
4838
  parameters: {
4213
4839
  all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
4214
4840
  },
@@ -4278,7 +4904,7 @@ export default {
4278
4904
  },
4279
4905
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4280
4906
  async execute(args) {
4281
- const blocked = await sceneSwitchBlock();
4907
+ const blocked = await lockedSceneGuard();
4282
4908
  if (blocked)
4283
4909
  throw new Error(blocked);
4284
4910
  const root = String(args.root || 'dsh');
@@ -4286,6 +4912,8 @@ export default {
4286
4912
  const r = await skillsService.ops[op]({ name: args.name, root });
4287
4913
  if (!r || r.ok === false)
4288
4914
  throw new Error((r && r.error) || 'skill toggle failed');
4915
+ // 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
4916
+ const syncErr = await syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
4289
4917
  // 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
4290
4918
  // 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
4291
4919
  let shadowedBy = '';
@@ -4308,7 +4936,8 @@ export default {
4308
4936
  const warn = shadowedBy
4309
4937
  ? ' — 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'
4310
4938
  : '';
4311
- return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn;
4939
+ const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
4940
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
4312
4941
  },
4313
4942
  }));
4314
4943
  tools.register(defineTool({
@@ -4324,6 +4953,9 @@ export default {
4324
4953
  },
4325
4954
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4326
4955
  async execute(args) {
4956
+ const blocked = await lockedSceneGuard();
4957
+ if (blocked)
4958
+ throw new Error(blocked);
4327
4959
  const r = await skillsService.ops['skill-create']({ name: args.name, description: args.description, body: args.body });
4328
4960
  if (!r || r.ok === false)
4329
4961
  throw new Error((r && r.error) || 'skill create failed');
@@ -4334,7 +4966,7 @@ export default {
4334
4966
  // AGENTS.md 预设库:模型可查/切,不能造/删(避免模型乱删用户预设)。
4335
4967
  tools.register(defineTool({
4336
4968
  name: 'prompt_manager_list',
4337
- 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.',
4969
+ description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. The preset in effect right now is injected into your context each turn (the「本机提示词」system-reminder, whose「来源:」line names the file). Defaults to the preset currently in effect; pass all=true for the whole library.',
4338
4970
  parameters: {
4339
4971
  all: { type: 'boolean', description: 'Include inactive presets (default false).' },
4340
4972
  },
@@ -4379,6 +5011,9 @@ export default {
4379
5011
  },
4380
5012
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4381
5013
  async execute(args) {
5014
+ const blocked = await lockedSceneGuard();
5015
+ if (blocked)
5016
+ throw new Error(blocked);
4382
5017
  const r = await applyPresetGuarded(args.id);
4383
5018
  if (!r.ok)
4384
5019
  throw new Error(r.error);
@@ -4391,7 +5026,7 @@ export default {
4391
5026
  // 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
4392
5027
  tools.register(defineTool({
4393
5028
  name: 'memory_manager_list',
4394
- 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.',
5029
+ description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). The ones actually injected are carried in your context each turn (the「本机当前的场景和记忆」system-reminder); use this tool to find ids/paths or to see entries that are off. Defaults to the memories that will actually be injected; pass all=true for every entry.',
4395
5030
  parameters: {
4396
5031
  group: { type: 'string', description: 'Optional scene filter.' },
4397
5032
  all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
@@ -4457,6 +5092,9 @@ export default {
4457
5092
  },
4458
5093
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4459
5094
  async execute(args) {
5095
+ const blocked = await lockedSceneGuard();
5096
+ if (blocked)
5097
+ throw new Error(blocked);
4460
5098
  const r = await rulesService.ops['rules-create'](args);
4461
5099
  if (!r || r.ok === false)
4462
5100
  throw new Error((r && r.error) || '创建规则失败');
@@ -4537,11 +5175,13 @@ export default {
4537
5175
  .catch(() => ({ kind: 'ask', reason: 'Write a memory under ~/.dsh/tool-management/memories' }));
4538
5176
  }
4539
5177
  // subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
5178
+ // `inherit: true` 时子代理会读到本次会话已完成的对话 —— 卡里如实说明(用户批准的是
5179
+ // "把这段对话交给它",不只是"跑个子代理")。文案只在与 fork 有关的部分分叉。
5180
+ const inherits = !!(exec && exec.arguments && exec.arguments.inherit);
5181
+ const ask = { kind: 'ask', reason: inherits ? 'Run a subagent on this conversation (it inherits the conversation; consumes tokens)' : 'Run a subagent (consumes tokens)' };
4540
5182
  return readPluginSettings()
4541
- .then((s) => (s.requireConfirmForModelSubagentRun !== false
4542
- ? { kind: 'ask', reason: 'Run a subagent (consumes tokens)' }
4543
- : next()))
4544
- .catch(() => ({ kind: 'ask', reason: 'Run a subagent (consumes tokens)' }));
5183
+ .then((s) => (s.requireConfirmForModelSubagentRun !== false ? ask : next()))
5184
+ .catch(() => ask);
4545
5185
  });
4546
5186
  }
4547
5187
  // ---------- per-tool MCP switches: execution guard + visibility ----------
@@ -4658,7 +5298,7 @@ export default {
4658
5298
  res.end(JSON.stringify({ ok: false, error: 'missing plugin gate header' }));
4659
5299
  return;
4660
5300
  }
4661
- const tokenAccepted = TOKEN !== '' && hdr('x-dsh-token') === TOKEN;
5301
+ const tokenAccepted = TOKEN !== '' && tokenMatches(hdr('x-dsh-token'));
4662
5302
  if (!tokenAccepted) {
4663
5303
  const fence = fenceRejection(req, connection);
4664
5304
  if (fence) {
@@ -4692,7 +5332,7 @@ export default {
4692
5332
  return;
4693
5333
  }
4694
5334
  }
4695
- if (TOKEN && WRITE_OPS.has(op) && hdr('x-dsh-token') !== TOKEN) {
5335
+ if (TOKEN && WRITE_OPS.has(op) && !tokenMatches(hdr('x-dsh-token'))) {
4696
5336
  res.end(JSON.stringify({ ok: false, error: '缺少或错误的访问令牌(x-dsh-token)' }));
4697
5337
  return;
4698
5338
  }