dsh-plugin-tool-management 0.9.0 → 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.
package/lib/index.js CHANGED
@@ -284,12 +284,18 @@ export default {
284
284
  const states = {};
285
285
  const all = {};
286
286
  const shadowed = new Set();
287
+ const unloadable = new Set();
287
288
  for (const root of ((r && r.data && r.data.roots) || [])) {
288
289
  for (const sk of (root.skills || [])) {
289
290
  const name = String(sk.declaredName || sk.name || '');
290
291
  if (!name)
291
292
  continue;
292
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);
293
299
  if (sk.shadowedBy) {
294
300
  shadowed.add(key);
295
301
  all[key] = false;
@@ -300,7 +306,24 @@ export default {
300
306
  all[key] = on;
301
307
  }
302
308
  }
303
- 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;
304
327
  }
305
328
  async function skillStates() {
306
329
  // knownSkillKeys 需要**全量键**(含被覆盖的副本):场景档案里允许勾选任意一条技能,
@@ -333,6 +356,7 @@ export default {
333
356
  name,
334
357
  enabled: sk.enabled === true && !sk.shadowedBy,
335
358
  shadowed: !!sk.shadowedBy,
359
+ loadable: sk.loadable !== false,
336
360
  rootKey,
337
361
  rootLabel: String((root && root.label) || rootKey),
338
362
  ...(root && root.localeKey ? { rootLocaleKey: String(root.localeKey) } : {}),
@@ -392,22 +416,31 @@ export default {
392
416
  scheduleToolRestrictions();
393
417
  },
394
418
  knownSkillKeys: async () => new Set(Object.keys(await skillStates())),
419
+ // 档案保存校验用:被同名覆盖的副本与结构不完整的技能都不算「可勾选」(见 selectableSkillKeys)。
420
+ selectableSkillKeys,
395
421
  // 快照只取**真实生效**的条目(不含被同名覆盖的副本)——副本不参与模式应用,
396
422
  // 退出回放时也不该去动它们,否则会改写用户对副本的显式策略记录。
397
423
  currentSkills: async () => (await skillScan()).states,
398
424
  applySkills: async (target) => {
399
- // 两道过滤,缺一不可:
425
+ // 三道过滤,缺一不可:
400
426
  // 1. 被同名覆盖的副本直接跳过——它们本来就不生效,写策略只会污染用户的记录。
401
- // 2. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
427
+ // 2. 结构不完整的技能也跳过——core 对它们一律拒绝启停(error.skill.notLoadable),
428
+ // 不跳过就会抛成"应用失败",**整个场景进不去**(老档案里的残留键能踩到)。
429
+ // 3. **已经处于目标状态的技能不再写**。`applySkills` 写的是显式策略记录,而目标
402
430
  // 状态是生效状态,两者不等价:对已经生效为「停用」的技能再 disable 一次,
403
431
  // 只是把「没有记录」固化成「显式停用」,场景模式进出一次就会改写 state.json。
404
- const { states, shadowed } = await skillScan();
432
+ const { states, shadowed, unloadable } = await skillScan();
405
433
  for (const [key, on] of Object.entries(target)) {
406
434
  const i = key.indexOf('/');
407
435
  if (i <= 0)
408
436
  throw new Error(`技能 key 不合法: ${key}`);
437
+ // 被同名覆盖的副本跳过(它本来就不生效,写策略只会污染用户的记录)。
409
438
  if (shadowed.has(key))
410
439
  continue;
440
+ // 结构不完整的技能跳过:core 对它们**一律拒绝启停**(error.skill.notLoadable),
441
+ // 继续往下走会抛成"应用失败",**整个场景就进不去了**(老档案里的残留键能踩到)。
442
+ if (unloadable.has(key))
443
+ continue;
411
444
  if (states[key] === on)
412
445
  continue;
413
446
  const root = key.slice(0, i);
@@ -487,12 +520,38 @@ export default {
487
520
  }
488
521
  return out;
489
522
  },
523
+ /**
524
+ * 来源级启停(进入 / 退出 / 就地重应用共用)。
525
+ *
526
+ * 容错口径(用户 2026-09-17 报「退出模式失败 → 卡在场景里」之后加的):**结构性**失败
527
+ * (来源解析不到 / 该来源本来就不允许启停)只记警告并跳过 —— 这一行与本次改动无关,
528
+ * 拿它中断整个恢复只会把用户困在场景里;**写盘**类失败仍然抛错,那说明确实没还原成功。
529
+ * 结构性失败的正解在 `skills/service.ts` 的 `requestSwitchRoot`(项目来源按 key 前缀解析)。
530
+ */
490
531
  applySkillSourceSwitches: async (switches) => {
532
+ const skipped = [];
491
533
  for (const s of switches) {
492
534
  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)
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
+ }
495
550
  throw new Error(`技能来源 ${s.root} ${s.enabled ? '启用' : '停用'}失败:${r.error}`);
551
+ }
552
+ }
553
+ if (skipped.length) {
554
+ ctx.logger?.warn?.(`scene: 跳过无法启停的技能来源 —— ${skipped.join('、')}`);
496
555
  }
497
556
  },
498
557
  sceneExists: async (name) => {
@@ -638,6 +697,9 @@ export default {
638
697
  return undefined;
639
698
  }
640
699
  }
700
+ // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
701
+ // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
702
+ let contextInjectorLive = null;
641
703
  try {
642
704
  ctx.effect(() => {
643
705
  const injector = createContextInjector({
@@ -655,7 +717,8 @@ export default {
655
717
  settings: () => injectSettingsSync(),
656
718
  factsFor: (agent) => presetFactsForAgent(agent),
657
719
  });
658
- return () => injector.dispose();
720
+ contextInjectorLive = () => injector.live();
721
+ return () => { contextInjectorLive = null; injector.dispose(); };
659
722
  }, 'dsh-plugin-tool-management: context injection');
660
723
  }
661
724
  catch (e) {
@@ -788,7 +851,10 @@ export default {
788
851
  if (toOn.length || toOff.length)
789
852
  void subagentCatalog.refresh();
790
853
  const snapshot = mode.snapshot;
791
- if (snapshot && (toOn.length || toOff.length)) {
854
+ // 新快照带**全量映射**(`subagentsAll`,v0.9.1)→ 退出按映射逐个精确还原,这两个
855
+ // 部分名单不再需要,也不再往新快照里写(老快照没有映射,仍然照旧维护,见
856
+ // archive-engine 的 restoreSnapshot 兼容分支)。
857
+ if (snapshot && !snapshot.subagentsAll && (toOn.length || toOff.length)) {
792
858
  const backOn = Array.isArray(snapshot.subagents) ? snapshot.subagents.slice() : [];
793
859
  const offList = Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn.slice() : [];
794
860
  for (const n of toOn)
@@ -912,22 +978,27 @@ export default {
912
978
  /**
913
979
  * **应用提示词预设的唯一入口**(界面 op 与模型工具共用)。
914
980
  *
915
- * 场景接管期间(启用场景 / 全局绑定在驱动基线)只允许「重新应用场景绑定的那一份」:
916
- * 应用别的预设会绕过场景绑定,于是「场景页显示 A、实际注入 B、A 不可删而 B 可删」
917
- * 四套状态各说各话(用户实测)。同一份 = 修复(把被手改的基线写回去),走场景同步
918
- * 而不是裸写 —— 同步会在写之前补上「进场景前的基线」快照,退出场景才恢复得回来。
919
- * 换提示词的唯一正路在场景设置里改绑定(`rules-update-scene`,成功后同样同步基线)。
981
+ * 场景接管期间(启用场景 / 全局绑定在驱动基线):
982
+ * - 应用**同一份** = 重新应用(把被手改的基线写回去),走场景同步;
983
+ * - 应用**别的份** = 换掉这个场景的绑定(用户裁定 2026-09-17:未锁定时切换要可用,
984
+ * 并且同步写进场景档案)。绑定改完再走一次场景同步,于是「场景页显示什么 / 实际注入
985
+ * 什么 / 谁是引用方」三处立刻重新对齐(此前的做法是直接拒绝,用户得先退出场景)。
986
+ * 锁定仍由 `guardLockedOps` 挡住(agentsmd-apply 在冻结清单里)。
920
987
  */
921
988
  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()) {
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
+ }
931
1002
  const r = await scenePromptSync.sync();
932
1003
  if (r.error)
933
1004
  return { ok: false, error: r.error };
@@ -3612,6 +3683,18 @@ export default {
3612
3683
  },
3613
3684
  // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
3614
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
+ },
3615
3698
  'history-unarchive': async (args) => {
3616
3699
  const registry = getHistoryRegistry();
3617
3700
  if (!registry)
@@ -4009,33 +4092,327 @@ export default {
4009
4092
  return null;
4010
4093
  }
4011
4094
  }
4012
- /** 「场景内开关一律禁用」的拒绝文案(界面 / 模型工具共用;不在场景里返回 null)。 */
4013
- async function sceneSwitchBlock() {
4095
+ /** 「当前场景已锁定」的拒绝文案(模型工具用;无活动场景 / 未锁定 → null)。 */
4096
+ async function lockedSceneGuard() {
4014
4097
  const scene = await activeSceneName();
4015
4098
  if (!scene)
4016
4099
  return null;
4017
- return `当前处于场景「${scene}」:开关由场景档案定义,不能在页面上直接改。请到场景页的档案编辑器里改(改完立即生效;退出场景仍按进场景前的状态还原),或先退出场景。`;
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;
4018
4192
  }
4019
4193
  /**
4020
- * 场景内「开关」类操作一律禁用(用户裁定 2026-09-16)。
4194
+ * 某台服务器当前**启用**的工具名(把档案里的 `'*'` 具化成具体名单时用)。
4021
4195
  *
4022
- * 场景里的环境由**档案**定义,页面开关在场景里本来就是"白改":退出场景时 MCP 停用表、
4023
- * 技能表按快照整体写回,那些改动会被静默丢弃,而档案又不会跟着变 —— 于是「页面开关 /
4024
- * 实际生效 / 档案勾选」三处各说各话(用户实测数次)。要改就改档案:那里改完立即生效、
4025
- * 且档案持久化(下次进这个场景照旧生效);退出场景时运行时仍按进场景前的快照还原。
4026
- * 只拦**启停**:新增 / 改名 / 删除 / 备注 / 重启 / 导入导出这些内容操作照旧可用
4027
- * (要整体冻结用场景「锁定」)。
4196
+ * live schema 优先(就是此刻真的存在的工具);服务器没跑起来时退回「最后见过的工具名单 −
4197
+ * 停用表」——与 `computeMcpPlan` 的 `knownTools` 同源,别把「不知道」当成「一个都没有」。
4028
4198
  */
4029
- function guardSceneSwitches(opNames, what) {
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) {
4030
4406
  for (const opName of opNames) {
4031
4407
  const original = handlers[opName];
4032
4408
  if (typeof original !== 'function')
4033
4409
  continue;
4034
4410
  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);
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;
4039
4416
  };
4040
4417
  }
4041
4418
  }
@@ -4055,9 +4432,9 @@ export default {
4055
4432
  guardLockedOps([
4056
4433
  // MCP:改配置 / 服务器启停 / 工具启停 / 导入导出配置 / 备注 / 设置。restart 只重连不改配置,放行。
4057
4434
  'mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-note', 'mcpm-settings',
4058
- // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站。
4435
+ // 技能:启停 / 来源启停与移除恢复 / 首选 / 删除 / 创建导入 / 自定义目录 / 回收站 / 批量启停。
4059
4436
  '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',
4437
+ 'skill-prefer', 'skill-unprefer', 'skill-delete', 'skill-create', 'skill-import', 'skill-upload', 'skill-set-all',
4061
4438
  'skill-custom-add', 'skill-custom-remove', 'skill-trash-restore', 'skill-trash-delete',
4062
4439
  // 子智能体:开关 / 改名保存 / 删除 / 导入 / 回收站。
4063
4440
  'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-toggle', 'subagent-import', 'subagent-trash-restore', 'subagent-trash-delete',
@@ -4066,14 +4443,13 @@ export default {
4066
4443
  // 提示词:建改删 / 应用(切换生效基线)/ 导入 / 回收站。
4067
4444
  'agentsmd-create', 'agentsmd-update', 'agentsmd-remove', 'agentsmd-apply', 'agentsmd-import', 'agentsmd-trash-restore', 'agentsmd-trash-delete',
4068
4445
  ], '修改');
4069
- // 场景内「开关」类操作一律禁用(用户裁定):改开关请去场景档案编辑器。
4070
- // 与「锁定」正交:锁定冻结全部写操作,这里只拦启停,内容编辑照旧。
4071
- guardSceneSwitches([
4446
+ // 场景内「开关」类操作(用户裁定 2026-09-17):未锁定时**可用**,改动同步进当前场景档案。
4447
+ // 与「锁定」正交:锁定冻结全部写操作,这里只是把页面开关的意图也写进档案。
4448
+ syncSceneArchiveOnSwitch([
4072
4449
  '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',
4450
+ 'skill-enable', 'skill-disable', 'skill-set-all', 'skill-source-enable', 'skill-source-disable',
4075
4451
  'subagent-toggle',
4076
- ], '开关');
4452
+ ]);
4077
4453
  // 被锁场景自身的档案与删除:只挡它自己,别的场景照常。
4078
4454
  for (const [opName, pickScene, what] of [
4079
4455
  ['scene-archive-save', (args) => (args && args.scene) || '', '改档案'],
@@ -4159,13 +4535,16 @@ export default {
4159
4535
  },
4160
4536
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4161
4537
  async execute(args) {
4162
- const blocked = await sceneSwitchBlock();
4538
+ const blocked = await lockedSceneGuard();
4163
4539
  if (blocked)
4164
4540
  throw new Error(blocked);
4165
4541
  const r = await mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
4166
4542
  if (!r.ok)
4167
4543
  throw new Error(r.error);
4168
- 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 + ')' : '');
4169
4548
  },
4170
4549
  }));
4171
4550
  tools.register(defineTool({
@@ -4278,7 +4657,7 @@ export default {
4278
4657
  },
4279
4658
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
4280
4659
  async execute(args) {
4281
- const blocked = await sceneSwitchBlock();
4660
+ const blocked = await lockedSceneGuard();
4282
4661
  if (blocked)
4283
4662
  throw new Error(blocked);
4284
4663
  const root = String(args.root || 'dsh');
@@ -4286,6 +4665,8 @@ export default {
4286
4665
  const r = await skillsService.ops[op]({ name: args.name, root });
4287
4666
  if (!r || r.ok === false)
4288
4667
  throw new Error((r && r.error) || 'skill toggle failed');
4668
+ // 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
4669
+ const syncErr = await syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
4289
4670
  // 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
4290
4671
  // 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
4291
4672
  let shadowedBy = '';
@@ -4308,7 +4689,8 @@ export default {
4308
4689
  const warn = shadowedBy
4309
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'
4310
4691
  : '';
4311
- return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn;
4692
+ const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
4693
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
4312
4694
  },
4313
4695
  }));
4314
4696
  tools.register(defineTool({